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
+50
View File
@@ -1,5 +1,55 @@
## Changelog ## Changelog
### v0.32.1 — Comprehensive debug logging across all modules (2026-02-26)
#### Added
- **stacks/delete.go**: Debug logging for DeleteStack/RemoveStack with stack state, HDD mounts, compose output, path removal; GetStackHDDData/GetStackBackupData path scanning
- **stacks/manager.go**: Debug logging for ScanStacks per-stack discovery, refreshStatusLocked container resolution, Start/Stop/Restart pre-operation state, MigrateEncryption progress, getCatalogTemplateSlugs count
- **stacks/deploy.go**: Debug logging for UpdateStackConfig/UpdateOptionalConfig changed keys, InjectMissingFields per-stack checks, SaveAppConfig encryption counts, LoadAppConfig results
- **stacks/healthprobe.go**: Debug logging for per-target interval calculations and target collection summary
- **backup/restic.go**: `debug` field + `SetDebug()` method; debug logs for Snapshot/Prune/Check/ListSnapshots/LatestSnapshot/Stats/RestoreAppData with timing, sizes, and command details
- **backup/restore_scan.go**: Debug logging for ScanDrivesForBackups drive/app scanning with per-drive availability and backup component summary
- **backup/restore_app_linux.go**: Debug logging for RestoreAppFromBackup step timing, restoreUserData per-dir rsync, restoreDBDumps per-file copying
- **backup/restore_drives_linux.go**: Debug logging for MountDrivesFromLayout device discovery, mount strategy selection, fstab checks
- **cloudflare/geosync.go**: `debug` field + `SetDebug()` method; debug logs for Sync zone/ruleset resolution, existing/desired rule diffing, rule create/update/delete operations
- **cloudflare/waf.go**: Debug logging for GetCustomRulesetID/GetRules/GetFelhomRules counts, CreateRule/UpdateRule expression snippets
- **cloudflare/zone.go**: Debug logging for GetZoneID progressive domain lookup attempts
- **integrations/manager.go**: `debug` field + `SetDebug()` method; debug logs for Toggle validation/timing, ListForProvider counts, buildApplyContext details, ReapplyConfigForTarget per-integration progress
- **integrations/lifecycle.go**: Debug logging for OnStackStop/OnStackStart/OnStackRemove with integration counts, state checks, revoke/re-apply operations
- **integrations/onlyoffice_filebrowser.go**: Debug logging for Apply/Revoke config path, JWT secret presence, office URL
- **system/**: Package-level `DebugLogger` variable; debug logs for GetInfo timing/summary, readMemInfo/readDiskUsage/readLoadAvg/readTemperature raw values, CPU collector samples, GetDiskUsage/GetFSInfo/CheckBackupDestination/ProbeStoragePath/IsUSBDevice details
- **monitor/pinger.go**: `debug` field + `SetDebug()` method; debug logs for Ping/Fail/Start with UUIDs, send URL/attempts/response status
- **settings/settings.go**: `debug` field (json:"-") + `SetDebug()` method; debug logs for Load counts, save data size, AddStoragePath/RemoveStoragePath, SetDisconnected/SetDecommissioned, AddPendingEvent/DrainPendingEvents, SetGeoRestriction, SetIntegrationState, AutoDiscoverStoragePaths
- **scheduler**: `debug` field + `SetDebug()` method; debug logs for job registration, execution timing, daily job wait calculations
- **storage/**: Consistent `[DEBUG] [storage]` prefix; scan timing; drive migration debug logging
- **metrics/logscanner**: Debug logging for per-container scan timing, error/warning counts
- **api/router**: `debug` field + `SetDebug()` method; logs incoming API requests and handler entry points
- **selfupdate**: Expanded debug coverage with `dbg()` helper for TriggerUpdate preconditions, performUpdate step transitions, docker pull timing
- **assets/syncer**: Expanded debug coverage with `dbg()` helper for per-file hash comparison, download timing, manifest fetch details
- **web/auth.go**: Debug logging for RequireAuth middleware decisions, login attempts (IP, success/fail), session creation/cleanup
- **web/handlers.go**: Debug logging for deploy/restore/settings/storage handler entry points with key parameters
- **web/handler_restore.go**: Debug logging for restore page, status polls, restore-all execution per-app timing
- **web/storage_handlers.go**: Debug logging for all storage API operations (scan, init, migrate, disconnect, reconnect, attach, cleanup)
- **web/server.go**: Debug logging for NewServer initialization, template loading, ServeHTTP request routing
- **main.go**: Wire `SetDebug()` for settings, pinger, geoSync, integrationMgr, scheduler, apiRouter
### v0.32.0 — App export/import (.fab bundles) (2026-02-26)
#### Added
- **App export**: Per-app export to `.fab` bundles containing config, database dump, and all user data (HDD bind mounts or Docker named volumes)
- **App import**: Restore apps from `.fab` bundles — works for both existing and new apps (standalone import page)
- **Password protection**: Optional AES-256-CTR + HMAC-SHA256 encryption with scrypt key derivation for exported bundles
- **Pre-export estimation**: Size estimation with free space check before starting export
- **Export UI**: New export page accessible from app info header with drive picker, password field, stop-app checkbox, and real-time progress tracking
- **Import UI**: Standalone import page (`/import`) scans all registered storage drives for `.fab` files, shows manifest details, and handles encrypted bundles with password prompt
- **FileBrowser link**: After export, link to open the exports directory in FileBrowser
- **Bundle format**: `{appname}_{timestamp}.fab` — tar.gz internally with `manifest.json`, `config/`, `database/`, `data/` directories
- **New package**: `internal/appexport/` — export/import engine with provider adapter pattern (same as backup.StackDataProvider)
- **API endpoints**: `/api/export/estimate`, `/api/export/start`, `/api/export/status`, `/api/export/bundles`, `/api/export/manifest`, `/api/export/import`, `/api/export/import/status`
#### Changed
- **backup/appdata.go**: Exported `ParseComposeNamedVolumes` (was lowercase) for reuse by appexport package
### v0.31.7 — Infra backup retention + version picker (2026-02-26) ### v0.31.7 — Infra backup retention + version picker (2026-02-26)
#### Changed #### Changed
+70 -26
View File
@@ -13,19 +13,20 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s
- [Architecture](#architecture) - [Architecture](#architecture)
- [Features](#features) - [Features](#features)
- [App Management](#1-app-management) - [App Management](#1-app-management)
- [Backup System](#2-backup-system) - [App Export/Import](#2-app-exportimport-fab-bundles)
- [Storage Management](#3-storage-management) - [Backup System](#3-backup-system)
- [Monitoring & Health](#4-monitoring--health) - [Storage Management](#4-storage-management)
- [Notifications](#5-notifications) - [Monitoring & Health](#5-monitoring--health)
- [Update Management](#6-update-management) - [Notifications](#6-notifications)
- [Authentication & Settings](#7-authentication--settings) - [Update Management](#7-update-management)
- [Central Hub](#8-central-hub-reporting) - [Authentication & Settings](#8-authentication--settings)
- [Setup Wizard](#9-first-run-setup-wizard) - [Central Hub](#9-central-hub-reporting)
- [Disaster Recovery](#10-disaster-recovery) - [Setup Wizard](#10-first-run-setup-wizard)
- [Asset Sync](#11-asset-sync) - [Disaster Recovery](#11-disaster-recovery)
- [Debug Mode](#12-debug-mode) - [Asset Sync](#12-asset-sync)
- [Geo-Restriction](#13-geo-restriction) - [Debug Mode](#13-debug-mode)
- [App-to-App Integrations](#14-app-to-app-integrations) - [Geo-Restriction](#14-geo-restriction)
- [App-to-App Integrations](#15-app-to-app-integrations)
- [Repository Layout](#repository-layout) - [Repository Layout](#repository-layout)
- [Configuration](#configuration) - [Configuration](#configuration)
- [REST API](#rest-api) - [REST API](#rest-api)
@@ -106,6 +107,7 @@ A single, lightweight Go container that replaces Portainer + scattered systemd s
| **Assets** | `internal/assets/` | Hub-managed asset syncer: downloads logos/screenshots with SHA-256 change detection | | **Assets** | `internal/assets/` | Hub-managed asset syncer: downloads logos/screenshots with SHA-256 change detection |
| **SelfTest** | `internal/selftest/` | Startup self-test: 9 diagnostic checks (Docker, dirs, storage, hub, restic, metrics) | | **SelfTest** | `internal/selftest/` | Startup self-test: 9 diagnostic checks (Docker, dirs, storage, hub, restic, metrics) |
| **Util** | `internal/util/` | Shared utilities: `TruncateStr` for debug log output truncation | | **Util** | `internal/util/` | Shared utilities: `TruncateStr` for debug log output truncation |
| **AppExport** | `internal/appexport/` | Per-app export/import via `.fab` bundles (config + DB + user data), optional AES-256 encryption |
| **API** | `internal/api/` | REST JSON endpoints, diagnostic dump (`/api/debug/dump`) | | **API** | `internal/api/` | REST JSON endpoints, diagnostic dump (`/api/debug/dump`) |
| **Web** | `internal/web/` | Hungarian dashboard, auth, page handlers, template functions, alerts | | **Web** | `internal/web/` | Hungarian dashboard, auth, page handlers, template functions, alerts |
@@ -234,7 +236,27 @@ Multiple checks per app are supported (all must pass). The probe scheduler runs
--- ---
### 2. Backup System ### 2. App Export/Import (.fab bundles)
Per-app export creates a self-contained `.fab` file (tar.gz, optionally encrypted) that can be stored externally or used to restore the app on the same server. Distinct from the automatic backup system — user-initiated, per-app, produces a single portable file.
**Bundle contents:** `manifest.json` + `config/` (compose, .felhom.yml, app.yaml with plaintext secrets) + `database/` (gzipped SQL dump) + `data/` (HDD bind mount tars or Docker named volume tars).
**Encryption:** Optional AES-256-CTR + HMAC-SHA256 with scrypt key derivation (N=32768). Format: `"FABE"` magic header + salt + IV + encrypted tar.gz + HMAC tag. Streaming for multi-GB files.
**Export flow:** Estimate size → check free space → optionally stop app → copy config → dump DB → tar user data → create tar.gz → optionally encrypt → atomic rename. App restarts automatically after export if it was stopped.
**Import flow:** Decrypt if needed → extract → prepare stack dir (create new or `compose down --volumes` for existing) → restore config (re-encrypt app.yaml with current server key) → restore user data (HDD or volumes) → restore DB (start DB service, wait for ready, import dump) → start full stack → refresh UI.
**Architecture:** `internal/appexport/` package with `ExportStackProvider` adapter interface (same pattern as `backup.StackDataProvider`). `exportAdapter` in `main.go` bridges `stacks.Manager` to the provider.
**API endpoints:** `/api/export/estimate`, `/api/export/start`, `/api/export/status`, `/api/export/bundles`, `/api/export/manifest`, `/api/export/import`, `/api/export/import/status`.
**UI:** Export button on app info page, standalone import page at `/import` accessible from the stacks page header.
---
### 3. Backup System
The backup system implements a **3-2-1 backup architecture**. Each tier is a **complete, The backup system implements a **3-2-1 backup architecture**. Each tier is a **complete,
self-sufficient backup** — any single tier can fully restore an app. self-sufficient backup** — any single tier can fully restore an app.
@@ -441,7 +463,7 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
--- ---
### 3. Storage Management ### 4. Storage Management
The storage subsystem handles the full lifecycle of external storage: detection, initialization, path registration, and data migration. The storage subsystem handles the full lifecycle of external storage: detection, initialization, path registration, and data migration.
@@ -554,7 +576,7 @@ Continuously monitors registered storage paths for disconnection/reconnection (p
--- ---
### 4. Monitoring & Health ### 5. Monitoring & Health
#### System Health Checks (`internal/monitor/healthcheck.go`) #### System Health Checks (`internal/monitor/healthcheck.go`)
@@ -610,7 +632,7 @@ State-based alerts displayed on all pages:
--- ---
### 5. Notifications ### 6. Notifications
#### Hub Event System (`internal/notify/notifier.go`) #### Hub Event System (`internal/notify/notifier.go`)
@@ -660,7 +682,7 @@ Notification preferences (email, enabled events, cooldown hours) are:
--- ---
### 6. Update Management ### 7. Update Management
#### App Catalog Sync #### App Catalog Sync
@@ -807,7 +829,7 @@ self_update:
--- ---
### 7. Authentication & Settings ### 8. Authentication & Settings
#### Session Auth (`internal/web/auth.go`) #### Session Auth (`internal/web/auth.go`)
@@ -868,7 +890,7 @@ Five sections:
--- ---
### 8. Central Hub Reporting ### 9. Central Hub Reporting
#### Report Push (`internal/report/`) #### Report Push (`internal/report/`)
@@ -923,7 +945,7 @@ The hub service (separate Go app in the `felhom.eu` repo) provides:
- Color coding: green (<30min), yellow (30-60min), red (>60min since last report) - Color coding: green (<30min), yellow (30-60min), red (>60min since last report)
- 90-day report + event retention with daily prune at 04:30 Budapest time - 90-day report + event retention with daily prune at 04:30 Budapest time
### 9. First-Run Setup Wizard ### 10. First-Run Setup Wizard
When the controller starts with no valid customer configuration (`customer.id` empty), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`. When the controller starts with no valid customer configuration (`customer.id` empty), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`.
@@ -1010,7 +1032,7 @@ During setup wizard drive scan, both current and historical backups are discover
Generates `recovery-info.txt` on the system data partition with customer ID, Hub URL, retrieval password, and recovery instructions in Hungarian. Updated on startup and after config changes. Also displayed on the Settings page in a "Vészhelyzeti információk" section. Generates `recovery-info.txt` on the system data partition with customer ID, Hub URL, retrieval password, and recovery instructions in Hungarian. Updated on startup and after config changes. Also displayed on the Settings page in a "Vészhelyzeti információk" section.
### 10. Disaster Recovery ### 11. Disaster Recovery
When a system drive fails and is replaced, the recovery flow uses the setup wizard: When a system drive fails and is replaced, the recovery flow uses the setup wizard:
@@ -1040,7 +1062,7 @@ When a system drive fails and is replaced, the recovery flow uses the setup wiza
--- ---
### 11. Asset Sync ### 12. Asset Sync
App assets (logos, screenshots) are managed centrally by the Hub and downloaded to each controller via a daily sync process. This decouples asset updates from controller image rebuilds — new app icons only require a Hub redeploy. App assets (logos, screenshots) are managed centrally by the Hub and downloaded to each controller via a daily sync process. This decouples asset updates from controller image rebuilds — new app icons only require a Hub redeploy.
@@ -1096,7 +1118,7 @@ The Hub serves three asset types per app:
--- ---
### 12. Debug Mode ### 13. Debug Mode
When `logging.level: "debug"` is set in `controller.yaml`, the controller exposes a full diagnostic dashboard at `/debug` with 9 testing sections. All debug endpoints are gated — at `info` level, the sidebar link disappears and all `/api/debug/*` routes return 404. When `logging.level: "debug"` is set in `controller.yaml`, the controller exposes a full diagnostic dashboard at `/debug` with 9 testing sections. All debug endpoints are gated — at `info` level, the sidebar link disappears and all `/api/debug/*` routes return 404.
@@ -1123,9 +1145,31 @@ When `logging.level: "debug"` is set in `controller.yaml`, the controller expose
- **DebugCallbacks**: 7 closures wired from main.go for operations needing modules not on Server struct (hub push, infra backup, connectivity tests, telemetry preview). - **DebugCallbacks**: 7 closures wired from main.go for operations needing modules not on Server struct (hub push, infra backup, connectivity tests, telemetry preview).
- **Telemetry debug**: `GetTelemetryPreview` callback calls `report.BuildAppTelemetryForDebug()` (exported wrapper around the private `buildAppTelemetrySection()`). Result renders as a table with collapsible raw JSON. Available regardless of hub configuration. - **Telemetry debug**: `GetTelemetryPreview` callback calls `report.BuildAppTelemetryForDebug()` (exported wrapper around the private `buildAppTelemetrySection()`). Result renders as a table with collapsible raw JSON. Available regardless of hub configuration.
#### Per-Module Debug Logging
When `logging.level: "debug"`, every module emits detailed `[DEBUG] [module]` prefixed log lines. Each module with stateful debug (struct-based) exposes a `SetDebug(bool)` method, wired from `main.go`. Modules without a struct use package-level `DebugLogger` variables (e.g., `system.DebugLogger`).
| Module | Debug Field | Prefix | Key Areas |
|--------|------------|--------|-----------|
| `stacks` | `cfg.Logging.Level` | `[DEBUG] [stacks]` | Stack CRUD, compose commands, env vars, HDD mounts, encryption migration, health probes |
| `backup` | `ResticManager.debug` | `[DEBUG] [restic]` / `[DEBUG] [backup]` | Restic commands, snapshot operations, restore scanning, drive mounting |
| `cloudflare` | `Client.debug` + `GeoSyncManager.debug` | `[CF-DEBUG]` / `[DEBUG] [cloudflare]` | API requests/responses, WAF rule CRUD, zone resolution, geo sync diff |
| `integrations` | `Manager.debug` | `[DEBUG] [integrations]` | Toggle apply/revoke timing, lifecycle hooks, config reapply |
| `system` | `DebugLogger` | `[DEBUG] [system]` | Memory/disk/CPU/load/temp collection, mount probing, USB detection |
| `monitor` | `Pinger.debug` | `[DEBUG] [pinger]` | Health ping URLs, retry attempts, response codes |
| `settings` | `Settings.debug` | `[DEBUG] [settings]` | Load/save sizes, storage path ops, geo/integration state changes |
| `scheduler` | `Scheduler.debug` | `[DEBUG] [sched]` | Job registration, execution timing, daily schedule calculations |
| `web` | `cfg.Logging.Level` | `[DEBUG] [web]` | HTTP requests, auth decisions, session management, storage API ops |
| `api` | `Router.debug` | `[DEBUG] [api]` | API routing, handler entry points, request details |
| `selfupdate` | `Updater.debug` | `[DEBUG] [selfupdate]` | Version checks, update preconditions, docker pull timing |
| `assets` | `Syncer.debug` | `[DEBUG] [assets]` | Manifest fetch, hash comparison, file download timing |
| `storage` | logger-based | `[DEBUG] [storage]` | Disk scanning, formatting, attach, drive migration |
| `metrics` | logger-based | `[DEBUG] [metrics]` | Per-container log scanning, error/warning counts |
| `appexport` | `Exporter.debug` | `[DEBUG] [appexport]` | Export/import steps, crypto operations, bundle scanning |
--- ---
### 13. Geo-Restriction ### 14. Geo-Restriction
Country-based access control via **Cloudflare WAF Custom Rules**. The controller manages WAF rules in the `http_request_firewall_custom` phase to block requests from non-allowed countries. Rules are identified by a `[felhom-geo]` description prefix — other WAF rules are never touched. Country-based access control via **Cloudflare WAF Custom Rules**. The controller manages WAF rules in the `http_request_firewall_custom` phase to block requests from non-allowed countries. Rules are identified by a `[felhom-geo]` description prefix — other WAF rules are never touched.
@@ -1223,7 +1267,7 @@ All mutating endpoints trigger an async Cloudflare sync. The `/api/geo/` path ac
--- ---
### 14. App-to-App Integrations ### 15. App-to-App Integrations
Generic framework for connecting deployed applications to each other. Provider apps declare available integrations in `.felhom.yml`, and users enable/disable them via toggle switches on the provider's deploy/settings page ("Beállítások"). Generic framework for connecting deployed applications to each other. Provider apps declare available integrations in `.felhom.yml`, and users enable/disable them via toggle switches on the provider's deploy/settings page ("Beállítások").
+173
View File
@@ -9,6 +9,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"syscall" "syscall"
@@ -18,6 +19,7 @@ import (
"strings" "strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/api" "gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/assets" "gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare" cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
@@ -71,6 +73,11 @@ func main() {
logger, logBuffer := setupLogger(cfg) logger, logBuffer := setupLogger(cfg)
// --- Wire system package debug logging ---
if cfg.Logging.Level == "debug" {
system.DebugLogger = logger
}
// --- Setup mode: if no customer ID configured, run setup wizard --- // --- Setup mode: if no customer ID configured, run setup wizard ---
if setup.NeedsSetup(cfg) { if setup.NeedsSetup(cfg) {
logger.Printf("[INFO] felhom-controller %s — setup mode", Version) logger.Printf("[INFO] felhom-controller %s — setup mode", Version)
@@ -87,6 +94,7 @@ func main() {
if err != nil { if err != nil {
logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err) logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err)
} }
sett.SetDebug(cfg.Logging.Level == "debug")
// --- Auto-discover storage paths from deployed apps --- // --- Auto-discover storage paths from deployed apps ---
discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger) discoveredPaths := discoverHDDPaths(cfg.Paths.StacksDir, logger)
@@ -159,6 +167,7 @@ func main() {
// --- Initialize health pinger (legacy, will be removed) --- // --- Initialize health pinger (legacy, will be removed) ---
pinger := monitor.NewPinger(&cfg.Monitoring, logger) pinger := monitor.NewPinger(&cfg.Monitoring, logger)
pinger.SetDebug(cfg.Logging.Level == "debug")
// Deprecation notice for ping UUIDs // Deprecation notice for ping UUIDs
uuids := cfg.Monitoring.PingUUIDs uuids := cfg.Monitoring.PingUUIDs
@@ -215,6 +224,7 @@ func main() {
// --- Initialize scheduler --- // --- Initialize scheduler ---
sched := scheduler.New(logger) sched := scheduler.New(logger)
sched.SetDebug(cfg.Logging.Level == "debug")
// Existing periodic tasks (migrated from ad-hoc goroutines) // Existing periodic tasks (migrated from ad-hoc goroutines)
sched.Every("status-refresh", 30*time.Second, func(ctx context.Context) error { sched.Every("status-refresh", 30*time.Second, func(ctx context.Context) error {
@@ -614,6 +624,7 @@ func main() {
cfClient := cf.New(cfg.Infrastructure.CFAPIToken, logger, cfg.Logging.Level == "debug") cfClient := cf.New(cfg.Infrastructure.CFAPIToken, logger, cfg.Logging.Level == "debug")
geoStacks := &geoStackAdapter{mgr: stackMgr, domain: cfg.Customer.Domain} geoStacks := &geoStackAdapter{mgr: stackMgr, domain: cfg.Customer.Domain}
geoSync = cf.NewGeoSyncManager(cfClient, sett, cfg.Customer.Domain, geoStacks, logger) geoSync = cf.NewGeoSyncManager(cfClient, sett, cfg.Customer.Domain, geoStacks, logger)
geoSync.SetDebug(cfg.Logging.Level == "debug")
apiRouter.SetGeoSync(geoSync) apiRouter.SetGeoSync(geoSync)
// Re-sync geo rules when apps are deployed/removed // Re-sync geo rules when apps are deployed/removed
@@ -651,11 +662,19 @@ func main() {
// --- Initialize integration manager --- // --- Initialize integration manager ---
integrationStacks := &integrationStackAdapter{mgr: stackMgr} integrationStacks := &integrationStackAdapter{mgr: stackMgr}
integrationMgr := integrations.NewManager(sett, integrationStacks, cfg.Customer.Domain, cfg.Paths.StacksDir, encKey, logger) integrationMgr := integrations.NewManager(sett, integrationStacks, cfg.Customer.Domain, cfg.Paths.StacksDir, encKey, logger)
integrationMgr.SetDebug(cfg.Logging.Level == "debug")
apiRouter.SetIntegrationManager(integrationMgr) apiRouter.SetIntegrationManager(integrationMgr)
// --- Initialize app exporter ---
exportProv := &exportAdapter{mgr: stackMgr, encKey: encKey}
appExporter := appexport.NewExporter(exportProv, logger, Version)
appExporter.SetDebug(cfg.Logging.Level == "debug")
apiRouter.SetDebug(cfg.Logging.Level == "debug")
// --- Initialize web server --- // --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version) webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
webServer.SetEncryptionKey(encKey) webServer.SetEncryptionKey(encKey)
webServer.SetAppExporter(appExporter)
webServer.SetIntegrationManager(integrationMgr) webServer.SetIntegrationManager(integrationMgr)
webServer.SetStorageWatchdog(storageWatchdog) webServer.SetStorageWatchdog(storageWatchdog)
if assetsSyncer != nil { if assetsSyncer != nil {
@@ -773,6 +792,8 @@ func main() {
mux.HandleFunc("/api/health", apiRouter.HealthHandler) mux.HandleFunc("/api/health", apiRouter.HealthHandler)
// Storage API routes handled by web server (longer prefix takes precedence over /api/) // Storage API routes handled by web server (longer prefix takes precedence over /api/)
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI)))) mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
// App export/import API routes handled by web server
mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI))))
// Debug API routes handled by web server (debug-mode gating inside handler) // Debug API routes handled by web server (debug-mode gating inside handler)
mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI)))) mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI))))
// Self-update API — accepts session auth OR hub API key (for external triggering) // Self-update API — accepts session auth OR hub API key (for external triggering)
@@ -1063,6 +1084,158 @@ func (a *driveMigrateStackAdapter) StackExists(name string) bool {
return ok return ok
} }
// exportAdapter implements appexport.ExportStackProvider using stacks.Manager.
type exportAdapter struct {
mgr *stacks.Manager
encKey []byte
}
func (a *exportAdapter) GetStackDir(name string) (string, bool) {
s, ok := a.mgr.GetStack(name)
if !ok {
return "", false
}
return filepath.Dir(s.ComposePath), true
}
func (a *exportAdapter) GetStackComposePath(name string) (string, bool) {
s, ok := a.mgr.GetStack(name)
if !ok {
return "", false
}
return s.ComposePath, true
}
func (a *exportAdapter) GetStackHDDMounts(name string) []string {
s, ok := a.mgr.GetStack(name)
if !ok {
return nil
}
stackDir := filepath.Dir(s.ComposePath)
appCfg := stacks.LoadAppConfig(stackDir)
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
return stacks.ParseComposeHDDMounts(s.ComposePath, appCfg.Env["HDD_PATH"])
}
return nil
}
func (a *exportAdapter) GetStackHDDPath(name string) string {
s, ok := a.mgr.GetStack(name)
if !ok {
return ""
}
stackDir := filepath.Dir(s.ComposePath)
appCfg := stacks.LoadAppConfig(stackDir)
if appCfg != nil && appCfg.Env["HDD_PATH"] != "" {
return filepath.Clean(appCfg.Env["HDD_PATH"])
}
return ""
}
func (a *exportAdapter) IsStackRunning(name string) bool {
s, ok := a.mgr.GetStack(name)
return ok && s.State == stacks.StateRunning
}
func (a *exportAdapter) StopStack(name string) error {
return a.mgr.StopStack(name)
}
func (a *exportAdapter) StartStack(name string) error {
return a.mgr.StartStack(name)
}
func (a *exportAdapter) GetStackDisplayName(name string) string {
s, ok := a.mgr.GetStack(name)
if !ok {
return name
}
return s.Meta.DisplayName
}
func (a *exportAdapter) GetStackNeedsHDD(name string) bool {
s, ok := a.mgr.GetStack(name)
return ok && s.Meta.Resources.NeedsHDD
}
func (a *exportAdapter) GetDockerVolumes(name string) []string {
s, ok := a.mgr.GetStack(name)
if !ok {
return nil
}
vols := backup.ParseComposeNamedVolumes(s.ComposePath)
var names []string
for _, v := range vols {
names = append(names, v.Name)
}
return names
}
func (a *exportAdapter) IsStackDeployed(name string) bool {
s, ok := a.mgr.GetStack(name)
return ok && s.Deployed
}
func (a *exportAdapter) GetDecryptedEnv(name string) map[string]string {
s, ok := a.mgr.GetStack(name)
if !ok {
return nil
}
stackDir := filepath.Dir(s.ComposePath)
cfg := stacks.LoadAppConfigDecrypted(stackDir, a.encKey)
if cfg == nil {
return nil
}
return cfg.Env
}
func (a *exportAdapter) GetStacksBaseDir() string {
return a.mgr.GetStacksBaseDir()
}
func (a *exportAdapter) SaveEncryptedAppConfig(stackDir string, env map[string]string) error {
meta := stacks.LoadMetadata(stackDir)
sensitiveVars := stacks.SensitiveEnvVars(&meta)
cfg := &stacks.AppConfig{
Deployed: true,
DeployedAt: time.Now().Format(time.RFC3339),
Env: env,
}
return stacks.SaveAppConfig(stackDir, cfg, a.encKey, sensitiveVars)
}
func (a *exportAdapter) RefreshStacks() error {
return a.mgr.RefreshStatus()
}
func (a *exportAdapter) RemoveStackVolumes(name string) error {
s, ok := a.mgr.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(s.ComposePath)
// Build env from decrypted app config
cmdEnv := os.Environ()
appCfg := stacks.LoadAppConfigDecrypted(stackDir, a.encKey)
if appCfg != nil {
for k, v := range appCfg.Env {
cmdEnv = append(cmdEnv, k+"="+v)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", "compose", "down", "--volumes")
cmd.Dir = stackDir
cmd.Env = cmdEnv
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("compose down --volumes: %s — %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// pushInfraBackup builds and sends the infrastructure snapshot to the Hub. // pushInfraBackup builds and sends the infrastructure snapshot to the Hub.
func pushInfraBackup(cfg *config.Config, sett *settings.Settings, func pushInfraBackup(cfg *config.Config, sett *settings.Settings,
stackProv *stackAdapter, pusher *report.Pusher, logger *log.Logger) { stackProv *stackAdapter, pusher *report.Pusher, logger *log.Logger) {
+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) { func (r *Router) geoUpdateSettings(w http.ResponseWriter, req *http.Request) {
limitBody(w, req) limitBody(w, req)
r.dbg("geoUpdateSettings: contentLength=%d", req.ContentLength)
var body struct { var body struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
+23
View File
@@ -59,6 +59,19 @@ type Router struct {
// App-to-app integration manager (nil if not configured) // App-to-app integration manager (nil if not configured)
integrationMgr *integrations.Manager 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. // 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.TrimPrefix(req.URL.Path, "/api")
path = strings.TrimSuffix(path, "/") path = strings.TrimSuffix(path, "/")
r.dbg("%s %s (path=%s)", req.Method, req.URL.Path, path)
switch { switch {
// GET /api/stacks // GET /api/stacks
case path == "/stacks" && req.Method == http.MethodGet: 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")) r.geoRemoveAppOverride(w, req, extractName(path, "/geo/override"))
default: default:
r.dbg("no matching route: %s %s", req.Method, path)
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"}) 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) { func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req) limitBody(w, req)
r.logger.Printf("[API] Deploy requested for stack: %s", name) r.logger.Printf("[API] Deploy requested for stack: %s", name)
r.dbg("deployStack: name=%s contentLength=%d", name, req.ContentLength)
var body struct { var body struct {
Values map[string]string `json:"values"` 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) { func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[API] %s requested for stack: %s", action, name) 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 // Protected stacks only allow restart — block all other actions
if r.cfg.IsProtectedStack(name) && action != "restart" { 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) limitBody(w, req)
r.logger.Printf("[API] Remove requested for stack: %s", name) r.logger.Printf("[API] Remove requested for stack: %s", name)
r.dbg("removeStack: name=%s", name)
var body struct { var body struct {
RemoveHDDData bool `json:"remove_hdd_data"` 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.RemoveHDDData = false
body.RemoveBackups = 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 // Compute backup paths to remove if requested
var backupPaths []string 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) { func (r *Router) triggerBackup(w http.ResponseWriter, _ *http.Request) {
r.dbg("triggerBackup: backupMgr=%v", r.backupMgr != nil)
if r.backupMgr == nil { if r.backupMgr == nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"}) writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "Backup not configured"})
return return
} }
if r.backupMgr.IsRunning() { if r.backupMgr.IsRunning() {
r.dbg("triggerBackup: backup already running, rejecting")
writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"}) writeJSON(w, http.StatusConflict, apiResponse{OK: false, Error: "Mentés már folyamatban"})
return return
} }
@@ -1130,6 +1152,7 @@ func (r *Router) selfupdateTrigger(w http.ResponseWriter, _ *http.Request) {
// --- Config apply handler --- // --- Config apply handler ---
func (r *Router) configApply(w http.ResponseWriter, req *http.Request) { 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) // Read YAML body (limit to 1MB)
body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20)) body, err := io.ReadAll(io.LimitReader(req.Body, 1<<20))
if err != nil { 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 // Sync fetches the manifest from the Hub, compares checksums, and downloads
// changed/new files. It also removes local files not in the Hub manifest. // changed/new files. It also removes local files not in the Hub manifest.
func (s *Syncer) Sync(ctx context.Context) error { 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...") s.logger.Println("[INFO] Asset sync starting...")
syncStart := time.Now()
if err := os.MkdirAll(s.assetsDir, 0755); err != nil { if err := os.MkdirAll(s.assetsDir, 0755); err != nil {
s.setError(fmt.Errorf("create assets dir: %w", err)) 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 // 1. Fetch Hub manifest
if s.debug { s.dbg("fetching manifest from %s/api/v1/assets/manifest", s.hubURL)
s.logger.Printf("[DEBUG] Asset sync: fetching manifest from %s/api/v1/assets/manifest", s.hubURL) manifestStart := time.Now()
}
manifest, err := s.fetchManifest(ctx) manifest, err := s.fetchManifest(ctx)
if err != nil { if err != nil {
s.setError(fmt.Errorf("fetch manifest: %w", err)) s.setError(fmt.Errorf("fetch manifest: %w", err))
return err return err
} }
if s.debug { s.dbg("manifest fetched in %s: %d files, generated=%s",
s.logger.Printf("[DEBUG] Asset sync: manifest has %d files", len(manifest.Files)) time.Since(manifestStart).Round(time.Millisecond), len(manifest.Files), manifest.Generated)
}
// 2. Build local hash map // 2. Build local hash map
hashStart := time.Now()
localHashes, err := s.buildLocalHashes() localHashes, err := s.buildLocalHashes()
if err != nil { if err != nil {
s.setError(fmt.Errorf("scan local assets: %w", err)) s.setError(fmt.Errorf("scan local assets: %w", err))
return err return err
} }
if s.debug { s.dbg("local hash scan: %d files in %s", len(localHashes), time.Since(hashStart).Round(time.Millisecond))
s.logger.Printf("[DEBUG] Asset sync: %d local files found", len(localHashes))
}
// 3. Download changed/new files // 3. Download changed/new files
hubFiles := make(map[string]bool, len(manifest.Files)) hubFiles := make(map[string]bool, len(manifest.Files))
@@ -124,17 +128,22 @@ func (s *Syncer) Sync(ctx context.Context) error {
totalBytes += entry.Size totalBytes += entry.Size
if localHash, ok := localHashes[entry.Filename]; ok && localHash == entry.SHA256 { if localHash, ok := localHashes[entry.Filename]; ok && localHash == entry.SHA256 {
s.dbg("file %s: hash match (%s), skipping", entry.Filename, entry.SHA256[:12]+"...")
skipped++ skipped++
continue continue
} }
if s.debug { reason := "new"
s.logger.Printf("[DEBUG] Asset sync: downloading %s (remote sha256=%s)", entry.Filename, entry.SHA256[:12]+"...") 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 { if err := s.downloadFile(ctx, entry.Filename); err != nil {
s.logger.Printf("[WARN] Failed to download asset %s: %v", entry.Filename, err) s.logger.Printf("[WARN] Failed to download asset %s: %v", entry.Filename, err)
continue continue
} }
s.dbg("file %s: downloaded in %s", entry.Filename, time.Since(dlStart).Round(time.Millisecond))
downloaded++ downloaded++
} }
@@ -143,9 +152,7 @@ func (s *Syncer) Sync(ctx context.Context) error {
for name := range localHashes { for name := range localHashes {
if !hubFiles[name] { if !hubFiles[name] {
path := filepath.Join(s.assetsDir, name) path := filepath.Join(s.assetsDir, name)
if s.debug { s.dbg("removing stale file %s", name)
s.logger.Printf("[DEBUG] Asset sync: removing stale file %s", name)
}
if err := os.Remove(path); err != nil { if err := os.Remove(path); err != nil {
s.logger.Printf("[WARN] Failed to remove stale asset %s: %v", name, err) s.logger.Printf("[WARN] Failed to remove stale asset %s: %v", name, err)
} else { } 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)", s.logger.Printf("[INFO] Asset sync complete: %d downloaded, %d unchanged, %d removed (%d total files)",
downloaded, skipped, removed, len(manifest.Files)) downloaded, skipped, removed, len(manifest.Files))
s.dbg("sync completed in %s", time.Since(syncStart).Round(time.Millisecond))
return nil return nil
} }
@@ -223,9 +231,11 @@ func (s *Syncer) fetchManifest(ctx context.Context) (*HubManifest, error) {
resp, err := s.httpClient.Do(req) resp, err := s.httpClient.Do(req)
if err != nil { if err != nil {
s.dbg("fetchManifest: HTTP request failed: %v", err)
return nil, fmt.Errorf("HTTP request: %w", err) return nil, fmt.Errorf("HTTP request: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
s.dbg("fetchManifest: HTTP %d, content-length=%d", resp.StatusCode, resp.ContentLength)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) 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) resp, err := s.httpClient.Do(req)
if err != nil { if err != nil {
s.dbg("downloadFile %s: HTTP request failed: %v", filename, err)
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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) 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 // Atomic write: write to .tmp, rename
dst := filepath.Join(s.assetsDir, filepath.Base(filename)) 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 info.HasHDDData = len(info.HDDPaths) > 0
// Discover Docker named volumes from compose // 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) // Check if app has a DB container (already backed up via DB dump)
for _, db := range discoveredDBs { for _, db := range discoveredDBs {
@@ -108,8 +108,8 @@ func DiscoverAppData(provider StackDataProvider, discoveredDBs []DiscoveredDB) [
return result return result
} }
// parseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml. // ParseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
func parseComposeNamedVolumes(composePath string) []AppDockerVolume { func ParseComposeNamedVolumes(composePath string) []AppDockerVolume {
data, err := os.ReadFile(composePath) data, err := os.ReadFile(composePath)
if err != nil { if err != nil {
return nil return nil
+3 -1
View File
@@ -156,9 +156,11 @@ func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Setti
if dataDir == "" { if dataDir == "" {
dataDir = "/opt/docker/felhom-controller/data" dataDir = "/opt/docker/felhom-controller/data"
} }
restic := NewResticManager(cfg, logger)
restic.SetDebug(cfg.Logging.Level == "debug")
return &Manager{ return &Manager{
cfg: cfg, cfg: cfg,
restic: NewResticManager(cfg, logger), restic: restic,
logger: logger, logger: logger,
pinger: pinger, pinger: pinger,
settings: sett, settings: sett,
+94 -1
View File
@@ -23,6 +23,7 @@ type ResticManager struct {
logger *log.Logger logger *log.Logger
customerID string customerID string
cacheDir string cacheDir string
debug bool
} }
// SnapshotResult holds the outcome of a restic backup. // 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. // EnsureInitialized checks if the restic repo exists and initializes it if not.
// Also auto-generates the password file if missing. // Also auto-generates the password file if missing.
func (r *ResticManager) EnsureInitialized(repoPath string) error { 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 // Ensure password file exists
if _, err := os.Stat(r.passwordFile); os.IsNotExist(err) { if _, err := os.Stat(r.passwordFile); os.IsNotExist(err) {
if err := r.generatePassword(); err != nil { if err := r.generatePassword(); err != nil {
@@ -109,6 +118,9 @@ func (r *ResticManager) Snapshot(repoPath string, paths []string, tags []string)
defer cancel() defer cancel()
start := time.Now() 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"} args := []string{"backup", "--json"}
for _, tag := range tags { for _, tag := range tags {
@@ -129,6 +141,9 @@ func (r *ResticManager) Snapshot(repoPath string, paths []string, tags []string)
if len(existingPaths) == 0 { if len(existingPaths) == 0 {
return nil, fmt.Errorf("no backup paths exist") 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...) args = append(args, existingPaths...)
cmd := r.command(ctx, repoPath, args...) 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 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) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() 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{ args := []string{
"forget", "forget",
"--keep-daily", fmt.Sprintf("%d", retention.KeepDaily), "--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)) 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) r.logger.Printf("[INFO] Restic prune completed for %s", repoPath)
return nil return nil
} }
@@ -218,11 +247,23 @@ func (r *ResticManager) Check(repoPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel() defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] Check: repo=%s", repoPath)
}
start := time.Now()
cmd := r.command(ctx, repoPath, "check") cmd := r.command(ctx, repoPath, "check")
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { 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)) 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 return nil
} }
@@ -231,6 +272,10 @@ func (r *ResticManager) ListSnapshots(repoPath string, limit int) ([]SnapshotInf
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] ListSnapshots: repo=%s, limit=%d", repoPath, limit)
}
cmd := r.command(ctx, repoPath, "snapshots", "--json") cmd := r.command(ctx, repoPath, "snapshots", "--json")
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
@@ -251,6 +296,11 @@ func (r *ResticManager) ListSnapshots(repoPath string, limit int) ([]SnapshotInf
snapshots = snapshots[:limit] 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 return snapshots, nil
} }
@@ -259,6 +309,10 @@ func (r *ResticManager) LatestSnapshot(repoPath string) (*SnapshotInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel() defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] LatestSnapshot: repo=%s", repoPath)
}
cmd := r.command(ctx, repoPath, "snapshots", "--latest", "1", "--json") cmd := r.command(ctx, repoPath, "snapshots", "--latest", "1", "--json")
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
@@ -271,9 +325,17 @@ func (r *ResticManager) LatestSnapshot(repoPath string) (*SnapshotInfo, error) {
} }
if len(snapshots) == 0 { if len(snapshots) == 0 {
if r.debug {
r.logger.Printf("[DEBUG] [restic] LatestSnapshot: repo=%s, no snapshots found", repoPath)
}
return nil, nil 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 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) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel() defer cancel()
if r.debug {
r.logger.Printf("[DEBUG] [restic] Stats: repo=%s", repoPath)
}
start := time.Now()
stats := &RepoStats{} stats := &RepoStats{}
// Get repo size // 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 return stats, nil
} }
@@ -336,6 +412,12 @@ func (r *ResticManager) RestoreAppData(repoPath string, snapshotID string, paths
args = append(args, "--include", p) 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) r.logger.Printf("[WARN] RESTORE started: repo=%s, snapshot=%s, paths=%v", repoPath, snapshotID, paths)
cmd := r.command(ctx, repoPath, args...) 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) 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) r.logger.Printf("[INFO] RESTORE completed: snapshot=%s, paths=%v", snapshotID, paths)
return nil return nil
} }
// RepoExists checks if a restic repo is initialized at the given path. // RepoExists checks if a restic repo is initialized at the given path.
func (r *ResticManager) RepoExists(repoPath string) bool { func (r *ResticManager) RepoExists(repoPath string) bool {
exists := false
_, err := os.Stat(filepath.Join(repoPath, "config")) _, 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. // 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 { 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 := exec.CommandContext(ctx, "restic", args...)
cmd.Env = append(os.Environ(), cmd.Env = append(os.Environ(),
"RESTIC_REPOSITORY="+repoPath, "RESTIC_REPOSITORY="+repoPath,
@@ -10,19 +10,26 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
// RestoreAppFromBackup restores a single app from its cross-drive backup. // RestoreAppFromBackup restores a single app from its cross-drive backup.
// Steps: restore config → verify/restore data → copy DB dumps → docker compose up. // 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 { func RestoreAppFromBackup(ctx context.Context, app *RestorableApp, stacksDir string, logger *log.Logger) error {
stackDir := filepath.Join(stacksDir, app.Name) 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 // Step 1: Restore stack config from _config/ backup
if app.HasConfig { if app.HasConfig {
logger.Printf("[INFO] Restoring config for %s from %s", app.Name, app.ConfigPath) 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 { if err := restoreConfigDir(ctx, app.ConfigPath, stackDir); err != nil {
return fmt.Errorf("restoring config: %w", err) 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 { } else {
// No config backup — check if stack dir already exists (from catalog sync) // No config backup — check if stack dir already exists (from catalog sync)
if !dirExists(stackDir) { if !dirExists(stackDir) {
@@ -35,20 +42,29 @@ func RestoreAppFromBackup(ctx context.Context, app *RestorableApp, stacksDir str
if app.NeedsHDD && !app.HasData && app.HasRsyncData { if app.NeedsHDD && !app.HasData && app.HasRsyncData {
// App data is missing but rsync backup exists — restore it // App data is missing but rsync backup exists — restore it
logger.Printf("[INFO] Restoring user data for %s from rsync backup", app.Name) logger.Printf("[INFO] Restoring user data for %s from rsync backup", app.Name)
stepStart := time.Now()
if err := restoreUserData(ctx, app, logger); err != nil { if err := restoreUserData(ctx, app, logger); err != nil {
logger.Printf("[WARN] User data restore failed for %s: %v", app.Name, err) logger.Printf("[WARN] User data restore failed for %s: %v", app.Name, err)
// Non-fatal: app might still start without all data // 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 { } else if app.HasData {
logger.Printf("[INFO] App data for %s found at %s — no restore needed", app.Name, app.DataPath) 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 // Step 3: Copy DB dumps to primary backup location
if app.HasDBDump { if app.HasDBDump {
logger.Printf("[INFO] Restoring DB dumps for %s", app.Name) logger.Printf("[INFO] Restoring DB dumps for %s", app.Name)
stepStart := time.Now()
if err := restoreDBDumps(app, logger); err != nil { if err := restoreDBDumps(app, logger); err != nil {
logger.Printf("[WARN] DB dump restore failed for %s: %v", app.Name, err) logger.Printf("[WARN] DB dump restore failed for %s: %v", app.Name, err)
// Non-fatal // 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) 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) logger.Printf("[INFO] Pulling images for %s", app.Name)
pullStart := time.Now()
pullCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "pull") pullCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "pull")
pullCmd.Dir = composeDir pullCmd.Dir = composeDir
if out, err := pullCmd.CombinedOutput(); err != nil { 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))) 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 // 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) logger.Printf("[INFO] Starting %s", app.Name)
upStart := time.Now()
upCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "up", "-d") upCmd := exec.CommandContext(ctx, "docker", "compose", "-f", composePath, "up", "-d")
upCmd.Dir = composeDir upCmd.Dir = composeDir
if out, err := upCmd.CombinedOutput(); err != nil { if out, err := upCmd.CombinedOutput(); err != nil {
return fmt.Errorf("docker compose up: %v (%s)", err, strings.TrimSpace(string(out))) 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 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") 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. // The rsync backup contains the app's data directories.
// Walk the backup dir and rsync each subdirectory (excluding _config/_db) // Walk the backup dir and rsync each subdirectory (excluding _config/_db)
// back to the app's HDD data directory. // 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) 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 { if err := os.MkdirAll(dataDir, 0755); err != nil {
return fmt.Errorf("creating data dir: %w", err) return fmt.Errorf("creating data dir: %w", err)
} }
restored := 0
for _, e := range entries { for _, e := range entries {
name := e.Name() name := e.Name()
if name == "_config" || name == "_db" || strings.HasPrefix(name, ".") { if name == "_config" || name == "_db" || strings.HasPrefix(name, ".") {
@@ -132,9 +160,12 @@ func restoreUserData(ctx context.Context, app *RestorableApp, logger *log.Logger
continue continue
} }
dst = strings.TrimRight(dst, "/") + "/" 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) cmd := exec.CommandContext(ctx, "rsync", "-a", src, dst)
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
logger.Printf("[WARN] rsync data %s: %v (%s)", name, err, strings.TrimSpace(string(out))) logger.Printf("[WARN] rsync data %s: %v (%s)", name, err, strings.TrimSpace(string(out)))
} else {
restored++
} }
} else { } else {
// Single file — copy directly // 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) logger.Printf("[WARN] Cannot read %s: %v", src, err)
continue 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 { if err := os.WriteFile(dst, data, 0644); err != nil {
logger.Printf("[WARN] Cannot write %s: %v", dst, err) 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 return nil
} }
@@ -170,6 +205,7 @@ func restoreDBDumps(app *RestorableApp, logger *log.Logger) error {
} }
destDir := AppDBDumpPath(drivePath, app.Name) 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 { if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("creating dump dir: %w", err) return fmt.Errorf("creating dump dir: %w", err)
} }
@@ -179,6 +215,7 @@ func restoreDBDumps(app *RestorableApp, logger *log.Logger) error {
return err return err
} }
copied := 0
for _, e := range entries { for _, e := range entries {
if e.IsDir() { if e.IsDir() {
continue continue
@@ -190,11 +227,15 @@ func restoreDBDumps(app *RestorableApp, logger *log.Logger) error {
logger.Printf("[WARN] Cannot read dump %s: %v", e.Name(), err) logger.Printf("[WARN] Cannot read dump %s: %v", e.Name(), err)
continue 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 { if err := os.WriteFile(dst, data, 0644); err != nil {
logger.Printf("[WARN] Cannot write dump %s: %v", e.Name(), err) 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 return nil
} }
@@ -25,22 +25,38 @@ import (
// Returns the list of successfully mounted final mount paths. // Returns the list of successfully mounted final mount paths.
func MountDrivesFromLayout(ctx context.Context, layout DiskLayout, logger *log.Logger) ([]string, error) { func MountDrivesFromLayout(ctx context.Context, layout DiskLayout, logger *log.Logger) ([]string, error) {
if len(layout.Mounts) == 0 { if len(layout.Mounts) == 0 {
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: no mounts in layout, nothing to do")
return nil, nil return nil, nil
} }
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: processing %d mount entries from disk layout", len(layout.Mounts))
// Get current block devices with UUIDs // Get current block devices with UUIDs
uuidToDevice, err := scanBlockDeviceUUIDs(ctx) uuidToDevice, err := scanBlockDeviceUUIDs(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning block devices: %w", err) 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 var mounted []string
for _, dm := range layout.Mounts { for _, dm := range layout.Mounts {
if dm.UUID == "" { if dm.UUID == "" {
logger.Printf("[DEBUG] [backup] MountDrivesFromLayout: skipping mount entry with empty UUID (label=%s)", dm.Label)
continue 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 // Find matching device by UUID
device := uuidToDevice[dm.UUID] device := uuidToDevice[dm.UUID]
if device == "" { if device == "" {
@@ -72,12 +88,15 @@ func MountDrivesFromLayout(ctx context.Context, layout DiskLayout, logger *log.L
// Mount using the appropriate pattern // Mount using the appropriate pattern
if dm.RawMount != "" && dm.BindSubdir != "" { if dm.RawMount != "" && dm.BindSubdir != "" {
// Two-layer HDD mount: raw → bind // 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 { if err := mountRawAndBind(ctx, device, dm, logger); err != nil {
logger.Printf("[ERROR] Failed to mount %s: %v", dm.Label, err) logger.Printf("[ERROR] Failed to mount %s: %v", dm.Label, err)
continue continue
} }
} else { } else {
// Simple direct mount (e.g., sys_drive) // 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 { if err := mountDirect(ctx, device, dm, logger); err != nil {
logger.Printf("[ERROR] Failed to mount %s: %v", dm.Label, err) logger.Printf("[ERROR] Failed to mount %s: %v", dm.Label, err)
continue 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("[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 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) { func scanBlockDeviceUUIDs(ctx context.Context) (map[string]string, error) {
// First try lsblk with UUID output // First try lsblk with UUID output
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-o", "NAME,UUID,FSTYPE,MOUNTPOINT").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 // Use host device path if available
devPath := hostDevPath(device) 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) cmd := exec.CommandContext(ctx, "mount", "-t", dm.FSType, "-o", "noatime", devPath, dm.MountPoint)
if out, err := cmd.CombinedOutput(); err != nil { if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("mount %s: %s: %w", devPath, strings.TrimSpace(string(out)), err) 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 return nil
} }
@@ -187,12 +209,14 @@ func mountRawAndBind(ctx context.Context, device string, dm DiskMount, logger *l
} }
devPath := hostDevPath(device) 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) cmd := exec.CommandContext(ctx, "mount", "-t", dm.FSType, "-o", "noatime", devPath, dm.RawMount)
if out, err := cmd.CombinedOutput(); err != nil { 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) bindSrc := filepath.Join(dm.RawMount, dm.BindSubdir)
if err := os.MkdirAll(bindSrc, 0755); err != nil { if err := os.MkdirAll(bindSrc, 0755); err != nil {
return fmt.Errorf("creating bind source dir: %w", err) 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) 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) cmd = exec.CommandContext(ctx, "mount", "--bind", bindSrc, dm.MountPoint)
if out, err := cmd.CombinedOutput(); err != nil { 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 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 { func addDRFstabEntries(dm DiskMount, logger *log.Logger) error {
const fstabPath = "/host-fstab" const fstabPath = "/host-fstab"
logger.Printf("[DEBUG] [backup] addDRFstabEntries: checking fstab for %s (UUID=%s)", dm.Label, dm.UUID)
data, err := os.ReadFile(fstabPath) data, err := os.ReadFile(fstabPath)
if err != nil { if err != nil {
return fmt.Errorf("reading fstab: %w", err) 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) // Skip if UUID already in fstab (idempotent)
if strings.Contains(content, dm.UUID) { if strings.Contains(content, dm.UUID) {
logger.Printf("[DEBUG] [backup] addDRFstabEntries: UUID %s already in fstab — skipping", dm.UUID)
return nil return nil
} }
@@ -163,6 +163,9 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
Status: "pending", 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 // Build drive info and find backup directories
type driveBackup struct { type driveBackup struct {
drivePath string drivePath string
@@ -181,6 +184,8 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
Available: avail, Available: avail,
} }
logger.Printf("[DEBUG] [backup] ScanDrivesForBackups: checking drive %s (label=%s, available=%v)", mp, label, avail)
secPath := SecondaryBackupPath(mp) secPath := SecondaryBackupPath(mp)
if dirExists(secPath) { if dirExists(secPath) {
di.HasBackup = true di.HasBackup = true
@@ -195,6 +200,8 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
plan.Drives = append(plan.Drives, di) 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 each stack from the manifest, look for backup data on drives
for _, stack := range stacks { for _, stack := range stacks {
app := RestorableApp{ app := RestorableApp{
@@ -205,12 +212,16 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
Status: "pending", 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) // Check if app data exists directly on HDD (common case: HDD survived)
if stack.HDDPath != "" { if stack.HDDPath != "" {
dataDir := AppDataDir(stack.HDDPath, stack.Name) dataDir := AppDataDir(stack.HDDPath, stack.Name)
if dirExists(dataDir) { if dirExists(dataDir) {
app.HasData = true app.HasData = true
app.DataPath = dataDir 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 // Found a backup for this app
app.DrivePath = db.drivePath app.DrivePath = db.drivePath
app.DriveLabel = db.label 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) // Check for _config/ (stack compose directory backup)
configDir := filepath.Join(rsyncBase, "_config") configDir := filepath.Join(rsyncBase, "_config")
@@ -245,6 +258,9 @@ func ScanDrivesForBackups(mountedPaths []string, stacks []InfraStackInfo, logger
app.RsyncDataPath = rsyncBase 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 break // use first drive with backup for this app
} }
+81 -1
View File
@@ -23,6 +23,7 @@ type GeoSyncManager struct {
domain string domain string
stacks StackLister stacks StackLister
logger *log.Logger logger *log.Logger
debug bool
mu sync.Mutex mu sync.Mutex
running bool 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. // IsRunning returns true if a sync operation is in progress.
func (g *GeoSyncManager) IsRunning() bool { func (g *GeoSyncManager) IsRunning() bool {
g.mu.Lock() g.mu.Lock()
@@ -75,17 +81,28 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
// 1. Resolve zone ID (use cached value if available) // 1. Resolve zone ID (use cached value if available)
zoneID := geo.ZoneID zoneID := geo.ZoneID
if 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 var err error
zoneID, err = g.client.GetZoneID(ctx, g.domain) zoneID, err = g.client.GetZoneID(ctx, g.domain)
if err != nil { if err != nil {
g.saveError(zoneID, "", err.Error()) g.saveError(zoneID, "", err.Error())
return fmt.Errorf("resolve zone: %w", err) 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 // 2. Get or create the custom WAF ruleset
rulesetID := geo.RulesetID rulesetID := geo.RulesetID
if rulesetID == "" { if rulesetID == "" {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] Ruleset ID not cached, looking up for zone %s", zoneID)
}
var err error var err error
rulesetID, err = g.client.GetCustomRulesetID(ctx, zoneID) rulesetID, err = g.client.GetCustomRulesetID(ctx, zoneID)
if err != nil { if err != nil {
@@ -93,12 +110,20 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
return fmt.Errorf("get ruleset: %w", err) return fmt.Errorf("get ruleset: %w", err)
} }
if rulesetID == "" { 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) rulesetID, err = g.client.CreateCustomRuleset(ctx, zoneID)
if err != nil { if err != nil {
g.saveError(zoneID, "", err.Error()) g.saveError(zoneID, "", err.Error())
return fmt.Errorf("create ruleset: %w", err) 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 // 3. List existing felhom-managed rules
@@ -107,9 +132,21 @@ func (g *GeoSyncManager) Sync(ctx context.Context) error {
g.saveError(zoneID, rulesetID, err.Error()) g.saveError(zoneID, rulesetID, err.Error())
return fmt.Errorf("list existing rules: %w", err) 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 // 4. Build desired rules
desired := g.buildDesiredRules(geo) 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 // 5. Diff and apply
if err := g.applyDiff(ctx, zoneID, rulesetID, existing, desired); err != nil { 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 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 // No cached IDs — nothing to clean up
return nil 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) existing, err := g.client.GetFelhomRules(ctx, zoneID, rulesetID)
if err != nil { if err != nil {
g.logger.Printf("[GEO] Warning: could not list rules for cleanup: %v", err) g.logger.Printf("[GEO] Warning: could not list rules for cleanup: %v", err)
return nil 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 { for _, r := range existing {
if err := g.client.DeleteRule(ctx, zoneID, rulesetID, r.ID); err != nil { 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) g.logger.Printf("[GEO] Warning: could not delete rule %s: %v", r.ID, err)
} else {
deleted++
} }
} }
if len(existing) > 0 { if len(existing) > 0 {
g.logger.Printf("[GEO] Deleted %d felhom-geo rules (feature disabled)", len(existing)) 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, "") g.saveError(zoneID, rulesetID, "")
@@ -169,6 +223,9 @@ func (g *GeoSyncManager) buildDesiredRules(geo *settings.GeoRestriction) []desir
var rules []desiredRule var rules []desiredRule
hostnames := g.stacks.GetDeployedHostnames() 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) // Collect app hostnames that have overrides (to exclude from global rule)
var excludeHostnames []string var excludeHostnames []string
@@ -177,6 +234,9 @@ func (g *GeoSyncManager) buildDesiredRules(geo *settings.GeoRestriction) []desir
for appName, override := range geo.AppOverrides { for appName, override := range geo.AppOverrides {
hostname, ok := hostnames[appName] hostname, ok := hostnames[appName]
if !ok { if !ok {
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] buildDesiredRules: skipping override for %q (not deployed)", appName)
}
continue // app not deployed, skip continue // app not deployed, skip
} }
overrideApps[appName] = true 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 exclude hostnames for deterministic expression
sort.Strings(excludeHostnames) sort.Strings(excludeHostnames)
// Global rule (excludes apps with their own rules) // Global rule (excludes apps with their own rules)
globalExpr := BuildGlobalExpression(geo.AllowedCountries, excludeHostnames)
rules = append(rules, desiredRule{ rules = append(rules, desiredRule{
description: globalRuleDesc, 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 return rules
} }
@@ -220,13 +289,21 @@ func (g *GeoSyncManager) applyDiff(ctx context.Context, zoneID, rulesetID string
if ex, ok := existingByDesc[d.description]; ok { if ex, ok := existingByDesc[d.description]; ok {
// Rule exists — check if expression changed // Rule exists — check if expression changed
if ex.Expression != d.expression { 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) r := newBlockRule(d.description, d.expression)
if err := g.client.UpdateRule(ctx, zoneID, rulesetID, ex.ID, r); err != nil { if err := g.client.UpdateRule(ctx, zoneID, rulesetID, ex.ID, r); err != nil {
return fmt.Errorf("update rule %q: %w", d.description, err) 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 { } else {
// New rule — create // New rule — create
if g.debug {
g.logger.Printf("[DEBUG] [cloudflare] applyDiff: creating new rule %q", d.description)
}
r := newBlockRule(d.description, d.expression) r := newBlockRule(d.description, d.expression)
if _, err := g.client.CreateRule(ctx, zoneID, rulesetID, r); err != nil { if _, err := g.client.CreateRule(ctx, zoneID, rulesetID, r); err != nil {
return fmt.Errorf("create rule %q: %w", d.description, err) 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 // Delete rules that are no longer desired
for _, ex := range existing { for _, ex := range existing {
if _, ok := desiredByDesc[ex.Description]; !ok { 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 { if err := g.client.DeleteRule(ctx, zoneID, rulesetID, ex.ID); err != nil {
return fmt.Errorf("delete rule %q: %w", ex.Description, err) 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) 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 { for _, rs := range rulesets {
if rs.Phase == wafPhase { 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 return rs.ID, nil
} }
} }
if c.debug {
c.logger.Printf("[CF-DEBUG] GetCustomRulesetID: no ruleset with phase %s found", wafPhase)
}
return "", nil 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) 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 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 return result, nil
} }
@@ -163,6 +182,13 @@ func (c *Client) CreateRule(ctx context.Context, zoneID, rulesetID string, r rul
for _, created := range rs.Rules { for _, created := range rs.Rules {
if created.Description == r.Description { if created.Description == r.Description {
c.logger.Printf("[CF] Created rule %q → %s", r.Description, created.ID) 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 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) return fmt.Errorf("update rule %s: %w", ruleID, err)
} }
c.logger.Printf("[CF] Updated rule %q (%s)", r.Description, ruleID) 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 return nil
} }
+10
View File
@@ -16,7 +16,14 @@ type zone struct {
// GetZoneID resolves the Cloudflare zone ID for a domain. // GetZoneID resolves the Cloudflare zone ID for a domain.
// It tries the exact domain first, then strips subdomains progressively. // It tries the exact domain first, then strips subdomains progressively.
func (c *Client) GetZoneID(ctx context.Context, domain string) (string, error) { 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") // 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) id, err := c.lookupZone(ctx, domain)
if err != nil { if err != nil {
return "", err return "", err
@@ -32,6 +39,9 @@ func (c *Client) GetZoneID(ctx context.Context, domain string) (string, error) {
if parent == "" { if parent == "" {
break break
} }
if c.debug {
c.logger.Printf("[CF-DEBUG] GetZoneID: trying parent domain %q", parent)
}
id, err = c.lookupZone(ctx, parent) id, err = c.lookupZone(ctx, parent)
if err != nil { if err != nil {
return "", err return "", err
@@ -20,6 +20,10 @@ func (m *Manager) OnStackStop(_ context.Context, stackName string) {
all[k] = v all[k] = v
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStop: stack=%s integrationsFound=%d", stackName, len(all))
}
for key, state := range all { for key, state := range all {
if !state.Enabled || state.Status == "disabled" { if !state.Enabled || state.Status == "disabled" {
continue continue
@@ -41,6 +45,9 @@ func (m *Manager) OnStackStop(_ context.Context, stackName string) {
continue continue
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStop: revoking %s", key)
}
if err := handler.Revoke(ac); err != nil { if err := handler.Revoke(ac); err != nil {
m.logger.Printf("[WARN] Integration revoke on stop failed for %s: %v", key, err) 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. // Re-applies integrations that were previously enabled but are not currently active.
// Waits briefly for the stack manager to refresh container state. // Waits briefly for the stack manager to refresh container state.
func (m *Manager) OnStackStart(_ context.Context, stackName string) { 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 // Brief delay so the stack manager's periodic status refresh
// picks up the new container state (runs every 30s). // picks up the new container state (runs every 30s).
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
@@ -72,6 +82,10 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
all[k] = v all[k] = v
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStart: stack=%s integrationsFound=%d", stackName, len(all))
}
for key, state := range all { for key, state := range all {
if !state.Enabled || state.Status == "active" { if !state.Enabled || state.Status == "active" {
continue continue
@@ -86,11 +100,17 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
// StateStarting = container running but healthcheck hasn't passed yet — still connectable. // StateStarting = container running but healthcheck hasn't passed yet — still connectable.
provStack, pOk := m.stacks.GetStack(provider) provStack, pOk := m.stacks.GetStack(provider)
if !pOk || !provStack.Deployed || !isStackUp(provStack.State) { 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 continue
} }
if target != "filebrowser" { if target != "filebrowser" {
tgtStack, tOk := m.stacks.GetStack(target) tgtStack, tOk := m.stacks.GetStack(target)
if !tOk || !tgtStack.Deployed || !isStackUp(tgtStack.State) { 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 continue
} }
} }
@@ -100,6 +120,10 @@ func (m *Manager) OnStackStart(_ context.Context, stackName string) {
continue continue
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackStart: re-applying %s (currentStatus=%s)", key, state.Status)
}
ac, err := m.buildApplyContext(provider, target) ac, err := m.buildApplyContext(provider, target)
if err != nil { if err != nil {
m.logger.Printf("[WARN] Cannot re-apply integration %s on start: %v", key, err) 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 all[k] = v
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackRemove: stack=%s integrationsFound=%d", stackName, len(all))
}
for key, state := range all { for key, state := range all {
provider, target, ok := ParseIntegrationKey(key) provider, target, ok := ParseIntegrationKey(key)
if !ok { if !ok {
@@ -145,6 +173,9 @@ func (m *Manager) OnStackRemove(_ context.Context, stackName string) {
} }
if state.Enabled { if state.Enabled {
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] OnStackRemove: revoking enabled integration %s", key)
}
handler, hOk := m.handlers[key] handler, hOk := m.handlers[key]
if hOk { if hOk {
ac, _ := m.buildApplyContext(provider, target) ac, _ := m.buildApplyContext(provider, target)
@@ -31,6 +31,7 @@ type Manager struct {
handlers map[string]Handler // key: "provider:target" -> Handler handlers map[string]Handler // key: "provider:target" -> Handler
mu sync.Mutex // serialize apply/revoke operations mu sync.Mutex // serialize apply/revoke operations
debug bool
} }
// NewManager creates an integration manager and registers built-in handlers. // 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 // Register built-in handlers
m.RegisterHandler("onlyoffice:filebrowser", &OnlyOfficeFileBrowserHandler{}) m.RegisterHandler("onlyoffice:filebrowser", &OnlyOfficeFileBrowserHandler{})
m.RegisterHandler("onlyoffice:nextcloud", &OnlyOfficeNextcloudHandler{}) 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 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. // RegisterHandler registers a handler for a provider:target integration key.
func (m *Manager) RegisterHandler(key string, h Handler) { func (m *Manager) RegisterHandler(key string, h Handler) {
m.handlers[key] = h m.handlers[key] = h
@@ -61,6 +81,10 @@ func (m *Manager) Toggle(ctx context.Context, provider, target string, enable bo
defer m.mu.Unlock() defer m.mu.Unlock()
key := IntegrationKey(provider, target) 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] handler, ok := m.handlers[key]
if !ok { if !ok {
return settings.IntegrationState{}, fmt.Errorf("nincs kezelő a(z) %s integrációhoz", key) 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 { if enable {
// Validate: provider must be deployed and running // Validate: provider must be deployed and running
provStack, pOk := m.stacks.GetStack(provider) 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 { if !pOk || !provStack.Deployed {
return state, fmt.Errorf("a szolgáltató alkalmazás (%s) nincs telepítve", provider) 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) // Validate: target must be deployed and running (filebrowser is infra, always present)
if target != "filebrowser" { if target != "filebrowser" {
tgtStack, tOk := m.stacks.GetStack(target) 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 { if !tOk || !tgtStack.Deployed {
return state, fmt.Errorf("a célalkalmazás (%s) nincs telepítve", target) 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 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.Enabled = true
state.Status = "error" state.Status = "error"
state.LastError = err.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) _ = m.sett.SetIntegrationState(key, state)
return state, fmt.Errorf("integráció alkalmazása sikertelen: %w", err) 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.Enabled = true
state.Status = "active" state.Status = "active"
state.EnabledAt = time.Now().UTC().Format(time.RFC3339) state.EnabledAt = time.Now().UTC().Format(time.RFC3339)
m.logger.Printf("[INFO] Integration %s enabled", key) m.logger.Printf("[INFO] Integration %s enabled", key)
} else { } else {
start := time.Now()
if err := handler.Revoke(ac); err != nil { if err := handler.Revoke(ac); err != nil {
m.logger.Printf("[WARN] Integration revoke failed for %s: %v", key, err) m.logger.Printf("[WARN] Integration revoke failed for %s: %v", key, err)
state.LastError = err.Error() 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.Enabled = false
state.Status = "disabled" state.Status = "disabled"
m.logger.Printf("[INFO] Integration %s disabled", key) m.logger.Printf("[INFO] Integration %s disabled", key)
@@ -128,6 +169,10 @@ func (m *Manager) ListForProvider(providerSlug string) []StatusInfo {
return nil return nil
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ListForProvider: provider=%s integrationDefs=%d", providerSlug, len(provStack.Meta.Integrations))
}
var result []StatusInfo var result []StatusInfo
for _, idef := range provStack.Meta.Integrations { for _, idef := range provStack.Meta.Integrations {
key := IntegrationKey(providerSlug, idef.Target) 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 // Load decrypted env from provider's app.yaml
provEnv := m.loadDecryptedEnv(provStack) 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 provMeta := provStack.Meta
return &ApplyContext{ return &ApplyContext{
ProviderName: provider, ProviderName: provider,
@@ -192,6 +242,9 @@ func (m *Manager) ReapplyConfigForTarget(targetName string) {
defer m.mu.Unlock() defer m.mu.Unlock()
all := m.sett.GetIntegrationsForTarget(targetName) 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 { for key, state := range all {
if !state.Enabled || state.Status == "disabled" { if !state.Enabled || state.Status == "disabled" {
continue continue
@@ -207,6 +260,10 @@ func (m *Manager) ReapplyConfigForTarget(targetName string) {
continue continue
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: reapplying %s (status=%s)", key, state.Status)
}
ac, err := m.buildApplyContext(provider, target) ac, err := m.buildApplyContext(provider, target)
if err != nil { if err != nil {
m.logger.Printf("[WARN] Cannot build context for integration %s reapply: %v", key, err) 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 { if err := handler.Apply(ac); err != nil {
m.logger.Printf("[WARN] Integration config reapply failed for %s: %v", key, err) 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.Status = "error"
state.LastError = err.Error() state.LastError = err.Error()
_ = m.sett.SetIntegrationState(key, state) _ = m.sett.SetIntegrationState(key, state)
continue continue
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [integrations] ReapplyConfigForTarget: %s succeeded", key)
}
state.Status = "active" state.Status = "active"
state.LastError = "" state.LastError = ""
_ = m.sett.SetIntegrationState(key, state) _ = m.sett.SetIntegrationState(key, state)
@@ -241,5 +304,8 @@ func (m *Manager) loadDecryptedEnv(s *stacks.Stack) map[string]string {
if m.encKey != nil { if m.encKey != nil {
cfg.Env = crypto.DecryptMap(m.encKey, cfg.Env) 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 return cfg.Env
} }
@@ -25,6 +25,10 @@ func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error {
} }
configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml") 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) configData, err := os.ReadFile(configPath)
if err != nil { if err != nil {
return fmt.Errorf("FileBrowser config olvasási hiba: %w", err) 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 // Remove any existing integrations section, then append the new one
configStr := removeIntegrationsSection(string(configData)) configStr := removeIntegrationsSection(string(configData))
officeURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain)
internalURL := "http://onlyoffice:80" internalURL := "http://onlyoffice:80"
integrationsBlock := fmt.Sprintf("integrations:\n office:\n url: %q\n internalUrl: %q\n secret: %q\n viewOnly: false\n", 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 { func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error {
configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml") configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml")
ac.Logger.Printf("[DEBUG] [integrations] OnlyOfficeFileBrowser.Revoke: configPath=%s", configPath)
configData, err := os.ReadFile(configPath) configData, err := os.ReadFile(configPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@@ -66,7 +71,9 @@ func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error {
} }
cleaned := removeIntegrationsSection(string(configData)) 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 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{} 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() start := time.Now()
results := make([]ContainerLogSummary, 0, len(containerNames)) results := make([]ContainerLogSummary, 0, len(containerNames))
for _, name := range containerNames { for _, name := range containerNames {
cStart := time.Now()
summary := scanOneContainer(name, since, logger) 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) results = append(results, summary)
} }
elapsed := time.Since(start) 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 { if elapsed > 5*time.Minute && logger != nil {
logger.Printf("[WARN] Log scan took %s (>5min) for %d containers", elapsed.Round(time.Second), len(containerNames)) 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 httpClient *http.Client
logger *log.Logger logger *log.Logger
enabled bool enabled bool
debug bool
} }
// NewPinger creates a new Pinger from monitoring config. // 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. // Ping sends a success signal with optional diagnostic body.
func (p *Pinger) Ping(uuid string, body string) error { 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) return p.send(uuid, "", body)
} }
// Fail sends a failure signal with diagnostic body. // Fail sends a failure signal with diagnostic body.
func (p *Pinger) Fail(uuid string, body string) error { 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) return p.send(uuid, "/fail", body)
} }
// Start sends a "job started" signal (for duration tracking). // Start sends a "job started" signal (for duration tracking).
func (p *Pinger) Start(uuid string) error { func (p *Pinger) Start(uuid string) error {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] Start uuid=%s", uuid)
}
return p.send(uuid, "/start", "") 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) 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 var lastErr error
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 { if attempt > 0 {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] retry attempt=%d uuid=%s", attempt+1, uuid)
}
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
} }
@@ -81,7 +102,14 @@ func (p *Pinger) send(uuid, suffix, body string) error {
} }
resp.Body.Close() 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 resp.StatusCode >= 200 && resp.StatusCode < 300 {
if p.debug {
p.logger.Printf("[DEBUG] [pinger] success uuid=%s", uuid)
}
return nil return nil
} }
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
@@ -44,12 +44,26 @@ type Scheduler struct {
mu sync.Mutex mu sync.Mutex
jobs []*Job jobs []*Job
logger *log.Logger logger *log.Logger
debug bool
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
wg sync.WaitGroup wg sync.WaitGroup
started bool 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. // New creates a new Scheduler.
func New(logger *log.Logger) *Scheduler { func New(logger *log.Logger) *Scheduler {
return &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.jobs = append(s.jobs, job)
s.logger.Printf("[SCHED] Registered periodic job: %s (every %s)", name, interval) 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 { if s.started {
s.wg.Add(1) s.wg.Add(1)
@@ -103,6 +118,7 @@ func (s *Scheduler) Daily(name string, timeStr string, fn JobFunc) {
nextRun := nextDailyRun(timeStr) nextRun := nextDailyRun(timeStr)
s.logger.Printf("[SCHED] Daily job %s scheduled for %s", name, nextRun.Format("2006-01-02 15:04 MST")) 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 { if s.started {
s.wg.Add(1) 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.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() s.mu.Unlock()
} }
@@ -200,10 +233,13 @@ func (s *Scheduler) runDailyJob(job *Job) {
waitDuration = 0 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) timer := time.NewTimer(waitDuration)
select { select {
case <-s.ctx.Done(): case <-s.ctx.Done():
timer.Stop() timer.Stop()
s.dbg("daily job %s: context cancelled, stopping", job.Name)
return return
case <-timer.C: case <-timer.C:
s.executeJob(job, false) s.executeJob(job, false)
@@ -241,6 +277,7 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
if !quiet { if !quiet {
s.logger.Printf("[SCHED] Running job: %s", job.Name) s.logger.Printf("[SCHED] Running job: %s", job.Name)
} }
s.dbg("job %s: execution starting", job.Name)
start := time.Now() start := time.Now()
err := job.Fn(s.ctx) err := job.Fn(s.ctx)
@@ -253,9 +290,11 @@ func (s *Scheduler) executeJob(job *Job, quiet bool) {
if err != nil { if err != nil {
s.logger.Printf("[WARN] Job %s failed: %v (took %s)", job.Name, err, elapsed.Round(time.Millisecond)) 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 { } else if !quiet {
s.logger.Printf("[SCHED] Job %s completed (took %s)", job.Name, elapsed.Round(time.Millisecond)) 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. // 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. // SetBackupRunningCheck sets the callback to check if a backup is in progress.
func (u *Updater) SetBackupRunningCheck(fn func() bool) { func (u *Updater) SetBackupRunningCheck(fn func() bool) {
u.mu.Lock() u.mu.Lock()
@@ -140,10 +146,10 @@ func (u *Updater) CheckForUpdate() CheckResult {
result.UpdateAvailable = true result.UpdateAvailable = true
} }
if u.debug { u.dbg("version comparison: current=%s (%d.%d.%d), latest=%s (%d.%d.%d), cmp=%d, updateAvailable=%v",
u.logger.Printf("[DEBUG] [SELFUPDATE] Version comparison: current=%s, latest=%s, cmp=%d, updateAvailable=%v", u.currentVer, currentVer.Major, currentVer.Minor, currentVer.Patch,
u.currentVer, latestStr, cmp, result.UpdateAvailable) latestStr, latestVer.Major, latestVer.Minor, latestVer.Patch,
} cmp, result.UpdateAvailable)
u.mu.Lock() u.mu.Lock()
u.latestVersion = latestStr u.latestVersion = latestStr
@@ -163,9 +169,7 @@ func (u *Updater) queryRegistry() (string, error) {
// Gitea registry V2: GET /v2/<owner>/<repo>/tags/list // Gitea registry V2: GET /v2/<owner>/<repo>/tags/list
url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image)) url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", registryImagePath(u.cfg.Image))
if u.debug { u.dbg("queryRegistry: url=%s user=%s", url, u.gitCfg.Username)
u.logger.Printf("[DEBUG] [SELFUPDATE] Registry API URL: %s (user: %s)", url, u.gitCfg.Username)
}
req, err := http.NewRequest("GET", url, nil) req, err := http.NewRequest("GET", url, nil)
if err != nil { if err != nil {
@@ -176,9 +180,11 @@ func (u *Updater) queryRegistry() (string, error) {
client := &http.Client{Timeout: 15 * time.Second} client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
u.dbg("queryRegistry: HTTP request failed: %v", err)
return "", fmt.Errorf("HTTP request failed: %w", err) return "", fmt.Errorf("HTTP request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
u.dbg("queryRegistry: HTTP %d", resp.StatusCode)
if resp.StatusCode == 401 { if resp.StatusCode == 401 {
return "", fmt.Errorf("authentication failed (401)") return "", fmt.Errorf("authentication failed (401)")
@@ -195,9 +201,7 @@ func (u *Updater) queryRegistry() (string, error) {
return "", fmt.Errorf("decoding response: %w", err) return "", fmt.Errorf("decoding response: %w", err)
} }
if u.debug { u.dbg("queryRegistry: %d tags returned: %v", len(tagsResp.Tags), tagsResp.Tags)
u.logger.Printf("[DEBUG] [SELFUPDATE] Registry returned %d tags: %v", len(tagsResp.Tags), tagsResp.Tags)
}
// Find highest semver tag // Find highest semver tag
var highest *Version var highest *Version
@@ -293,9 +297,11 @@ func (u *Updater) DryRun() *DryRunResult {
// TriggerUpdate starts the self-update process. Returns error immediately if // TriggerUpdate starts the self-update process. Returns error immediately if
// preconditions fail. The actual update runs in a goroutine. // preconditions fail. The actual update runs in a goroutine.
func (u *Updater) TriggerUpdate(initiatedBy string) error { func (u *Updater) TriggerUpdate(initiatedBy string) error {
u.dbg("TriggerUpdate: initiatedBy=%s currentVer=%s", initiatedBy, u.currentVer)
u.mu.Lock() u.mu.Lock()
if u.updateRunning { if u.updateRunning {
u.mu.Unlock() u.mu.Unlock()
u.dbg("TriggerUpdate: rejected — update already running")
return fmt.Errorf("Frissítés már folyamatban") 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) 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.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) go u.performUpdate(targetVersion, targetImage, previousImage, initiatedBy)
@@ -348,6 +355,7 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
u.mu.Unlock() u.mu.Unlock()
}() }()
u.dbg("performUpdate: starting — target=%s image=%s", targetVersion, targetImage)
// 1. Write pending state // 1. Write pending state
state := &UpdateState{ state := &UpdateState{
Status: "pending", Status: "pending",
@@ -364,7 +372,9 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
} }
// 2. Docker pull // 2. Docker pull
u.dbg("performUpdate: step 2 — docker pull %s", targetImage)
u.logger.Printf("[INFO] Pulling image: %s", targetImage) u.logger.Printf("[INFO] Pulling image: %s", targetImage)
pullStart := time.Now()
pullOut, pullErr := runCommand("docker", "pull", targetImage) pullOut, pullErr := runCommand("docker", "pull", targetImage)
if pullErr != nil { if pullErr != nil {
state.Status = "failed" state.Status = "failed"
@@ -375,8 +385,10 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
return return
} }
u.logger.Printf("[INFO] Image pulled successfully: %s", targetImage) 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) // 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 { if err := u.updateComposeFile(targetImage); err != nil {
state.Status = "failed" state.Status = "failed"
state.Error = fmt.Sprintf("compose update failed: %v", err) 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) u.logger.Printf("[INFO] Compose file updated with new image: %s", targetImage)
// 4. Docker compose up -d (this kills the current container) // 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") u.logger.Printf("[INFO] Running docker compose up -d — container will restart")
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml") composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d") 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 // Replace image line: "image: gitea.dooplex.hu/admin/felhom-controller:..." → new image
re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`) re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
if u.debug { // Log old image line for debugging
// Log old image line for debugging oldMatch := re.Find(data)
oldMatch := re.Find(data) if oldMatch != nil {
if oldMatch != nil { u.dbg("updateComposeFile: %q → %q", string(oldMatch), "image: "+newImage)
u.logger.Printf("[DEBUG] [SELFUPDATE] Compose file edit: %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)) 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. // Called once from main.go before the scheduler starts.
// Returns the state if a pending update was detected, nil otherwise. // Returns the state if a pending update was detected, nil otherwise.
func (u *Updater) VerifyStartup() *UpdateState { func (u *Updater) VerifyStartup() *UpdateState {
u.dbg("VerifyStartup: checking update state in %s", u.dataDir)
state, err := LoadState(u.dataDir) state, err := LoadState(u.dataDir)
if err != nil { if err != nil {
u.logger.Printf("[WARN] Failed to load update state on startup: %v — clearing", err) 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 return nil
} }
if state == nil || state.Status != "pending" { if state == nil || state.Status != "pending" {
u.dbg("VerifyStartup: no pending update (state=%v)", state)
return nil return nil
} }
u.dbg("VerifyStartup: pending update found — target=%s previous=%s", state.TargetVersion, state.PreviousVersion)
// Compare current version with target // Compare current version with target
currentVer, curErr := ParseVersion(u.currentVer) currentVer, curErr := ParseVersion(u.currentVer)
+48 -4
View File
@@ -14,9 +14,10 @@ import (
// Settings holds customer-modifiable overrides and cached state. // Settings holds customer-modifiable overrides and cached state.
// Persisted as a single JSON file (settings.json) in the data directory. // Persisted as a single JSON file (settings.json) in the data directory.
type Settings struct { type Settings struct {
mu sync.RWMutex `json:"-"` mu sync.RWMutex `json:"-"`
path string `json:"-"` path string `json:"-"`
log *log.Logger `json:"-"` log *log.Logger `json:"-"`
debug bool `json:"-"`
// Auth // Auth
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
@@ -156,6 +157,11 @@ type DBValidationCache struct {
Error string `json:"error,omitempty"` 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. // Load reads settings from the given file path.
// Returns empty Settings if the file doesn't exist (not an error). // Returns empty Settings if the file doesn't exist (not an error).
func Load(path string, logger *log.Logger) (*Settings, 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) 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() s.migrateResticToRsync()
return s, nil return s, nil
} }
@@ -226,7 +236,9 @@ func (s *Settings) save() error {
return fmt.Errorf("renaming settings file: %w", err) 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 return nil
} }
@@ -435,6 +447,9 @@ func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
func (s *Settings) AddStoragePath(sp StoragePath) error { func (s *Settings) AddStoragePath(sp StoragePath) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { for _, existing := range s.StoragePaths {
if existing.Path == sp.Path { if existing.Path == sp.Path {
return fmt.Errorf("storage path %q already registered", 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 { func (s *Settings) RemoveStoragePath(path string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.debug {
s.log.Printf("[DEBUG] [settings] RemoveStoragePath path=%q", path)
}
var kept []StoragePath var kept []StoragePath
for _, sp := range s.StoragePaths { for _, sp := range s.StoragePaths {
if sp.Path != path { if sp.Path != path {
@@ -515,6 +533,10 @@ func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHD
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { if len(s.StoragePaths) > 0 {
return // already configured return // already configured
} }
@@ -572,6 +594,9 @@ func InferStorageLabel(path string) string {
func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error { func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path { if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Disconnected = disconnected s.StoragePaths[i].Disconnected = disconnected
@@ -679,6 +704,9 @@ func (s *Settings) ClearStoppedStacks(path string) error {
func (s *Settings) SetDecommissioned(path, migratedTo string) error { func (s *Settings) SetDecommissioned(path, migratedTo string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path { if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Decommissioned = true s.StoragePaths[i].Decommissioned = true
@@ -811,6 +839,9 @@ func (s *Settings) SetRetrievalPassword(password string) error {
func (s *Settings) AddPendingEvent(event PendingEvent) error { func (s *Settings) AddPendingEvent(event PendingEvent) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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) s.PendingEvents = append(s.PendingEvents, event)
return s.save() return s.save()
} }
@@ -822,6 +853,9 @@ func (s *Settings) DrainPendingEvents() []PendingEvent {
if len(s.PendingEvents) == 0 { if len(s.PendingEvents) == 0 {
return nil return nil
} }
if s.debug {
s.log.Printf("[DEBUG] [settings] DrainPendingEvents count=%d", len(s.PendingEvents))
}
events := make([]PendingEvent, len(s.PendingEvents)) events := make([]PendingEvent, len(s.PendingEvents))
copy(events, s.PendingEvents) copy(events, s.PendingEvents)
s.PendingEvents = nil s.PendingEvents = nil
@@ -862,6 +896,13 @@ func (s *Settings) GetGeoRestriction() *GeoRestriction {
func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error { func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { if geo == nil {
s.GeoRestriction = nil s.GeoRestriction = nil
return s.save() return s.save()
@@ -953,6 +994,9 @@ func (s *Settings) GetIntegrationState(key string) (IntegrationState, bool) {
func (s *Settings) SetIntegrationState(key string, state IntegrationState) error { func (s *Settings) SetIntegrationState(key string, state IntegrationState) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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 { if s.Integrations == nil {
s.Integrations = make(map[string]IntegrationState) 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, // DeleteStack removes an orphaned stack: stops containers, removes volumes,
// optionally removes HDD data, and deletes the stack directory. // optionally removes HDD data, and deletes the stack directory.
func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse, error) { 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 // Safety: never delete protected stacks
if m.cfg.IsProtectedStack(name) { if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be deleted", 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) 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 // Must be orphaned
if !stack.Orphaned { if !stack.Orphaned {
return nil, fmt.Errorf("stack %q is not orphaned — only orphaned stacks can be deleted", name) 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 // Step 1: Parse compose file for HDD bind mounts
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) 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 // Step 2: Run docker compose down --rmi local --volumes
// H14: Return error if docker compose down fails — continuing would leave orphaned containers. // H14: Return error if docker compose down fails — continuing would leave orphaned containers.
env := m.stackEnv(stackDir) env := m.stackEnv(stackDir)
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--rmi", "local", "--volumes") 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 { if err != nil {
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200)) 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) 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 _, 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 continue // path doesn't exist, nothing to do
} }
if removeHDDData { if removeHDDData {
// Get size before removal // Get size before removal
sizeHuman := getDirSizeHuman(cleanPath) 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 { if err := os.RemoveAll(cleanPath); err != nil {
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err) m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
} else { } else {
@@ -151,11 +175,17 @@ func (m *Manager) DeleteStack(name string, removeHDDData bool) (*DeleteResponse,
} }
} else { } else {
sizeHuman := getDirSizeHuman(cleanPath) 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)) resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
} }
} }
// Step 5: Remove stack directory // 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 { if err := os.RemoveAll(stackDir); err != nil {
m.logger.Printf("[ERROR] Failed to remove stack directory %s: %v", stackDir, err) m.logger.Printf("[ERROR] Failed to remove stack directory %s: %v", stackDir, err)
return resp, fmt.Errorf("failed to remove stack directory: %w", 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 hddPath == "" {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackHDDData %s: no HDD path configured, returning empty", name)
}
return resp, nil return resp, nil
} }
mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) mounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
protected := ProtectedHDDPaths(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 { for _, mount := range mounts {
cleanPath := filepath.Clean(mount) cleanPath := filepath.Clean(mount)
@@ -221,6 +258,14 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
} }
resp.HasHDDData = len(resp.HDDPaths) > 0 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 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, // so the stack reverts to "not deployed" state. The template files (docker-compose.yml,
// .felhom.yml) are preserved so the user can redeploy. // .felhom.yml) are preserved so the user can redeploy.
func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemove []string) (*RemoveResponse, error) { 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 // Safety: never remove protected stacks
if m.cfg.IsProtectedStack(name) { if m.cfg.IsProtectedStack(name) {
return nil, fmt.Errorf("stack %q is protected and cannot be removed", 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) 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 // Must be deployed
if !stack.Deployed { if !stack.Deployed {
return nil, fmt.Errorf("stack %q is not deployed", name) 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 // Step 1: Parse compose file for HDD bind mounts
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath) 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) // Step 2: Run docker compose down --volumes (keep images for potential redeploy)
env := m.stackEnv(stackDir) env := m.stackEnv(stackDir)
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes") 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 { if err != nil {
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200)) 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) 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 _, 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 continue
} }
if removeHDDData { if removeHDDData {
sizeHuman := getDirSizeHuman(cleanPath) 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 { if err := os.RemoveAll(cleanPath); err != nil {
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err) m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
} else { } else {
@@ -306,12 +375,18 @@ func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemo
} }
} else { } else {
sizeHuman := getDirSizeHuman(cleanPath) 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)) resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
} }
} }
// Step 5: Handle backup data cleanup // Step 5: Handle backup data cleanup
backupsBase := filepath.Join(hddPath, felhomDataDir, "backups") 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 { for _, bkPath := range backupPathsToRemove {
cleanPath := filepath.Clean(bkPath) cleanPath := filepath.Clean(bkPath)
// Validate path is under the expected backups directory // 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) // Step 6: Remove app.yaml only (keep template files for redeploy)
appYAMLPath := filepath.Join(stackDir, "app.yaml") 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) { if err := os.Remove(appYAMLPath); err != nil && !os.IsNotExist(err) {
m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err) m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err)
return resp, fmt.Errorf("failed to remove app.yaml: %w", 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 drivePath == "" {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] GetStackBackupData %s: no drive path provided, returning empty", name)
}
return resp, nil 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") rsyncPath := filepath.Join(drivePath, felhomDataDir, "backups", "secondary", name, "rsync")
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath)) 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 { for _, p := range resp.BackupPaths {
if p.Exists { if p.Exists {
resp.HasBackups = true 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 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. // UpdateStackConfig updates non-locked fields for a deployed stack.
func (m *Manager) UpdateStackConfig(name string, values map[string]string) error { 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) stack, ok := m.GetStack(name)
if !ok { if !ok {
return fmt.Errorf("stack %q not found", name) 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) meta := LoadMetadata(stackDir)
var changedKeys []string
for key, val := range values { for key, val := range values {
if lockedSet[key] { if lockedSet[key] {
return fmt.Errorf("field %q is locked and cannot be changed after deployment", 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 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 { if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving updated config: %w", err) 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. // 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. // 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 { 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) stack, ok := m.GetStack(stackName)
if !ok { if !ok {
return fmt.Errorf("stack %q not found", stackName) 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) 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) // Load existing app.yaml (or create empty one)
stackDir := filepath.Dir(stack.ComposePath) stackDir := filepath.Dir(stack.ComposePath)
appCfg := LoadAppConfig(stackDir) appCfg := LoadAppConfig(stackDir)
@@ -564,12 +588,14 @@ func LoadAppConfig(stackDir string) *AppConfig {
} }
cfg := &AppConfig{} cfg := &AppConfig{}
if err := yaml.Unmarshal(data, cfg); err != nil { if err := yaml.Unmarshal(data, cfg); err != nil {
log.Printf("[DEBUG] [stacks] LoadAppConfig: failed to parse %s: %v", path, err)
return nil return nil
} }
return cfg return cfg
} }
func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error { func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error {
encryptedCount := 0
// Clone env and encrypt sensitive values // Clone env and encrypt sensitive values
saveCfg := &AppConfig{ saveCfg := &AppConfig{
Deployed: cfg.Deployed, 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 encKey != nil && sensitiveSet[k] && !crypto.IsEncrypted(v) && v != "" {
if enc, err := crypto.Encrypt(encKey, v); err == nil { if enc, err := crypto.Encrypt(encKey, v); err == nil {
saveCfg.Env[k] = enc saveCfg.Env[k] = enc
encryptedCount++
continue continue
} else { } else {
// H10 fix: log encryption failure — value will be saved in plaintext. // 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 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) data, err := yaml.Marshal(saveCfg)
if err != nil { if err != nil {
return fmt.Errorf("marshaling app config: %w", err) 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. // LoadAppConfigDecrypted loads app.yaml and decrypts any encrypted values.
func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig { func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig {
cfg := LoadAppConfig(stackDir) 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 return cfg
} }
cfg.Env = crypto.DecryptMap(encKey, cfg.Env) 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. // 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). // Called after sync (for updated stacks) and on startup (for all deployed stacks).
func (m *Manager) InjectMissingFields(stackNames []string) { func (m *Manager) InjectMissingFields(stackNames []string) {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: checking %d stacks", len(stackNames))
}
for _, name := range stackNames { for _, name := range stackNames {
stack, ok := m.GetStack(name) stack, ok := m.GetStack(name)
if !ok { if !ok {
@@ -696,9 +734,17 @@ func (m *Manager) InjectMissingFields(stackNames []string) {
meta := LoadMetadata(stackDir) meta := LoadMetadata(stackDir)
appCfg := LoadAppConfig(stackDir) appCfg := LoadAppConfig(stackDir)
if appCfg == nil || !appCfg.Deployed { if appCfg == nil || !appCfg.Deployed {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] InjectMissingFields: skipping %s (not deployed or no app config)", name)
}
continue 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 var injected []string
for _, field := range meta.DeployFields { for _, field := range meta.DeployFields {
if _, exists := appCfg.Env[field.EnvVar]; exists { 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) // Phase 1: collect targets (under lock)
m.mu.RLock() m.mu.RLock()
var targets []probeTarget var targets []probeTarget
skippedNotDue := 0
skippedNoContainer := 0
for name, stack := range m.stacks { for name, stack := range m.stacks {
if stack.State != StateRunning && stack.State != StateUnhealthy { if stack.State != StateRunning && stack.State != StateUnhealthy {
continue continue
@@ -43,6 +45,12 @@ func (m *Manager) RunHealthProbes() error {
effectiveInterval = 10 * time.Second effectiveInterval = 10 * time.Second
} }
if time.Since(stack.HealthProbe.LastCheck) < effectiveInterval { 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 continue
} }
} }
@@ -50,6 +58,7 @@ func (m *Manager) RunHealthProbes() error {
// Find the main container to probe (matching stack name) // Find the main container to probe (matching stack name)
containerName := findProbeContainer(name, stack.Containers) containerName := findProbeContainer(name, stack.Containers)
if containerName == "" { if containerName == "" {
skippedNoContainer++
continue continue
} }
@@ -61,6 +70,11 @@ func (m *Manager) RunHealthProbes() error {
} }
m.mu.RUnlock() 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 { if len(targets) == 0 {
return nil return nil
} }
+69
View File
@@ -117,15 +117,33 @@ func (m *Manager) SetEncryptionKey(key []byte) {
m.encKey = key 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 // MigrateEncryption re-saves app.yaml for deployed stacks that still have
// plaintext values in sensitive fields. Called once on startup. // plaintext values in sensitive fields. Called once on startup.
func (m *Manager) MigrateEncryption() { func (m *Manager) MigrateEncryption() {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if m.encKey == nil { if m.encKey == nil {
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: no encryption key set, skipping")
}
return 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 migrated := 0
for _, s := range m.stacks { for _, s := range m.stacks {
if !s.Deployed { if !s.Deployed {
@@ -141,6 +159,11 @@ func (m *Manager) MigrateEncryption() {
if len(sensitive) == 0 { if len(sensitive) == 0 {
continue continue
} }
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] MigrateEncryption: checking stack %q (%d sensitive fields)", s.Name, len(sensitive))
}
needsMigration := false needsMigration := false
for _, envVar := range sensitive { for _, envVar := range sensitive {
if v, ok := appCfg.Env[envVar]; ok && v != "" && !crypto.IsEncrypted(v) { if v, ok := appCfg.Env[envVar]; ok && v != "" && !crypto.IsEncrypted(v) {
@@ -149,6 +172,9 @@ func (m *Manager) MigrateEncryption() {
} }
} }
if needsMigration { 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 { if err := SaveAppConfig(stackDir, appCfg, m.encKey, sensitive); err != nil {
m.logger.Printf("[WARN] Encryption migration failed for %s: %v", s.Name, err) m.logger.Printf("[WARN] Encryption migration failed for %s: %v", s.Name, err)
} else { } else {
@@ -229,6 +255,10 @@ func (m *Manager) ScanStacks() error {
appCfg := LoadAppConfig(stackDir) appCfg := LoadAppConfig(stackDir)
deployed := appCfg != nil && appCfg.Deployed 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 { if existing, ok := m.stacks[name]; ok {
existing.ComposePath = composePath existing.ComposePath = composePath
existing.Meta = meta existing.Meta = meta
@@ -261,6 +291,13 @@ func (m *Manager) ScanStacks() error {
// Detect orphaned stacks (deployed but no longer in catalog) // Detect orphaned stacks (deployed but no longer in catalog)
catalogTemplates := m.getCatalogTemplateSlugs() 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 { if catalogTemplates != nil {
orphanCount := 0 orphanCount := 0
for _, stack := range m.stacks { for _, stack := range m.stacks {
@@ -271,6 +308,9 @@ func (m *Manager) ScanStacks() error {
stack.Orphaned = !catalogTemplates[stack.Name] stack.Orphaned = !catalogTemplates[stack.Name]
if stack.Orphaned { if stack.Orphaned {
orphanCount++ orphanCount++
if m.isDebug() {
m.logger.Printf("[DEBUG] [stacks] ScanStacks: stack %q is orphaned (deployed but not in catalog)", stack.Name)
}
} }
} }
if orphanCount > 0 { if orphanCount > 0 {
@@ -306,6 +346,7 @@ func (m *Manager) refreshStatusLocked() error {
projectContainers := make(map[string][]ContainerInfo) projectContainers := make(map[string][]ContainerInfo)
totalContainers := 0
for _, line := range strings.Split(strings.TrimSpace(output), "\n") { for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" { if line == "" {
continue continue
@@ -322,6 +363,11 @@ func (m *Manager) refreshStatusLocked() error {
Status: parts[3], Status: parts[3],
} }
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci) 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 { for name, stack := range m.stacks {
@@ -346,6 +392,10 @@ func (m *Manager) refreshStatusLocked() error {
stack.State = StateUnhealthy 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() stack.LastUpdated = time.Now()
} }
@@ -569,12 +619,20 @@ func (m *Manager) StartStack(name string) error {
return fmt.Errorf("stack %q not found", name) 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) m.logger.Printf("[INFO] Starting stack: %s", name)
start := time.Now() start := time.Now()
dir := filepath.Dir(stack.ComposePath) dir := filepath.Dir(stack.ComposePath)
env := m.stackEnv(dir) 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 { 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) 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) 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) 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) m.logger.Printf("[INFO] Stopping stack: %s", name)
start := time.Now() start := time.Now()
dir := filepath.Dir(stack.ComposePath) dir := filepath.Dir(stack.ComposePath)
@@ -623,6 +685,10 @@ func (m *Manager) RestartStack(name string) error {
return fmt.Errorf("stack %q not found", name) 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) m.logger.Printf("[INFO] Restarting stack: %s", name)
start := time.Now() start := time.Now()
dir := filepath.Dir(stack.ComposePath) 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 return slugs
} }
+1 -1
View File
@@ -188,7 +188,7 @@ func FinalizeAttach(req AttachRequest, progress chan<- FormatProgress) (string,
} }
dbg := func(format string, args ...interface{}) { dbg := func(format string, args ...interface{}) {
if req.Logger != nil && req.Debug { 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{}) { dbg := func(format string, args ...interface{}) {
if req.Logger != nil && req.Debug { 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{}) { dbg := func(format string, args ...interface{}) {
if req.Logger != nil && req.Debug { 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. // 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 { func (dm *DriveMigrator) MigrateDrive(ctx context.Context, req DriveMigrateRequest, progress chan<- DriveMigrateProgress) error {
start := time.Now() 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) { send := func(step, msg string, pct int) {
progress <- DriveMigrateProgress{ 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 { if len(appsToMigrate) == 0 {
return fail("A forrás meghajtón nincs telepített alkalmazás", fmt.Errorf("no apps on source")) 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", dm.Logger.Printf("[INFO] Drive migration: %s (%s) → %s (%s), %d apps, ~%s data",
req.SourcePath, srcLabel, req.DestPath, dstLabel, len(appsToMigrate), bytesHuman(totalBytes)) 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 { if err := rsyncCmd.Wait(); err != nil {
stderrWg.Wait() 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) send("rolling_back", "rsync sikertelen, visszagörgetés...", 0)
tx.rollback() tx.rollback()
return fail("Adatmásolás sikertelen", fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String())) return fail("Adatmásolás sikertelen", fmt.Errorf("rsync failed: %w — %s", err, stderrBuf.String()))
} }
stderrWg.Wait() stderrWg.Wait()
dbg("rsync completed in %s", time.Since(start).Round(time.Second))
// --- Step 3: Verify copy --- // --- Step 3: Verify copy ---
send("verifying", "Másolat ellenőrzése...", 62) 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 --- // --- Step 4: Update all app configs ---
send("configuring", "Konfiguráció frissítése...", 65) send("configuring", "Konfiguráció frissítése...", 65)
dbg("updating HDD_PATH for %d apps", len(appsToMigrate))
var configuredApps []string var configuredApps []string
for i, app := range appsToMigrate { for i, app := range appsToMigrate {
// Guard: verify app still exists // Guard: verify app still exists
+8 -2
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/util" "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) { func enrichWithBlkid(disks []BlockDevice, logger *log.Logger, debug bool) {
dbg := func(format string, args ...interface{}) { dbg := func(format string, args ...interface{}) {
if debug && logger != nil { 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) { func ScanDisks(logger *log.Logger, debug bool) (*ScanResult, error) {
dbg := func(format string, args ...interface{}) { dbg := func(format string, args ...interface{}) {
if debug && logger != nil { 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( out, err := exec.Command(
"lsblk", "-J", "-b", "lsblk", "-J", "-b",
"-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,RM", "-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.AvailableDisks, logger, debug)
enrichWithBlkid(result.SystemDisks, logger, debug) enrichWithBlkid(result.SystemDisks, logger, debug)
dbg("disk scan completed in %s", time.Since(scanStart).Round(time.Millisecond))
return result, nil return result, nil
} }
+8
View File
@@ -30,6 +30,7 @@ func NewCPUCollector(sampleRate time.Duration) *CPUCollector {
// Start begins background CPU sampling. // Start begins background CPU sampling.
func (c *CPUCollector) Start(ctx context.Context) { func (c *CPUCollector) Start(ctx context.Context) {
ctx, c.cancel = context.WithCancel(ctx) ctx, c.cancel = context.WithCancel(ctx)
debugf("[DEBUG] [system] CPUCollector.Start: sampleRate=%s", c.sampleRate)
go c.loop(ctx) go c.loop(ctx)
} }
@@ -48,10 +49,12 @@ func (c *CPUCollector) CPUPercent() float64 {
} }
func (c *CPUCollector) loop(ctx context.Context) { func (c *CPUCollector) loop(ctx context.Context) {
firstSample := true
for { for {
// Read first sample // Read first sample
idle1, total1, err := readCPUStat() idle1, total1, err := readCPUStat()
if err != nil { if err != nil {
debugf("[DEBUG] [system] CPUCollector: readCPUStat error: %v", err)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
@@ -82,6 +85,11 @@ func (c *CPUCollector) loop(ctx context.Context) {
c.mu.Lock() c.mu.Lock()
c.cpuPercent = percent c.cpuPercent = percent
c.mu.Unlock() 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 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. // SystemInfo holds system resource usage information.
type SystemInfo struct { type SystemInfo struct {
TotalMemMB uint64 `json:"total_mem_mb"` TotalMemMB uint64 `json:"total_mem_mb"`
+29
View File
@@ -10,12 +10,16 @@ import (
"sort" "sort"
"strings" "strings"
"syscall" "syscall"
"time"
) )
// GetInfo reads system memory, disk, CPU, load, and temperature info. // GetInfo reads system memory, disk, CPU, load, and temperature info.
// hddPath is the mount path for external HDD; if empty, HDD info is skipped. // hddPath is the mount path for external HDD; if empty, HDD info is skipped.
// cpuCollector provides the latest CPU usage sample; may be nil. // cpuCollector provides the latest CPU usage sample; may be nil.
func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo { func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
start := time.Now()
debugf("[DEBUG] [system] GetInfo starting (hddPath=%q, hasCPUCollector=%v)", hddPath, cpuCollector != nil)
info := SystemInfo{} info := SystemInfo{}
// --- Memory from /proc/meminfo --- // --- Memory from /proc/meminfo ---
@@ -41,6 +45,15 @@ func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
info.CPUPercent = cpuCollector.CPUPercent() 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 return info
} }
@@ -67,6 +80,7 @@ func GetMemoryMB() (totalMB, usedMB int, err error) {
func readMemInfo(info *SystemInfo) { func readMemInfo(info *SystemInfo) {
f, err := os.Open("/proc/meminfo") f, err := os.Open("/proc/meminfo")
if err != nil { if err != nil {
debugf("[DEBUG] [system] readMemInfo: failed to open /proc/meminfo: %v", err)
return return
} }
defer f.Close() defer f.Close()
@@ -91,6 +105,10 @@ func readMemInfo(info *SystemInfo) {
info.AvailMemMB = availKB / 1024 info.AvailMemMB = availKB / 1024
info.UsedMemMB = info.TotalMemMB - info.AvailMemMB info.UsedMemMB = info.TotalMemMB - info.AvailMemMB
info.MemPercent = float64(info.UsedMemMB) / float64(info.TotalMemMB) * 100 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) { func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) {
var stat syscall.Statfs_t var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil { if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] readDiskUsage: statfs(%q) failed: %v", path, err)
return return
} }
@@ -131,15 +150,20 @@ func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *floa
if total > 0 { if total > 0 {
*percent = float64(used) / float64(total) * 100 *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. // readLoadAvg reads 1/5/15 minute load averages from /proc/loadavg.
func readLoadAvg(info *SystemInfo) { func readLoadAvg(info *SystemInfo) {
data, err := os.ReadFile("/proc/loadavg") data, err := os.ReadFile("/proc/loadavg")
if err != nil { if err != nil {
debugf("[DEBUG] [system] readLoadAvg: failed to read /proc/loadavg: %v", err)
return return
} }
fmt.Sscanf(string(data), "%f %f %f", &info.LoadAvg1, &info.LoadAvg5, &info.LoadAvg15) 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. // readTemperature reads CPU/SoC temperature from thermal zones.
@@ -149,6 +173,7 @@ func readTemperature(info *SystemInfo) {
for _, prefix := range prefixes { for _, prefix := range prefixes {
if readThermalZones(prefix, info) { if readThermalZones(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via thermal_zone at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return return
} }
} }
@@ -156,9 +181,12 @@ func readTemperature(info *SystemInfo) {
// Fallback: try hwmon // Fallback: try hwmon
for _, prefix := range prefixes { for _, prefix := range prefixes {
if readHwmon(prefix, info) { if readHwmon(prefix, info) {
debugf("[DEBUG] [system] readTemperature: found via hwmon at %s — %.1f°C (%s)", prefix, info.TemperatureCelsius, info.TemperatureSource)
return return
} }
} }
debugf("[DEBUG] [system] readTemperature: no temperature source found")
} }
func readThermalZones(sysPrefix string, info *SystemInfo) bool { func readThermalZones(sysPrefix string, info *SystemInfo) bool {
@@ -169,6 +197,7 @@ func readThermalZones(sysPrefix string, info *SystemInfo) bool {
} }
sort.Strings(matches) sort.Strings(matches)
debugf("[DEBUG] [system] readThermalZones: %s — found %d zones", sysPrefix, len(matches))
var maxTemp float64 var maxTemp float64
var maxSource string var maxSource string
+25 -4
View File
@@ -65,6 +65,7 @@ type DiskUsageInfo struct {
func GetDiskUsage(path string) *DiskUsageInfo { func GetDiskUsage(path string) *DiskUsageInfo {
var stat syscall.Statfs_t var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil { if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] GetDiskUsage: statfs(%q) failed: %v", path, err)
return nil return nil
} }
@@ -84,6 +85,8 @@ func GetDiskUsage(path string) *DiskUsageInfo {
} }
info.TotalHuman = formatGB(info.TotalGB) info.TotalHuman = formatGB(info.TotalGB)
info.UsedHuman = formatGB(info.UsedGB) 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 return info
} }
@@ -105,10 +108,12 @@ type FSInfo struct {
func GetFSInfo(path string) *FSInfo { func GetFSInfo(path string) *FSInfo {
out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output() out, err := exec.Command("findmnt", "-n", "-o", "SOURCE,FSTYPE", "--target", path).Output()
if err != nil { if err != nil {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) failed: %v", path, err)
return nil return nil
} }
fields := strings.Fields(strings.TrimSpace(string(out))) fields := strings.Fields(strings.TrimSpace(string(out)))
if len(fields) < 2 { if len(fields) < 2 {
debugf("[DEBUG] [system] GetFSInfo: findmnt(%q) returned unexpected output: %q", path, strings.TrimSpace(string(out)))
return nil return nil
} }
info := &FSInfo{ info := &FSInfo{
@@ -117,6 +122,7 @@ func GetFSInfo(path string) *FSInfo {
} }
// Try to get disk model from sysfs // Try to get disk model from sysfs
info.Model = diskModel(info.Device) 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 return info
} }
@@ -136,6 +142,7 @@ type DestinationHealth struct {
// CheckBackupDestination performs tiered validation of a cross-drive backup destination. // CheckBackupDestination performs tiered validation of a cross-drive backup destination.
// Returns a DestinationHealth describing any issues found. // Returns a DestinationHealth describing any issues found.
func CheckBackupDestination(path string) DestinationHealth { func CheckBackupDestination(path string) DestinationHealth {
debugf("[DEBUG] [system] CheckBackupDestination: path=%q", path)
h := DestinationHealth{Severity: "ok"} h := DestinationHealth{Severity: "ok"}
// Tier 1: path must exist // 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.Warning = "A cél tárhely (" + path + ") nem létezik!"
h.Blocked = true h.Blocked = true
h.Severity = "critical" h.Severity = "critical"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier1 FAIL (not exists)", path)
return h return h
} }
h.Exists = true 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.Warning = "A cél tárhely (" + path + ") nem írható! Ellenőrizd a jogosultságokat."
h.Blocked = true h.Blocked = true
h.Severity = "critical" h.Severity = "critical"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier2 FAIL (not writable)", path)
return h return h
} }
h.Writable = true 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. " + "Meghajtóhiba esetén az eredeti adat és a mentés is elveszhet. " +
"Külső meghajtó használata javasolt." "Külső meghajtó használata javasolt."
h.Severity = "warning" h.Severity = "warning"
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 WARN (same block device as /)", path)
// Don't return early — also check disk usage // Don't return early — also check disk usage
} else { } else {
h.MountPoint = true h.MountPoint = true
debugf("[DEBUG] [system] CheckBackupDestination: path=%q — tier3 OK (different block device)", path)
} }
// Tier 4: disk usage checks // 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 return h
} }
@@ -256,8 +269,11 @@ type ProbeResult struct {
// ProbeStoragePath checks if a storage path is responsive. // ProbeStoragePath checks if a storage path is responsive.
// Uses a goroutine with a 3-second timeout to avoid blocking on dead mounts. // Uses a goroutine with a 3-second timeout to avoid blocking on dead mounts.
func ProbeStoragePath(path string) ProbeResult { func ProbeStoragePath(path string) ProbeResult {
start := time.Now()
// Quick check: does the path exist at all? // Quick check: does the path exist at all?
if _, err := os.Lstat(path); os.IsNotExist(err) { 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} return ProbeResult{Status: ProbeDisconnected, Err: err}
} }
@@ -273,17 +289,22 @@ func ProbeStoragePath(path string) ProbeResult {
select { select {
case res := <-ch: case res := <-ch:
elapsed := time.Since(start).Round(time.Millisecond)
if res.err == nil { if res.err == nil {
debugf("[DEBUG] [system] ProbeStoragePath: path=%q — connected (%s)", path, elapsed)
return ProbeResult{Status: ProbeConnected} return ProbeResult{Status: ProbeConnected}
} }
errStr := res.err.Error() errStr := res.err.Error()
if strings.Contains(errStr, "transport endpoint") || if strings.Contains(errStr, "transport endpoint") ||
strings.Contains(errStr, "input/output error") || strings.Contains(errStr, "input/output error") ||
strings.Contains(errStr, "no such device") { 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} 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} return ProbeResult{Status: ProbeDisconnected, Err: res.err}
case <-time.After(3 * time.Second): 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")} return ProbeResult{Status: ProbeTimeout, Err: fmt.Errorf("stat timed out after 3s")}
} }
} }
@@ -302,11 +323,11 @@ func IsUSBDevice(devicePath string) bool {
if err != nil { if err != nil {
continue continue
} }
if strings.Contains(link, "/usb") { isUSB := strings.Contains(link, "/usb")
return true debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q sysfs=%s → usb=%v", devicePath, disk, link, isUSB)
} return isUSB
return false // found the sysfs entry, but not USB
} }
debugf("[DEBUG] [system] IsUSBDevice: device=%q disk=%q — no sysfs entry found", devicePath, disk)
return false 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth if no password is configured // Skip auth if no password is configured
if !s.authEnabled() { 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) next.ServeHTTP(w, r)
return return
} }
@@ -77,6 +80,13 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
cookie, err := r.Cookie(sessionCookieName) cookie, err := r.Cookie(sessionCookieName)
if err != nil || !s.isValidSession(cookie.Value) { 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/") { if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
@@ -92,6 +102,9 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return return
} }
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] auth: valid session for %s %s", r.Method, r.URL.Path)
}
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
}) })
} }
@@ -101,6 +114,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
password := r.FormValue("password") password := r.FormValue("password")
nextURL := r.FormValue("next") 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 == "" { if password == "" {
s.renderLogin(w, "Kérjük adja meg a jelszót", "") s.renderLogin(w, "Kérjük adja meg a jelszót", "")
return return
@@ -147,6 +164,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
delete(s.loginAttempts, ip) delete(s.loginAttempts, ip)
s.loginAttemptMu.Unlock() s.loginAttemptMu.Unlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] login successful from %s, creating session", ip)
}
token := s.createSession() token := s.createSession()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{ 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) http.Redirect(w, r, "/", http.StatusFound)
return return
} }
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
}
if cookie, err := r.Cookie(sessionCookieName); err == nil { if cookie, err := r.Cookie(sessionCookieName); err == nil {
s.sessionsMu.Lock() s.sessionsMu.Lock()
delete(s.sessions, cookie.Value) delete(s.sessions, cookie.Value)
@@ -197,8 +221,13 @@ func (s *Server) createSession() string {
expiresAt: time.Now().Add(sessionMaxAge), expiresAt: time.Now().Add(sessionMaxAge),
csrfToken: csrfToken, csrfToken: csrfToken,
} }
sessionCount := len(s.sessions)
s.sessionsMu.Unlock() 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 return token
} }
@@ -225,8 +254,12 @@ func (s *Server) isValidSession(token string) bool {
// Used after password change. // Used after password change.
func (s *Server) invalidateAllSessions() { func (s *Server) invalidateAllSessions() {
s.sessionsMu.Lock() s.sessionsMu.Lock()
count := len(s.sessions)
s.sessions = make(map[string]*session) s.sessions = make(map[string]*session)
s.sessionsMu.Unlock() s.sessionsMu.Unlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] invalidated all sessions (cleared %d)", count)
}
} }
func (s *Server) cleanupSessions() { func (s *Server) cleanupSessions() {
@@ -239,12 +272,18 @@ func (s *Server) cleanupSessions() {
case <-ticker.C: case <-ticker.C:
s.sessionsMu.Lock() s.sessionsMu.Lock()
now := time.Now() now := time.Now()
expired := 0
for t, sess := range s.sessions { for t, sess := range s.sessions {
if now.After(sess.expiresAt) { if now.After(sess.expiresAt) {
delete(s.sessions, t) delete(s.sessions, t)
expired++
} }
} }
remaining := len(s.sessions)
s.sessionsMu.Unlock() 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" "strings"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/report" "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: case subpath == "logs" && r.Method == http.MethodGet:
s.debugLogBuffer(w, r) 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: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -725,3 +734,90 @@ func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) {
"total": total, "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. // restorePageHandler renders the full-page DR restore UI.
func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) { 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() s.restoreMu.RLock()
plan := s.restorePlan plan := s.restorePlan
if plan == nil { if plan == nil {
s.restoreMu.RUnlock() s.restoreMu.RUnlock()
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] restorePageHandler: no restore plan, redirecting to /")
}
http.Redirect(w, r, "/", http.StatusFound) http.Redirect(w, r, "/", http.StatusFound)
return return
} }
@@ -26,6 +32,9 @@ func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
copy(drives, plan.Drives) copy(drives, plan.Drives)
status := plan.GetStatus() status := plan.GetStatus()
s.restoreMu.RUnlock() 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{}{ data := map[string]interface{}{
"Title": "Katasztrófa utáni visszaállítás", "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. // apiRestoreStatus returns the current restore plan status as JSON.
func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) { 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() s.restoreMu.RLock()
plan := s.restorePlan plan := s.restorePlan
if plan == nil { if plan == nil {
@@ -60,6 +72,9 @@ func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) {
// apiRestoreAll starts restoring all pending apps sequentially. // apiRestoreAll starts restoring all pending apps sequentially.
func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) { 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() s.restoreMu.RLock()
plan := s.restorePlan plan := s.restorePlan
s.restoreMu.RUnlock() s.restoreMu.RUnlock()
@@ -68,6 +83,9 @@ func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
return return
} }
if !plan.TryStartRestore() { 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) jsonError(w, "restore already in progress", http.StatusConflict)
return return
} }
@@ -81,6 +99,9 @@ func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
// apiRestoreSkip exits restore mode without restoring. // apiRestoreSkip exits restore mode without restoring.
func (s *Server) apiRestoreSkip(w http.ResponseWriter, r *http.Request) { 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() s.restoreMu.RLock()
plan := s.restorePlan plan := s.restorePlan
s.restoreMu.RUnlock() 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. // executeAllRestores runs the restore for each pending app sequentially.
func (s *Server) executeAllRestores() { func (s *Server) executeAllRestores() {
s.logger.Println("[INFO] Starting DR restore for all apps") s.logger.Println("[INFO] Starting DR restore for all apps")
restoreStart := time.Now()
s.restoreMu.RLock() s.restoreMu.RLock()
plan := s.restorePlan plan := s.restorePlan
@@ -117,6 +139,9 @@ func (s *Server) executeAllRestores() {
pendingCount++ pendingCount++
} }
} }
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] executeAllRestores: %d pending apps to restore", pendingCount)
}
if s.notifier != nil { if s.notifier != nil {
s.notifier.NotifyDRStarted(pendingCount) s.notifier.NotifyDRStarted(pendingCount)
} }
@@ -130,6 +155,7 @@ func (s *Server) executeAllRestores() {
plan.UpdateApp(app.Name, "restoring", "") plan.UpdateApp(app.Name, "restoring", "")
s.logger.Printf("[INFO] Restoring app %s (%s)", app.Name, app.DisplayName) s.logger.Printf("[INFO] Restoring app %s (%s)", app.Name, app.DisplayName)
appStart := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
err := backup.RestoreAppFromBackup(ctx, app, s.cfg.Paths.StacksDir, s.logger) err := backup.RestoreAppFromBackup(ctx, app, s.cfg.Paths.StacksDir, s.logger)
@@ -138,16 +164,25 @@ func (s *Server) executeAllRestores() {
if err != nil { if err != nil {
plan.UpdateApp(app.Name, "failed", err.Error()) plan.UpdateApp(app.Name, "failed", err.Error())
s.logger.Printf("[ERROR] Restore failed for %s: %v", app.Name, err) 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++ failCount++
} else { } else {
plan.UpdateApp(app.Name, "done", "") plan.UpdateApp(app.Name, "done", "")
s.logger.Printf("[INFO] Restore completed for %s", app.Name) 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++ successCount++
} }
} }
plan.SetStatus("done") plan.SetStatus("done")
s.logger.Println("[INFO] All app restores completed") 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 // Push DR completion event
if s.notifier != nil { 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) { 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) meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil { if err != nil {
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
}
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -953,6 +959,10 @@ func (s *Server) buildAppBackupRows(
func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) { func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) {
_ = r.ParseForm() _ = 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" enabled := r.FormValue("cross_drive_enabled") == "on"
// Preserve existing runtime status fields and config when disabling // 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") stackName := r.FormValue("stack_name")
snapshotID := r.FormValue("snapshot_id") 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 == "" { if stackName == "" || snapshotID == "" {
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound) http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
return 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) 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 { if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
s.logger.Printf("[ERROR] Restore failed: %v", err) 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()) errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound) http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
return 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 + ").") msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound) 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") newPassword := r.FormValue("new_password")
confirmPassword := r.FormValue("confirm_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() data := s.settingsData()
// Validate current password // Validate current password
effectiveHash := s.effectivePasswordHash() effectiveHash := s.effectivePasswordHash()
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil { 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ó" data["PasswordError"] = "Hibás jelenlegi jelszó"
s.executeTemplate(w, r, "settings", data) s.executeTemplate(w, r, "settings", data)
return 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) { func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm() _ = 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")) email := strings.TrimSpace(r.FormValue("notification_email"))
cooldownStr := r.FormValue("cooldown_hours") cooldownStr := r.FormValue("cooldown_hours")
cooldownHours := 6 cooldownHours := 6
@@ -1410,6 +1443,10 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
label := strings.TrimSpace(r.FormValue("storage_label")) label := strings.TrimSpace(r.FormValue("storage_label"))
isDefault := r.FormValue("storage_default") == "true" 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 == "" { if label == "" {
label = settings.InferStorageLabel(path) label = settings.InferStorageLabel(path)
} }
@@ -1476,6 +1513,10 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
_ = r.ParseForm() _ = r.ParseForm()
path := r.FormValue("storage_path") path := r.FormValue("storage_path")
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
}
data := s.settingsData() data := s.settingsData()
// Check: apps using this path // Check: apps using this path
@@ -1518,6 +1559,10 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re
_ = r.ParseForm() _ = r.ParseForm()
path := r.FormValue("storage_path") 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 { if err := s.settings.SetDefaultStoragePath(path); err != nil {
s.logger.Printf("[ERROR] Failed to set default storage path: %v", err) s.logger.Printf("[ERROR] Failed to set default storage path: %v", err)
http.Redirect(w, r, "/settings", http.StatusFound) 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") path := r.FormValue("storage_path")
schedulable := r.FormValue("schedulable") == "true" 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 { if err := s.settings.SetSchedulable(path, schedulable); err != nil {
s.logger.Printf("[ERROR] Failed to update schedulable: %v", err) s.logger.Printf("[ERROR] Failed to update schedulable: %v", err)
http.Redirect(w, r, "/settings", http.StatusFound) 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") path := r.FormValue("storage_path")
label := strings.TrimSpace(r.FormValue("storage_label")) 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 { if label == "" || len(label) > 50 {
data := s.settingsData() data := s.settingsData()
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter." data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
+39
View File
@@ -12,6 +12,7 @@ import (
"sync" "sync"
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
"gitea.dooplex.hu/admin/felhom-controller/internal/assets" "gitea.dooplex.hu/admin/felhom-controller/internal/assets"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/config"
@@ -78,6 +79,9 @@ type Server struct {
// App-to-app integration manager (optional) // App-to-app integration manager (optional)
integrationMgr *integrations.Manager integrationMgr *integrations.Manager
// App export/import engine (optional)
appExporter *appexport.Exporter
// Debug mode support // Debug mode support
logBuffer *LogBuffer logBuffer *LogBuffer
debugCallbacks *DebugCallbacks debugCallbacks *DebugCallbacks
@@ -102,6 +106,13 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
loginAttempts: make(map[string]*loginAttempt), loginAttempts: make(map[string]*loginAttempt),
done: make(chan struct{}), 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() s.loadTemplates()
go s.cleanupSessions() go s.cleanupSessions()
@@ -138,6 +149,10 @@ func (s *Server) loadTemplates() {
s.tmpl = template.Must( s.tmpl = template.Must(
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"), 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. // 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 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. // SetStartTime records the controller start time for uptime calculation.
func (s *Server) SetStartTime(t time.Time) { func (s *Server) SetStartTime(t time.Time) {
s.startTime = t s.startTime = t
@@ -221,6 +241,10 @@ func (s *Server) InRestoreMode() bool {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path 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 // DR restore mode: intercept all routes except restore page, static, and restore API
if s.InRestoreMode() { if s.InRestoreMode() {
switch { switch {
@@ -283,6 +307,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.storageAttachHandler(w, r) s.storageAttachHandler(w, r)
case path == "/settings/storage/migrate-drive": case path == "/settings/storage/migrate-drive":
s.migrateDrivePageHandler(w, r) 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"): case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
name := strings.TrimPrefix(path, "/stacks/") name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/migrate") 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.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/deploy") name = strings.TrimSuffix(name, "/deploy")
s.deployHandler(w, r, name) s.deployHandler(w, r, name)
case path == "/import":
s.importPageHandler(w, r)
case path == "/static/style.css": case path == "/static/style.css":
s.serveCSSHandler(w, r) s.serveCSSHandler(w, r)
case path == "/static/chart.min.js": 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. // the controller host (felhom.DOMAIN) pass through normally.
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler { func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
controllerHost := "felhom." + s.cfg.Customer.Domain 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host host := r.Host
if idx := strings.LastIndex(host, ":"); idx != -1 { if idx := strings.LastIndex(host, ":"); idx != -1 {
@@ -335,6 +368,9 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: non-controller host=%s, serving catch-all page", host)
}
s.serveCatchAll(w, r, 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). // ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { 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) 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) { func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path path := r.URL.Path
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageAPI: %s %s from %s", r.Method, path, r.RemoteAddr)
}
switch { switch {
case path == "/api/storage/scan" && r.Method == http.MethodPost: case path == "/api/storage/scan" && r.Method == http.MethodPost:
s.storageScanAPIHandler(w, r) s.storageScanAPIHandler(w, r)
@@ -197,12 +201,18 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
// storageScanAPIHandler handles POST /api/storage/scan. // storageScanAPIHandler handles POST /api/storage/scan.
func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) { 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") result, err := storage.ScanDisks(s.logger, s.cfg.Logging.Level == "debug")
if err != nil { if err != nil {
s.logger.Printf("[ERROR] storageScan: %v", err) s.logger.Printf("[ERROR] storageScan: %v", err)
jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError) jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError)
return 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{}{ jsonResponse(w, map[string]interface{}{
"ok": true, "ok": true,
"available": result.AvailableDisks, "available": result.AvailableDisks,
@@ -226,6 +236,11 @@ func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) {
return 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" { if req.Confirm != "FORMÁZÁS" {
jsonError(w, "Megerősítés szükséges: írja be 'FORMÁZÁS'", http.StatusBadRequest) jsonError(w, "Megerősítés szükséges: írja be 'FORMÁZÁS'", http.StatusBadRequest)
return return
@@ -432,6 +447,10 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
return 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 == "" { if req.StackName == "" || req.TargetPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return return
@@ -794,6 +813,10 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
return 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 == "" { if req.StackName == "" || req.StalePath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return return
@@ -915,6 +938,10 @@ func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Req
return 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 // Hold lock across entire cleanup+mount+set to prevent races
s.diskJobMu.Lock() s.diskJobMu.Lock()
if s.activeRawMount != "" { if s.activeRawMount != "" {
@@ -1021,6 +1048,11 @@ func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request)
return 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 == "" { if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return return
@@ -1164,6 +1196,10 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
return return
} }
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil { if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable) jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return 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) // Check if USB device (only USB drives can be safely disconnected)
fsInfo := system.GetFSInfo(req.Path) fsInfo := system.GetFSInfo(req.Path)
if fsInfo != nil && fsInfo.Device != "" && !system.IsUSBDevice(fsInfo.Device) { 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) jsonError(w, "Csak USB meghajtó választható le biztonságosan", http.StatusBadRequest)
return return
} }
@@ -1183,6 +1222,10 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
return 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{}{ jsonResponse(w, map[string]interface{}{
"ok": true, "ok": true,
"message": "A meghajtó biztonságosan eltávolítható.", "message": "A meghajtó biztonságosan eltávolítható.",
@@ -1205,6 +1248,10 @@ func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request)
return return
} }
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil { if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable) jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return return
@@ -1217,6 +1264,10 @@ func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request)
return 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{}{ jsonResponse(w, map[string]interface{}{
"ok": true, "ok": true,
"message": "Meghajtó sikeresen csatlakoztatva.", "message": "Meghajtó sikeresen csatlakoztatva.",
@@ -1239,6 +1290,10 @@ func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Reques
return return
} }
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s from %s", req.Path, r.RemoteAddr)
}
if s.storageWatchdog == nil { if s.storageWatchdog == nil {
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable) jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
return return
@@ -1246,11 +1301,17 @@ func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Reques
// Validate drive is connected // Validate drive is connected
if s.settings.IsDisconnected(req.Path) { 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) jsonError(w, "A meghajtó jelenleg leválasztva — először csatlakoztassa", http.StatusBadRequest)
return return
} }
started, failed := s.storageWatchdog.RestartStoppedApps(req.Path) 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{}{ jsonResponse(w, map[string]interface{}{
"ok": true, "ok": true,
"started": started, "started": started,
@@ -1387,6 +1448,10 @@ func (s *Server) driveMigrateAPIHandler(w http.ResponseWriter, r *http.Request)
return 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 == "" { if req.SourcePath == "" || req.DestPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest) jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return return
@@ -1497,6 +1562,10 @@ func (s *Server) decommissionRemoveHandler(w http.ResponseWriter, r *http.Reques
req.Path = r.FormValue("storage_path") 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 == "" { if req.Path == "" {
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest) jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
return 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}} {{if .Stack.Orphaned}}
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button> <button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
{{else}} {{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> <a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
{{end}} {{end}}
{{else}} {{else}}
@@ -184,6 +184,27 @@
</div> </div>
</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 --> <!-- Section 8: Log Viewer -->
<div class="card debug-section" id="section-logs"> <div class="card debug-section" id="section-logs">
<div class="card-header debug-section-header" onclick="toggleSection('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 'telemetry': break; // no auto-load, user triggers manually
case 'selfupdate': loadSelfUpdateStatus(); break; case 'selfupdate': loadSelfUpdateStatus(); break;
case 'dr': loadDRStatus(); break; case 'dr': loadDRStatus(); break;
case 'appexport': loadAppExportStatus(); break;
case 'logs': initLogViewer(); break; case 'logs': initLogViewer(); break;
} }
} }
@@ -693,6 +715,137 @@ function renderTelemetryDetail(data) {
detail.style.display = 'block'; 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 ── // ── Helpers ──
function fmtTime(ts) { function fmtTime(ts) {
if (!ts) return '-'; if (!ts) return '-';
@@ -4,6 +4,7 @@
<div class="page-header"> <div class="page-header">
<h2>Alkalmazások</h2> <h2>Alkalmazások</h2>
<span class="domain-badge">{{.Domain}}</span> <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> <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>
<div id="sync-toast" class="sync-toast" style="display:none"></div> <div id="sync-toast" class="sync-toast" style="display:none"></div>