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
+1
View File
@@ -34,6 +34,7 @@ func (r *Router) geoStatus(w http.ResponseWriter, _ *http.Request) {
func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
limitBody(w, req)
r.dbg("geoUpdateSettings: contentLength=%d", req.ContentLength)
var body struct {
Enabled bool `json:"enabled"`
+23
View File
@@ -59,6 +59,19 @@ type Router struct {
// App-to-app integration manager (nil if not configured)
integrationMgr *integrations.Manager
debug bool
}
// SetDebug enables or disables debug logging for API routing.
func (r *Router) SetDebug(on bool) {
r.debug = on
}
func (r *Router) dbg(format string, args ...interface{}) {
if r.debug {
r.logger.Printf("[DEBUG] [api] "+format, args...)
}
}
// SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers.
@@ -92,6 +105,8 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/api")
path = strings.TrimSuffix(path, "/")
r.dbg("%s %s (path=%s)", req.Method, req.URL.Path, path)
switch {
// GET /api/stacks
case path == "/stacks" && req.Method == http.MethodGet:
@@ -282,6 +297,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.geoRemoveAppOverride(w, req, extractName(path, "/geo/override"))
default:
r.dbg("no matching route: %s %s", req.Method, path)
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
}
}
@@ -340,6 +356,7 @@ func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name st
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req)
r.logger.Printf("[API] Deploy requested for stack: %s", name)
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
var body struct {
Values map[string]string `json:"values"`
@@ -396,6 +413,7 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[API] %s requested for stack: %s", action, name)
r.dbg("actionStack: action=%s name=%s", action, name)
// Protected stacks only allow restart — block all other actions
if r.cfg.IsProtectedStack(name) && action != "restart" {
@@ -587,6 +605,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
}
limitBody(w, req)
r.logger.Printf("[API] Remove requested for stack: %s", name)
r.dbg("removeStack: name=%s", name)
var body struct {
RemoveHDDData bool `json:"remove_hdd_data"`
@@ -596,6 +615,7 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri
body.RemoveHDDData = false
body.RemoveBackups = false
}
r.dbg("removeStack: name=%s removeHDDData=%v removeBackups=%v", name, body.RemoveHDDData, body.RemoveBackups)
// Compute backup paths to remove if requested
var backupPaths []string
@@ -750,12 +770,14 @@ func (r *Router) backupStatus(w http.ResponseWriter, _ *http.Request) {
}
func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return
}
if r.backupMgr.IsRunning() {
r.dbg("triggerBackup: backup already running, rejecting")
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
return
}
@@ -1130,6 +1152,7 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
// --- Config apply handler ---
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) {
r.dbg("configApply: contentLength=%d remoteAddr=%s", req.ContentLength, req.RemoteAddr)
// Read YAML body (limit to 1MB)
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
if err != nil {
+228
View File
@@ -0,0 +1,228 @@
package appexport
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"golang.org/x/crypto/scrypt"
)
const (
magicHeader = "FABE" // Felhom App Bundle Encrypted
scryptN = 1 << 15 // 32768
scryptR = 8
scryptP = 1
saltSize = 32
aesKeySize = 32
hmacKeySize = 32
ivSize = aes.BlockSize // 16
)
// deriveKeys derives an AES-256 key and HMAC-SHA256 key from password + salt.
func deriveKeys(password string, salt []byte) (aesKey, hmacKey []byte, err error) {
derived, err := scrypt.Key([]byte(password), salt, scryptN, scryptR, scryptP, aesKeySize+hmacKeySize)
if err != nil {
return nil, nil, err
}
return derived[:aesKeySize], derived[aesKeySize:], nil
}
// IsEncryptedFAB checks if a file starts with the "FABE" magic header.
func IsEncryptedFAB(path string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
magic := make([]byte, 4)
n, err := f.Read(magic)
if err != nil || n < 4 {
return false, nil
}
return string(magic) == magicHeader, nil
}
// EncryptFile encrypts a plaintext file with a password.
// Uses AES-256-CTR + HMAC-SHA256 with scrypt key derivation.
// Format: "FABE" (4) || salt (32) || IV (16) || encrypted_data || HMAC-SHA256 (32)
func EncryptFile(inputPath, outputPath, password string) error {
in, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer out.Close()
salt := make([]byte, saltSize)
if _, err := rand.Read(salt); err != nil {
return fmt.Errorf("generating salt: %w", err)
}
iv := make([]byte, ivSize)
if _, err := rand.Read(iv); err != nil {
return fmt.Errorf("generating IV: %w", err)
}
aesKey, hmKey, err := deriveKeys(password, salt)
if err != nil {
return fmt.Errorf("deriving keys: %w", err)
}
block, err := aes.NewCipher(aesKey)
if err != nil {
return err
}
stream := cipher.NewCTR(block, iv)
mac := hmac.New(sha256.New, hmKey)
// Write header (magic is NOT in HMAC; salt + IV are)
if _, err := out.Write([]byte(magicHeader)); err != nil {
return err
}
mac.Write(salt)
if _, err := out.Write(salt); err != nil {
return err
}
mac.Write(iv)
if _, err := out.Write(iv); err != nil {
return err
}
// Encrypt and stream data
buf := make([]byte, 64*1024)
for {
n, readErr := in.Read(buf)
if n > 0 {
encrypted := make([]byte, n)
stream.XORKeyStream(encrypted, buf[:n])
mac.Write(encrypted)
if _, err := out.Write(encrypted); err != nil {
return err
}
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("read: %w", readErr)
}
}
// Append HMAC tag
if _, err := out.Write(mac.Sum(nil)); err != nil {
return err
}
return out.Sync()
}
// DecryptFile decrypts an encrypted .fab file with a password.
// Returns a clear error if the password is wrong or the file is corrupted.
func DecryptFile(inputPath, outputPath, password string) error {
in, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("open input: %w", err)
}
defer in.Close()
// Verify magic header
magic := make([]byte, 4)
if _, err := io.ReadFull(in, magic); err != nil {
return fmt.Errorf("reading header: %w", err)
}
if string(magic) != magicHeader {
return errors.New("not an encrypted FAB file")
}
// Read salt and IV
salt := make([]byte, saltSize)
if _, err := io.ReadFull(in, salt); err != nil {
return fmt.Errorf("reading salt: %w", err)
}
iv := make([]byte, ivSize)
if _, err := io.ReadFull(in, iv); err != nil {
return fmt.Errorf("reading IV: %w", err)
}
// Calculate data size (total - header - HMAC tag)
stat, err := in.Stat()
if err != nil {
return err
}
headerSize := int64(4 + saltSize + ivSize)
tagSize := int64(sha256.Size)
dataSize := stat.Size() - headerSize - tagSize
if dataSize < 0 {
return errors.New("file too small to be valid")
}
aesKey, hmKey, err := deriveKeys(password, salt)
if err != nil {
return fmt.Errorf("deriving keys: %w", err)
}
mac := hmac.New(sha256.New, hmKey)
mac.Write(salt)
mac.Write(iv)
block, err := aes.NewCipher(aesKey)
if err != nil {
return err
}
stream := cipher.NewCTR(block, iv)
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("create output: %w", err)
}
defer out.Close()
// Decrypt data section
buf := make([]byte, 64*1024)
remaining := dataSize
for remaining > 0 {
toRead := int64(len(buf))
if toRead > remaining {
toRead = remaining
}
n, readErr := in.Read(buf[:toRead])
if n > 0 {
mac.Write(buf[:n])
decrypted := make([]byte, n)
stream.XORKeyStream(decrypted, buf[:n])
if _, err := out.Write(decrypted); err != nil {
return err
}
remaining -= int64(n)
}
if readErr == io.EOF {
break
}
if readErr != nil {
return fmt.Errorf("read: %w", readErr)
}
}
// Verify HMAC tag
storedMAC := make([]byte, sha256.Size)
if _, err := io.ReadFull(in, storedMAC); err != nil {
return fmt.Errorf("reading HMAC: %w", err)
}
if !hmac.Equal(mac.Sum(nil), storedMAC) {
os.Remove(outputPath)
return errors.New("jelszó hibás vagy a fájl sérült")
}
return out.Sync()
}
+177
View File
@@ -0,0 +1,177 @@
package appexport
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// ExportEstimate holds pre-export size and space estimation.
type ExportEstimate struct {
ConfigSizeBytes int64 `json:"config_size_bytes"`
ConfigSizeHuman string `json:"config_size_human"`
DataSizeBytes int64 `json:"data_size_bytes"`
DataSizeHuman string `json:"data_size_human"`
TotalSizeBytes int64 `json:"total_size_bytes"`
TotalSizeHuman string `json:"total_size_human"`
EstimatedMinutes int `json:"estimated_minutes"`
DestFreeBytes int64 `json:"dest_free_bytes"`
DestFreeHuman string `json:"dest_free_human"`
FitsOnDest bool `json:"fits_on_dest"`
}
// EstimateExport calculates size estimates for an app export.
func (e *Exporter) EstimateExport(stackName, destDrive string) (*ExportEstimate, error) {
stackDir, ok := e.provider.GetStackDir(stackName)
if !ok {
return nil, fmt.Errorf("stack %q not found", stackName)
}
e.debugf("EstimateExport: stack=%s stackDir=%s destDrive=%s", stackName, stackDir, destDrive)
est := &ExportEstimate{}
// Config size: sum of all files in the stack directory
est.ConfigSizeBytes = dirSize(stackDir)
est.ConfigSizeHuman = humanizeBytes(est.ConfigSizeBytes)
e.debugf("EstimateExport: configSize=%s (%d bytes)", est.ConfigSizeHuman, est.ConfigSizeBytes)
// Data size: HDD bind mounts or Docker volumes
if e.provider.GetStackNeedsHDD(stackName) {
mounts := e.provider.GetStackHDDMounts(stackName)
e.debugf("EstimateExport: HDD mounts: %v", mounts)
for _, mount := range mounts {
mountSize := duBytes(mount)
e.debugf("EstimateExport: mount %s = %s", mount, humanizeBytes(mountSize))
est.DataSizeBytes += mountSize
}
} else {
volumes := e.provider.GetDockerVolumes(stackName)
e.debugf("EstimateExport: Docker volumes: %v", volumes)
for _, vol := range volumes {
volSize := dockerVolumeSize(vol)
e.debugf("EstimateExport: volume %s = %s", vol, humanizeBytes(volSize))
est.DataSizeBytes += volSize
}
}
est.DataSizeHuman = humanizeBytes(est.DataSizeBytes)
est.TotalSizeBytes = est.ConfigSizeBytes + est.DataSizeBytes
est.TotalSizeHuman = humanizeBytes(est.TotalSizeBytes)
// Rough time estimate: ~500 MB/min for HDDs, minimum 1 minute
minutes := int(est.TotalSizeBytes / (500 * 1024 * 1024))
if minutes < 1 {
minutes = 1
}
est.EstimatedMinutes = minutes
// Destination free space
exportDir := ExportDir(destDrive)
os.MkdirAll(exportDir, 0755)
est.DestFreeBytes = diskFree(exportDir)
est.DestFreeHuman = humanizeBytes(est.DestFreeBytes)
// Need ~10% overhead for tar.gz metadata + compression margin
needed := est.TotalSizeBytes + est.TotalSizeBytes/10
est.FitsOnDest = est.DestFreeBytes >= needed
e.debugf("EstimateExport: total=%s free=%s fits=%v needed=%s minutes=%d",
est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest, humanizeBytes(needed), est.EstimatedMinutes)
return est, nil
}
// dirSize returns the total size of all files in a directory (non-recursive for config dirs).
func dirSize(dir string) int64 {
var total int64
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
total += info.Size()
}
return total
}
// duBytes runs du -sb on a path and returns the byte count.
func duBytes(path string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "du", "-sb", path).Output()
if err != nil {
return 0
}
var size int64
fmt.Sscanf(strings.Fields(string(out))[0], "%d", &size)
return size
}
// dockerVolumeSize estimates the size of a Docker named volume.
func dockerVolumeSize(volumeName string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Use docker system df -v and parse, or inspect the volume mount path
out, err := exec.CommandContext(ctx, "docker", "volume", "inspect",
"--format", "{{.Mountpoint}}", volumeName).Output()
if err != nil {
return 0
}
mountpoint := strings.TrimSpace(string(out))
if mountpoint == "" {
return 0
}
return duBytes(mountpoint)
}
// diskFree returns available bytes on the filesystem containing path.
func diskFree(path string) int64 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "df", "--output=avail", "-B1", path).Output()
if err != nil {
return 0
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) < 2 {
return 0
}
var size int64
fmt.Sscanf(strings.TrimSpace(lines[1]), "%d", &size)
return size
}
// ExportDir returns the exports directory on a drive.
func ExportDir(drivePath string) string {
return filepath.Join(drivePath, "felhom-data", "exports")
}
// humanizeBytes converts bytes to human-readable format.
func humanizeBytes(b int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case b >= GB:
return fmt.Sprintf("%.1f GB", float64(b)/float64(GB))
case b >= MB:
return fmt.Sprintf("%.1f MB", float64(b)/float64(MB))
case b >= KB:
return fmt.Sprintf("%.1f KB", float64(b)/float64(KB))
default:
return fmt.Sprintf("%d B", b)
}
}
+793
View File
@@ -0,0 +1,793 @@
package appexport
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// Step tracks one step of an export/import operation.
type Step struct {
Label string `json:"label"`
Status string `json:"status"` // "pending", "running", "done", "failed"
Error string `json:"error,omitempty"`
}
// Job tracks an in-progress export or import operation.
type Job struct {
mu sync.RWMutex
StackName string `json:"stack_name"`
DisplayName string `json:"display_name"`
Steps []Step `json:"steps"`
Running bool `json:"running"`
Done bool `json:"done"`
Error string `json:"error,omitempty"`
OutputPath string `json:"output_path,omitempty"`
OutputSize string `json:"output_size,omitempty"`
JobType string `json:"job_type"` // "export" or "import"
}
// Snapshot returns a thread-safe copy for JSON serialization.
func (j *Job) Snapshot() map[string]interface{} {
j.mu.RLock()
defer j.mu.RUnlock()
steps := make([]Step, len(j.Steps))
copy(steps, j.Steps)
return map[string]interface{}{
"ok": true,
"running": j.Running,
"done": j.Done,
"error": j.Error,
"steps": steps,
"output_path": j.OutputPath,
"output_size": j.OutputSize,
"stack_name": j.StackName,
"display_name": j.DisplayName,
"job_type": j.JobType,
}
}
func (j *Job) setStep(idx int, status, errMsg string) {
j.mu.Lock()
defer j.mu.Unlock()
if idx < len(j.Steps) {
j.Steps[idx].Status = status
j.Steps[idx].Error = errMsg
}
}
// ExportRequest holds user-provided parameters for an export.
type ExportRequest struct {
StackName string
DestDrive string // drive mount path (e.g., "/mnt/hdd_1")
Password string // empty = no encryption
StopApp bool // stop app before export
}
// Exporter manages app export/import operations.
type Exporter struct {
provider ExportStackProvider
logger *log.Logger
version string
debug bool
mu sync.Mutex
activeJob *Job
}
// NewExporter creates a new export/import engine.
func NewExporter(provider ExportStackProvider, logger *log.Logger, version string) *Exporter {
return &Exporter{
provider: provider,
logger: logger,
version: version,
}
}
// SetDebug enables or disables verbose debug logging.
func (e *Exporter) SetDebug(debug bool) {
e.debug = debug
}
// debugf logs a message only when debug mode is enabled.
func (e *Exporter) debugf(format string, args ...interface{}) {
if e.debug {
e.logger.Printf("[DEBUG] [appexport] "+format, args...)
}
}
// IsRunning returns true if an export/import is in progress.
func (e *Exporter) IsRunning() bool {
e.mu.Lock()
defer e.mu.Unlock()
return e.activeJob != nil && e.activeJob.Running
}
// GetActiveJob returns the current job (for status polling).
func (e *Exporter) GetActiveJob() *Job {
e.mu.Lock()
defer e.mu.Unlock()
return e.activeJob
}
// StartExport validates and starts an async export. Returns error if blocked.
func (e *Exporter) StartExport(req ExportRequest) error {
e.mu.Lock()
if e.activeJob != nil && e.activeJob.Running {
e.mu.Unlock()
e.debugf("StartExport rejected: another job is already running")
return fmt.Errorf("export or import already in progress")
}
if !e.provider.IsStackDeployed(req.StackName) {
e.mu.Unlock()
e.debugf("StartExport rejected: stack %q is not deployed", req.StackName)
return fmt.Errorf("stack %q is not deployed", req.StackName)
}
e.debugf("StartExport: stack=%q dest=%q password=%v stopApp=%v",
req.StackName, req.DestDrive, req.Password != "", req.StopApp)
steps := []Step{
{Label: "Előkészítés", Status: "pending"},
{Label: "Konfiguráció mentése", Status: "pending"},
{Label: "Adatbázis mentése", Status: "pending"},
{Label: "Felhasználói adatok", Status: "pending"},
{Label: "Csomag készítése", Status: "pending"},
}
if req.Password != "" {
steps = append(steps, Step{Label: "Titkosítás", Status: "pending"})
}
job := &Job{
StackName: req.StackName,
DisplayName: e.provider.GetStackDisplayName(req.StackName),
Steps: steps,
Running: true,
JobType: "export",
}
e.activeJob = job
e.mu.Unlock()
go e.executeExport(req, job)
return nil
}
func (e *Exporter) executeExport(req ExportRequest, job *Job) {
exportStart := time.Now()
e.debugf("=== EXPORT START: stack=%q dest=%q encrypted=%v stopApp=%v ===",
req.StackName, req.DestDrive, req.Password != "", req.StopApp)
defer func() {
job.mu.Lock()
job.Running = false
job.Done = true
job.mu.Unlock()
e.debugf("=== EXPORT END: stack=%q elapsed=%v ===", req.StackName, time.Since(exportStart))
}()
step := 0
// --- Step 0: Preparation ---
job.setStep(step, "running", "")
stepStart := time.Now()
destDir := ExportDir(req.DestDrive)
e.debugf("export dest dir: %s", destDir)
if err := os.MkdirAll(destDir, 0755); err != nil {
e.failJob(job, step, fmt.Sprintf("Nem sikerült létrehozni az export könyvtárat: %v", err))
return
}
// Check free space
est, err := e.EstimateExport(req.StackName, req.DestDrive)
if err != nil {
e.debugf("estimate error (non-fatal): %v", err)
} else {
e.debugf("estimate: config=%s data=%s total=%s destFree=%s fits=%v",
est.ConfigSizeHuman, est.DataSizeHuman, est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest)
if !est.FitsOnDest {
e.failJob(job, step, fmt.Sprintf("Nincs elég hely: szükséges ~%s, szabad %s",
est.TotalSizeHuman, est.DestFreeHuman))
return
}
}
// Optionally stop the app
wasRunning := false
if req.StopApp && e.provider.IsStackRunning(req.StackName) {
wasRunning = true
e.logger.Printf("[INFO] Export: stopping %s", req.StackName)
e.debugf("stopping stack %s before export", req.StackName)
if err := e.provider.StopStack(req.StackName); err != nil {
e.logger.Printf("[WARN] Export: could not stop %s: %v", req.StackName, err)
} else {
e.debugf("stack %s stopped successfully", req.StackName)
}
} else {
e.debugf("skip stop: stopApp=%v isRunning=%v", req.StopApp, e.provider.IsStackRunning(req.StackName))
}
// Always restart after export if we stopped it
if wasRunning {
defer func() {
e.logger.Printf("[INFO] Export: restarting %s", req.StackName)
e.debugf("restarting stack %s after export", req.StackName)
if err := e.provider.StartStack(req.StackName); err != nil {
e.logger.Printf("[WARN] Export: could not restart %s: %v", req.StackName, err)
} else {
e.debugf("stack %s restarted successfully", req.StackName)
}
}()
}
e.debugf("step 0 (preparation) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 1: Config files ---
job.setStep(step, "running", "")
stepStart = time.Now()
tmpDir, err := os.MkdirTemp("", "felhom-export-*")
if err != nil {
e.failJob(job, step, fmt.Sprintf("Temp könyvtár hiba: %v", err))
return
}
e.debugf("temp dir: %s", tmpDir)
defer os.RemoveAll(tmpDir)
configDir := filepath.Join(tmpDir, "config")
if err := os.MkdirAll(configDir, 0755); err != nil {
e.failJob(job, step, err.Error())
return
}
stackDir, ok := e.provider.GetStackDir(req.StackName)
if !ok {
e.failJob(job, step, "Stack könyvtár nem található")
return
}
e.debugf("stack dir: %s", stackDir)
configFiles, err := copyStackConfig(stackDir, configDir, req.StackName, e.provider)
if err != nil {
e.failJob(job, step, fmt.Sprintf("Konfiguráció mentése sikertelen: %v", err))
return
}
e.debugf("config files copied: %v (%d files)", configFiles, len(configFiles))
e.debugf("step 1 (config) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 2: Database dump ---
job.setStep(step, "running", "")
stepStart = time.Now()
dbDir := filepath.Join(tmpDir, "database")
os.MkdirAll(dbDir, 0755)
manifest := &Manifest{
Version: ManifestVersion,
AppName: req.StackName,
DisplayName: e.provider.GetStackDisplayName(req.StackName),
ExportedAt: time.Now().UTC(),
ControllerVer: e.version,
NeedsHDD: e.provider.GetStackNeedsHDD(req.StackName),
Encrypted: req.Password != "",
ConfigFiles: configFiles,
}
e.debugf("manifest: app=%s display=%s needsHDD=%v encrypted=%v",
manifest.AppName, manifest.DisplayName, manifest.NeedsHDD, manifest.Encrypted)
dbDumped := e.dumpDatabase(req.StackName, dbDir, manifest)
if !dbDumped {
e.debugf("no database found for %s — skipping DB step", req.StackName)
os.Remove(dbDir)
} else {
e.debugf("database dumped: type=%s", manifest.DBType)
// Log the dump file size
entries, _ := os.ReadDir(dbDir)
for _, entry := range entries {
if info, err := entry.Info(); err == nil {
e.debugf(" db dump file: %s (%s)", entry.Name(), humanizeBytes(info.Size()))
}
}
}
e.debugf("step 2 (database) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 3: User data ---
job.setStep(step, "running", "")
stepStart = time.Now()
dataDir := filepath.Join(tmpDir, "data")
os.MkdirAll(dataDir, 0755)
if e.provider.GetStackNeedsHDD(req.StackName) {
e.debugf("exporting HDD data for %s", req.StackName)
e.exportHDDData(req.StackName, dataDir, manifest)
e.debugf("HDD data exported: subdirs=%v hasData=%v", manifest.HDDSubdirs, manifest.HasHDDData)
} else {
e.debugf("exporting Docker volumes for %s", req.StackName)
e.exportVolumeData(req.StackName, dataDir, manifest)
e.debugf("volume data exported: volumes=%v hasData=%v", manifest.VolumeNames, manifest.HasVolumeData)
}
e.debugf("step 3 (user data) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 4: Create .fab bundle ---
job.setStep(step, "running", "")
stepStart = time.Now()
// Calculate total size
manifest.TotalSizeBytes = calcDirSize(tmpDir)
e.debugf("total bundle content size: %s (%d bytes)", humanizeBytes(manifest.TotalSizeBytes), manifest.TotalSizeBytes)
// Write manifest.json to tmpDir root
manifestData, err := manifest.Marshal()
if err != nil {
e.failJob(job, step, fmt.Sprintf("Manifest hiba: %v", err))
return
}
e.debugf("manifest JSON: %d bytes", len(manifestData))
if err := os.WriteFile(filepath.Join(tmpDir, "manifest.json"), manifestData, 0644); err != nil {
e.failJob(job, step, err.Error())
return
}
timestamp := time.Now().Format("20060102-150405")
fabName := fmt.Sprintf("%s_%s.fab", req.StackName, timestamp)
fabPath := filepath.Join(destDir, fabName)
e.debugf("target .fab path: %s", fabPath)
// Build tar.gz (to .tmp if encrypting, to final path if not)
targetPath := fabPath
if req.Password != "" {
targetPath = fabPath + ".tgz.tmp"
} else {
targetPath = fabPath + ".tmp"
}
e.debugf("creating tar.gz: %s", targetPath)
tgzStart := time.Now()
if err := createTarGz(targetPath, tmpDir); err != nil {
os.Remove(targetPath)
e.failJob(job, step, fmt.Sprintf("Csomag készítése sikertelen: %v", err))
return
}
if tgzInfo, err := os.Stat(targetPath); err == nil {
e.debugf("tar.gz created: %s (%s) in %v", targetPath, humanizeBytes(tgzInfo.Size()), time.Since(tgzStart))
}
job.setStep(step, "done", "")
step++
// --- Step 5: Encrypt (optional) ---
if req.Password != "" {
job.setStep(step, "running", "")
encStart := time.Now()
encPath := fabPath + ".tmp"
e.debugf("encrypting: %s → %s", targetPath, encPath)
if err := EncryptFile(targetPath, encPath, req.Password); err != nil {
os.Remove(targetPath)
e.failJob(job, step, fmt.Sprintf("Titkosítás sikertelen: %v", err))
return
}
os.Remove(targetPath)
targetPath = encPath
if encInfo, err := os.Stat(encPath); err == nil {
e.debugf("encryption done: %s in %v", humanizeBytes(encInfo.Size()), time.Since(encStart))
}
job.setStep(step, "done", "")
}
// Atomic rename to final path
e.debugf("atomic rename: %s → %s", targetPath, fabPath)
if err := os.Rename(targetPath, fabPath); err != nil {
e.failJob(job, step, fmt.Sprintf("Fájl átnevezés sikertelen: %v", err))
return
}
// Record result
stat, _ := os.Stat(fabPath)
job.mu.Lock()
job.OutputPath = fabPath
if stat != nil {
job.OutputSize = humanizeBytes(stat.Size())
}
job.mu.Unlock()
e.logger.Printf("[INFO] Export completed: %s → %s (%s) in %v", req.StackName, fabPath, job.OutputSize, time.Since(exportStart))
}
func (e *Exporter) failJob(job *Job, stepIdx int, msg string) {
job.setStep(stepIdx, "failed", msg)
job.mu.Lock()
job.Error = msg
job.mu.Unlock()
e.logger.Printf("[ERROR] Export/import failed at step %d: %s", stepIdx, msg)
e.debugf("FAIL at step %d: %s", stepIdx, msg)
}
// GetDebugInfo returns diagnostic information about the exporter state.
func (e *Exporter) GetDebugInfo() map[string]interface{} {
e.mu.Lock()
defer e.mu.Unlock()
info := map[string]interface{}{
"debug_enabled": e.debug,
"version": e.version,
"has_active_job": e.activeJob != nil,
}
if e.activeJob != nil {
info["active_job"] = e.activeJob.Snapshot()
}
return info
}
// copyStackConfig copies all relevant config files from the stack dir.
// app.yaml is saved with decrypted (plaintext) secrets for portability.
func copyStackConfig(stackDir, configDir, stackName string, provider ExportStackProvider) ([]string, error) {
var copied []string
entries, err := os.ReadDir(stackDir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip temp files
if strings.HasSuffix(name, ".tmp") {
continue
}
src := filepath.Join(stackDir, name)
dst := filepath.Join(configDir, name)
// app.yaml: save with plaintext secrets
if name == "app.yaml" {
env := provider.GetDecryptedEnv(stackName)
if env != nil {
if err := writeDecryptedAppYaml(dst, env); err != nil {
return nil, fmt.Errorf("writing decrypted app.yaml: %w", err)
}
copied = append(copied, name)
continue
}
}
// Copy file as-is
if err := copyFile(src, dst); err != nil {
return nil, fmt.Errorf("copying %s: %w", name, err)
}
copied = append(copied, name)
}
return copied, nil
}
// writeDecryptedAppYaml writes a plaintext app.yaml with the given env map.
func writeDecryptedAppYaml(dst string, env map[string]string) error {
var sb strings.Builder
sb.WriteString("# Exported by felhom-controller — plaintext secrets\n")
sb.WriteString("deployed: true\n")
sb.WriteString("env:\n")
for k, v := range env {
// YAML-safe: quote values
sb.WriteString(fmt.Sprintf(" %s: %q\n", k, v))
}
return os.WriteFile(dst, []byte(sb.String()), 0644)
}
// dumpDatabase discovers and dumps the database for a stack.
func (e *Exporter) dumpDatabase(stackName, dbDir string, manifest *Manifest) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
e.debugf("discovering databases (looking for stack %s)...", stackName)
dbs, err := backup.DiscoverDatabases(ctx, e.logger, e.debug)
if err != nil {
e.logger.Printf("[WARN] Export: DB discovery error: %v", err)
return false
}
e.debugf("found %d databases total", len(dbs))
for i := range dbs {
e.debugf(" db[%d]: stack=%s container=%s type=%s", i, dbs[i].StackName, dbs[i].ContainerName, dbs[i].DBType)
}
var stackDB *backup.DiscoveredDB
for i := range dbs {
if dbs[i].StackName == stackName {
stackDB = &dbs[i]
break
}
}
if stackDB == nil {
e.debugf("no database container found for stack %s", stackName)
return false
}
e.debugf("matched DB: container=%s type=%s", stackDB.ContainerName, stackDB.DBType)
dumpStart := time.Now()
result := backup.DumpOne(ctx, *stackDB, dbDir, e.logger, e.debug)
if result.Error != nil {
e.logger.Printf("[WARN] Export: DB dump failed for %s: %v", stackName, result.Error)
return false
}
e.debugf("DB dump completed in %v: %s", time.Since(dumpStart), result.FilePath)
// Gzip the dump
if result.FilePath != "" {
gzPath := result.FilePath + ".gz"
gzStart := time.Now()
if err := gzipFile(result.FilePath, gzPath); err != nil {
e.logger.Printf("[WARN] Export: gzip dump failed: %v", err)
} else {
if origInfo, _ := os.Stat(result.FilePath); origInfo != nil {
if gzInfo, _ := os.Stat(gzPath); gzInfo != nil {
e.debugf("gzip: %s → %s (ratio %.1f%%) in %v",
humanizeBytes(origInfo.Size()), humanizeBytes(gzInfo.Size()),
float64(gzInfo.Size())/float64(origInfo.Size())*100, time.Since(gzStart))
}
}
os.Remove(result.FilePath)
}
}
manifest.HasDatabase = true
manifest.DBType = string(stackDB.DBType)
return true
}
// exportHDDData copies HDD bind mount data for the export.
func (e *Exporter) exportHDDData(stackName, dataDir string, manifest *Manifest) {
hddDir := filepath.Join(dataDir, "hdd")
os.MkdirAll(hddDir, 0755)
mounts := e.provider.GetStackHDDMounts(stackName)
e.debugf("HDD mounts for %s: %v (%d total)", stackName, mounts, len(mounts))
if len(mounts) == 0 {
e.debugf("no HDD mounts — skipping HDD data export")
return
}
for _, mount := range mounts {
if _, err := os.Stat(mount); os.IsNotExist(err) {
e.debugf("HDD mount %s does not exist — skipping", mount)
continue
}
subdir := filepath.Base(mount)
manifest.HDDSubdirs = append(manifest.HDDSubdirs, subdir)
tarPath := filepath.Join(hddDir, subdir+".tar")
e.debugf("tarring HDD mount: %s → %s", mount, tarPath)
tarStart := time.Now()
if err := tarDirectory(mount, tarPath); err != nil {
e.logger.Printf("[WARN] Export: failed to tar %s: %v", mount, err)
} else {
if info, _ := os.Stat(tarPath); info != nil {
e.debugf("HDD tar complete: %s (%s) in %v", subdir, humanizeBytes(info.Size()), time.Since(tarStart))
}
}
}
manifest.HasHDDData = len(manifest.HDDSubdirs) > 0
}
// exportVolumeData exports Docker named volumes for apps without HDD storage.
func (e *Exporter) exportVolumeData(stackName, dataDir string, manifest *Manifest) {
volDir := filepath.Join(dataDir, "volumes")
os.MkdirAll(volDir, 0755)
volumes := e.provider.GetDockerVolumes(stackName)
e.debugf("Docker volumes for %s: %v (%d total)", stackName, volumes, len(volumes))
if len(volumes) == 0 {
e.debugf("no Docker volumes — skipping volume data export")
return
}
for _, volName := range volumes {
tarPath := filepath.Join(volDir, volName+".tar")
e.debugf("exporting volume %s via docker run alpine tar...", volName)
volStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
"-v", volName+":/vol:ro",
"-v", volDir+":/out",
"alpine", "tar", "cf", "/out/"+volName+".tar", "-C", "/vol", ".")
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
e.logger.Printf("[WARN] Export: volume %s export failed: %s — %v",
volName, strings.TrimSpace(string(out)), err)
e.debugf("volume %s export failed: %s", volName, strings.TrimSpace(string(out)))
os.Remove(tarPath)
continue
}
if info, _ := os.Stat(tarPath); info != nil {
e.debugf("volume %s exported: %s in %v", volName, humanizeBytes(info.Size()), time.Since(volStart))
}
manifest.VolumeNames = append(manifest.VolumeNames, volName)
}
manifest.HasVolumeData = len(manifest.VolumeNames) > 0
}
// createTarGz creates a gzipped tar archive of a directory.
func createTarGz(outputPath, sourceDir string) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
gw := gzip.NewWriter(outFile)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Get path relative to sourceDir
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
}
// tarDirectory creates a tar (not gzipped) of a directory's contents.
func tarDirectory(sourceDir, outputPath string) error {
outFile, err := os.Create(outputPath)
if err != nil {
return err
}
defer outFile.Close()
tw := tar.NewWriter(outFile)
defer tw.Close()
return filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
}
// gzipFile compresses a file with gzip.
func gzipFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
gw := gzip.NewWriter(out)
defer gw.Close()
_, err = io.Copy(gw, in)
return err
}
// copyFile copies a file from src to dst.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Sync()
}
// calcDirSize recursively calculates total file size in a directory.
func calcDirSize(dir string) int64 {
var total int64
filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
total += info.Size()
return nil
})
return total
}
+42
View File
@@ -0,0 +1,42 @@
package appexport
import (
"encoding/json"
"time"
)
// ManifestVersion is the current bundle format version.
const ManifestVersion = 1
// Manifest is the JSON metadata stored inside a .fab file.
type Manifest struct {
Version int `json:"version"`
AppName string `json:"app_name"`
DisplayName string `json:"display_name"`
ExportedAt time.Time `json:"exported_at"`
ControllerVer string `json:"controller_version"`
NeedsHDD bool `json:"needs_hdd"`
Encrypted bool `json:"encrypted"`
HasDatabase bool `json:"has_database"`
HasHDDData bool `json:"has_hdd_data"`
HasVolumeData bool `json:"has_volume_data"`
DBType string `json:"db_type,omitempty"`
TotalSizeBytes int64 `json:"total_size_bytes"`
ConfigFiles []string `json:"config_files"`
VolumeNames []string `json:"volume_names,omitempty"`
HDDSubdirs []string `json:"hdd_subdirs,omitempty"`
}
// Marshal returns the manifest as indented JSON.
func (m *Manifest) Marshal() ([]byte, error) {
return json.MarshalIndent(m, "", " ")
}
// UnmarshalManifest parses a manifest from JSON bytes.
func UnmarshalManifest(data []byte) (*Manifest, error) {
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
+52
View File
@@ -0,0 +1,52 @@
// Package appexport provides per-app export/import via .fab bundles.
// A .fab file is a tar.gz (optionally password-encrypted) containing an app's
// config, database dump, and all user data — everything needed to restore
// the app to its current state.
package appexport
// ExportStackProvider provides stack data without circular imports.
// Implemented by exportAdapter in main.go (same pattern as backup.StackDataProvider).
type ExportStackProvider interface {
// GetStackDir returns the stack's directory path (e.g., /opt/docker/stacks/nextcloud).
GetStackDir(name string) (string, bool)
// GetStackComposePath returns the compose file path.
GetStackComposePath(name string) (string, bool)
// GetStackHDDMounts returns resolved HDD bind mount host paths for the stack.
GetStackHDDMounts(name string) []string
// GetStackHDDPath returns the raw HDD_PATH env var from app.yaml.
GetStackHDDPath(name string) string
// IsStackRunning returns true if the stack has running containers.
IsStackRunning(name string) bool
// StopStack stops the stack via docker compose down.
StopStack(name string) error
// StartStack starts the stack via docker compose up -d.
StartStack(name string) error
// GetStackDisplayName returns the human-readable name from .felhom.yml.
GetStackDisplayName(name string) string
// GetStackNeedsHDD returns true if the app requires HDD storage.
GetStackNeedsHDD(name string) bool
// GetDockerVolumes returns named Docker volume names from the compose file.
GetDockerVolumes(name string) []string
// IsStackDeployed returns true if the stack has a saved app.yaml config.
IsStackDeployed(name string) bool
// GetDecryptedEnv returns the decrypted env var map from app.yaml.
GetDecryptedEnv(name string) map[string]string
// --- Import-specific methods ---
// GetStacksBaseDir returns the base directory where stacks live (e.g., /opt/docker/stacks).
GetStacksBaseDir() string
// SaveEncryptedAppConfig saves app.yaml with re-encrypted sensitive fields.
// env is the plaintext env map from the bundle; encryption uses the current server key.
SaveEncryptedAppConfig(stackDir string, env map[string]string) error
// RefreshStacks rescans all stacks and refreshes container state.
RefreshStacks() error
// RemoveStackVolumes stops the stack and removes its named Docker volumes.
RemoveStackVolumes(name string) error
}
// DrivePathInfo holds a registered storage path and its label.
type DrivePathInfo struct {
Path string
Label string
}
File diff suppressed because it is too large Load Diff
+27 -14
View File
@@ -67,6 +67,12 @@ func New(hubURL, apiKey, assetsDir, fallbackDir string, logger *log.Logger, debu
}
}
func (s *Syncer) dbg(format string, args ...interface{}) {
if s.debug {
s.logger.Printf("[DEBUG] [assets] "+format, args...)
}
}
// Sync fetches the manifest from the Hub, compares checksums, and downloads
// changed/new files. It also removes local files not in the Hub manifest.
func (s *Syncer) Sync(ctx context.Context) error {
@@ -85,6 +91,7 @@ func (s *Syncer) Sync(ctx context.Context) error {
}()
s.logger.Println("[INFO] Asset sync starting...")
syncStart := time.Now()
if err := os.MkdirAll(s.assetsDir, 0755); err != nil {
s.setError(fmt.Errorf("create assets dir: %w", err))
@@ -92,27 +99,24 @@ func (s *Syncer) Sync(ctx context.Context) error {
}
// 1. Fetch Hub manifest
if s.debug {
s.logger.Printf("[DEBUG] Asset sync: fetching manifest from %s/api/v1/assets/manifest", s.hubURL)
}
s.dbg("fetching manifest from %s/api/v1/assets/manifest", s.hubURL)
manifestStart := time.Now()
manifest, err := s.fetchManifest(ctx)
if err != nil {
s.setError(fmt.Errorf("fetch manifest: %w", err))
return err
}
if s.debug {
s.logger.Printf("[DEBUG] Asset sync: manifest has %d files", len(manifest.Files))
}
s.dbg("manifest fetched in %s: %d files, generated=%s",
time.Since(manifestStart).Round(time.Millisecond), len(manifest.Files), manifest.Generated)
// 2. Build local hash map
hashStart := time.Now()
localHashes, err := s.buildLocalHashes()
if err != nil {
s.setError(fmt.Errorf("scan local assets: %w", err))
return err
}
if s.debug {
s.logger.Printf("[DEBUG] Asset sync: %d local files found", len(localHashes))
}
s.dbg("local hash scan: %d files in %s", len(localHashes), time.Since(hashStart).Round(time.Millisecond))
// 3. Download changed/new files
hubFiles := make(map[string]bool, len(manifest.Files))
@@ -124,17 +128,22 @@ func (s *Syncer) Sync(ctx context.Context) error {
totalBytes += entry.Size
if localHash, ok := localHashes[entry.Filename]; ok && localHash == entry.SHA256 {
s.dbg("file %s: hash match (%s), skipping", entry.Filename, entry.SHA256[:12]+"...")
skipped++
continue
}
if s.debug {
s.logger.Printf("[DEBUG] Asset sync: downloading %s (remote sha256=%s)", entry.Filename, entry.SHA256[:12]+"...")
reason := "new"
if localHash, ok := localHashes[entry.Filename]; ok {
reason = fmt.Sprintf("hash mismatch (local=%s remote=%s)", localHash[:12]+"...", entry.SHA256[:12]+"...")
}
s.dbg("file %s: downloading (%s, %d bytes)", entry.Filename, reason, entry.Size)
dlStart := time.Now()
if err := s.downloadFile(ctx, entry.Filename); err != nil {
s.logger.Printf("[WARN] Failed to download asset %s: %v", entry.Filename, err)
continue
}
s.dbg("file %s: downloaded in %s", entry.Filename, time.Since(dlStart).Round(time.Millisecond))
downloaded++
}
@@ -143,9 +152,7 @@ func (s *Syncer) Sync(ctx context.Context) error {
for name := range localHashes {
if !hubFiles[name] {
path := filepath.Join(s.assetsDir, name)
if s.debug {
s.logger.Printf("[DEBUG] Asset sync: removing stale file %s", name)
}
s.dbg("removing stale file %s", name)
if err := os.Remove(path); err != nil {
s.logger.Printf("[WARN] Failed to remove stale asset %s: %v", name, err)
} else {
@@ -169,6 +176,7 @@ func (s *Syncer) Sync(ctx context.Context) error {
s.logger.Printf("[INFO] Asset sync complete: %d downloaded, %d unchanged, %d removed (%d total files)",
downloaded, skipped, removed, len(manifest.Files))
s.dbg("sync completed in %s", time.Since(syncStart).Round(time.Millisecond))
return nil
}
@@ -223,9 +231,11 @@ func (s *Syncer) fetchManifest(ctx context.Context) (*HubManifest, error) {
resp, err := s.httpClient.Do(req)
if err != nil {
s.dbg("fetchManifest: HTTP request failed: %v", err)
return nil, fmt.Errorf("HTTP request: %w", err)
}
defer resp.Body.Close()
s.dbg("fetchManifest: HTTP %d, content-length=%d", resp.StatusCode, resp.ContentLength)
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
@@ -270,13 +280,16 @@ func (s *Syncer) downloadFile(ctx context.Context, filename string) error {
resp, err := s.httpClient.Do(req)
if err != nil {
s.dbg("downloadFile %s: HTTP request failed: %v", filename, err)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
s.dbg("downloadFile %s: unexpected HTTP %d", filename, resp.StatusCode)
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, filename)
}
s.dbg("downloadFile %s: HTTP %d, content-length=%d", filename, resp.StatusCode, resp.ContentLength)
// Atomic write: write to .tmp, rename
dst := filepath.Join(s.assetsDir, filepath.Base(filename))
+3 -3
View File
@@ -89,7 +89,7 @@ func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) [
info.HasHDDData = len(info.HDDPaths) > 0
// Discover Docker named volumes from compose
info.DockerVolumes = parseComposeNamedVolumes(stack.ComposePath)
info.DockerVolumes = ParseComposeNamedVolumes(stack.ComposePath)
// Check if app has a DB container (already backed up via DB dump)
for _, db := range discoveredDBs {
@@ -108,8 +108,8 @@ func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) [
return result
}
// parseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
func parseComposeNamedVolumes(composePath string) []AppDockerVolume {
// ParseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
func ParseComposeNamedVolumes(composePath string) []AppDockerVolume {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
+3 -1
View File
@@ -156,9 +156,11 @@ func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Setti
if dataDir == "" {
dataDir = "/opt/docker/felhom-controller/data"
}
restic := NewResticManager(cfg, logger)
restic.SetDebug(cfg.Logging.Level == "debug")
return &Manager{
cfg: cfg,
restic: NewResticManager(cfg, logger),
restic: restic,
logger: logger,
pinger: pinger,
settings: sett,
+94 -1
View File
@@ -23,6 +23,7 @@ type ResticManager struct {
logger *log.Logger
customerID string
cacheDir string
debug bool
}
// SnapshotResult holds the outcome of a restic backup.
@@ -63,9 +64,17 @@ func NewResticManager(cfg *config.Config, logger *log.Logger) *ResticManager {
}
}
// SetDebug enables or disables debug logging.
func (r *ResticManager) SetDebug(debug bool) {
r.debug = debug
}
// EnsureInitialized checks if the restic repo exists and initializes it if not.
// Also auto-generates the password file if missing.
func (r *ResticManager) EnsureInitialized(repoPath string) error {
if r.debug {
r.logger.Printf("[DEBUG] [restic] EnsureInitialized: repoPath=%s, passwordFile=%s", repoPath, r.passwordFile)
}
// Ensure password file exists
if _, err := os.Stat(r.passwordFile); os.IsNotExist(err) {
if err := r.generatePassword(); err != nil {
@@ -109,6 +118,9 @@ func (r *ResticManager) Snapshot(repoPath string, paths []string, tags []string)
defer cancel()
start := time.Now()
if r.debug {
r.logger.Printf("[DEBUG] [restic] Snapshot: repo=%s, paths=%v, tags=%v", repoPath, paths, tags)
}
args := []string{"backup", "--json"}
for _, tag := range tags {
@@ -129,6 +141,9 @@ func (r *ResticManager) Snapshot(repoPath string, paths []string, tags []string)
if len(existingPaths) == 0 {
return nil, fmt.Errorf("no backup paths exist")
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] Snapshot: %d/%d paths exist, backing up: %v", len(existingPaths), len(paths), existingPaths)
}
args = append(args, existingPaths...)
cmd := r.command(ctx, repoPath, args...)
@@ -187,6 +202,11 @@ func (r *ResticManager) Snapshot(repoPath string, paths []string, tags []string)
}
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] Snapshot: completed in %s, snapshotID=%s, filesNew=%d, filesChanged=%d, dataAdded=%s",
result.Duration, result.SnapshotID, result.FilesNew, result.FilesChanged, result.DataAdded)
}
return result, nil
}
@@ -195,6 +215,12 @@ func (r *ResticManager) Prune(repoPath string, retention config.RetentionConfig)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] Prune: repo=%s, keepDaily=%d, keepWeekly=%d, keepMonthly=%d",
repoPath, retention.KeepDaily, retention.KeepWeekly, retention.KeepMonthly)
}
start := time.Now()
args := []string{
"forget",
"--keep-daily", fmt.Sprintf("%d", retention.KeepDaily),
@@ -209,6 +235,9 @@ func (r *ResticManager) Prune(repoPath string, retention config.RetentionConfig)
return fmt.Errorf("restic forget/prune failed: %v — %s", err, truncate(string(out), 200))
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] Prune: completed in %s, output=%d bytes", time.Since(start), len(out))
}
r.logger.Printf("[INFO] Restic prune completed for %s", repoPath)
return nil
}
@@ -218,11 +247,23 @@ func (r *ResticManager) Check(repoPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] Check: repo=%s", repoPath)
}
start := time.Now()
cmd := r.command(ctx, repoPath, "check")
out, err := cmd.CombinedOutput()
if err != nil {
if r.debug {
r.logger.Printf("[DEBUG] [restic] Check: failed after %s, output=%s", time.Since(start), truncate(string(out), 300))
}
return fmt.Errorf("restic check failed: %v — %s", err, truncate(string(out), 200))
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] Check: repo=%s OK, completed in %s", repoPath, time.Since(start))
}
return nil
}
@@ -231,6 +272,10 @@ func (r *ResticManager) ListSnapshots(repoPath string, limit int) ([]SnapshotInf
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] ListSnapshots: repo=%s, limit=%d", repoPath, limit)
}
cmd := r.command(ctx, repoPath, "snapshots", "--json")
out, err := cmd.Output()
if err != nil {
@@ -251,6 +296,11 @@ func (r *ResticManager) ListSnapshots(repoPath string, limit int) ([]SnapshotInf
snapshots = snapshots[:limit]
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] ListSnapshots: repo=%s, found %d total snapshots, returning %d",
repoPath, len(snapshots), len(snapshots))
}
return snapshots, nil
}
@@ -259,6 +309,10 @@ func (r *ResticManager) LatestSnapshot(repoPath string) (*SnapshotInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] LatestSnapshot: repo=%s", repoPath)
}
cmd := r.command(ctx, repoPath, "snapshots", "--latest", "1", "--json")
out, err := cmd.Output()
if err != nil {
@@ -271,9 +325,17 @@ func (r *ResticManager) LatestSnapshot(repoPath string) (*SnapshotInfo, error) {
}
if len(snapshots) == 0 {
if r.debug {
r.logger.Printf("[DEBUG] [restic] LatestSnapshot: repo=%s, no snapshots found", repoPath)
}
return nil, nil
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] LatestSnapshot: repo=%s, id=%s, time=%s, paths=%v",
repoPath, snapshots[0].ID, snapshots[0].Time.Format(time.RFC3339), snapshots[0].Paths)
}
return &snapshots[0], nil
}
@@ -282,6 +344,11 @@ func (r *ResticManager) Stats(repoPath string) (*RepoStats, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] Stats: repo=%s", repoPath)
}
start := time.Now()
stats := &RepoStats{}
// Get repo size
@@ -311,6 +378,15 @@ func (r *ResticManager) Stats(repoPath string) (*RepoStats, error) {
}
}
if r.debug {
latestID := "none"
if stats.LatestSnapshot != nil {
latestID = stats.LatestSnapshot.ID
}
r.logger.Printf("[DEBUG] [restic] Stats: repo=%s, totalSize=%s, snapshots=%d, latest=%s, took %s",
repoPath, stats.TotalSize, stats.SnapshotCount, latestID, time.Since(start))
}
return stats, nil
}
@@ -336,6 +412,12 @@ func (r *ResticManager) RestoreAppData(repoPath string, snapshotID string, paths
args = append(args, "--include", p)
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] RestoreAppData: repo=%s, snapshot=%s, %d include paths=%v",
repoPath, snapshotID, len(paths), paths)
}
start := time.Now()
r.logger.Printf("[WARN] RESTORE started: repo=%s, snapshot=%s, paths=%v", repoPath, snapshotID, paths)
cmd := r.command(ctx, repoPath, args...)
@@ -345,14 +427,22 @@ func (r *ResticManager) RestoreAppData(repoPath string, snapshotID string, paths
return fmt.Errorf("restic restore failed: %w", err)
}
if r.debug {
r.logger.Printf("[DEBUG] [restic] RestoreAppData: completed in %s, output=%d bytes", time.Since(start), len(output))
}
r.logger.Printf("[INFO] RESTORE completed: snapshot=%s, paths=%v", snapshotID, paths)
return nil
}
// RepoExists checks if a restic repo is initialized at the given path.
func (r *ResticManager) RepoExists(repoPath string) bool {
exists := false
_, err := os.Stat(filepath.Join(repoPath, "config"))
return err == nil
exists = err == nil
if r.debug {
r.logger.Printf("[DEBUG] [restic] RepoExists: repo=%s, exists=%v", repoPath, exists)
}
return exists
}
// UnlockCommand returns an exec.Cmd that runs restic unlock on the given repo.
@@ -361,6 +451,9 @@ func (r *ResticManager) UnlockCommand(ctx context.Context, repoPath string) *exe
}
func (r *ResticManager) command(ctx context.Context, repoPath string, args ...string) *exec.Cmd {
if r.debug {
r.logger.Printf("[DEBUG] [restic] command: restic %s (repo=%s)", strings.Join(args, " "), repoPath)
}
cmd := exec.CommandContext(ctx, "restic", args...)
cmd.Env = append(os.Environ(),
"RESTIC_REPOSITORY="+repoPath,
@@ -10,19 +10,26 @@ import (
"os/exec"
"path/filepath"
"strings"
"time"
)
// RestoreAppFromBackup restores a single app from its cross-drive backup.
// Steps: restore config → verify/restore data → copy DB dumps → docker compose up.
func RestoreAppFromBackup(ctx context.Context, app *RestorableApp, stacksDir string, logger *log.Logger) error {
stackDir := filepath.Join(stacksDir, app.Name)
start := time.Now()
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: app=%s, stackDir=%s, hasConfig=%v, hasData=%v, hasDBDump=%v, hasRsyncData=%v",
app.Name, stackDir, app.HasConfig, app.HasData, app.HasDBDump, app.HasRsyncData)
// Step 1: Restore stack config from _config/ backup
if app.HasConfig {
logger.Printf("[INFO] Restoring config for %s from %s", app.Name, app.ConfigPath)
stepStart := time.Now()
if err := restoreConfigDir(ctx, app.ConfigPath, stackDir); err != nil {
return fmt.Errorf("restoring config: %w", err)
}
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: config restore for %s completed in %s", app.Name, time.Since(stepStart))
} else {
// No config backup — check if stack dir already exists (from catalog sync)
if !dirExists(stackDir) {
@@ -35,20 +42,29 @@ func RestoreAppFromBackup(ctx context.Context, app *RestorableApp, stacksDir str
if app.NeedsHDD && !app.HasData && app.HasRsyncData {
// App data is missing but rsync backup exists — restore it
logger.Printf("[INFO] Restoring user data for %s from rsync backup", app.Name)
stepStart := time.Now()
if err := restoreUserData(ctx, app, logger); err != nil {
logger.Printf("[WARN] User data restore failed for %s: %v", app.Name, err)
// Non-fatal: app might still start without all data
} else {
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: user data restore for %s completed in %s", app.Name, time.Since(stepStart))
}
} else if app.HasData {
logger.Printf("[INFO] App data for %s found at %s — no restore needed", app.Name, app.DataPath)
} else {
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: %s — no user data to restore (needsHDD=%v, hasData=%v, hasRsyncData=%v)",
app.Name, app.NeedsHDD, app.HasData, app.HasRsyncData)
}
// Step 3: Copy DB dumps to primary backup location
if app.HasDBDump {
logger.Printf("[INFO] Restoring DB dumps for %s", app.Name)
stepStart := time.Now()
if err := restoreDBDumps(app, logger); err != nil {
logger.Printf("[WARN] DB dump restore failed for %s: %v", app.Name, err)
// Non-fatal
} else {
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: DB dump restore for %s completed in %s", app.Name, time.Since(stepStart))
}
}
@@ -62,22 +78,30 @@ func RestoreAppFromBackup(ctx context.Context, app *RestorableApp, stacksDir str
}
composeDir := filepath.Dir(composePath)
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: %s using compose file %s", app.Name, composePath)
logger.Printf("[INFO] Pulling images for %s", app.Name)
pullStart := time.Now()
pullCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "pull")
pullCmd.Dir = composeDir
if out, err := pullCmd.CombinedOutput(); err != nil {
logger.Printf("[WARN] docker compose pull failed for %s: %v (%s)", app.Name, err, strings.TrimSpace(string(out)))
// Non-fatal: might work with cached images
} else {
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: docker compose pull for %s completed in %s", app.Name, time.Since(pullStart))
}
logger.Printf("[INFO] Starting %s", app.Name)
upStart := time.Now()
upCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "up", "-d")
upCmd.Dir = composeDir
if out, err := upCmd.CombinedOutput(); err != nil {
return fmt.Errorf("docker compose up: %v (%s)", err, strings.TrimSpace(string(out)))
}
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: %s fully restored and started in %s", app.Name, time.Since(start))
logger.Printf("[DEBUG] [backup] RestoreAppFromBackup: docker compose up for %s completed in %s", app.Name, time.Since(upStart))
return nil
}
@@ -103,6 +127,8 @@ func restoreUserData(ctx context.Context, app *RestorableApp, logger *log.Logger
return fmt.Errorf("no rsync data path or HDD path")
}
logger.Printf("[DEBUG] [backup] restoreUserData: app=%s, rsyncPath=%s, hddPath=%s", app.Name, app.RsyncDataPath, app.HDDPath)
// The rsync backup contains the app's data directories.
// Walk the backup dir and rsync each subdirectory (excluding _config/_db)
// back to the app's HDD data directory.
@@ -112,10 +138,12 @@ func restoreUserData(ctx context.Context, app *RestorableApp, logger *log.Logger
}
dataDir := AppDataDir(app.HDDPath, app.Name)
logger.Printf("[DEBUG] [backup] restoreUserData: %s — target dataDir=%s, %d entries in backup", app.Name, dataDir, len(entries))
if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("creating data dir: %w", err)
}
restored := 0
for _, e := range entries {
name := e.Name()
if name == "_config" || name == "_db" || strings.HasPrefix(name, ".") {
@@ -132,9 +160,12 @@ func restoreUserData(ctx context.Context, app *RestorableApp, logger *log.Logger
continue
}
dst = strings.TrimRight(dst, "/") + "/"
logger.Printf("[DEBUG] [backup] restoreUserData: %s — rsync dir %s → %s", app.Name, src, dst)
cmd := exec.CommandContext(ctx, "rsync", "-a", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
logger.Printf("[WARN] rsync data %s: %v (%s)", name, err, strings.TrimSpace(string(out)))
} else {
restored++
}
} else {
// Single file — copy directly
@@ -143,12 +174,16 @@ func restoreUserData(ctx context.Context, app *RestorableApp, logger *log.Logger
logger.Printf("[WARN] Cannot read %s: %v", src, err)
continue
}
logger.Printf("[DEBUG] [backup] restoreUserData: %s — copying file %s (%d bytes)", app.Name, name, len(data))
if err := os.WriteFile(dst, data, 0644); err != nil {
logger.Printf("[WARN] Cannot write %s: %v", dst, err)
} else {
restored++
}
}
}
logger.Printf("[DEBUG] [backup] restoreUserData: %s — restored %d items", app.Name, restored)
return nil
}
@@ -170,6 +205,7 @@ func restoreDBDumps(app *RestorableApp, logger *log.Logger) error {
}
destDir := AppDBDumpPath(drivePath, app.Name)
logger.Printf("[DEBUG] [backup] restoreDBDumps: app=%s, src=%s, destDir=%s", app.Name, app.DBDumpPath, destDir)
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("creating dump dir: %w", err)
}
@@ -179,6 +215,7 @@ func restoreDBDumps(app *RestorableApp, logger *log.Logger) error {
return err
}
copied := 0
for _, e := range entries {
if e.IsDir() {
continue
@@ -190,11 +227,15 @@ func restoreDBDumps(app *RestorableApp, logger *log.Logger) error {
logger.Printf("[WARN] Cannot read dump %s: %v", e.Name(), err)
continue
}
logger.Printf("[DEBUG] [backup] restoreDBDumps: %s — copying %s (%d bytes)", app.Name, e.Name(), len(data))
if err := os.WriteFile(dst, data, 0644); err != nil {
logger.Printf("[WARN] Cannot write dump %s: %v", e.Name(), err)
} else {
copied++
}
}
logger.Printf("[DEBUG] [backup] restoreDBDumps: %s — copied %d dump files", app.Name, copied)
return nil
}
@@ -25,22 +25,38 @@ import (
// Returns the list of successfully mounted final mount paths.
func MountDrivesFromLayout(ctx context.Context, layout DiskLayout, logger *log.Logger) ([]string, error) {
if len(layout.Mounts) == 0 {
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: no mounts in layout, nothing to do")
return nil, nil
}
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: processing %d mount entries from disk layout", len(layout.Mounts))
// Get current block devices with UUIDs
uuidToDevice, err := scanBlockDeviceUUIDs(ctx)
if err != nil {
return nil, fmt.Errorf("scanning block devices: %w", err)
}
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: discovered %d block devices with UUIDs", len(uuidToDevice))
for uuid, dev := range uuidToDevice {
uuidShort := uuid
if len(uuidShort) > 12 {
uuidShort = uuidShort[:12]
}
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: device %s → UUID=%s...", dev, uuidShort)
}
var mounted []string
for _, dm := range layout.Mounts {
if dm.UUID == "" {
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: skipping mount entry with empty UUID (label=%s)", dm.Label)
continue
}
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: processing %s (UUID=%s, mountPoint=%s, rawMount=%s, fsType=%s)",
dm.Label, dm.UUID, dm.MountPoint, dm.RawMount, dm.FSType)
// Find matching device by UUID
device := uuidToDevice[dm.UUID]
if device == "" {
@@ -72,12 +88,15 @@ func MountDrivesFromLayout(ctx context.Context, layout DiskLayout, logger *log.L
// Mount using the appropriate pattern
if dm.RawMount != "" && dm.BindSubdir != "" {
// Two-layer HDD mount: raw → bind
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: %s — two-layer mount (raw=%s, bindSubdir=%s)",
dm.Label, dm.RawMount, dm.BindSubdir)
if err := mountRawAndBind(ctx, device, dm, logger); err != nil {
logger.Printf("[ERROR] Failed to mount %s: %v", dm.Label, err)
continue
}
} else {
// Simple direct mount (e.g., sys_drive)
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: %s — direct mount to %s", dm.Label, dm.MountPoint)
if err := mountDirect(ctx, device, dm, logger); err != nil {
logger.Printf("[ERROR] Failed to mount %s: %v", dm.Label, err)
continue
@@ -93,10 +112,11 @@ func MountDrivesFromLayout(ctx context.Context, layout DiskLayout, logger *log.L
logger.Printf("[INFO] Successfully mounted %s at %s", dm.Label, finalMount)
}
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: done — %d/%d drives mounted", len(mounted), len(layout.Mounts))
return mounted, nil
}
// scanBlockDeviceUUIDs runs lsblk + blkid to build a UUID device path map.
// scanBlockDeviceUUIDs runs lsblk + blkid to build a UUID -> device path map.
func scanBlockDeviceUUIDs(ctx context.Context) (map[string]string, error) {
// First try lsblk with UUID output
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-o", "NAME,UUID,FSTYPE,MOUNTPOINT").Output()
@@ -172,10 +192,12 @@ func mountDirect(ctx context.Context, device string, dm DiskMount, logger *log.L
// Use host device path if available
devPath := hostDevPath(device)
logger.Printf("[DEBUG] [backup] mountDirect: mount -t %s -o noatime %s %s", dm.FSType, devPath, dm.MountPoint)
cmd := exec.CommandContext(ctx, "mount", "-t", dm.FSType, "-o", "noatime", devPath, dm.MountPoint)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("mount %s: %s: %w", devPath, strings.TrimSpace(string(out)), err)
}
logger.Printf("[DEBUG] [backup] mountDirect: %s mounted successfully at %s", devPath, dm.MountPoint)
return nil
}
@@ -187,12 +209,14 @@ func mountRawAndBind(ctx context.Context, device string, dm DiskMount, logger *l
}
devPath := hostDevPath(device)
logger.Printf("[DEBUG] [backup] mountRawAndBind: layer 1 — mount -t %s -o noatime %s %s", dm.FSType, devPath, dm.RawMount)
cmd := exec.CommandContext(ctx, "mount", "-t", dm.FSType, "-o", "noatime", devPath, dm.RawMount)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("raw mount %s %s: %s: %w", devPath, dm.RawMount, strings.TrimSpace(string(out)), err)
return fmt.Errorf("raw mount %s -> %s: %s: %w", devPath, dm.RawMount, strings.TrimSpace(string(out)), err)
}
logger.Printf("[DEBUG] [backup] mountRawAndBind: layer 1 OK — %s mounted at %s", devPath, dm.RawMount)
// Layer 2: bind mount (subdir final mount point)
// Layer 2: bind mount (subdir -> final mount point)
bindSrc := filepath.Join(dm.RawMount, dm.BindSubdir)
if err := os.MkdirAll(bindSrc, 0755); err != nil {
return fmt.Errorf("creating bind source dir: %w", err)
@@ -201,10 +225,12 @@ func mountRawAndBind(ctx context.Context, device string, dm DiskMount, logger *l
return fmt.Errorf("creating final mount point: %w", err)
}
logger.Printf("[DEBUG] [backup] mountRawAndBind: layer 2 — mount --bind %s %s", bindSrc, dm.MountPoint)
cmd = exec.CommandContext(ctx, "mount", "--bind", bindSrc, dm.MountPoint)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("bind mount %s %s: %s: %w", bindSrc, dm.MountPoint, strings.TrimSpace(string(out)), err)
return fmt.Errorf("bind mount %s -> %s: %s: %w", bindSrc, dm.MountPoint, strings.TrimSpace(string(out)), err)
}
logger.Printf("[DEBUG] [backup] mountRawAndBind: layer 2 OK — %s bound to %s", bindSrc, dm.MountPoint)
return nil
}
@@ -213,6 +239,8 @@ func mountRawAndBind(ctx context.Context, device string, dm DiskMount, logger *l
func addDRFstabEntries(dm DiskMount, logger *log.Logger) error {
const fstabPath = "/host-fstab"
logger.Printf("[DEBUG] [backup] addDRFstabEntries: checking fstab for %s (UUID=%s)", dm.Label, dm.UUID)
data, err := os.ReadFile(fstabPath)
if err != nil {
return fmt.Errorf("reading fstab: %w", err)
@@ -222,6 +250,7 @@ func addDRFstabEntries(dm DiskMount, logger *log.Logger) error {
// Skip if UUID already in fstab (idempotent)
if strings.Contains(content, dm.UUID) {
logger.Printf("[DEBUG] [backup] addDRFstabEntries: UUID %s already in fstab — skipping", dm.UUID)
return nil
}
@@ -163,6 +163,9 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
Status: "pending",
}
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: scanning %d mount paths, %d stacks from manifest",
len(mountedPaths), len(stacks))
// Build drive info and find backup directories
type driveBackup struct {
drivePath string
@@ -181,6 +184,8 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
Available: avail,
}
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: checking drive %s (label=%s, available=%v)", mp, label, avail)
secPath := SecondaryBackupPath(mp)
if dirExists(secPath) {
di.HasBackup = true
@@ -195,6 +200,8 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
plan.Drives = append(plan.Drives, di)
}
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: found %d drives with backup data", len(backupDrives))
// For each stack from the manifest, look for backup data on drives
for _, stack := range stacks {
app := RestorableApp{
@@ -205,12 +212,16 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
Status: "pending",
}
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: scanning for app %s (needsHDD=%v, hddPath=%s)",
stack.Name, stack.NeedsHDD, stack.HDDPath)
// Check if app data exists directly on HDD (common case: HDD survived)
if stack.HDDPath != "" {
dataDir := AppDataDir(stack.HDDPath, stack.Name)
if dirExists(dataDir) {
app.HasData = true
app.DataPath = dataDir
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: %s — live data found at %s", stack.Name, dataDir)
}
}
@@ -224,6 +235,8 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
// Found a backup for this app
app.DrivePath = db.drivePath
app.DriveLabel = db.label
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: %s — backup found on drive %s at %s",
stack.Name, db.label, rsyncBase)
// Check for _config/ (stack compose directory backup)
configDir := filepath.Join(rsyncBase, "_config")
@@ -245,6 +258,9 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
app.RsyncDataPath = rsyncBase
}
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: %s — config=%v, dbDump=%v, rsyncData=%v",
stack.Name, app.HasConfig, app.HasDBDump, app.HasRsyncData)
break // use first drive with backup for this app
}
+81 -1
View File
@@ -23,6 +23,7 @@ type GeoSyncManager struct {
domain string
stacks StackLister
logger *log.Logger
debug bool
mu sync.Mutex
running bool
@@ -39,6 +40,11 @@ func NewGeoSyncManager(client *Client, sett *settings.Settings, domain string, s
}
}
// SetDebug enables or disables debug logging for the geo sync manager.
func (g *GeoSyncManager) SetDebug(debug bool) {
g.debug = debug
}
// IsRunning returns true if a sync operation is in progress.
func (g *GeoSyncManager) IsRunning() bool {
g.mu.Lock()
@@ -75,17 +81,28 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
// 1. Resolve zone ID (use cached value if available)
zoneID := geo.ZoneID
if zoneID == "" {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Zone ID not cached, resolving via API for domain %s", g.domain)
}
var err error
zoneID, err = g.client.GetZoneID(ctx, g.domain)
if err != nil {
g.saveError(zoneID, "", err.Error())
return fmt.Errorf("resolve zone: %w", err)
}
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Resolved zone ID: %s", zoneID)
}
} else if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Using cached zone ID: %s", zoneID)
}
// 2. Get or create the custom WAF ruleset
rulesetID := geo.RulesetID
if rulesetID == "" {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Ruleset ID not cached, looking up for zone %s", zoneID)
}
var err error
rulesetID, err = g.client.GetCustomRulesetID(ctx, zoneID)
if err != nil {
@@ -93,12 +110,20 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
return fmt.Errorf("get ruleset: %w", err)
}
if rulesetID == "" {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] No existing custom ruleset found, creating new one")
}
rulesetID, err = g.client.CreateCustomRuleset(ctx, zoneID)
if err != nil {
g.saveError(zoneID, "", err.Error())
return fmt.Errorf("create ruleset: %w", err)
}
}
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Using ruleset ID: %s", rulesetID)
}
} else if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Using cached ruleset ID: %s", rulesetID)
}
// 3. List existing felhom-managed rules
@@ -107,9 +132,21 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
g.saveError(zoneID, rulesetID, err.Error())
return fmt.Errorf("list existing rules: %w", err)
}
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Found %d existing felhom rules", len(existing))
for _, r := range existing {
g.logger.Printf("[DEBUG] [cloudflare] existing: %s (id=%s)", r.Description, r.ID)
}
}
// 4. Build desired rules
desired := g.buildDesiredRules(geo)
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Built %d desired rules", len(desired))
for _, d := range desired {
g.logger.Printf("[DEBUG] [cloudflare] desired: %s", d.description)
}
}
// 5. Diff and apply
if err := g.applyDiff(ctx, zoneID, rulesetID, existing, desired); err != nil {
@@ -134,24 +171,41 @@ func (g *GeoSyncManager) deleteAllRules(ctx context.Context, geo *settings.GeoRe
}
if zoneID == "" || rulesetID == "" {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: no cached zone/ruleset IDs, nothing to clean up")
}
// No cached IDs — nothing to clean up
return nil
}
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: listing rules for zone=%s ruleset=%s", zoneID, rulesetID)
}
existing, err := g.client.GetFelhomRules(ctx, zoneID, rulesetID)
if err != nil {
g.logger.Printf("[GEO] Warning: could not list rules for cleanup: %v", err)
return nil
}
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: found %d felhom rules to delete", len(existing))
}
deleted := 0
for _, r := range existing {
if err := g.client.DeleteRule(ctx, zoneID, rulesetID, r.ID); err != nil {
g.logger.Printf("[GEO] Warning: could not delete rule %s: %v", r.ID, err)
} else {
deleted++
}
}
if len(existing) > 0 {
g.logger.Printf("[GEO] Deleted %d felhom-geo rules (feature disabled)", len(existing))
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] deleteAllRules: successfully deleted %d/%d rules", deleted, len(existing))
}
}
g.saveError(zoneID, rulesetID, "")
@@ -169,6 +223,9 @@ func (g *GeoSyncManager) buildDesiredRules(geo *settings.GeoRestriction) []desir
var rules []desiredRule
hostnames := g.stacks.GetDeployedHostnames()
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: %d deployed hostnames from stacks", len(hostnames))
}
// Collect app hostnames that have overrides (to exclude from global rule)
var excludeHostnames []string
@@ -177,6 +234,9 @@ func (g *GeoSyncManager) buildDesiredRules(geo *settings.GeoRestriction) []desir
for appName, override := range geo.AppOverrides {
hostname, ok := hostnames[appName]
if !ok {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: skipping override for %q (not deployed)", appName)
}
continue // app not deployed, skip
}
overrideApps[appName] = true
@@ -189,15 +249,24 @@ func (g *GeoSyncManager) buildDesiredRules(geo *settings.GeoRestriction) []desir
})
}
if g.debug && len(overrideApps) > 0 {
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: %d app overrides active (deployed)", len(overrideApps))
}
// Sort exclude hostnames for deterministic expression
sort.Strings(excludeHostnames)
// Global rule (excludes apps with their own rules)
globalExpr := BuildGlobalExpression(geo.AllowedCountries, excludeHostnames)
rules = append(rules, desiredRule{
description: globalRuleDesc,
expression: BuildGlobalExpression(geo.AllowedCountries, excludeHostnames),
expression: globalExpr,
})
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: global rule expression: %s", globalExpr)
}
return rules
}
@@ -220,13 +289,21 @@ func (g *GeoSyncManager) applyDiff(ctx context.Context, zoneID, rulesetID string
if ex, ok := existingByDesc[d.description]; ok {
// Rule exists — check if expression changed
if ex.Expression != d.expression {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: updating rule %q (id=%s) — expression changed", d.description, ex.ID)
}
r := newBlockRule(d.description, d.expression)
if err := g.client.UpdateRule(ctx, zoneID, rulesetID, ex.ID, r); err != nil {
return fmt.Errorf("update rule %q: %w", d.description, err)
}
} else if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: rule %q unchanged, skipping", d.description)
}
} else {
// New rule — create
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: creating new rule %q", d.description)
}
r := newBlockRule(d.description, d.expression)
if _, err := g.client.CreateRule(ctx, zoneID, rulesetID, r); err != nil {
return fmt.Errorf("create rule %q: %w", d.description, err)
@@ -237,6 +314,9 @@ func (g *GeoSyncManager) applyDiff(ctx context.Context, zoneID, rulesetID string
// Delete rules that are no longer desired
for _, ex := range existing {
if _, ok := desiredByDesc[ex.Description]; !ok {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: deleting obsolete rule %q (id=%s)", ex.Description, ex.ID)
}
if err := g.client.DeleteRule(ctx, zoneID, rulesetID, ex.ID); err != nil {
return fmt.Errorf("delete rule %q: %w", ex.Description, err)
}
+33
View File
@@ -71,12 +71,23 @@ func (c *Client) GetCustomRulesetID(ctx context.Context, zoneID string) (string,
return "", fmt.Errorf("decode rulesets: %w", err)
}
if c.debug {
c.logger.Printf("[CF-DEBUG] GetCustomRulesetID: found %d rulesets for zone %s", len(rulesets), zoneID)
}
for _, rs := range rulesets {
if rs.Phase == wafPhase {
if c.debug {
c.logger.Printf("[CF-DEBUG] GetCustomRulesetID: matched ruleset %s (phase=%s)", rs.ID, wafPhase)
}
return rs.ID, nil
}
}
if c.debug {
c.logger.Printf("[CF-DEBUG] GetCustomRulesetID: no ruleset with phase %s found", wafPhase)
}
return "", nil
}
@@ -119,6 +130,10 @@ func (c *Client) GetRules(ctx context.Context, zoneID, rulesetID string) ([]rule
return nil, fmt.Errorf("decode rules: %w", err)
}
if c.debug {
c.logger.Printf("[CF-DEBUG] GetRules: %d total rules in ruleset %s", len(rs.Rules), rulesetID)
}
return rs.Rules, nil
}
@@ -141,6 +156,10 @@ func (c *Client) GetFelhomRules(ctx context.Context, zoneID, rulesetID string) (
}
}
if c.debug {
c.logger.Printf("[CF-DEBUG] GetFelhomRules: %d felhom rules out of %d total", len(result), len(rules))
}
return result, nil
}
@@ -163,6 +182,13 @@ func (c *Client) CreateRule(ctx context.Context, zoneID, rulesetID string, r rul
for _, created := range rs.Rules {
if created.Description == r.Description {
c.logger.Printf("[CF] Created rule %q → %s", r.Description, created.ID)
if c.debug {
expr := r.Expression
if len(expr) > 120 {
expr = expr[:120] + "..."
}
c.logger.Printf("[CF-DEBUG] CreateRule: expression: %s", expr)
}
return created.ID, nil
}
}
@@ -178,6 +204,13 @@ func (c *Client) UpdateRule(ctx context.Context, zoneID, rulesetID, ruleID strin
return fmt.Errorf("update rule %s: %w", ruleID, err)
}
c.logger.Printf("[CF] Updated rule %q (%s)", r.Description, ruleID)
if c.debug {
expr := r.Expression
if len(expr) > 120 {
expr = expr[:120] + "..."
}
c.logger.Printf("[CF-DEBUG] UpdateRule: expression: %s", expr)
}
return nil
}
+10
View File
@@ -16,7 +16,14 @@ type zone struct {
// GetZoneID resolves the Cloudflare zone ID for a domain.
// It tries the exact domain first, then strips subdomains progressively.
func (c *Client) GetZoneID(ctx context.Context, domain string) (string, error) {
if c.debug {
c.logger.Printf("[CF-DEBUG] GetZoneID: looking up zone for domain %q", domain)
}
// Try exact domain first (e.g., "demo-felhom.eu")
if c.debug {
c.logger.Printf("[CF-DEBUG] GetZoneID: trying exact domain %q", domain)
}
id, err := c.lookupZone(ctx, domain)
if err != nil {
return "", err
@@ -32,6 +39,9 @@ func (c *Client) GetZoneID(ctx context.Context, domain string) (string, error) {
if parent == "" {
break
}
if c.debug {
c.logger.Printf("[CF-DEBUG] GetZoneID: trying parent domain %q", parent)
}
id, err = c.lookupZone(ctx, parent)
if err != nil {
return "", err
@@ -20,6 +20,10 @@ func (m *Manager) OnStackStop(_ context.Context, stackName string) {
all[k] = v
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStop: stack=%s integrationsFound=%d", stackName, len(all))
}
for key, state := range all {
if !state.Enabled || state.Status == "disabled" {
continue
@@ -41,6 +45,9 @@ func (m *Manager) OnStackStop(_ context.Context, stackName string) {
continue
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStop: revoking %s", key)
}
if err := handler.Revoke(ac); err != nil {
m.logger.Printf("[WARN] Integration revoke on stop failed for %s: %v", key, err)
}
@@ -59,6 +66,9 @@ func (m *Manager) OnStackStop(_ context.Context, stackName string) {
// Re-applies integrations that were previously enabled but are not currently active.
// Waits briefly for the stack manager to refresh container state.
func (m *Manager) OnStackStart(_ context.Context, stackName string) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStart: stack=%s, waiting 5s for state refresh", stackName)
}
// Brief delay so the stack manager's periodic status refresh
// picks up the new container state (runs every 30s).
time.Sleep(5 * time.Second)
@@ -72,6 +82,10 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
all[k] = v
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStart: stack=%s integrationsFound=%d", stackName, len(all))
}
for key, state := range all {
if !state.Enabled || state.Status == "active" {
continue
@@ -86,11 +100,17 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
// StateStarting = container running but healthcheck hasn't passed yet — still connectable.
provStack, pOk := m.stacks.GetStack(provider)
if !pOk || !provStack.Deployed || !isStackUp(provStack.State) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStart: skipping %s — provider %s not up (found=%v deployed=%v state=%v)", key, provider, pOk, pOk && provStack.Deployed, func() stacks.ContainerState { if pOk { return provStack.State }; return "" }())
}
continue
}
if target != "filebrowser" {
tgtStack, tOk := m.stacks.GetStack(target)
if !tOk || !tgtStack.Deployed || !isStackUp(tgtStack.State) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStart: skipping %s — target %s not up (found=%v deployed=%v state=%v)", key, target, tOk, tOk && tgtStack.Deployed, func() stacks.ContainerState { if tOk { return tgtStack.State }; return "" }())
}
continue
}
}
@@ -100,6 +120,10 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
continue
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStart: re-applying %s (currentStatus=%s)", key, state.Status)
}
ac, err := m.buildApplyContext(provider, target)
if err != nil {
m.logger.Printf("[WARN] Cannot re-apply integration %s on start: %v", key, err)
@@ -138,6 +162,10 @@ func (m *Manager) OnStackRemove(_ context.Context, stackName string) {
all[k] = v
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackRemove: stack=%s integrationsFound=%d", stackName, len(all))
}
for key, state := range all {
provider, target, ok := ParseIntegrationKey(key)
if !ok {
@@ -145,6 +173,9 @@ func (m *Manager) OnStackRemove(_ context.Context, stackName string) {
}
if state.Enabled {
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackRemove: revoking enabled integration %s", key)
}
handler, hOk := m.handlers[key]
if hOk {
ac, _ := m.buildApplyContext(provider, target)
@@ -31,6 +31,7 @@ type Manager struct {
handlers map[string]Handler // key: "provider:target" -> Handler
mu sync.Mutex // serialize apply/revoke operations
debug bool
}
// NewManager creates an integration manager and registers built-in handlers.
@@ -47,9 +48,28 @@ func NewManager(sett *settings.Settings, sp StackProvider, domain, stacksDir str
// Register built-in handlers
m.RegisterHandler("onlyoffice:filebrowser", &OnlyOfficeFileBrowserHandler{})
m.RegisterHandler("onlyoffice:nextcloud", &OnlyOfficeNextcloudHandler{})
if m.isDebug() {
keys := make([]string, 0, len(m.handlers))
for k := range m.handlers {
keys = append(keys, k)
}
m.logger.Printf("[DEBUG] [integrations] NewManager: registered handlers: %v", keys)
}
return m
}
// SetDebug enables or disables debug logging.
func (m *Manager) SetDebug(debug bool) {
m.debug = debug
}
// isDebug returns whether debug logging is enabled.
func (m *Manager) isDebug() bool {
return m.debug
}
// RegisterHandler registers a handler for a provider:target integration key.
func (m *Manager) RegisterHandler(key string, h Handler) {
m.handlers[key] = h
@@ -61,6 +81,10 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
defer m.mu.Unlock()
key := IntegrationKey(provider, target)
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] Toggle: key=%s provider=%s target=%s enable=%v", key, provider, target, enable)
}
handler, ok := m.handlers[key]
if !ok {
return settings.IntegrationState{}, fmt.Errorf("nincs kezelő a(z) %s integrációhoz", key)
@@ -75,6 +99,9 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
if enable {
// Validate: provider must be deployed and running
provStack, pOk := m.stacks.GetStack(provider)
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] Toggle: provider %s found=%v deployed=%v state=%v", provider, pOk, pOk && provStack.Deployed, func() stacks.ContainerState { if pOk { return provStack.State }; return "" }())
}
if !pOk || !provStack.Deployed {
return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nincs telepítve", provider)
}
@@ -85,6 +112,9 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
// Validate: target must be deployed and running (filebrowser is infra, always present)
if target != "filebrowser" {
tgtStack, tOk := m.stacks.GetStack(target)
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] Toggle: target %s found=%v deployed=%v state=%v", target, tOk, tOk && tgtStack.Deployed, func() stacks.ContainerState { if tOk { return tgtStack.State }; return "" }())
}
if !tOk || !tgtStack.Deployed {
return state, fmt.Errorf("a célalkalmazás (%s) nincs telepítve", target)
}
@@ -93,7 +123,11 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
}
}
start := time.Now()
if err := handler.Apply(ac); err != nil {
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] Toggle: Apply failed for %s in %v: %v", key, time.Since(start), err)
}
state.Enabled = true
state.Status = "error"
state.LastError = err.Error()
@@ -101,15 +135,22 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
_ = m.sett.SetIntegrationState(key, state)
return state, fmt.Errorf("integráció alkalmazása sikertelen: %w", err)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] Toggle: Apply succeeded for %s in %v", key, time.Since(start))
}
state.Enabled = true
state.Status = "active"
state.EnabledAt = time.Now().UTC().Format(time.RFC3339)
m.logger.Printf("[INFO] Integration %s enabled", key)
} else {
start := time.Now()
if err := handler.Revoke(ac); err != nil {
m.logger.Printf("[WARN] Integration revoke failed for %s: %v", key, err)
state.LastError = err.Error()
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] Toggle: Revoke for %s completed in %v", key, time.Since(start))
}
state.Enabled = false
state.Status = "disabled"
m.logger.Printf("[INFO] Integration %s disabled", key)
@@ -128,6 +169,10 @@ func (m *Manager) ListForProvider(providerSlug string) []StatusInfo {
return nil
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ListForProvider: provider=%s integrationDefs=%d", providerSlug, len(provStack.Meta.Integrations))
}
var result []StatusInfo
for _, idef := range provStack.Meta.Integrations {
key := IntegrationKey(providerSlug, idef.Target)
@@ -171,6 +216,11 @@ func (m *Manager) buildApplyContext(provider, target string) (*ApplyContext, err
// Load decrypted env from provider's app.yaml
provEnv := m.loadDecryptedEnv(provStack)
if m.isDebug() {
envKeyCount := len(provEnv)
m.logger.Printf("[DEBUG] [integrations] buildApplyContext: provider=%s target=%s domain=%s envKeys=%d", provider, target, m.domain, envKeyCount)
}
provMeta := provStack.Meta
return &ApplyContext{
ProviderName: provider,
@@ -192,6 +242,9 @@ func (m *Manager) ReapplyConfigForTarget(targetName string) {
defer m.mu.Unlock()
all := m.sett.GetIntegrationsForTarget(targetName)
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: target=%s integrations=%d", targetName, len(all))
}
for key, state := range all {
if !state.Enabled || state.Status == "disabled" {
continue
@@ -207,6 +260,10 @@ func (m *Manager) ReapplyConfigForTarget(targetName string) {
continue
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: reapplying %s (status=%s)", key, state.Status)
}
ac, err := m.buildApplyContext(provider, target)
if err != nil {
m.logger.Printf("[WARN] Cannot build context for integration %s reapply: %v", key, err)
@@ -218,12 +275,18 @@ func (m *Manager) ReapplyConfigForTarget(targetName string) {
if err := handler.Apply(ac); err != nil {
m.logger.Printf("[WARN] Integration config reapply failed for %s: %v", key, err)
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: %s failed: %v", key, err)
}
state.Status = "error"
state.LastError = err.Error()
_ = m.sett.SetIntegrationState(key, state)
continue
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: %s succeeded", key)
}
state.Status = "active"
state.LastError = ""
_ = m.sett.SetIntegrationState(key, state)
@@ -241,5 +304,8 @@ func (m *Manager) loadDecryptedEnv(s *stacks.Stack) map[string]string {
if m.encKey != nil {
cfg.Env = crypto.DecryptMap(m.encKey, cfg.Env)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] loadDecryptedEnv: stack=%s envKeys=%d", s.Name, len(cfg.Env))
}
return cfg.Env
}
@@ -25,6 +25,10 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error {
}
configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml")
officeURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain)
ac.Logger.Printf("[DEBUG] [integrations] OnlyOfficeFileBrowser.Apply: jwtSecretPresent=%v subdomain=%s configPath=%s officeURL=%s", jwtSecret != "", subdomain, configPath, officeURL)
configData, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("FileBrowser config olvasási hiba: %w", err)
@@ -33,7 +37,6 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error {
// Remove any existing integrations section, then append the new one
configStr := removeIntegrationsSection(string(configData))
officeURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain)
internalURL := "http://onlyoffice:80"
integrationsBlock := fmt.Sprintf("integrations:\n office:\n url: %q\n internalUrl: %q\n secret: %q\n viewOnly: false\n",
@@ -57,6 +60,8 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error {
func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error {
configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml")
ac.Logger.Printf("[DEBUG] [integrations] OnlyOfficeFileBrowser.Revoke: configPath=%s", configPath)
configData, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
@@ -66,7 +71,9 @@ func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error {
}
cleaned := removeIntegrationsSection(string(configData))
if cleaned == string(configData) {
hadIntegrations := cleaned != string(configData)
ac.Logger.Printf("[DEBUG] [integrations] OnlyOfficeFileBrowser.Revoke: integrationsSectionFound=%v", hadIntegrations)
if !hadIntegrations {
return nil // no integrations section, nothing to remove
}
+11
View File
@@ -54,15 +54,26 @@ func ScanContainerLogs(containerNames []string, since time.Duration, logger *log
return []ContainerLogSummary{}
}
dbg := func(format string, args ...interface{}) {
if logger != nil {
logger.Printf("[DEBUG] [metrics] logscanner: "+format, args...)
}
}
dbg("starting log scan for %d containers (since %s)", len(containerNames), since)
start := time.Now()
results := make([]ContainerLogSummary, 0, len(containerNames))
for _, name := range containerNames {
cStart := time.Now()
summary := scanOneContainer(name, since, logger)
dbg("scanned %s: errors=%d warnings=%d issues=%d (took %s)",
name, summary.ErrorCount, summary.WarnCount, len(summary.RecentIssues), time.Since(cStart).Round(time.Millisecond))
results = append(results, summary)
}
elapsed := time.Since(start)
dbg("log scan completed: %d containers in %s", len(containerNames), elapsed.Round(time.Millisecond))
if elapsed > 5*time.Minute && logger != nil {
logger.Printf("[WARN] Log scan took %s (>5min) for %d containers", elapsed.Round(time.Second), len(containerNames))
}
+28
View File
@@ -17,6 +17,7 @@ type Pinger struct {
httpClient *http.Client
logger *log.Logger
enabled bool
debug bool
}
// NewPinger creates a new Pinger from monitoring config.
@@ -31,18 +32,32 @@ func NewPinger(cfg *config.MonitoringConfig, logger *log.Logger) *Pinger {
}
}
// SetDebug enables or disables debug logging for the pinger.
func (p *Pinger) SetDebug(debug bool) {
p.debug = debug
}
// Ping sends a success signal with optional diagnostic body.
func (p *Pinger) Ping(uuid string, body string) error {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] Ping uuid=%s body_len=%d", uuid, len(body))
}
return p.send(uuid, "", body)
}
// Fail sends a failure signal with diagnostic body.
func (p *Pinger) Fail(uuid string, body string) error {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] Fail uuid=%s body=%q", uuid, body)
}
return p.send(uuid, "/fail", body)
}
// Start sends a "job started" signal (for duration tracking).
func (p *Pinger) Start(uuid string) error {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] Start uuid=%s", uuid)
}
return p.send(uuid, "/start", "")
}
@@ -56,10 +71,16 @@ func (p *Pinger) send(uuid, suffix, body string) error {
}
url := fmt.Sprintf("%s/ping/%s%s", p.baseURL, uuid, suffix)
if p.debug {
p.logger.Printf("[DEBUG] [pinger] send url=%s", url)
}
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] retry attempt=%d uuid=%s", attempt+1, uuid)
}
time.Sleep(2 * time.Second)
}
@@ -81,7 +102,14 @@ func (p *Pinger) send(uuid, suffix, body string) error {
}
resp.Body.Close()
if p.debug {
p.logger.Printf("[DEBUG] [pinger] response status=%d uuid=%s", resp.StatusCode, uuid)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] success uuid=%s", uuid)
}
return nil
}
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
@@ -44,12 +44,26 @@ type Scheduler struct {
mu sync.Mutex
jobs []*Job
logger *log.Logger
debug bool
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
started bool
}
// SetDebug enables or disables debug logging.
func (s *Scheduler) SetDebug(on bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.debug = on
}
func (s *Scheduler) dbg(format string, args ...interface{}) {
if s.debug {
s.logger.Printf("[DEBUG] [sched] "+format, args...)
}
}
// New creates a new Scheduler.
func New(logger *log.Logger) *Scheduler {
return &Scheduler{
@@ -75,6 +89,7 @@ func (s *Scheduler) Every(name string, interval time.Duration, fn JobFunc) {
}
s.jobs = append(s.jobs, job)
s.logger.Printf("[SCHED] Registered periodic job: %s (every %s)", name, interval)
s.dbg("periodic job registered: name=%q interval=%s totalJobs=%d", name, interval, len(s.jobs))
if s.started {
s.wg.Add(1)
@@ -103,6 +118,7 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
nextRun := nextDailyRun(timeStr)
s.logger.Printf("[SCHED] Daily job %s scheduled for %s", name, nextRun.Format("2006-01-02 15:04 MST"))
s.dbg("daily job registered: name=%q schedule=%q nextRun=%s totalJobs=%d", name, timeStr, nextRun.Format(time.RFC3339), len(s.jobs))
if s.started {
s.wg.Add(1)
@@ -132,6 +148,23 @@ func (s *Scheduler) Start(ctx context.Context) {
}
s.logger.Printf("[SCHED] Scheduler started with %d jobs", len(s.jobs))
s.dbg("scheduler started: periodic=%d daily=%d", func() int {
n := 0
for _, j := range s.jobs {
if j.Interval > 0 {
n++
}
}
return n
}(), func() int {
n := 0
for _, j := range s.jobs {
if j.Schedule != "" {
n++
}
}
return n
}())
s.mu.Unlock()
}
@@ -200,10 +233,13 @@ func (s *Scheduler) runDailyJob(job *Job) {
waitDuration = 0
}
s.dbg("daily job %s: next run at %s (waiting %s)", job.Name, nextRun.Format("2006-01-02 15:04:05 MST"), waitDuration.Round(time.Second))
timer := time.NewTimer(waitDuration)
select {
case <-s.ctx.Done():
timer.Stop()
s.dbg("daily job %s: context cancelled, stopping", job.Name)
return
case <-timer.C:
s.executeJob(job, false)
@@ -241,6 +277,7 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
if !quiet {
s.logger.Printf("[SCHED] Running job: %s", job.Name)
}
s.dbg("job %s: execution starting", job.Name)
start := time.Now()
err := job.Fn(s.ctx)
@@ -253,9 +290,11 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
if err != nil {
s.logger.Printf("[WARN] Job %s failed: %v (took %s)", job.Name, err, elapsed.Round(time.Millisecond))
s.dbg("job %s: failed after %s: %v", job.Name, elapsed.Round(time.Millisecond), err)
} else if !quiet {
s.logger.Printf("[SCHED] Job %s completed (took %s)", job.Name, elapsed.Round(time.Millisecond))
}
s.dbg("job %s: finished in %s (err=%v)", job.Name, elapsed.Round(time.Millisecond), err)
}
// parseDailyTime parses "HH:MM" and returns hour and minute.
+32 -16
View File
@@ -62,6 +62,12 @@ func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentV
}
}
func (u *Updater) dbg(format string, args ...interface{}) {
if u.debug {
u.logger.Printf("[DEBUG] [selfupdate] "+format, args...)
}
}
// SetBackupRunningCheck sets the callback to check if a backup is in progress.
func (u *Updater) SetBackupRunningCheck(fn func() bool) {
u.mu.Lock()
@@ -140,10 +146,10 @@ func (u *Updater) CheckForUpdate() CheckResult {
result.UpdateAvailable = true
}
if u.debug {
u.logger.Printf("[DEBUG] [SELFUPDATE] Version comparison: current=%s, latest=%s, cmp=%d, updateAvailable=%v",
u.currentVer, latestStr, cmp, result.UpdateAvailable)
}
u.dbg("version comparison: current=%s (%d.%d.%d), latest=%s (%d.%d.%d), cmp=%d, updateAvailable=%v",
u.currentVer, currentVer.Major, currentVer.Minor, currentVer.Patch,
latestStr, latestVer.Major, latestVer.Minor, latestVer.Patch,
cmp, result.UpdateAvailable)
u.mu.Lock()
u.latestVersion = latestStr
@@ -163,9 +169,7 @@ func (u *Updater) queryRegistry() (string, error) {
// Gitea registry V2: GET /v2/<owner>/<repo>/tags/list
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image))
if u.debug {
u.logger.Printf("[DEBUG] [SELFUPDATE] Registry API URL: %s (user: %s)", url, u.gitCfg.Username)
}
u.dbg("queryRegistry: url=%s user=%s", url, u.gitCfg.Username)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
@@ -176,9 +180,11 @@ func (u *Updater) queryRegistry() (string, error) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
u.dbg("queryRegistry: HTTP request failed: %v", err)
return "", fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
u.dbg("queryRegistry: HTTP %d", resp.StatusCode)
if resp.StatusCode == 401 {
return "", fmt.Errorf("authentication failed (401)")
@@ -195,9 +201,7 @@ func (u *Updater) queryRegistry() (string, error) {
return "", fmt.Errorf("decoding response: %w", err)
}
if u.debug {
u.logger.Printf("[DEBUG] [SELFUPDATE] Registry returned %d tags: %v", len(tagsResp.Tags), tagsResp.Tags)
}
u.dbg("queryRegistry: %d tags returned: %v", len(tagsResp.Tags), tagsResp.Tags)
// Find highest semver tag
var highest *Version
@@ -293,9 +297,11 @@ func (u *Updater) DryRun() *DryRunResult {
// TriggerUpdate starts the self-update process. Returns error immediately if
// preconditions fail. The actual update runs in a goroutine.
func (u *Updater) TriggerUpdate(initiatedBy string) error {
u.dbg("TriggerUpdate: initiatedBy=%s currentVer=%s", initiatedBy, u.currentVer)
u.mu.Lock()
if u.updateRunning {
u.mu.Unlock()
u.dbg("TriggerUpdate: rejected — update already running")
return fmt.Errorf("Frissítés már folyamatban")
}
@@ -334,6 +340,7 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error {
previousImage := fmt.Sprintf("%s:%s", u.cfg.Image, u.currentVer)
u.logger.Printf("[INFO] Starting self-update: %s → %s (initiated by: %s)", u.currentVer, targetVersion, initiatedBy)
u.dbg("TriggerUpdate: target=%s image=%s previousImage=%s", targetVersion, targetImage, previousImage)
go u.performUpdate(targetVersion, targetImage, previousImage, initiatedBy)
@@ -348,6 +355,7 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
u.mu.Unlock()
}()
u.dbg("performUpdate: starting — target=%s image=%s", targetVersion, targetImage)
// 1. Write pending state
state := &UpdateState{
Status: "pending",
@@ -364,7 +372,9 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
}
// 2. Docker pull
u.dbg("performUpdate: step 2 — docker pull %s", targetImage)
u.logger.Printf("[INFO] Pulling image: %s", targetImage)
pullStart := time.Now()
pullOut, pullErr := runCommand("docker", "pull", targetImage)
if pullErr != nil {
state.Status = "failed"
@@ -375,8 +385,10 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
return
}
u.logger.Printf("[INFO] Image pulled successfully: %s", targetImage)
u.dbg("performUpdate: docker pull completed in %s", time.Since(pullStart).Round(time.Millisecond))
// 3. Update compose file (replace image tag)
u.dbg("performUpdate: step 3 — updating compose file %s", u.composePath)
if err := u.updateComposeFile(targetImage); err != nil {
state.Status = "failed"
state.Error = fmt.Sprintf("compose update failed: %v", err)
@@ -388,6 +400,7 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
u.logger.Printf("[INFO] Compose file updated with new image: %s", targetImage)
// 4. Docker compose up -d (this kills the current container)
u.dbg("performUpdate: step 4 — docker compose up -d")
u.logger.Printf("[INFO] Running docker compose up -d — container will restart")
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
@@ -417,12 +430,12 @@ func (u *Updater) updateComposeFile(newImage string) error {
// Replace image line: "image: gitea.dooplex.hu/admin/felhom-controller:..." → new image
re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
if u.debug {
// Log old image line for debugging
oldMatch := re.Find(data)
if oldMatch != nil {
u.logger.Printf("[DEBUG] [SELFUPDATE] Compose file edit: %q → %q", string(oldMatch), "image: "+newImage)
}
// Log old image line for debugging
oldMatch := re.Find(data)
if oldMatch != nil {
u.dbg("updateComposeFile: %q → %q", string(oldMatch), "image: "+newImage)
} else {
u.dbg("updateComposeFile: no matching image line found in %s", u.composePath)
}
newData := re.ReplaceAll(data, []byte("${1}"+newImage))
@@ -447,6 +460,7 @@ func (u *Updater) updateComposeFile(newImage string) error {
// Called once from main.go before the scheduler starts.
// Returns the state if a pending update was detected, nil otherwise.
func (u *Updater) VerifyStartup() *UpdateState {
u.dbg("VerifyStartup: checking update state in %s", u.dataDir)
state, err := LoadState(u.dataDir)
if err != nil {
u.logger.Printf("[WARN] Failed to load update state on startup: %v — clearing", err)
@@ -454,8 +468,10 @@ func (u *Updater) VerifyStartup() *UpdateState {
return nil
}
if state == nil || state.Status != "pending" {
u.dbg("VerifyStartup: no pending update (state=%v)", state)
return nil
}
u.dbg("VerifyStartup: pending update found — target=%s previous=%s", state.TargetVersion, state.PreviousVersion)
// Compare current version with target
currentVer, curErr := ParseVersion(u.currentVer)
+48 -4
View File
@@ -14,9 +14,10 @@ import (
// Settings holds customer-modifiable overrides and cached state.
// Persisted as a single JSON file (settings.json) in the data directory.
type Settings struct {
mu sync.RWMutex `json:"-"`
path string `json:"-"`
log *log.Logger `json:"-"`
mu sync.RWMutex `json:"-"`
path string `json:"-"`
log *log.Logger `json:"-"`
debug bool `json:"-"`
// Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
@@ -156,6 +157,11 @@ type DBValidationCache struct {
Error string `json:"error,omitempty"`
}
// SetDebug enables or disables debug logging for settings operations.
func (s *Settings) SetDebug(debug bool) {
s.debug = debug
}
// Load reads settings from the given file path.
// Returns empty Settings if the file doesn't exist (not an error).
func Load(path string, logger *log.Logger) (*Settings, error) {
@@ -178,6 +184,10 @@ func Load(path string, logger *log.Logger) (*Settings, error) {
}
logger.Printf("[DEBUG] Settings loaded from %s", path)
if s.debug {
s.log.Printf("[DEBUG] [settings] loaded: storage_paths=%d integrations=%d pending_events=%d",
len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents))
}
s.migrateResticToRsync()
return s, nil
}
@@ -226,7 +236,9 @@ func (s *Settings) save() error {
return fmt.Errorf("renaming settings file: %w", err)
}
s.log.Printf("[DEBUG] Settings saved to %s", s.path)
if s.debug {
s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data))
}
return nil
}
@@ -435,6 +447,9 @@ func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
func (s *Settings) AddStoragePath(sp StoragePath) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] AddStoragePath path=%q label=%q default=%v", sp.Path, sp.Label, sp.IsDefault)
}
for _, existing := range s.StoragePaths {
if existing.Path == sp.Path {
return fmt.Errorf("storage path %q already registered", sp.Path)
@@ -453,6 +468,9 @@ func (s *Settings) AddStoragePath(sp StoragePath) error {
func (s *Settings) RemoveStoragePath(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] RemoveStoragePath path=%q", path)
}
var kept []StoragePath
for _, sp := range s.StoragePaths {
if sp.Path != path {
@@ -515,6 +533,10 @@ func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHD
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v fallback=%q existing=%d", discoveredPaths, fallbackHDDPath, len(s.StoragePaths))
}
if len(s.StoragePaths) > 0 {
return // already configured
}
@@ -572,6 +594,9 @@ func InferStorageLabel(path string) string {
func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] SetDisconnected path=%q disconnected=%v stopped_stacks=%d", path, disconnected, len(stoppedStacks))
}
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Disconnected = disconnected
@@ -679,6 +704,9 @@ func (s *Settings) ClearStoppedStacks(path string) error {
func (s *Settings) SetDecommissioned(path, migratedTo string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] SetDecommissioned path=%q migrated_to=%q", path, migratedTo)
}
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Decommissioned = true
@@ -811,6 +839,9 @@ func (s *Settings) SetRetrievalPassword(password string) error {
func (s *Settings) AddPendingEvent(event PendingEvent) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] AddPendingEvent type=%q severity=%q", event.EventType, event.Severity)
}
s.PendingEvents = append(s.PendingEvents, event)
return s.save()
}
@@ -822,6 +853,9 @@ func (s *Settings) DrainPendingEvents() []PendingEvent {
if len(s.PendingEvents) == 0 {
return nil
}
if s.debug {
s.log.Printf("[DEBUG] [settings] DrainPendingEvents count=%d", len(s.PendingEvents))
}
events := make([]PendingEvent, len(s.PendingEvents))
copy(events, s.PendingEvents)
s.PendingEvents = nil
@@ -862,6 +896,13 @@ func (s *Settings) GetGeoRestriction() *GeoRestriction {
func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
if geo == nil {
s.log.Printf("[DEBUG] [settings] SetGeoRestriction geo=nil (clearing)")
} else {
s.log.Printf("[DEBUG] [settings] SetGeoRestriction enabled=%v countries=%d", geo.Enabled, len(geo.AllowedCountries))
}
}
if geo == nil {
s.GeoRestriction = nil
return s.save()
@@ -953,6 +994,9 @@ func (s *Settings) GetIntegrationState(key string) (IntegrationState, bool) {
func (s *Settings) SetIntegrationState(key string, state IntegrationState) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] SetIntegrationState key=%q status=%q enabled=%v", key, state.Status, state.Enabled)
}
if s.Integrations == nil {
s.Integrations = make(map[string]IntegrationState)
}
+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
}
+47 -1
View File
@@ -375,6 +375,10 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
// UpdateStackConfig updates non-locked fields for a deployed stack.
func (m *Manager) UpdateStackConfig(name string, values map[string]string) error {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig called: name=%q, %d values to update", name, len(values))
}
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
@@ -396,13 +400,21 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
}
meta := LoadMetadata(stackDir)
var changedKeys []string
for key, val := range values {
if lockedSet[key] {
return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
}
if appCfg.Env[key] != val {
changedKeys = append(changedKeys, key)
}
appCfg.Env[key] = val
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] UpdateStackConfig %s: changed keys: [%s], locked keys: %d", name, strings.Join(changedKeys, ", "), len(lockedSet))
}
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving updated config: %w", err)
}
@@ -445,6 +457,10 @@ func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
// UpdateOptionalConfig updates optional env vars in app.yaml and restarts the stack if deployed.
// Only updates env vars that are listed in the metadata's optional_config sections.
func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]string) error {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] UpdateOptionalConfig called: stack=%q, %d values provided", stackName, len(values))
}
stack, ok := m.GetStack(stackName)
if !ok {
return fmt.Errorf("stack %q not found", stackName)
@@ -461,6 +477,14 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin
return fmt.Errorf("no optional config fields defined for %s", stackName)
}
if m.isDebug() {
allowedKeys := make([]string, 0, len(allowed))
for k := range allowed {
allowedKeys = append(allowedKeys, k)
}
m.logger.Printf("[DEBUG] [stacks] UpdateOptionalConfig %s: allowed fields: [%s]", stackName, strings.Join(allowedKeys, ", "))
}
// Load existing app.yaml (or create empty one)
stackDir := filepath.Dir(stack.ComposePath)
appCfg := LoadAppConfig(stackDir)
@@ -564,12 +588,14 @@ func LoadAppConfig(stackDir string) *AppConfig {
}
cfg := &AppConfig{}
if err := yaml.Unmarshal(data, cfg); err != nil {
log.Printf("[DEBUG] [stacks] LoadAppConfig: failed to parse %s: %v", path, err)
return nil
}
return cfg
}
func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error {
encryptedCount := 0
// Clone env and encrypt sensitive values
saveCfg := &AppConfig{
Deployed: cfg.Deployed,
@@ -585,6 +611,7 @@ func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars
if encKey != nil && sensitiveSet[k] && !crypto.IsEncrypted(v) && v != "" {
if enc, err := crypto.Encrypt(encKey, v); err == nil {
saveCfg.Env[k] = enc
encryptedCount++
continue
} else {
// H10 fix: log encryption failure — value will be saved in plaintext.
@@ -594,6 +621,9 @@ func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars
saveCfg.Env[k] = v
}
log.Printf("[DEBUG] [stacks] SaveAppConfig: saving %s — %d env vars, %d encrypted, %d sensitive fields",
stackDir, len(saveCfg.Env), encryptedCount, len(sensitiveVars))
data, err := yaml.Marshal(saveCfg)
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
@@ -617,7 +647,11 @@ func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars
// LoadAppConfigDecrypted loads app.yaml and decrypts any encrypted values.
func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig {
cfg := LoadAppConfig(stackDir)
if cfg == nil || encKey == nil {
if cfg == nil {
return cfg
}
if encKey == nil {
log.Printf("[DEBUG] [stacks] LoadAppConfigDecrypted: no encryption key, returning raw config for %s", stackDir)
return cfg
}
cfg.Env = crypto.DecryptMap(encKey, cfg.Env)
@@ -686,6 +720,10 @@ func generateValue(spec string) (string, error) {
// 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) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: checking %d stacks", len(stackNames))
}
for _, name := range stackNames {
stack, ok := m.GetStack(name)
if !ok {
@@ -696,9 +734,17 @@ func (m *Manager) InjectMissingFields(stackNames []string) {
meta := LoadMetadata(stackDir)
appCfg := LoadAppConfig(stackDir)
if appCfg == nil || !appCfg.Deployed {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: skipping %s (not deployed or no app config)", name)
}
continue
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: checking stack %s — %d deploy fields, %d existing env vars",
name, len(meta.DeployFields), len(appCfg.Env))
}
var injected []string
for _, field := range meta.DeployFields {
if _, exists := appCfg.Env[field.EnvVar]; exists {
+14
View File
@@ -24,6 +24,8 @@ func (m *Manager) RunHealthProbes() error {
// Phase 1: collect targets (under lock)
m.mu.RLock()
var targets []probeTarget
skippedNotDue := 0
skippedNoContainer := 0
for name, stack := range m.stacks {
if stack.State != StateRunning && stack.State != StateUnhealthy {
continue
@@ -43,6 +45,12 @@ func (m *Manager) RunHealthProbes() error {
effectiveInterval = 10 * time.Second
}
if time.Since(stack.HealthProbe.LastCheck) < effectiveInterval {
skippedNotDue++
if m.isDebug() {
sinceLastCheck := time.Since(stack.HealthProbe.LastCheck).Round(time.Second)
m.logger.Printf("[DEBUG] [stacks] RunHealthProbes: skipping %s — last check %s ago, effective interval %s, healthy=%v",
name, sinceLastCheck, effectiveInterval, stack.HealthProbe.Healthy)
}
continue
}
}
@@ -50,6 +58,7 @@ func (m *Manager) RunHealthProbes() error {
// Find the main container to probe (matching stack name)
containerName := findProbeContainer(name, stack.Containers)
if containerName == "" {
skippedNoContainer++
continue
}
@@ -61,6 +70,11 @@ func (m *Manager) RunHealthProbes() error {
}
m.mu.RUnlock()
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RunHealthProbes: collected %d targets (%d skipped not due, %d skipped no container)",
len(targets), skippedNotDue, skippedNoContainer)
}
if len(targets) == 0 {
return nil
}
+69
View File
@@ -117,15 +117,33 @@ func (m *Manager) SetEncryptionKey(key []byte) {
m.encKey = key
}
// GetStacksBaseDir returns the base directory where stacks live.
func (m *Manager) GetStacksBaseDir() string {
return m.cfg.Paths.StacksDir
}
// MigrateEncryption re-saves app.yaml for deployed stacks that still have
// plaintext values in sensitive fields. Called once on startup.
func (m *Manager) MigrateEncryption() {
m.mu.Lock()
defer m.mu.Unlock()
if m.encKey == nil {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: no encryption key set, skipping")
}
return
}
if m.isDebug() {
deployedCount := 0
for _, s := range m.stacks {
if s.Deployed {
deployedCount++
}
}
m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: checking %d deployed stacks for plaintext sensitive values", deployedCount)
}
migrated := 0
for _, s := range m.stacks {
if !s.Deployed {
@@ -141,6 +159,11 @@ func (m *Manager) MigrateEncryption() {
if len(sensitive) == 0 {
continue
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: checking stack %q (%d sensitive fields)", s.Name, len(sensitive))
}
needsMigration := false
for _, envVar := range sensitive {
if v, ok := appCfg.Env[envVar]; ok && v != "" && !crypto.IsEncrypted(v) {
@@ -149,6 +172,9 @@ func (m *Manager) MigrateEncryption() {
}
}
if needsMigration {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: stack %q needs migration — re-saving with encryption", s.Name)
}
if err := SaveAppConfig(stackDir, appCfg, m.encKey, sensitive); err != nil {
m.logger.Printf("[WARN] Encryption migration failed for %s: %v", s.Name, err)
} else {
@@ -229,6 +255,10 @@ func (m *Manager) ScanStacks() error {
appCfg := LoadAppConfig(stackDir)
deployed := appCfg != nil && appCfg.Deployed
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] ScanStacks: found stack %q deployed=%v composePath=%s", name, deployed, composePath)
}
if existing, ok := m.stacks[name]; ok {
existing.ComposePath = composePath
existing.Meta = meta
@@ -261,6 +291,13 @@ func (m *Manager) ScanStacks() error {
// Detect orphaned stacks (deployed but no longer in catalog)
catalogTemplates := m.getCatalogTemplateSlugs()
if m.isDebug() {
if catalogTemplates != nil {
m.logger.Printf("[DEBUG] [stacks] ScanStacks: catalog has %d template slugs for orphan detection", len(catalogTemplates))
} else {
m.logger.Printf("[DEBUG] [stacks] ScanStacks: catalog templates unavailable, skipping orphan detection")
}
}
if catalogTemplates != nil {
orphanCount := 0
for _, stack := range m.stacks {
@@ -271,6 +308,9 @@ func (m *Manager) ScanStacks() error {
stack.Orphaned = !catalogTemplates[stack.Name]
if stack.Orphaned {
orphanCount++
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] ScanStacks: stack %q is orphaned (deployed but not in catalog)", stack.Name)
}
}
}
if orphanCount > 0 {
@@ -306,6 +346,7 @@ func (m *Manager) refreshStatusLocked() error {
projectContainers := make(map[string][]ContainerInfo)
totalContainers := 0
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
@@ -322,6 +363,11 @@ func (m *Manager) refreshStatusLocked() error {
Status: parts[3],
}
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci)
totalContainers++
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] refreshStatusLocked: docker ps returned %d containers across %d projects", totalContainers, len(projectContainers))
}
for name, stack := range m.stacks {
@@ -346,6 +392,10 @@ func (m *Manager) refreshStatusLocked() error {
stack.State = StateUnhealthy
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] refreshStatusLocked: stack %q → state=%s containers=%d", name, stack.State, len(stack.Containers))
}
stack.LastUpdated = time.Now()
}
@@ -569,12 +619,20 @@ func (m *Manager) StartStack(name string) error {
return fmt.Errorf("stack %q not found", name)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] StartStack %s: current state=%s deployed=%v", name, stack.State, stack.Deployed)
}
m.logger.Printf("[INFO] Starting stack: %s", name)
start := time.Now()
dir := filepath.Dir(stack.ComposePath)
env := m.stackEnv(dir)
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] StartStack %s: prepared %d env vars for compose", name, len(env))
}
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil {
m.logger.Printf("[ERROR] Stack %s start failed after %.1fs: %v", name, time.Since(start).Seconds(), err)
return fmt.Errorf("starting stack %s: %w", name, err)
@@ -604,6 +662,10 @@ func (m *Manager) StopStack(name string) error {
return fmt.Errorf("stack %q not found", name)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] StopStack %s: current state=%s deployed=%v containers=%d", name, stack.State, stack.Deployed, len(stack.Containers))
}
m.logger.Printf("[INFO] Stopping stack: %s", name)
start := time.Now()
dir := filepath.Dir(stack.ComposePath)
@@ -623,6 +685,10 @@ func (m *Manager) RestartStack(name string) error {
return fmt.Errorf("stack %q not found", name)
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] RestartStack %s: current state=%s deployed=%v containers=%d", name, stack.State, stack.Deployed, len(stack.Containers))
}
m.logger.Printf("[INFO] Restarting stack: %s", name)
start := time.Now()
dir := filepath.Dir(stack.ComposePath)
@@ -997,5 +1063,8 @@ func (m *Manager) getCatalogTemplateSlugs() map[string]bool {
}
}
}
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] getCatalogTemplateSlugs: found %d template slugs in %s", len(slugs), cacheDir)
}
return slugs
}
+1 -1
View File
@@ -188,7 +188,7 @@ func FinalizeAttach(req AttachRequest, progress chan<- FormatProgress) (string,
}
dbg := func(format string, args ...interface{}) {
if req.Logger != nil && req.Debug {
req.Logger.Printf("[DEBUG] FinalizeAttach: "+format, args...)
req.Logger.Printf("[DEBUG] [storage] FinalizeAttach: "+format, args...)
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
}
dbg := func(format string, args ...interface{}) {
if req.Logger != nil && req.Debug {
req.Logger.Printf("[DEBUG] FormatAndMount: "+format, args...)
req.Logger.Printf("[DEBUG] [storage] FormatAndMount: "+format, args...)
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ func MigrateAppData(
dbg := func(format string, args ...interface{}) {
if req.Logger != nil && req.Debug {
req.Logger.Printf("[DEBUG] MigrateAppData: "+format, args...)
req.Logger.Printf("[DEBUG] [storage] MigrateAppData: "+format, args...)
}
}
@@ -101,6 +101,16 @@ func (tx *migrationTx) rollback() {
// MigrateDrive performs a full drive migration, moving all apps from source to dest.
func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateRequest, progress chan<- DriveMigrateProgress) error {
start := time.Now()
debug := dm.Logger != nil
dbg := func(format string, args ...interface{}) {
if debug {
dm.Logger.Printf("[DEBUG] [storage] MigrateDrive: "+format, args...)
}
}
_ = dbg // used below
dbg("starting drive migration: source=%s dest=%s", req.SourcePath, req.DestPath)
send := func(step, msg string, pct int) {
progress <- DriveMigrateProgress{
@@ -175,6 +185,14 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
}
}
dbg("found %d apps on source drive: %v", len(appsToMigrate), func() []string {
names := make([]string, len(appsToMigrate))
for i, a := range appsToMigrate {
names[i] = a.Name
}
return names
}())
if len(appsToMigrate) == 0 {
return fail("A forrás meghajtón nincs telepített alkalmazás", fmt.Errorf("no apps on source"))
}
@@ -230,6 +248,7 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
)
}
dbg("estimated data: %s (%d bytes), free on dest: %s (%d bytes)", bytesHuman(totalBytes), totalBytes, bytesHuman(freeBytes), freeBytes)
dm.Logger.Printf("[INFO] Drive migration: %s (%s) → %s (%s), %d apps, ~%s data",
req.SourcePath, srcLabel, req.DestPath, dstLabel, len(appsToMigrate), bytesHuman(totalBytes))
@@ -330,11 +349,13 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
if err := rsyncCmd.Wait(); err != nil {
stderrWg.Wait()
dbg("rsync failed after %s: %v — stderr: %s", time.Since(start).Round(time.Second), err, stderrBuf.String())
send("rolling_back", "rsync sikertelen, visszagörgetés...", 0)
tx.rollback()
return fail("Adatmásolás sikertelen", fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String()))
}
stderrWg.Wait()
dbg("rsync completed in %s", time.Since(start).Round(time.Second))
// --- Step 3: Verify copy ---
send("verifying", "Másolat ellenőrzése...", 62)
@@ -351,6 +372,7 @@ func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateReque
// --- Step 4: Update all app configs ---
send("configuring", "Konfiguráció frissítése...", 65)
dbg("updating HDD_PATH for %d apps", len(appsToMigrate))
var configuredApps []string
for i, app := range appsToMigrate {
// Guard: verify app still exists
+8 -2
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
@@ -199,7 +200,7 @@ func partitionToParentDisk(devPath string) string {
func enrichWithBlkid(disks []BlockDevice, logger *log.Logger, debug bool) {
dbg := func(format string, args ...interface{}) {
if debug && logger != nil {
logger.Printf("[DEBUG] enrichWithBlkid: "+format, args...)
logger.Printf("[DEBUG] [storage] enrichWithBlkid: "+format, args...)
}
}
@@ -241,10 +242,13 @@ func enrichWithBlkid(disks []BlockDevice, logger *log.Logger, debug bool) {
func ScanDisks(logger *log.Logger, debug bool) (*ScanResult, error) {
dbg := func(format string, args ...interface{}) {
if debug && logger != nil {
logger.Printf("[DEBUG] ScanDisks: "+format, args...)
logger.Printf("[DEBUG] [storage] ScanDisks: "+format, args...)
}
}
dbg("starting disk scan")
scanStart := time.Now()
out, err := exec.Command(
"lsblk", "-J", "-b",
"-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RM",
@@ -337,5 +341,7 @@ func ScanDisks(logger *log.Logger, debug bool) (*ScanResult, error) {
enrichWithBlkid(result.AvailableDisks, logger, debug)
enrichWithBlkid(result.SystemDisks, logger, debug)
dbg("disk scan completed in %s", time.Since(scanStart).Round(time.Millisecond))
return result, nil
}
+8
View File
@@ -30,6 +30,7 @@ func NewCPUCollector(sampleRate time.Duration) *CPUCollector {
// Start begins background CPU sampling.
func (c *CPUCollector) Start(ctx context.Context) {
ctx, c.cancel = context.WithCancel(ctx)
debugf("[DEBUG] [system] CPUCollector.Start: sampleRate=%s", c.sampleRate)
go c.loop(ctx)
}
@@ -48,10 +49,12 @@ func (c *CPUCollector) CPUPercent() float64 {
}
func (c *CPUCollector) loop(ctx context.Context) {
firstSample := true
for {
// Read first sample
idle1, total1, err := readCPUStat()
if err != nil {
debugf("[DEBUG] [system] CPUCollector: readCPUStat error: %v", err)
select {
case <-ctx.Done():
return
@@ -82,6 +85,11 @@ func (c *CPUCollector) loop(ctx context.Context) {
c.mu.Lock()
c.cpuPercent = percent
c.mu.Unlock()
if firstSample {
debugf("[DEBUG] [system] CPUCollector: first sample — cpu=%.1f%% (idle=%d total=%d)", percent, idleDelta, totalDelta)
firstSample = false
}
}
}
}
+14
View File
@@ -1,5 +1,19 @@
package system
import "log"
// DebugLogger, when non-nil, enables debug-level logging for the system package.
// Set this to the application's *log.Logger when config logging.level == "debug".
// When nil, no debug output is emitted.
var DebugLogger *log.Logger
// debugf logs a formatted message if DebugLogger is set.
func debugf(format string, args ...any) {
if DebugLogger != nil {
DebugLogger.Printf(format, args...)
}
}
// SystemInfo holds system resource usage information.
type SystemInfo struct {
TotalMemMB uint64 `json:"total_mem_mb"`
+29
View File
@@ -10,12 +10,16 @@ import (
"sort"
"strings"
"syscall"
"time"
)
// GetInfo reads system memory, disk, CPU, load, and temperature info.
// hddPath is the mount path for external HDD; if empty, HDD info is skipped.
// cpuCollector provides the latest CPU usage sample; may be nil.
func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
start := time.Now()
debugf("[DEBUG] [system] GetInfo starting (hddPath=%q, hasCPUCollector=%v)", hddPath, cpuCollector != nil)
info := SystemInfo{}
// --- Memory from /proc/meminfo ---
@@ -41,6 +45,15 @@ func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
info.CPUPercent = cpuCollector.CPUPercent()
}
debugf("[DEBUG] [system] GetInfo done in %s — mem=%dMB/%dMB (%.1f%%), rootDisk=%.1fGB/%.1fGB (%.1f%%), load=%.2f/%.2f/%.2f, temp=%.1f°C (%s), cpu=%.1f%%",
time.Since(start).Round(time.Millisecond),
info.UsedMemMB, info.TotalMemMB, info.MemPercent,
info.DiskUsedGB, info.DiskTotalGB, info.DiskPercent,
info.LoadAvg1, info.LoadAvg5, info.LoadAvg15,
info.TemperatureCelsius, info.TemperatureSource,
info.CPUPercent,
)
return info
}
@@ -67,6 +80,7 @@ func GetMemoryMB() (totalMB, usedMB int, err error) {
func readMemInfo(info *SystemInfo) {
f, err := os.Open("/proc/meminfo")
if err != nil {
debugf("[DEBUG] [system] readMemInfo: failed to open /proc/meminfo: %v", err)
return
}
defer f.Close()
@@ -91,6 +105,10 @@ func readMemInfo(info *SystemInfo) {
info.AvailMemMB = availKB / 1024
info.UsedMemMB = info.TotalMemMB - info.AvailMemMB
info.MemPercent = float64(info.UsedMemMB) / float64(info.TotalMemMB) * 100
debugf("[DEBUG] [system] readMemInfo: totalKB=%d availKB=%d → total=%dMB avail=%dMB used=%dMB (%.1f%%)",
totalKB, availKB, info.TotalMemMB, info.AvailMemMB, info.UsedMemMB, info.MemPercent)
} else {
debugf("[DEBUG] [system] readMemInfo: could not parse MemTotal from /proc/meminfo")
}
}
@@ -116,6 +134,7 @@ func parseMemLine(line string) uint64 {
func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] readDiskUsage: statfs(%q) failed: %v", path, err)
return
}
@@ -131,15 +150,20 @@ func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *floa
if total > 0 {
*percent = float64(used) / float64(total) * 100
}
debugf("[DEBUG] [system] readDiskUsage: path=%q bsize=%d total=%.1fGB used=%.1fGB avail=%.1fGB (%.1f%%)",
path, bsize, *totalGB, *usedGB, *availGB, *percent)
}
// readLoadAvg reads 1/5/15 minute load averages from /proc/loadavg.
func readLoadAvg(info *SystemInfo) {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
debugf("[DEBUG] [system] readLoadAvg: failed to read /proc/loadavg: %v", err)
return
}
fmt.Sscanf(string(data), "%f %f %f", &info.LoadAvg1, &info.LoadAvg5, &info.LoadAvg15)
debugf("[DEBUG] [system] readLoadAvg: raw=%q → 1m=%.2f 5m=%.2f 15m=%.2f",
strings.TrimSpace(string(data)), info.LoadAvg1, info.LoadAvg5, info.LoadAvg15)
}
// readTemperature reads CPU/SoC temperature from thermal zones.
@@ -149,6 +173,7 @@ func readTemperature(info *SystemInfo) {
for _, prefix := range prefixes {
if readThermalZones(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via thermal_zone at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return
}
}
@@ -156,9 +181,12 @@ func readTemperature(info *SystemInfo) {
// Fallback: try hwmon
for _, prefix := range prefixes {
if readHwmon(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via hwmon at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return
}
}
debugf("[DEBUG] [system] readTemperature: no temperature source found")
}
func readThermalZones(sysPrefix string, info *SystemInfo) bool {
@@ -169,6 +197,7 @@ func readThermalZones(sysPrefix string, info *SystemInfo) bool {
}
sort.Strings(matches)
debugf("[DEBUG] [system] readThermalZones: %s — found %d zones", sysPrefix, len(matches))
var maxTemp float64
var maxSource string
+25 -4
View File
@@ -65,6 +65,7 @@ type DiskUsageInfo struct {
func GetDiskUsage(path string) *DiskUsageInfo {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] GetDiskUsage: statfs(%q) failed: %v", path, err)
return nil
}
@@ -84,6 +85,8 @@ func GetDiskUsage(path string) *DiskUsageInfo {
}
info.TotalHuman = formatGB(info.TotalGB)
info.UsedHuman = formatGB(info.UsedGB)
debugf("[DEBUG] [system] GetDiskUsage: path=%q total=%s used=%s avail=%.1fGB (%.1f%%)",
path, info.TotalHuman, info.UsedHuman, info.AvailGB, info.UsedPercent)
return info
}
@@ -105,10 +108,12 @@ type FSInfo struct {
func GetFSInfo(path string) *FSInfo {
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output()
if err != nil {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) failed: %v", path, err)
return nil
}
fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) < 2 {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) returned unexpected output: %q", path, strings.TrimSpace(string(out)))
return nil
}
info := &FSInfo{
@@ -117,6 +122,7 @@ func GetFSInfo(path string) *FSInfo {
}
// Try to get disk model from sysfs
info.Model = diskModel(info.Device)
debugf("[DEBUG] [system] GetFSInfo: path=%q device=%s fstype=%s model=%q", path, info.Device, info.FSType, info.Model)
return info
}
@@ -136,6 +142,7 @@ type DestinationHealth struct {
// CheckBackupDestination performs tiered validation of a cross-drive backup destination.
// Returns a DestinationHealth describing any issues found.
func CheckBackupDestination(path string) DestinationHealth {
debugf("[DEBUG] [system] CheckBackupDestination: path=%q", path)
h := DestinationHealth{Severity: "ok"}
// Tier 1: path must exist
@@ -143,6 +150,7 @@ func CheckBackupDestination(path string) DestinationHealth {
h.Warning = "A cél tárhely (" + path + ") nem létezik!"
h.Blocked = true
h.Severity = "critical"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier1 FAIL (not exists)", path)
return h
}
h.Exists = true
@@ -152,6 +160,7 @@ func CheckBackupDestination(path string) DestinationHealth {
h.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat."
h.Blocked = true
h.Severity = "critical"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier2 FAIL (not writable)", path)
return h
}
h.Writable = true
@@ -165,9 +174,11 @@ func CheckBackupDestination(path string) DestinationHealth {
"Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " +
"Külső meghajtó használata javasolt."
h.Severity = "warning"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 WARN (same block device as /)", path)
// Don't return early — also check disk usage
} else {
h.MountPoint = true
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 OK (different block device)", path)
}
// Tier 4: disk usage checks
@@ -199,6 +210,8 @@ func CheckBackupDestination(path string) DestinationHealth {
}
}
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — result: severity=%s blocked=%v freeGB=%.1f usedPct=%.1f%%",
path, h.Severity, h.Blocked, h.FreeGB, h.UsedPercent)
return h
}
@@ -256,8 +269,11 @@ type ProbeResult struct {
// ProbeStoragePath checks if a storage path is responsive.
// Uses a goroutine with a 3-second timeout to avoid blocking on dead mounts.
func ProbeStoragePath(path string) ProbeResult {
start := time.Now()
// Quick check: does the path exist at all?
if _, err := os.Lstat(path); os.IsNotExist(err) {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — not exists (%s)", path, time.Since(start).Round(time.Millisecond))
return ProbeResult{Status: ProbeDisconnected, Err: err}
}
@@ -273,17 +289,22 @@ func ProbeStoragePath(path string) ProbeResult {
select {
case res := <-ch:
elapsed := time.Since(start).Round(time.Millisecond)
if res.err == nil {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — connected (%s)", path, elapsed)
return ProbeResult{Status: ProbeConnected}
}
errStr := res.err.Error()
if strings.Contains(errStr, "transport endpoint") ||
strings.Contains(errStr, "input/output error") ||
strings.Contains(errStr, "no such device") {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected: %v (%s)", path, res.err, elapsed)
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
}
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — disconnected (other error): %v (%s)", path, res.err, elapsed)
return ProbeResult{Status: ProbeDisconnected, Err: res.err}
case <-time.After(3 * time.Second):
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — TIMEOUT (3s)", path)
return ProbeResult{Status: ProbeTimeout, Err: fmt.Errorf("stat timed out after 3s")}
}
}
@@ -302,11 +323,11 @@ func IsUSBDevice(devicePath string) bool {
if err != nil {
continue
}
if strings.Contains(link, "/usb") {
return true
}
return false // found the sysfs entry, but not USB
isUSB := strings.Contains(link, "/usb")
debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q sysfs=%s → usb=%v", devicePath, disk, link, isUSB)
return isUSB
}
debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q — no sysfs entry found", devicePath, disk)
return false
}
+39
View File
@@ -53,6 +53,9 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth if no password is configured
if !s.authEnabled() {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] auth: no password configured, passing through %s %s", r.Method, r.URL.Path)
}
next.ServeHTTP(w, r)
return
}
@@ -77,6 +80,13 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || !s.isValidSession(cookie.Value) {
if s.isDebug() {
reason := "no cookie"
if err == nil {
reason = "invalid/expired session"
}
s.logger.Printf("[DEBUG] [web] auth: rejected %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
}
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
@@ -92,6 +102,9 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] auth: valid session for %s %s", r.Method, r.URL.Path)
}
next.ServeHTTP(w, r)
})
}
@@ -101,6 +114,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password")
nextURL := r.FormValue("next")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] login attempt from %s (X-Forwarded-For: %s)", r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
}
if password == "" {
s.renderLogin(w, "Kérjük adja meg a jelszót", "")
return
@@ -147,6 +164,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
delete(s.loginAttempts, ip)
s.loginAttemptMu.Unlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] login successful from %s, creating session", ip)
}
token := s.createSession()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
@@ -174,6 +195,9 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusFound)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
s.sessionsMu.Lock()
delete(s.sessions, cookie.Value)
@@ -197,8 +221,13 @@ func (s *Server) createSession() string {
expiresAt: time.Now().Add(sessionMaxAge),
csrfToken: csrfToken,
}
sessionCount := len(s.sessions)
s.sessionsMu.Unlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] session created, expires=%s, active_sessions=%d", time.Now().Add(sessionMaxAge).Format(time.RFC3339), sessionCount)
}
return token
}
@@ -225,8 +254,12 @@ func (s *Server) isValidSession(token string) bool {
// Used after password change.
func (s *Server) invalidateAllSessions() {
s.sessionsMu.Lock()
count := len(s.sessions)
s.sessions = make(map[string]*session)
s.sessionsMu.Unlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] invalidated all sessions (cleared %d)", count)
}
}
func (s *Server) cleanupSessions() {
@@ -239,12 +272,18 @@ func (s *Server) cleanupSessions() {
case <-ticker.C:
s.sessionsMu.Lock()
now := time.Now()
expired := 0
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
expired++
}
}
remaining := len(s.sessions)
s.sessionsMu.Unlock()
if s.isDebug() && expired > 0 {
s.logger.Printf("[DEBUG] [web] session cleanup: expired=%d remaining=%d", expired, remaining)
}
}
}
}
+96
View File
@@ -13,6 +13,7 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
@@ -100,6 +101,14 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
case subpath == "logs" && r.Method == http.MethodGet:
s.debugLogBuffer(w, r)
// Section 9: App Export/Import
case subpath == "appexport/status" && r.Method == http.MethodGet:
s.debugAppExportStatus(w, r)
case subpath == "appexport/bundles" && r.Method == http.MethodGet:
s.debugAppExportBundles(w, r)
case subpath == "appexport/cleanup" && r.Method == http.MethodPost:
s.debugAppExportCleanup(w, r)
default:
http.NotFound(w, r)
}
@@ -725,3 +734,90 @@ func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) {
"total": total,
})
}
// ── Section 9: App Export/Import ─────────────────────────────────────
func (s *Server) debugAppExportStatus(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{
"available": false,
})
return
}
info := s.appExporter.GetDebugInfo()
// Scan for bundles
drives := s.storageDriveList()
bundles := appexport.ScanForBundles(drives)
// Scan for stale temp files
staleFiles := appexport.ScanForStaleTempFiles(drives)
info["bundle_count"] = len(bundles)
info["stale_temp_files"] = staleFiles
info["stale_temp_count"] = len(staleFiles)
info["available"] = true
// Export dirs
exportDirs := make([]map[string]interface{}, 0, len(drives))
for _, d := range drives {
dir := appexport.ExportDir(d.Path)
dirInfo := map[string]interface{}{
"path": dir,
"label": d.Label,
}
if stat, err := os.Stat(dir); err == nil {
dirInfo["exists"] = true
dirInfo["modified"] = stat.ModTime()
} else {
dirInfo["exists"] = false
}
exportDirs = append(exportDirs, dirInfo)
}
info["export_dirs"] = exportDirs
writeDebugJSON(w, http.StatusOK, true, "", info)
}
func (s *Server) debugAppExportBundles(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "App export not available", nil)
return
}
drives := s.storageDriveList()
bundles := appexport.ScanForBundles(drives)
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("%d csomag található", len(bundles)),
map[string]interface{}{"bundles": bundles})
}
func (s *Server) debugAppExportCleanup(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
writeDebugJSON(w, http.StatusBadRequest, false, "App export not available", nil)
return
}
drives := s.storageDriveList()
staleFiles := appexport.ScanForStaleTempFiles(drives)
if len(staleFiles) == 0 {
writeDebugJSON(w, http.StatusOK, true, "Nincs eltávolítandó temp fájl", nil)
return
}
removed := 0
for _, f := range staleFiles {
if err := os.Remove(f); err != nil {
s.logger.Printf("[WARN] Failed to remove stale temp file %s: %v", f, err)
} else {
s.logger.Printf("[INFO] Removed stale temp file: %s", f)
removed++
}
}
writeDebugJSON(w, http.StatusOK, true,
fmt.Sprintf("%d/%d temp fájl eltávolítva", removed, len(staleFiles)), nil)
}
+356
View File
@@ -0,0 +1,356 @@
package web
import (
"encoding/json"
"net/http"
"path/filepath"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
)
// ServeExportAPI dispatches /api/export/* endpoints.
func (s *Server) ServeExportAPI(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
// GET /api/export/estimate?stack=X&drive=Y
case path == "/api/export/estimate" && r.Method == http.MethodGet:
s.apiExportEstimate(w, r)
// POST /api/export/start
case path == "/api/export/start" && r.Method == http.MethodPost:
s.apiExportStart(w, r)
// GET /api/export/status
case path == "/api/export/status" && r.Method == http.MethodGet:
s.apiExportStatus(w, r)
// GET /api/export/bundles — scan for .fab files on all drives
case path == "/api/export/bundles" && r.Method == http.MethodGet:
s.apiExportBundles(w, r)
// POST /api/export/manifest — read manifest from a .fab file
case path == "/api/export/manifest" && r.Method == http.MethodPost:
s.apiExportManifest(w, r)
// POST /api/export/import — start async import
case path == "/api/export/import" && r.Method == http.MethodPost:
s.apiImportStart(w, r)
// GET /api/export/import/status — poll import progress
case path == "/api/export/import/status" && r.Method == http.MethodGet:
s.apiImportStatus(w, r)
default:
http.NotFound(w, r)
}
}
// exportPageHandler renders the export form for a specific app.
func (s *Server) exportPageHandler(w http.ResponseWriter, r *http.Request, name string) {
if s.appExporter == nil {
http.Error(w, "App export not available", http.StatusServiceUnavailable)
return
}
stack, ok := s.stackMgr.GetStack(name)
if !ok || !stack.Deployed {
http.NotFound(w, r)
return
}
// Build drive list for the dropdown
drives := s.storageDriveList()
data := map[string]interface{}{
"Stack": stack,
"Drives": drives,
}
s.executeTemplate(w, r, "app_export", data)
}
// importPageHandler renders the import page (standalone, not tied to a stack).
func (s *Server) importPageHandler(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
http.Error(w, "App import not available", http.StatusServiceUnavailable)
return
}
drives := s.storageDriveList()
bundles := appexport.ScanForBundles(drives)
data := map[string]interface{}{
"Bundles": bundles,
}
s.executeTemplate(w, r, "app_import", data)
}
// apiExportEstimate returns size estimation for an export.
func (s *Server) apiExportEstimate(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App export not available", http.StatusServiceUnavailable)
return
}
stackName := r.URL.Query().Get("stack")
drive := r.URL.Query().Get("drive")
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate: stack=%q drive=%q", stackName, drive)
if stackName == "" || drive == "" {
jsonError(w, "Missing stack or drive parameter", http.StatusBadRequest)
return
}
if !s.isValidDrivePath(drive) {
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate: invalid drive path %q", drive)
jsonError(w, "Invalid drive path", http.StatusBadRequest)
return
}
est, err := s.appExporter.EstimateExport(stackName, drive)
if err != nil {
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate error: %v", err)
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate: total=%s free=%s fits=%v",
est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest)
jsonResponse(w, map[string]interface{}{
"ok": true,
"data": est,
})
}
// apiExportStart starts an async export.
func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App export not available", http.StatusServiceUnavailable)
return
}
var req struct {
StackName string `json:"stack_name"`
DestDrive string `json:"dest_drive"`
Password string `json:"password"`
StopApp bool `json:"stop_app"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.logger.Printf("[DEBUG] [handler_export] apiExportStart: invalid body: %v", err)
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
s.logger.Printf("[DEBUG] [handler_export] apiExportStart: stack=%q drive=%q encrypted=%v stopApp=%v",
req.StackName, req.DestDrive, req.Password != "", req.StopApp)
if req.StackName == "" || req.DestDrive == "" {
jsonError(w, "Missing stack_name or dest_drive", http.StatusBadRequest)
return
}
if !s.isValidDrivePath(req.DestDrive) {
s.logger.Printf("[DEBUG] [handler_export] apiExportStart: invalid drive path %q", req.DestDrive)
jsonError(w, "Invalid drive path", http.StatusBadRequest)
return
}
err := s.appExporter.StartExport(appexport.ExportRequest{
StackName: req.StackName,
DestDrive: req.DestDrive,
Password: req.Password,
StopApp: req.StopApp,
})
if err != nil {
s.logger.Printf("[DEBUG] [handler_export] apiExportStart error: %v", err)
jsonError(w, err.Error(), http.StatusConflict)
return
}
s.logger.Printf("[INFO] Export started for %s to %s", req.StackName, req.DestDrive)
jsonResponse(w, map[string]interface{}{"ok": true})
}
// apiExportStatus returns current export/import job status.
func (s *Server) apiExportStatus(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App export not available", http.StatusServiceUnavailable)
return
}
job := s.appExporter.GetActiveJob()
if job == nil {
jsonResponse(w, map[string]interface{}{
"ok": true,
"running": false,
"done": false,
})
return
}
jsonResponse(w, job.Snapshot())
}
// apiExportBundles scans all drives for .fab bundles.
func (s *Server) apiExportBundles(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App export not available", http.StatusServiceUnavailable)
return
}
drives := s.storageDriveList()
bundles := appexport.ScanForBundles(drives)
jsonResponse(w, map[string]interface{}{
"ok": true,
"bundles": bundles,
})
}
// apiExportManifest reads and returns the manifest from a .fab file.
func (s *Server) apiExportManifest(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App export not available", http.StatusServiceUnavailable)
return
}
var req struct {
Path string `json:"path"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Path == "" {
jsonError(w, "Missing path", http.StatusBadRequest)
return
}
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: path=%q hasPassword=%v", req.Path, req.Password != "")
// Security: validate path is within a registered exports directory
if !s.isValidExportPath(req.Path) {
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: invalid path %q", req.Path)
jsonError(w, "Invalid bundle path", http.StatusBadRequest)
return
}
encrypted, _ := appexport.IsEncryptedFAB(req.Path)
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: encrypted=%v", encrypted)
var manifest *appexport.Manifest
var err error
if encrypted {
if req.Password == "" {
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: encrypted, needs password")
jsonResponse(w, map[string]interface{}{
"ok": true,
"encrypted": true,
"needs_password": true,
})
return
}
manifest, err = appexport.ReadManifestFromEncryptedFAB(req.Path, req.Password)
} else {
manifest, err = appexport.ReadManifestFromFAB(req.Path)
}
if err != nil {
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: error: %v", err)
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: app=%s display=%s size=%d",
manifest.AppName, manifest.DisplayName, manifest.TotalSizeBytes)
jsonResponse(w, map[string]interface{}{
"ok": true,
"manifest": manifest,
})
}
// apiImportStart starts an async import.
func (s *Server) apiImportStart(w http.ResponseWriter, r *http.Request) {
if s.appExporter == nil {
jsonError(w, "App import not available", http.StatusServiceUnavailable)
return
}
var req struct {
Path string `json:"path"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.logger.Printf("[DEBUG] [handler_export] apiImportStart: invalid body: %v", err)
jsonError(w, "Invalid request body", http.StatusBadRequest)
return
}
s.logger.Printf("[DEBUG] [handler_export] apiImportStart: path=%q hasPassword=%v", req.Path, req.Password != "")
if req.Path == "" {
jsonError(w, "Missing path", http.StatusBadRequest)
return
}
if !s.isValidExportPath(req.Path) {
s.logger.Printf("[DEBUG] [handler_export] apiImportStart: invalid path %q", req.Path)
jsonError(w, "Invalid bundle path", http.StatusBadRequest)
return
}
err := s.appExporter.StartImport(appexport.ImportRequest{
FABPath: req.Path,
Password: req.Password,
})
if err != nil {
s.logger.Printf("[DEBUG] [handler_export] apiImportStart error: %v", err)
jsonError(w, err.Error(), http.StatusConflict)
return
}
s.logger.Printf("[INFO] Import started from %s", req.Path)
jsonResponse(w, map[string]interface{}{"ok": true})
}
// apiImportStatus returns current import job status (same as export status).
func (s *Server) apiImportStatus(w http.ResponseWriter, r *http.Request) {
s.apiExportStatus(w, r)
}
// storageDriveList converts settings StoragePaths to appexport DrivePathInfo.
func (s *Server) storageDriveList() []appexport.DrivePathInfo {
paths := s.settings.GetStoragePaths()
drives := make([]appexport.DrivePathInfo, 0, len(paths))
for _, sp := range paths {
drives = append(drives, appexport.DrivePathInfo{
Path: sp.Path,
Label: sp.Label,
})
}
return drives
}
// isValidDrivePath checks if a path is a registered storage path.
func (s *Server) isValidDrivePath(path string) bool {
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == path {
return true
}
}
return false
}
// isValidExportPath checks if a file path is within a registered exports directory.
func (s *Server) isValidExportPath(filePath string) bool {
cleanPath := filepath.Clean(filePath)
for _, sp := range s.settings.GetStoragePaths() {
exportDir := appexport.ExportDir(sp.Path)
if strings.HasPrefix(cleanPath, filepath.Clean(exportDir)+string(filepath.Separator)) {
return true
}
}
return false
}
@@ -11,10 +11,16 @@ import (
// restorePageHandler renders the full-page DR restore UI.
func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] restorePageHandler: rendering restore page")
}
s.restoreMu.RLock()
plan := s.restorePlan
if plan == nil {
s.restoreMu.RUnlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] restorePageHandler: no restore plan, redirecting to /")
}
http.Redirect(w, r, "/", http.StatusFound)
return
}
@@ -26,6 +32,9 @@ func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
copy(drives, plan.Drives)
status := plan.GetStatus()
s.restoreMu.RUnlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] restorePageHandler: customer=%s apps=%d drives=%d status=%s", customerID, len(apps), len(drives), status)
}
data := map[string]interface{}{
"Title": "Katasztrófa utáni visszaállítás",
@@ -44,6 +53,9 @@ func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
// apiRestoreStatus returns the current restore plan status as JSON.
func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] apiRestoreStatus: status poll from %s", r.RemoteAddr)
}
s.restoreMu.RLock()
plan := s.restorePlan
if plan == nil {
@@ -60,6 +72,9 @@ func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) {
// apiRestoreAll starts restoring all pending apps sequentially.
func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] apiRestoreAll: restore-all requested from %s", r.RemoteAddr)
}
s.restoreMu.RLock()
plan := s.restorePlan
s.restoreMu.RUnlock()
@@ -68,6 +83,9 @@ func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
return
}
if !plan.TryStartRestore() {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] apiRestoreAll: restore already in progress, rejecting")
}
jsonError(w, "restore already in progress", http.StatusConflict)
return
}
@@ -81,6 +99,9 @@ func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
// apiRestoreSkip exits restore mode without restoring.
func (s *Server) apiRestoreSkip(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] apiRestoreSkip: skip requested from %s", r.RemoteAddr)
}
s.restoreMu.RLock()
plan := s.restorePlan
s.restoreMu.RUnlock()
@@ -101,6 +122,7 @@ func (s *Server) apiRestoreSkip(w http.ResponseWriter, r *http.Request) {
// executeAllRestores runs the restore for each pending app sequentially.
func (s *Server) executeAllRestores() {
s.logger.Println("[INFO] Starting DR restore for all apps")
restoreStart := time.Now()
s.restoreMu.RLock()
plan := s.restorePlan
@@ -117,6 +139,9 @@ func (s *Server) executeAllRestores() {
pendingCount++
}
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] executeAllRestores: %d pending apps to restore", pendingCount)
}
if s.notifier != nil {
s.notifier.NotifyDRStarted(pendingCount)
}
@@ -130,6 +155,7 @@ func (s *Server) executeAllRestores() {
plan.UpdateApp(app.Name, "restoring", "")
s.logger.Printf("[INFO] Restoring app %s (%s)", app.Name, app.DisplayName)
appStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
err := backup.RestoreAppFromBackup(ctx, app, s.cfg.Paths.StacksDir, s.logger)
@@ -138,16 +164,25 @@ func (s *Server) executeAllRestores() {
if err != nil {
plan.UpdateApp(app.Name, "failed", err.Error())
s.logger.Printf("[ERROR] Restore failed for %s: %v", app.Name, err)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] executeAllRestores: app=%s failed after %s", app.Name, time.Since(appStart))
}
failCount++
} else {
plan.UpdateApp(app.Name, "done", "")
s.logger.Printf("[INFO] Restore completed for %s", app.Name)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] executeAllRestores: app=%s completed in %s", app.Name, time.Since(appStart))
}
successCount++
}
}
plan.SetStatus("done")
s.logger.Println("[INFO] All app restores completed")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] executeAllRestores: total=%d success=%d fail=%d elapsed=%s", pendingCount, successCount, failCount, time.Since(restoreStart))
}
// Push DR completion event
if s.notifier != nil {
+53
View File
@@ -276,8 +276,14 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
}
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
}
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
}
http.NotFound(w, r)
return
}
@@ -953,6 +959,10 @@ func (s *Server) buildAppBackupRows(
func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) {
_ = r.ParseForm()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsCrossBackupHandler: stack=%s from %s", name, r.RemoteAddr)
}
enabled := r.FormValue("cross_drive_enabled") == "on"
// Preserve existing runtime status fields and config when disabling
@@ -1023,6 +1033,10 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
stackName := r.FormValue("stack_name")
snapshotID := r.FormValue("snapshot_id")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
}
if stackName == "" || snapshotID == "" {
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return
@@ -1035,13 +1049,21 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
start := time.Now()
if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
s.logger.Printf("[ERROR] Restore failed: %v", err)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s failed after %s", stackName, time.Since(start))
}
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s completed in %s", stackName, time.Since(start))
}
msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
}
@@ -1167,11 +1189,18 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_password")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
}
data := s.settingsData()
// Validate current password
effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
}
data["PasswordError"] = "Hibás jelenlegi jelszó"
s.executeTemplate(w, r, "settings", data)
return
@@ -1221,6 +1250,10 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
}
email := strings.TrimSpace(r.FormValue("notification_email"))
cooldownStr := r.FormValue("cooldown_hours")
cooldownHours := 6
@@ -1410,6 +1443,10 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
label := strings.TrimSpace(r.FormValue("storage_label"))
isDefault := r.FormValue("storage_default") == "true"
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
}
if label == "" {
label = settings.InferStorageLabel(path)
}
@@ -1476,6 +1513,10 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
_ = r.ParseForm()
path := r.FormValue("storage_path")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
}
data := s.settingsData()
// Check: apps using this path
@@ -1518,6 +1559,10 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re
_ = r.ParseForm()
path := r.FormValue("storage_path")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
}
if err := s.settings.SetDefaultStoragePath(path); err != nil {
s.logger.Printf("[ERROR] Failed to set default storage path: %v", err)
http.Redirect(w, r, "/settings", http.StatusFound)
@@ -1531,6 +1576,10 @@ func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *htt
path := r.FormValue("storage_path")
schedulable := r.FormValue("schedulable") == "true"
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
}
if err := s.settings.SetSchedulable(path, schedulable); err != nil {
s.logger.Printf("[ERROR] Failed to update schedulable: %v", err)
http.Redirect(w, r, "/settings", http.StatusFound)
@@ -1544,6 +1593,10 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
path := r.FormValue("storage_path")
label := strings.TrimSpace(r.FormValue("storage_label"))
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
}
if label == "" || len(label) > 50 {
data := s.settingsData()
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
+39
View File
@@ -12,6 +12,7 @@ import (
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
@@ -78,6 +79,9 @@ type Server struct {
// App-to-app integration manager (optional)
integrationMgr *integrations.Manager
// App export/import engine (optional)
appExporter *appexport.Exporter
// Debug mode support
logBuffer *LogBuffer
debugCallbacks *DebugCallbacks
@@ -102,6 +106,13 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
loginAttempts: make(map[string]*loginAttempt),
done: make(chan struct{}),
}
if cfg.Logging.Level == "debug" {
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
logger.Printf("[DEBUG] [web] NewServer: backup=%v crossDrive=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
backupMgr != nil, crossDrive != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
}
s.loadTemplates()
go s.cleanupSessions()
@@ -138,6 +149,10 @@ func (s *Server) loadTemplates() {
s.tmpl = template.Must(
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
)
if s.isDebug() {
names := s.tmpl.Templates()
s.logger.Printf("[DEBUG] [web] loadTemplates: loaded %d templates", len(names))
}
}
// SetRestoreState puts the server into DR restore mode with the given plan.
@@ -190,6 +205,11 @@ func (s *Server) SetDebugCallbacks(dc *DebugCallbacks) {
s.debugCallbacks = dc
}
// SetAppExporter sets the app export/import engine.
func (s *Server) SetAppExporter(e *appexport.Exporter) {
s.appExporter = e
}
// SetStartTime records the controller start time for uptime calculation.
func (s *Server) SetStartTime(t time.Time) {
s.startTime = t
@@ -221,6 +241,10 @@ func (s *Server) InRestoreMode() bool {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
}
// DR restore mode: intercept all routes except restore page, static, and restore API
if s.InRestoreMode() {
switch {
@@ -283,6 +307,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.storageAttachHandler(w, r)
case path == "/settings/storage/migrate-drive":
s.migrateDrivePageHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/export")
s.exportPageHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/migrate")
@@ -295,6 +323,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/deploy")
s.deployHandler(w, r, name)
case path == "/import":
s.importPageHandler(w, r)
case path == "/static/style.css":
s.serveCSSHandler(w, r)
case path == "/static/chart.min.js":
@@ -324,6 +354,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// the controller host (felhom.DOMAIN) pass through normally.
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
controllerHost := "felhom." + s.cfg.Customer.Domain
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: controller host=%s", controllerHost)
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.LastIndex(host, ":"); idx != -1 {
@@ -335,6 +368,9 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: non-controller host=%s, serving catch-all page", host)
}
s.serveCatchAll(w, r, host)
})
}
@@ -405,6 +441,9 @@ func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] ServeStorageAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
}
s.storageAPIHandler(w, r)
}
@@ -151,6 +151,10 @@ func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageAPI: %s %s from %s", r.Method, path, r.RemoteAddr)
}
switch {
case path == "/api/storage/scan" && r.Method == http.MethodPost:
s.storageScanAPIHandler(w, r)
@@ -197,12 +201,18 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
// storageScanAPIHandler handles POST /api/storage/scan.
func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageScan: scanning disks")
}
result, err := storage.ScanDisks(s.logger, s.cfg.Logging.Level == "debug")
if err != nil {
s.logger.Printf("[ERROR] storageScan: %v", err)
jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageScan: found %d available disks, %d system disks", len(result.AvailableDisks), len(result.SystemDisks))
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"available": result.AvailableDisks,
@@ -226,6 +236,11 @@ func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) {
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageInit: device=%s mountName=%s label=%q partition=%v default=%v from %s",
req.DevicePath, req.MountName, req.Label, req.CreatePartition, req.SetDefault, r.RemoteAddr)
}
if req.Confirm != "FORMÁZÁS" {
jsonError(w, "Megerősítés szükséges: írja be 'FORMÁZÁS'", http.StatusBadRequest)
return
@@ -432,6 +447,10 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageMigrate: stack=%s target=%s from %s", req.StackName, req.TargetPath, r.RemoteAddr)
}
if req.StackName == "" || req.TargetPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
@@ -794,6 +813,10 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] staleDataCleanup: stack=%s stalePath=%s from %s", req.StackName, req.StalePath, r.RemoteAddr)
}
if req.StackName == "" || req.StalePath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
@@ -915,6 +938,10 @@ func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Req
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageAttachMountRaw: device=%s from %s", req.DevicePath, r.RemoteAddr)
}
// Hold lock across entire cleanup+mount+set to prevent races
s.diskJobMu.Lock()
if s.activeRawMount != "" {
@@ -1021,6 +1048,11 @@ func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageAttach: device=%s mountName=%s subPath=%s label=%q default=%v from %s",
req.DevicePath, req.MountName, req.SubPath, req.Label, req.SetDefault, r.RemoteAddr)
}
if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
@@ -1164,6 +1196,10 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return
@@ -1172,6 +1208,9 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
// Check if USB device (only USB drives can be safely disconnected)
fsInfo := system.GetFSInfo(req.Path)
if fsInfo != nil && fsInfo.Device != "" && !system.IsUSBDevice(fsInfo.Device) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s device=%s is not USB, rejecting", req.Path, fsInfo.Device)
}
jsonError(w, "Csak USB meghajtó választható le biztonságosan", http.StatusBadRequest)
return
}
@@ -1183,6 +1222,10 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s success, stopped %d stacks", req.Path, len(stoppedStacks))
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"message": "A meghajtó biztonságosan eltávolítható.",
@@ -1205,6 +1248,10 @@ func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return
@@ -1217,6 +1264,10 @@ func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s success, previously stopped stacks=%v", req.Path, stoppedStacks)
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"message": "Meghajtó sikeresen csatlakoztatva.",
@@ -1239,6 +1290,10 @@ func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Reques
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return
@@ -1246,11 +1301,17 @@ func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Reques
// Validate drive is connected
if s.settings.IsDisconnected(req.Path) {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s is disconnected, rejecting", req.Path)
}
jsonError(w, "A meghajtó jelenleg leválasztva — először csatlakoztassa", http.StatusBadRequest)
return
}
started, failed := s.storageWatchdog.RestartStoppedApps(req.Path)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s started=%v failed=%v", req.Path, started, failed)
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"started": started,
@@ -1387,6 +1448,10 @@ func (s *Server) driveMigrateAPIHandler(w http.ResponseWriter, r *http.Request)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] driveMigrate: source=%s dest=%s from %s", req.SourcePath, req.DestPath, r.RemoteAddr)
}
if req.SourcePath == "" || req.DestPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
@@ -1497,6 +1562,10 @@ func (s *Server) decommissionRemoveHandler(w http.ResponseWriter, r *http.Reques
req.Path = r.FormValue("storage_path")
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] decommissionRemove: path=%s from %s", req.Path, r.RemoteAddr)
}
if req.Path == "" {
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
return
@@ -0,0 +1,223 @@
{{define "app_export"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">&larr; Alkalmazások</a>
<h2>{{.Stack.Meta.DisplayName}} &mdash; Exportálás</h2>
</div>
</div>
<div class="card" style="max-width:700px">
<h3>Mentés helye</h3>
<select id="destDrive" onchange="loadEstimate()" style="width:100%;padding:.5rem;margin-bottom:1rem">
<option value="">Válassz tárolót...</option>
{{range .Drives}}
<option value="{{.Path}}">{{if .Label}}{{.Label}} ({{.Path}}){{else}}{{.Path}}{{end}}</option>
{{end}}
</select>
<div id="estimateBox" style="display:none;background:var(--bg-secondary);border-radius:8px;padding:1rem;margin-bottom:1.5rem">
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>Konfiguráció:</span>
<span id="estConfig">-</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>Felhasználói adatok:</span>
<span id="estData">-</span>
</div>
<div style="display:flex;justify-content:space-between;font-weight:600;margin-bottom:.25rem;border-top:1px solid var(--border);padding-top:.5rem">
<span>Összesen:</span>
<span><span id="estTotal">-</span> <span id="estFree" style="color:var(--text-muted)">(szabad: -)</span></span>
</div>
<div style="display:flex;justify-content:space-between">
<span>Becsült idő:</span>
<span id="estTime">-</span>
</div>
<div id="estWarning" style="display:none;color:var(--danger);margin-top:.5rem;font-weight:600"></div>
</div>
<h3>Jelszó (opcionális)</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<input type="password" id="exportPassword" placeholder="Titkosítási jelszó" style="flex:1;padding:.5rem">
<button class="btn btn-sm btn-outline" onclick="togglePw()" type="button" title="Jelszó mutatása">&#128065;</button>
</div>
<label style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.5rem;cursor:pointer">
<input type="checkbox" id="stopApp" checked style="margin-top:3px">
<div>
<strong>Alkalmazás leállítása mentés előtt (ajánlott)</strong>
<div style="color:var(--text-muted);font-size:.85rem">Az adatok konzisztenciája érdekében javasolt leállítani az alkalmazást mentés közben.</div>
</div>
</label>
<button id="startBtn" class="btn btn-primary" onclick="startExport()" style="margin-top:1rem;width:100%" disabled>
Exportálás indítása
</button>
</div>
<div id="progressCard" class="card" style="max-width:700px;display:none">
<h3>Folyamat</h3>
<div id="progressSteps"></div>
<div id="progressError" style="display:none;color:var(--danger);margin-top:1rem;font-weight:600"></div>
</div>
<div id="doneCard" class="card" style="max-width:700px;display:none">
<h3 style="color:var(--success)">Kész!</h3>
<div style="margin-bottom:1rem">
<span id="doneFile" style="font-weight:600"></span>
<span id="doneSize" style="color:var(--text-muted)"></span>
</div>
<a id="doneFBLink" href="#" target="_blank" class="btn btn-outline">Megnyitás FileBrowser-ben &nearr;</a>
</div>
<script>
var stackName = '{{.Stack.Name}}';
var domain = '{{.Stack.Meta.Subdomain}}' ? '{{.Stack.Meta.Subdomain}}.{{$.CSRFToken}}' : '';
var pollTimer = null;
function csrfH() {
var el = document.querySelector('meta[name="csrf-token"]');
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/json'} : {'Content-Type': 'application/json'};
}
function togglePw() {
var inp = document.getElementById('exportPassword');
inp.type = inp.type === 'password' ? 'text' : 'password';
}
async function loadEstimate() {
var drive = document.getElementById('destDrive').value;
var box = document.getElementById('estimateBox');
var btn = document.getElementById('startBtn');
if (!drive) {
box.style.display = 'none';
btn.disabled = true;
return;
}
try {
var resp = await fetch('/api/export/estimate?stack=' + encodeURIComponent(stackName) + '&drive=' + encodeURIComponent(drive));
var data = await resp.json();
if (!data.ok) {
box.style.display = 'none';
btn.disabled = true;
return;
}
var est = data.data;
document.getElementById('estConfig').textContent = est.config_size_human;
document.getElementById('estData').textContent = est.data_size_human;
document.getElementById('estTotal').textContent = '~' + est.total_size_human;
document.getElementById('estFree').textContent = '(szabad: ' + est.dest_free_human + ')';
document.getElementById('estTime').textContent = '~' + est.estimated_minutes + ' perc';
var warn = document.getElementById('estWarning');
if (!est.fits_on_dest) {
warn.textContent = 'Nincs elég szabad hely a kiválasztott tárolón!';
warn.style.display = 'block';
btn.disabled = true;
} else {
warn.style.display = 'none';
btn.disabled = false;
}
box.style.display = 'block';
} catch(e) {
console.error('Estimate error:', e);
}
}
async function startExport() {
var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value;
var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true;
document.getElementById('progressCard').style.display = 'block';
document.getElementById('doneCard').style.display = 'none';
try {
var resp = await fetch('/api/export/start', {
method: 'POST',
headers: csrfH(),
body: JSON.stringify({
stack_name: stackName,
dest_drive: drive,
password: password,
stop_app: stopApp
})
});
var data = await resp.json();
if (!data.ok) {
showError(data.error || 'Hiba történt');
return;
}
pollStatus();
} catch(e) {
showError(e.message);
}
}
function pollStatus() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async function() {
try {
var resp = await fetch('/api/export/status');
var data = await resp.json();
renderSteps(data.steps || []);
if (data.error) {
showError(data.error);
clearInterval(pollTimer);
return;
}
if (data.done && !data.error) {
clearInterval(pollTimer);
showDone(data);
}
} catch(e) {
console.error('Poll error:', e);
}
}, 1000);
}
function renderSteps(steps) {
var html = '';
for (var i = 0; i < steps.length; i++) {
var s = steps[i];
var icon = '&#9675;'; // pending
if (s.status === 'running') icon = '&#10227;';
if (s.status === 'done') icon = '&#10003;';
if (s.status === 'failed') icon = '&#10007;';
var cls = s.status === 'failed' ? 'color:var(--danger)' : s.status === 'done' ? 'color:var(--success)' : s.status === 'running' ? 'color:var(--primary)' : '';
html += '<div style="padding:.25rem 0;' + cls + '">' + icon + ' ' + s.label;
if (s.error) html += ' <span style="font-size:.85rem">(' + s.error + ')</span>';
html += '</div>';
}
document.getElementById('progressSteps').innerHTML = html;
}
function showError(msg) {
var el = document.getElementById('progressError');
el.textContent = msg;
el.style.display = 'block';
document.getElementById('startBtn').disabled = false;
}
function showDone(data) {
document.getElementById('progressCard').style.display = 'none';
document.getElementById('doneCard').style.display = 'block';
var fileName = (data.output_path || '').split('/').pop();
document.getElementById('doneFile').textContent = fileName;
document.getElementById('doneSize').textContent = data.output_size ? '(' + data.output_size + ')' : '';
// Build FileBrowser link to the exports directory
var drive = document.getElementById('destDrive').value;
var fbPath = drive + '/felhom-data/exports/';
document.getElementById('doneFBLink').href = 'https://files.' + location.hostname.split('.').slice(-2).join('.') + '/files' + fbPath;
}
</script>
{{template "layout_end" .}}
{{end}}
@@ -0,0 +1,287 @@
{{define "app_import"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">&larr; Alkalmazások</a>
<h2>Alkalmazás importálás</h2>
</div>
</div>
{{if not .Bundles}}
<div class="card" style="max-width:700px">
<p style="color:var(--text-muted)">Nem található .fab csomag a regisztrált tárolókon.</p>
<p style="color:var(--text-muted);font-size:.85rem">Exportálj egy alkalmazást az alkalmazás oldaláról, vagy másolj egy .fab fájlt a <code>{tároló}/felhom-data/exports/</code> könyvtárba.</p>
</div>
{{else}}
<div class="card" style="max-width:900px">
<table class="table" style="width:100%">
<thead>
<tr>
<th>Alkalmazás</th>
<th>Dátum</th>
<th>Méret</th>
<th>Tároló</th>
<th>Titkos</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Bundles}}
<tr>
<td><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</strong></td>
<td>{{.ExportedAt}}</td>
<td>{{.SizeHuman}}</td>
<td>{{if .DriveLabel}}{{.DriveLabel}}{{else}}{{.DrivePath}}{{end}}</td>
<td>{{if .Encrypted}}&#128274;{{end}}</td>
<td><button class="btn btn-sm btn-outline" onclick="showPreview('{{.Path}}', {{.Encrypted}})">Részletek &raquo;</button></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
<!-- Preview / password modal -->
<div id="previewCard" class="card" style="max-width:700px;display:none">
<h3 id="previewTitle">Csomag részletei</h3>
<div id="passwordPrompt" style="display:none;margin-bottom:1rem">
<p>Ez a csomag jelszóval védett. Kérlek add meg a jelszót:</p>
<div style="display:flex;gap:.5rem">
<input type="password" id="importPassword" placeholder="Jelszó" style="flex:1;padding:.5rem">
<button class="btn btn-primary" onclick="loadManifest()">Megnyitás</button>
</div>
<div id="passwordError" style="display:none;color:var(--danger);margin-top:.5rem"></div>
</div>
<div id="manifestInfo" style="display:none">
<div style="background:var(--bg-secondary);border-radius:8px;padding:1rem;margin-bottom:1rem">
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>Alkalmazás:</span>
<strong id="mfName">-</strong>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>Exportálva:</span>
<span id="mfDate">-</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>Controller verzió:</span>
<span id="mfVersion">-</span>
</div>
<div id="mfDBRow" style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>Adatbázis:</span>
<span id="mfDB">-</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>Adatok:</span>
<span id="mfDataType">-</span>
</div>
<div style="display:flex;justify-content:space-between">
<span>Méret:</span>
<span id="mfSize">-</span>
</div>
</div>
<div id="overwriteWarning" style="display:none;background:var(--warning-bg, #fff3cd);border:1px solid var(--warning-border, #ffc107);border-radius:8px;padding:1rem;margin-bottom:1rem">
<strong>&#9888; FIGYELEM:</strong> A meglévő <span id="overwriteAppName"></span> alkalmazás konfigurációja és összes adata felül lesz írva!
</div>
<button id="importBtn" class="btn btn-primary" onclick="startImport()" style="width:100%">
Visszaállítás indítása
</button>
</div>
</div>
<!-- Progress -->
<div id="progressCard" class="card" style="max-width:700px;display:none">
<h3>Importálás folyamata</h3>
<div id="progressSteps"></div>
<div id="progressError" style="display:none;color:var(--danger);margin-top:1rem;font-weight:600"></div>
</div>
<div id="doneCard" class="card" style="max-width:700px;display:none">
<h3 style="color:var(--success)">Importálás kész!</h3>
<p>Az alkalmazás sikeresen visszaállítva.</p>
<a id="doneLink" href="/stacks" class="btn btn-primary">Alkalmazások megtekintése</a>
</div>
<script>
var selectedPath = '';
var selectedEncrypted = false;
var selectedManifest = null;
var pollTimer = null;
function csrfH() {
var el = document.querySelector('meta[name="csrf-token"]');
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/json'} : {'Content-Type': 'application/json'};
}
function showPreview(path, encrypted) {
selectedPath = path;
selectedEncrypted = encrypted;
selectedManifest = null;
document.getElementById('previewCard').style.display = 'block';
document.getElementById('manifestInfo').style.display = 'none';
document.getElementById('passwordPrompt').style.display = 'none';
document.getElementById('passwordError').style.display = 'none';
document.getElementById('progressCard').style.display = 'none';
document.getElementById('doneCard').style.display = 'none';
if (encrypted) {
document.getElementById('passwordPrompt').style.display = 'block';
document.getElementById('importPassword').value = '';
document.getElementById('importPassword').focus();
} else {
loadManifest();
}
}
async function loadManifest() {
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
try {
var resp = await fetch('/api/export/manifest', {
method: 'POST',
headers: csrfH(),
body: JSON.stringify({path: selectedPath, password: password})
});
var data = await resp.json();
if (!data.ok) {
if (selectedEncrypted) {
document.getElementById('passwordError').textContent = data.error || 'Hibás jelszó';
document.getElementById('passwordError').style.display = 'block';
}
return;
}
if (data.needs_password) {
document.getElementById('passwordPrompt').style.display = 'block';
document.getElementById('importPassword').focus();
return;
}
selectedManifest = data.manifest;
showManifest(data.manifest);
} catch(e) {
console.error('Manifest error:', e);
}
}
function showManifest(m) {
document.getElementById('passwordPrompt').style.display = 'none';
document.getElementById('manifestInfo').style.display = 'block';
document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' — Csomag részletei';
document.getElementById('mfName').textContent = m.display_name || m.app_name;
document.getElementById('mfDate').textContent = m.exported_at ? new Date(m.exported_at).toLocaleString('hu-HU') : '-';
document.getElementById('mfVersion').textContent = m.controller_version || '-';
if (m.has_database && m.db_type) {
document.getElementById('mfDB').textContent = m.db_type;
document.getElementById('mfDBRow').style.display = 'flex';
} else {
document.getElementById('mfDBRow').style.display = 'none';
}
var dataType = [];
if (m.has_hdd_data) dataType.push('HDD');
if (m.has_volume_data) dataType.push('Docker volume');
document.getElementById('mfDataType').textContent = dataType.length ? dataType.join(', ') : 'Nincs';
// Format size
var bytes = m.total_size_bytes || 0;
var sizeStr = bytes > 1073741824 ? (bytes / 1073741824).toFixed(1) + ' GB' :
bytes > 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' :
bytes > 1024 ? (bytes / 1024).toFixed(1) + ' KB' : bytes + ' B';
document.getElementById('mfSize').textContent = sizeStr;
// Check if app already exists — show overwrite warning
// We detect this by checking if the app link exists in the nav
var warn = document.getElementById('overwriteWarning');
// Simple approach: always show warning for existing app names
document.getElementById('overwriteAppName').textContent = m.display_name || m.app_name;
warn.style.display = 'block';
}
async function startImport() {
document.getElementById('importBtn').disabled = true;
document.getElementById('progressCard').style.display = 'block';
document.getElementById('doneCard').style.display = 'none';
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
try {
var resp = await fetch('/api/export/import', {
method: 'POST',
headers: csrfH(),
body: JSON.stringify({path: selectedPath, password: password})
});
var data = await resp.json();
if (!data.ok) {
showError(data.error || 'Hiba történt');
return;
}
pollStatus();
} catch(e) {
showError(e.message);
}
}
function pollStatus() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async function() {
try {
var resp = await fetch('/api/export/import/status');
var data = await resp.json();
renderSteps(data.steps || []);
if (data.error) {
showError(data.error);
clearInterval(pollTimer);
return;
}
if (data.done && !data.error) {
clearInterval(pollTimer);
document.getElementById('progressCard').style.display = 'none';
document.getElementById('doneCard').style.display = 'block';
if (data.stack_name) {
document.getElementById('doneLink').href = '/stacks/' + data.stack_name + '/deploy';
document.getElementById('doneLink').textContent = 'Alkalmazás megtekintése';
}
}
} catch(e) {
console.error('Poll error:', e);
}
}, 1000);
}
function renderSteps(steps) {
var html = '';
for (var i = 0; i < steps.length; i++) {
var s = steps[i];
var icon = '&#9675;';
if (s.status === 'running') icon = '&#10227;';
if (s.status === 'done') icon = '&#10003;';
if (s.status === 'failed') icon = '&#10007;';
var cls = s.status === 'failed' ? 'color:var(--danger)' : s.status === 'done' ? 'color:var(--success)' : s.status === 'running' ? 'color:var(--primary)' : '';
html += '<div style="padding:.25rem 0;' + cls + '">' + icon + ' ' + s.label;
if (s.error) html += ' <span style="font-size:.85rem">(' + s.error + ')</span>';
html += '</div>';
}
document.getElementById('progressSteps').innerHTML = html;
}
function showError(msg) {
var el = document.getElementById('progressError');
el.textContent = msg;
el.style.display = 'block';
document.getElementById('importBtn').disabled = false;
}
</script>
{{template "layout_end" .}}
{{end}}
@@ -15,6 +15,7 @@
{{if .Stack.Orphaned}}
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
{{else}}
<a href="/stacks/{{.Stack.Name}}/export" class="btn btn-sm btn-outline">Exportálás</a>
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
{{end}}
{{else}}
@@ -184,6 +184,27 @@
</div>
</div>
<!-- Section 9: App Export/Import -->
<div class="card debug-section" id="section-appexport">
<div class="card-header debug-section-header" onclick="toggleSection('appexport')">
<h3>Alkalmazás Export/Import</h3>
<span class="section-toggle"></span>
</div>
<div class="card-body debug-section-body" style="display:none">
<div id="appexport-status"><span class="text-muted">Betöltés...</span></div>
<div class="debug-actions" style="margin-top:.75rem">
<button class="btn btn-secondary btn-sm" id="btn-appexport-scan" data-label="Csomagok keresése" onclick="scanAppBundles()">Csomagok keresése</button>
<span class="debug-result" id="btn-appexport-scan-result"></span>
<button class="btn btn-secondary btn-sm" id="btn-appexport-cleanup" data-label="Temp fájlok törlése" onclick="triggerAction('btn-appexport-cleanup','/api/debug/appexport/cleanup','POST')">Temp fájlok törlése</button>
<span class="debug-result" id="btn-appexport-cleanup-result"></span>
<button class="btn btn-secondary btn-sm" id="btn-appexport-refresh" data-label="Frissítés" onclick="loadSectionData('appexport')">Frissítés</button>
</div>
<div id="appexport-bundles" style="display:none;margin-top:1rem"></div>
</div>
</div>
<!-- Section 8: Log Viewer -->
<div class="card debug-section" id="section-logs">
<div class="card-header debug-section-header" onclick="toggleSection('logs')">
@@ -285,6 +306,7 @@ function loadSectionData(id) {
case 'telemetry': break; // no auto-load, user triggers manually
case 'selfupdate': loadSelfUpdateStatus(); break;
case 'dr': loadDRStatus(); break;
case 'appexport': loadAppExportStatus(); break;
case 'logs': initLogViewer(); break;
}
}
@@ -693,6 +715,137 @@ function renderTelemetryDetail(data) {
detail.style.display = 'block';
}
// ── Section 9: App Export/Import ──
function loadAppExportStatus() {
document.getElementById('appexport-status').innerHTML = '<span class="text-muted">Betöltés...</span>';
fetch('/api/debug/appexport/status', {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
if (!data.ok) { document.getElementById('appexport-status').innerHTML = '<span class="text-muted">Nem elérhető</span>'; return; }
renderAppExportStatus(data.data);
}).catch(function(e) {
document.getElementById('appexport-status').innerHTML = '<span class="debug-result-error">Hiba: ' + e.message + '</span>';
});
}
function renderAppExportStatus(d) {
if (!d.available) {
document.getElementById('appexport-status').innerHTML = '<span class="text-muted">App export modul nem elérhető</span>';
return;
}
var html = '<div class="debug-kv-grid">';
html += '<dt>Debug mód</dt><dd>' + (d.debug_enabled ? '<span class="state-text-green">Aktív</span>' : 'Inaktív') + '</dd>';
html += '<dt>Verzió</dt><dd class="mono">' + (d.version||'-') + '</dd>';
html += '<dt>Csomagok</dt><dd>' + (d.bundle_count||0) + ' db</dd>';
html += '<dt>Temp fájlok</dt><dd>' + (d.stale_temp_count||0) + ' db';
if (d.stale_temp_count > 0) html += ' <span style="color:var(--warning)"></span>';
html += '</dd>';
html += '</div>';
// Active job
if (d.has_active_job && d.active_job) {
var j = d.active_job;
html += '<h4 style="margin-top:.75rem">Aktív feladat</h4>';
html += '<div class="debug-kv-grid">';
html += '<dt>Típus</dt><dd>' + (j.job_type||'-') + '</dd>';
html += '<dt>Stack</dt><dd>' + (j.display_name || j.stack_name || '-') + '</dd>';
html += '<dt>Állapot</dt><dd>' + (j.running ? '🔄 Fut' : j.done ? '✅ Kész' : '⏸ Várakozik') + '</dd>';
if (j.error) html += '<dt>Hiba</dt><dd class="debug-result-error">' + escapeHtml(j.error) + '</dd>';
if (j.output_path) html += '<dt>Kimenet</dt><dd class="mono" style="font-size:.75rem">' + escapeHtml(j.output_path) + '</dd>';
if (j.output_size) html += '<dt>Méret</dt><dd>' + j.output_size + '</dd>';
html += '</div>';
if (j.steps && j.steps.length > 0) {
html += '<div style="margin-top:.5rem">';
j.steps.forEach(function(s) {
var icon = s.status === 'done' ? '✓' : s.status === 'running' ? '⟳' : s.status === 'failed' ? '✗' : '○';
var cls = s.status === 'failed' ? 'color:var(--danger)' : s.status === 'done' ? 'color:var(--success)' : s.status === 'running' ? 'color:var(--primary)' : 'color:var(--text-muted)';
html += '<div style="font-size:.85rem;padding:.1rem 0;' + cls + '">' + icon + ' ' + escapeHtml(s.label);
if (s.error) html += ' <span style="font-size:.8rem">(' + escapeHtml(s.error) + ')</span>';
html += '</div>';
});
html += '</div>';
}
}
// Export dirs
if (d.export_dirs && d.export_dirs.length > 0) {
html += '<h4 style="margin-top:.75rem">Export könyvtárak</h4>';
html += '<table class="info-table debug-table"><tr><th>Útvonal</th><th>Cimke</th><th>Létezik</th></tr>';
d.export_dirs.forEach(function(dir) {
html += '<tr><td class="mono" style="font-size:.75rem">' + escapeHtml(dir.path) + '</td><td>' + (dir.label||'-') + '</td><td>' + (dir.exists ? '✅' : '❌') + '</td></tr>';
});
html += '</table>';
}
// Stale temp files
if (d.stale_temp_files && d.stale_temp_files.length > 0) {
html += '<h4 style="margin-top:.75rem;color:var(--warning)">Elavult temp fájlok</h4>';
html += '<ul style="font-size:.85rem;margin:0;padding-left:1.5rem">';
d.stale_temp_files.forEach(function(f) {
html += '<li class="mono" style="font-size:.75rem">' + escapeHtml(f) + '</li>';
});
html += '</ul>';
}
document.getElementById('appexport-status').innerHTML = html;
// Auto-refresh if a job is running
if (d.has_active_job && d.active_job && d.active_job.running) {
startPolling('appexport', 2000, function() {
fetch('/api/debug/appexport/status', {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
if (data.ok) renderAppExportStatus(data.data);
}).catch(function(){});
});
}
}
function scanAppBundles() {
var btn = document.getElementById('btn-appexport-scan');
var result = document.getElementById('btn-appexport-scan-result');
btn.disabled = true;
btn.textContent = 'Keresés...';
result.className = 'debug-result';
result.textContent = '';
fetch('/api/debug/appexport/bundles', {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
if (data.ok) {
result.className = 'debug-result debug-result-ok';
result.textContent = data.message;
if (data.data && data.data.bundles) {
renderAppBundles(data.data.bundles);
}
} else {
result.className = 'debug-result debug-result-error';
result.textContent = data.error || 'Hiba';
}
}).catch(function(e) {
result.className = 'debug-result debug-result-error';
result.textContent = 'Hálózati hiba: ' + e.message;
}).finally(function() {
btn.disabled = false;
btn.textContent = btn.dataset.label;
});
}
function renderAppBundles(bundles) {
var container = document.getElementById('appexport-bundles');
if (!bundles || bundles.length === 0) {
container.innerHTML = '<span class="text-muted">Nem található .fab csomag.</span>';
container.style.display = 'block';
return;
}
var html = '<table class="info-table debug-table"><tr><th>Alkalmazás</th><th>Dátum</th><th>Méret</th><th>Tároló</th><th>Titkos</th><th>DB</th><th>HDD</th><th>Elérés</th></tr>';
bundles.forEach(function(b) {
html += '<tr>';
html += '<td><strong>' + escapeHtml(b.display_name || b.app_name) + '</strong></td>';
html += '<td>' + (b.exported_at || '-') + '</td>';
html += '<td>' + (b.size_human || '-') + '</td>';
html += '<td>' + escapeHtml(b.drive_label || b.drive_path) + '</td>';
html += '<td>' + (b.encrypted ? '🔒' : '-') + '</td>';
html += '<td>' + (b.has_db ? '✅' : '-') + '</td>';
html += '<td>' + (b.needs_hdd ? '✅' : '-') + '</td>';
html += '<td class="mono" style="font-size:.7rem;max-width:200px;overflow:hidden;text-overflow:ellipsis">' + escapeHtml(b.path) + '</td>';
html += '</tr>';
});
html += '</table>';
container.innerHTML = html;
container.style.display = 'block';
}
// ── Helpers ──
function fmtTime(ts) {
if (!ts) return '-';
@@ -4,6 +4,7 @@
<div class="page-header">
<h2>Alkalmazások</h2>
<span class="domain-badge">{{.Domain}}</span>
<a href="/import" class="btn btn-sm btn-outline" title="Alkalmazás visszaállítása exportált csomagból">Importálás</a>
<button class="btn btn-sm btn-outline" id="sync-btn" onclick="syncTemplates()" title="Sablonok frissítése a központi katalógusból">↻ Sablonok frissítése</button>
</div>
<div id="sync-toast" class="sync-toast" style="display:none"></div>