Files
felhom-controller/controller/internal/appexport/restore.go
T
admin 7c05b59708
gates / gates (push) Successful in 24s
v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by
whoever caught them: too late to translate where they are shown, too early where they are
made. Every one now carries its key across that gap. ZERO Hungarian error literals remain.

util.MsgError does three things at once, each earned:
  - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged;
  - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause);
  - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole.
A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours.

76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do
not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the
household's language come from one value; UpdateRefusal gained a Cause to carry it.

Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a
per-call-site flag — the producer somebody forgot would read "3 app is not running". The
guard caught a real key collision (alert.deadapp.one) the day the rule landed.

TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter
silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could
not see it, because every surviving fragment WAS a real base literal while the CALL had lost
text; two behaviour tests caught it. And the counting script was case-sensitive, so it said
"0 left" while five remained.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 11:44:30 +02:00

1177 lines
35 KiB
Go

package appexport
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// ScannedBundle describes a .fab file found on a storage drive.
type ScannedBundle struct {
Path string `json:"path"`
FileName string `json:"file_name"`
AppName string `json:"app_name"`
DisplayName string `json:"display_name"`
ExportedAt string `json:"exported_at"`
SizeBytes int64 `json:"size_bytes"`
SizeHuman string `json:"size_human"`
Encrypted bool `json:"encrypted"`
NeedsHDD bool `json:"needs_hdd"`
HasDB bool `json:"has_db"`
DrivePath string `json:"drive_path"`
DriveLabel string `json:"drive_label"`
}
// ImportRequest holds user-provided parameters for an import.
type ImportRequest struct {
FABPath string // full path to .fab file
Password string // empty for unencrypted bundles
}
// ScanForBundles scans export directories on all registered drives for .fab files.
// Pass a non-nil logger to enable debug output.
func ScanForBundles(drives []DrivePathInfo) []ScannedBundle {
var bundles []ScannedBundle
for _, drive := range drives {
exportDir := ExportDir(drive.Path)
entries, err := os.ReadDir(exportDir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".fab") {
continue
}
path := filepath.Join(exportDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
bundle := ScannedBundle{
Path: path,
FileName: entry.Name(),
SizeBytes: info.Size(),
SizeHuman: humanizeBytes(info.Size()),
DrivePath: drive.Path,
DriveLabel: drive.Label,
}
// Check encryption
encrypted, _ := IsEncryptedFAB(path)
bundle.Encrypted = encrypted
// Try to read manifest for metadata (unencrypted only)
var manifest *Manifest
if !encrypted {
manifest, _ = ReadManifestFromFAB(path)
}
if manifest != nil {
bundle.AppName = manifest.AppName
bundle.DisplayName = manifest.DisplayName
bundle.ExportedAt = manifest.ExportedAt.Format("2006-01-02 15:04")
bundle.NeedsHDD = manifest.NeedsHDD
bundle.HasDB = manifest.HasDatabase
} else {
// Parse app name from filename: {appname}_{timestamp}.fab
name := strings.TrimSuffix(entry.Name(), ".fab")
if idx := strings.LastIndex(name, "_"); idx > 0 {
bundle.AppName = name[:idx]
} else {
bundle.AppName = name
}
bundle.ExportedAt = info.ModTime().Format("2006-01-02 15:04")
}
bundles = append(bundles, bundle)
}
}
return bundles
}
// ScanForStaleTempFiles finds stale .fab.tmp and .tgz.tmp files in export directories.
func ScanForStaleTempFiles(drives []DrivePathInfo) []string {
var stale []string
for _, drive := range drives {
exportDir := ExportDir(drive.Path)
entries, err := os.ReadDir(exportDir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".tmp") || strings.HasSuffix(name, ".tgz.tmp") {
stale = append(stale, filepath.Join(exportDir, name))
}
}
}
return stale
}
// ReadManifestFromFAB reads the manifest from an unencrypted .fab file.
func ReadManifestFromFAB(fabPath string) (*Manifest, error) {
f, err := os.Open(fabPath)
if err != nil {
return nil, err
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
return nil, fmt.Errorf("not a valid gzip: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil, fmt.Errorf("manifest.json not found in bundle")
}
if err != nil {
return nil, err
}
if hdr.Name == "manifest.json" {
data, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
return UnmarshalManifest(data)
}
}
}
// ReadManifestFromEncryptedFAB decrypts and reads the manifest from an encrypted .fab file.
func ReadManifestFromEncryptedFAB(fabPath, password string) (*Manifest, error) {
tmpFile, err := os.CreateTemp("", "fab-manifest-*.tgz")
if err != nil {
return nil, err
}
tmpPath := tmpFile.Name()
tmpFile.Close()
defer os.Remove(tmpPath)
if err := DecryptFile(fabPath, tmpPath, password); err != nil {
return nil, util.MsgError("err.appexport.visszafejtes_sikertelen", err)
}
return ReadManifestFromFAB(tmpPath)
}
// StartImport validates and starts an async import. Returns error if blocked.
func (e *Exporter) StartImport(req ImportRequest) error {
e.mu.Lock()
if e.activeJob != nil && e.activeJob.Running {
e.mu.Unlock()
e.debugf("StartImport rejected: another job is already running")
return fmt.Errorf("export or import already in progress")
}
// Validate file exists
info, err := os.Stat(req.FABPath)
if err != nil {
e.mu.Unlock()
e.debugf("StartImport rejected: file not found: %s", req.FABPath)
return fmt.Errorf("bundle file not found: %w", err)
}
e.debugf("StartImport: path=%s size=%s encrypted=%v",
req.FABPath, humanizeBytes(info.Size()), req.Password != "")
steps := []Step{
{Label: "Csomag megnyitása", Status: "pending"},
{Label: "Alkalmazás előkészítése", Status: "pending"},
{Label: "Konfiguráció visszaállítása", Status: "pending"},
{Label: "Felhasználói adatok visszaállítása", Status: "pending"},
{Label: "Adatbázis visszaállítása", Status: "pending"},
{Label: "Alkalmazás indítása", Status: "pending"},
}
job := &Job{
StackName: filepath.Base(req.FABPath),
Steps: steps,
Running: true,
JobType: "import",
}
e.activeJob = job
e.mu.Unlock()
go e.executeImport(req, job)
return nil
}
func (e *Exporter) executeImport(req ImportRequest, job *Job) {
importStart := time.Now()
e.logger.Printf("[INFO] [appexport] Import started for %s", filepath.Base(req.FABPath))
e.debugf("=== IMPORT START: path=%s encrypted=%v ===", req.FABPath, req.Password != "")
defer func() {
job.mu.Lock()
job.Running = false
job.Done = true
job.mu.Unlock()
e.debugf("=== IMPORT END: elapsed=%v ===", time.Since(importStart))
}()
step := 0
// --- Step 0: Open bundle ---
job.setStep(step, "running", "")
stepStart := time.Now()
encrypted, _ := IsEncryptedFAB(req.FABPath)
e.debugf("bundle encrypted: %v", encrypted)
tgzPath := req.FABPath
// Decrypt if needed
if encrypted {
if req.Password == "" {
e.failJob(job, step, "Titkosított csomag — jelszó szükséges")
return
}
tmp, err := os.CreateTemp("", "fab-import-*.tgz")
if err != nil {
e.failJob(job, step, fmt.Sprintf("Temp fájl hiba: %v", err))
return
}
tmpPath := tmp.Name()
tmp.Close()
defer os.Remove(tmpPath)
e.debugf("decrypting bundle to %s", tmpPath)
decStart := time.Now()
if err := DecryptFile(req.FABPath, tmpPath, req.Password); err != nil {
e.failJob(job, step, fmt.Sprintf("Visszafejtés sikertelen: %v", err))
return
}
if decInfo, _ := os.Stat(tmpPath); decInfo != nil {
e.debugf("decrypted in %v: %s", time.Since(decStart), humanizeBytes(decInfo.Size()))
}
tgzPath = tmpPath
}
// Extract tar.gz to temp dir
tmpDir, err := os.MkdirTemp("", "felhom-import-*")
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)
e.debugf("extracting tar.gz from %s", tgzPath)
extractStart := time.Now()
if err := extractTarGz(tgzPath, tmpDir); err != nil {
e.failJob(job, step, fmt.Sprintf("Csomag kicsomagolása sikertelen: %v", err))
return
}
e.debugf("tar.gz extracted in %v", time.Since(extractStart))
// Log extracted contents
if e.debug {
filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(tmpDir, path)
if info.IsDir() {
e.debugf(" [dir] %s/", rel)
} else {
e.debugf(" [file] %s (%s)", rel, humanizeBytes(info.Size()))
}
return nil
})
}
// Read manifest
manifestData, err := os.ReadFile(filepath.Join(tmpDir, "manifest.json"))
if err != nil {
e.failJob(job, step, "manifest.json nem található a csomagban")
return
}
manifest, err := UnmarshalManifest(manifestData)
if err != nil {
e.failJob(job, step, fmt.Sprintf("Manifest hiba: %v", err))
return
}
e.debugf("manifest: app=%s display=%s version=%d controller=%s needsHDD=%v",
manifest.AppName, manifest.DisplayName, manifest.Version, manifest.ControllerVer, manifest.NeedsHDD)
e.debugf("manifest: hasDB=%v dbType=%s hasHDD=%v hasVolume=%v totalSize=%s",
manifest.HasDatabase, manifest.DBType, manifest.HasHDDData, manifest.HasVolumeData,
humanizeBytes(manifest.TotalSizeBytes))
e.debugf("manifest: configFiles=%v volumes=%v hddSubdirs=%v",
manifest.ConfigFiles, manifest.VolumeNames, manifest.HDDSubdirs)
// Update job with app info
job.mu.Lock()
job.StackName = manifest.AppName
job.DisplayName = manifest.DisplayName
job.mu.Unlock()
e.logger.Printf("[INFO] Import: opening bundle for %s (%s)", manifest.AppName, manifest.DisplayName)
// v0.125.0 validate-before-destroy (scenario C): every manifest-claimed data tar must be
// present and non-empty BEFORE the app is stopped and BEFORE any volume is removed. Hollow
// bundles (a containerized v<=0.124.0 exporter stranded the tars host-side) land HERE — the
// pre-fix order wiped the volumes first and only then discovered the emptiness.
if err := validateBundleData(tmpDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("A csomag hiányos — az importálás el sem indult, a meglévő alkalmazás érintetlen. (%v) A csomagot valószínűleg egy régebbi (≤0.124.0), konténerben futó vezérlő exportálta — készíts friss exportot.", err))
return
}
e.debugf("step 0 (open bundle) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 1: Prepare app ---
job.setStep(step, "running", "")
stepStart = time.Now()
stacksDir := e.provider.GetStacksBaseDir()
stackDir := filepath.Join(stacksDir, manifest.AppName)
e.debugf("stacksDir=%s stackDir=%s", stacksDir, stackDir)
isDeployed := e.provider.IsStackDeployed(manifest.AppName)
isRunning := false
if isDeployed {
isRunning = e.provider.IsStackRunning(manifest.AppName)
}
e.debugf("existing stack: deployed=%v running=%v", isDeployed, isRunning)
if isDeployed {
if isRunning {
e.logger.Printf("[INFO] Import: removing existing stack %s (with volumes)", manifest.AppName)
e.debugf("removing existing stack with volumes")
if err := e.provider.RemoveStackVolumes(manifest.AppName); err != nil {
e.logger.Printf("[WARN] Import: failed to remove stack volumes: %v", err)
e.debugf("RemoveStackVolumes failed: %v — trying just StopStack", err)
_ = e.provider.StopStack(manifest.AppName)
} else {
e.debugf("stack volumes removed successfully")
}
} else {
e.debugf("stack is deployed but not running — will overwrite config in place")
}
} else {
e.debugf("creating new stack directory: %s", stackDir)
if err := os.MkdirAll(stackDir, 0755); err != nil {
e.failJob(job, step, fmt.Sprintf("Könyvtár létrehozása sikertelen: %v", err))
return
}
}
e.debugf("step 1 (prepare app) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 2: Restore config ---
job.setStep(step, "running", "")
stepStart = time.Now()
configDir := filepath.Join(tmpDir, "config")
e.debugf("restoring config from %s → %s", configDir, stackDir)
env, err := e.restoreConfig(configDir, stackDir, manifest)
if err != nil {
e.failJob(job, step, fmt.Sprintf("Konfiguráció visszaállítása sikertelen: %v", err))
return
}
e.debugf("config restored: %d env vars", len(env))
if e.debug {
for k := range env {
e.debugf(" env: %s=%s", k, maskSecret(k, env[k]))
}
}
e.debugf("step 2 (restore config) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 3: Restore user data ---
job.setStep(step, "running", "")
stepStart = time.Now()
composePath := filepath.Join(stackDir, "docker-compose.yml")
if manifest.HasHDDData {
e.debugf("restoring HDD data: subdirs=%v", manifest.HDDSubdirs)
if err := e.restoreHDDData(tmpDir, manifest, composePath, env); err != nil {
e.failJob(job, step, fmt.Sprintf("HDD adatok visszaállítása sikertelen: %v", err))
return
}
} else {
e.debugf("no HDD data in bundle — skipping")
}
if manifest.HasVolumeData {
e.debugf("restoring Docker volumes: %v", manifest.VolumeNames)
if err := e.restoreVolumeData(tmpDir, manifest); err != nil {
e.failJob(job, step, fmt.Sprintf("Docker volume visszaállítása sikertelen: %v", err))
return
}
} else {
e.debugf("no volume data in bundle — skipping")
}
e.debugf("step 3 (restore user data) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 4: Restore database ---
job.setStep(step, "running", "")
stepStart = time.Now()
if manifest.HasDatabase {
e.debugf("restoring database: type=%s", manifest.DBType)
if err := e.restoreDatabase(manifest, tmpDir, composePath, stackDir, env); err != nil {
e.failJob(job, step, fmt.Sprintf("Adatbázis visszaállítása sikertelen: %v", err))
return
}
e.debugf("database restored successfully")
} else {
e.debugf("no database in bundle — skipping")
}
e.debugf("step 4 (restore database) done in %v", time.Since(stepStart))
job.setStep(step, "done", "")
step++
// --- Step 5: Start app ---
job.setStep(step, "running", "")
stepStart = time.Now()
e.debugf("starting stack %s", manifest.AppName)
if err := e.provider.StartStack(manifest.AppName); err != nil {
e.failJob(job, step, fmt.Sprintf("Alkalmazás indítása sikertelen: %v", err))
return
}
e.debugf("stack started in %v", time.Since(stepStart))
// Refresh stack list so UI sees the new/updated app
e.debugf("refreshing stack list")
if err := e.provider.RefreshStacks(); err != nil {
e.logger.Printf("[WARN] Import: refresh stacks failed: %v", err)
e.debugf("RefreshStacks error: %v", err)
} else {
e.debugf("stack list refreshed")
}
job.setStep(step, "done", "")
e.logger.Printf("[INFO] Import completed: %s (%s) in %v", manifest.AppName, manifest.DisplayName, time.Since(importStart))
}
// maskSecret masks sensitive env var values for debug logging.
func maskSecret(key, value string) string {
lower := strings.ToLower(key)
if strings.Contains(lower, "password") || strings.Contains(lower, "secret") ||
strings.Contains(lower, "token") || strings.Contains(lower, "key") {
if len(value) <= 3 {
return "***"
}
return value[:2] + "***"
}
return value
}
// restoreConfig extracts config files to the stack directory and re-encrypts app.yaml.
// Returns the plaintext env map for use in subsequent steps.
func (e *Exporter) restoreConfig(configDir, stackDir string, manifest *Manifest) (map[string]string, error) {
entries, err := os.ReadDir(configDir)
if err != nil {
return nil, fmt.Errorf("reading config dir: %w", err)
}
e.debugf("restoreConfig: %d entries in config dir", len(entries))
var env map[string]string
for _, entry := range entries {
if entry.IsDir() {
continue
}
src := filepath.Join(configDir, entry.Name())
dst := filepath.Join(stackDir, entry.Name())
// app.yaml: read plaintext env, then re-encrypt via provider
if entry.Name() == "app.yaml" {
e.debugf("restoreConfig: reading plaintext app.yaml from bundle")
env, err = readPlaintextAppYaml(src)
if err != nil {
return nil, fmt.Errorf("reading app.yaml: %w", err)
}
e.debugf("restoreConfig: app.yaml has %d env vars, re-encrypting via provider", len(env))
if err := e.provider.SaveEncryptedAppConfig(stackDir, env); err != nil {
return nil, fmt.Errorf("saving encrypted app.yaml: %w", err)
}
e.debugf("restoreConfig: app.yaml re-encrypted and saved")
continue
}
// Copy other config files as-is
if info, _ := entry.Info(); info != nil {
e.debugf("restoreConfig: copying %s (%s)", entry.Name(), humanizeBytes(info.Size()))
}
if err := copyFile(src, dst); err != nil {
return nil, fmt.Errorf("copying %s: %w", entry.Name(), err)
}
}
return env, nil
}
// readPlaintextAppYaml parses the plaintext app.yaml written by the export.
func readPlaintextAppYaml(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
env := make(map[string]string)
inEnv := false
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if trimmed == "env:" {
inEnv = true
continue
}
if inEnv && strings.HasPrefix(line, " ") {
parts := strings.SplitN(trimmed, ":", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
// Remove Go-style quotes (written by writeDecryptedAppYaml with %q)
if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {
if unquoted, err := strconv.Unquote(val); err == nil {
val = unquoted
}
}
env[key] = val
}
} else if inEnv && !strings.HasPrefix(line, " ") {
inEnv = false
}
}
return env, nil
}
// restoreHDDData extracts HDD bind mount data from the bundle.
func (e *Exporter) restoreHDDData(tmpDir string, manifest *Manifest, composePath string, env map[string]string) error {
hddDir := filepath.Join(tmpDir, "data", "hdd")
// Resolve HDD mount paths from compose file
hddPath := env["HDD_PATH"]
e.debugf("restoreHDDData: HDD_PATH=%s composePath=%s", hddPath, composePath)
mountByName := make(map[string]string)
if hddPath != "" {
mounts := resolveHDDMounts(composePath, env)
e.debugf("restoreHDDData: resolved mounts: %v", mounts)
for _, m := range mounts {
mountByName[filepath.Base(m)] = m
}
} else {
e.debugf("restoreHDDData: HDD_PATH is empty — will use fallback paths")
}
for _, subdir := range manifest.HDDSubdirs {
// [CTRL-001] defence-in-depth: refuse any subdir that is not a single
// safe segment before it reaches MkdirAll/extractTar on a user drive.
if err := ValidateSegment("hdd_subdir", subdir); err != nil {
return err
}
tarPath := filepath.Join(hddDir, subdir+".tar")
tarInfo, err := os.Stat(tarPath)
if err != nil {
// v0.125.0: a claimed-but-absent tar is an ERROR (validateBundleData already refused
// it pre-destroy; this is defense in depth, not a reachable soft path).
return fmt.Errorf("HDD tar missing from bundle: %s", subdir+".tar")
}
e.debugf("restoreHDDData: subdir=%s tarSize=%s", subdir, humanizeBytes(tarInfo.Size()))
destPath := mountByName[subdir]
if destPath == "" {
if filepath.Base(hddPath) == subdir {
destPath = hddPath
} else {
destPath = filepath.Join(hddPath, subdir)
}
e.debugf("restoreHDDData: no mount match for %s, using fallback: %s", subdir, destPath)
} else {
e.debugf("restoreHDDData: mount match for %s → %s", subdir, destPath)
}
if err := os.MkdirAll(destPath, 0755); err != nil {
return fmt.Errorf("creating %s: %w", destPath, err)
}
e.logger.Printf("[INFO] Import: extracting HDD data %s → %s", subdir, destPath)
extractStart := time.Now()
if err := extractTar(tarPath, destPath); err != nil {
return fmt.Errorf("extracting %s: %w", subdir, err)
}
e.debugf("restoreHDDData: extracted %s in %v", subdir, time.Since(extractStart))
}
return nil
}
// resolveHDDMounts parses compose volumes for HDD bind mounts and resolves env vars.
func resolveHDDMounts(composePath string, env map[string]string) []string {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
hddPath := env["HDD_PATH"]
if hddPath == "" {
return nil
}
var mounts []string
seen := make(map[string]bool)
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "- ") {
continue
}
vol := strings.TrimPrefix(trimmed, "- ")
// Parse "host:container" or "host:container:mode"
parts := strings.SplitN(vol, ":", 2)
if len(parts) < 2 {
continue
}
hostPart := parts[0]
if strings.Contains(hostPart, "${HDD_PATH}") {
resolved := strings.ReplaceAll(hostPart, "${HDD_PATH}", hddPath)
resolved = filepath.Clean(resolved)
if !seen[resolved] {
seen[resolved] = true
mounts = append(mounts, resolved)
}
}
}
return mounts
}
// validateBundleData asserts every manifest-claimed data tar exists non-empty in the extracted
// bundle (v0.125.0, scenario C). Pure read — called before ANY destructive import step.
func validateBundleData(tmpDir string, manifest *Manifest) error {
for _, v := range manifest.VolumeNames {
if err := ValidateSegment("volume_name", v); err != nil {
return err
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "volumes", v+".tar"))
if err != nil || fi.Size() == 0 {
return util.MsgError("err.appexport.a_z_kotet_adata_hianyzik_a", v)
}
}
for _, s := range manifest.HDDSubdirs {
if err := ValidateSegment("hdd_subdir", s); err != nil {
return err
}
fi, err := os.Stat(filepath.Join(tmpDir, "data", "hdd", s+".tar"))
if err != nil || fi.Size() == 0 {
return util.MsgError("err.appexport.a_z_adatkonyvtar_tartalma_hianyzik_a", s)
}
}
return nil
}
// importVolumeTar streams tarPath into the (existing) volume via `docker cp - <cid>:/vol` —
// zero shared paths, correct on bare metal AND under the containerized controller (the
// docker-run -v population was the v0.124.0 strand's import half).
func (e *Exporter) importVolumeTar(volName, tarPath string) error {
return e.withVolumeHelper(volName, func(cid string) error {
f, err := os.Open(tarPath)
if err != nil {
return fmt.Errorf("opening %s: %w", tarPath, err)
}
defer f.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if stderr, err := dockerExec(ctx, f, nil, "cp", "-", cid+":/vol"); err != nil {
return fmt.Errorf("streaming into volume %s: %s — %w", volName, stderr, err)
}
return nil
})
}
// restoreVolumeData recreates Docker named volumes from bundle tarballs. v0.125.0: a missing
// tar is an ERROR (defense in depth behind validateBundleData), and population streams via
// docker cp instead of a docker-run -v host mount.
func (e *Exporter) restoreVolumeData(tmpDir string, manifest *Manifest) error {
volDir := filepath.Join(tmpDir, "data", "volumes")
for _, volName := range manifest.VolumeNames {
// [CTRL-001] defence-in-depth: refuse any volume name that is not a
// single safe segment before it reaches a tar path / docker volume op.
if err := ValidateSegment("volume_name", volName); err != nil {
return err
}
tarPath := filepath.Join(volDir, volName+".tar")
tarInfo, err := os.Stat(tarPath)
if err != nil {
return fmt.Errorf("volume tar missing from bundle: %s", volName+".tar")
}
e.debugf("restoreVolumeData: volume=%s tarSize=%s", volName, humanizeBytes(tarInfo.Size()))
// Create the Docker volume
e.debugf("restoreVolumeData: creating docker volume %s", volName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
stderr, err := dockerExec(ctx, nil, nil, "volume", "create", volName)
cancel()
if err != nil {
return fmt.Errorf("creating volume %s: %s — %w", volName, stderr, err)
}
// Populate volume from tar (docker cp streaming)
e.logger.Printf("[INFO] Import: populating volume %s", volName)
popStart := time.Now()
if err := e.importVolumeTar(volName, tarPath); err != nil {
return fmt.Errorf("populating volume %s: %w", volName, err)
}
e.debugf("restoreVolumeData: volume %s populated in %v", volName, time.Since(popStart))
}
return nil
}
// restoreDatabase imports the database dump from the bundle.
func (e *Exporter) restoreDatabase(manifest *Manifest, tmpDir, composePath, stackDir string, env map[string]string) error {
dbDir := filepath.Join(tmpDir, "database")
// Find the gzipped dump file
dumpGzPath, err := findDumpFile(dbDir)
if err != nil {
e.debugf("restoreDatabase: no dump file found in %s: %v", dbDir, err)
return err
}
if dumpInfo, _ := os.Stat(dumpGzPath); dumpInfo != nil {
e.debugf("restoreDatabase: dump file=%s size=%s", dumpGzPath, humanizeBytes(dumpInfo.Size()))
}
// Find DB service in compose file
e.debugf("restoreDatabase: parsing compose for DB service: %s", composePath)
dbSvc := findDBServiceInCompose(composePath)
if dbSvc == nil {
e.debugf("restoreDatabase: no DB service found in compose file!")
return fmt.Errorf("no database service found in docker-compose.yml")
}
e.debugf("restoreDatabase: found DB service=%s type=%s container=%s",
dbSvc.ServiceName, dbSvc.DBType, dbSvc.ContainerName)
// Start only the DB service
e.logger.Printf("[INFO] Import: starting DB service %s (%s)", dbSvc.ServiceName, dbSvc.DBType)
e.debugf("restoreDatabase: docker compose up -d %s", dbSvc.ServiceName)
out, err := composeExecEnv(stackDir, env, "up", "-d", dbSvc.ServiceName)
if err != nil {
e.debugf("restoreDatabase: compose up failed: %s", strings.TrimSpace(string(out)))
return fmt.Errorf("starting DB service: %w", err)
}
e.debugf("restoreDatabase: compose up output: %s", strings.TrimSpace(string(out)))
// Ensure we stop the DB service after restore (full stack start is in the next step)
defer func() {
e.debugf("restoreDatabase: stopping DB service %s", dbSvc.ServiceName)
out, _ := composeExecEnv(stackDir, env, "stop", dbSvc.ServiceName)
e.debugf("restoreDatabase: compose stop output: %s", strings.TrimSpace(string(out)))
}()
// Get the container ID
e.debugf("restoreDatabase: getting container ID for service %s", dbSvc.ServiceName)
containerID, err := getComposeContainerID(stackDir, env, dbSvc.ServiceName)
if err != nil {
e.debugf("restoreDatabase: failed to get container ID: %v", err)
return fmt.Errorf("getting container ID: %w", err)
}
e.debugf("restoreDatabase: container ID=%s", containerID)
// Wait for DB readiness
e.logger.Printf("[INFO] Import: waiting for DB readiness (container %s)", containerID[:12])
e.debugf("restoreDatabase: waiting for DB readiness...")
waitStart := time.Now()
if err := waitForDB(containerID, dbSvc.DBType, env); err != nil {
e.debugf("restoreDatabase: DB not ready after %v: %v", time.Since(waitStart), err)
return fmt.Errorf("waiting for DB: %w", err)
}
e.debugf("restoreDatabase: DB ready in %v", time.Since(waitStart))
// Import the dump
e.logger.Printf("[INFO] Import: importing database dump")
e.debugf("restoreDatabase: importing dump into container %s", containerID[:12])
importStart := time.Now()
if err := importDBDump(containerID, dumpGzPath, dbSvc.DBType, env); err != nil {
e.debugf("restoreDatabase: import failed after %v: %v", time.Since(importStart), err)
return fmt.Errorf("importing dump: %w", err)
}
e.debugf("restoreDatabase: dump imported in %v", time.Since(importStart))
return nil
}
// findDumpFile finds the gzipped SQL dump in the database directory.
func findDumpFile(dbDir string) (string, error) {
entries, err := os.ReadDir(dbDir)
if err != nil {
return "", fmt.Errorf("reading database dir: %w", err)
}
for _, entry := range entries {
if strings.HasSuffix(entry.Name(), ".sql.gz") || strings.HasSuffix(entry.Name(), ".sql") {
return filepath.Join(dbDir, entry.Name()), nil
}
}
return "", fmt.Errorf("no database dump file found")
}
// dbServiceInfo describes a database service found in a compose file.
type dbServiceInfo struct {
ServiceName string
DBType string // "postgres" or "mariadb"
ContainerName string
}
// findDBServiceInCompose parses a compose file to find the database service.
func findDBServiceInCompose(composePath string) *dbServiceInfo {
data, err := os.ReadFile(composePath)
if err != nil {
return nil
}
inServices := false
currentService := ""
currentImage := ""
currentContainer := ""
checkAndReturn := func() *dbServiceInfo {
if currentService != "" && currentImage != "" {
return classifyDBImage(currentService, currentImage, currentContainer)
}
return nil
}
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
// Top-level section detection
if len(line) > 0 && line[0] != ' ' && line[0] != '#' {
if strings.HasPrefix(trimmed, "services:") {
inServices = true
} else {
// Leaving services section — check last service
if info := checkAndReturn(); info != nil {
return info
}
inServices = false
}
continue
}
if !inServices {
continue
}
// Service name: 2-space indent, ends with colon, no further nesting
if strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " ") &&
strings.HasSuffix(trimmed, ":") && !strings.Contains(trimmed, " ") {
// Check previous service
if info := checkAndReturn(); info != nil {
return info
}
currentService = strings.TrimSuffix(trimmed, ":")
currentImage = ""
currentContainer = ""
continue
}
// Properties at 4+ space indent
if strings.HasPrefix(trimmed, "image:") {
currentImage = strings.TrimSpace(strings.TrimPrefix(trimmed, "image:"))
}
if strings.HasPrefix(trimmed, "container_name:") {
currentContainer = strings.TrimSpace(strings.TrimPrefix(trimmed, "container_name:"))
}
}
// Check last service
return checkAndReturn()
}
func classifyDBImage(service, image, container string) *dbServiceInfo {
img := strings.ToLower(image)
if strings.Contains(img, "postgres") {
return &dbServiceInfo{ServiceName: service, DBType: "postgres", ContainerName: container}
}
if strings.Contains(img, "mariadb") || strings.Contains(img, "mysql") {
return &dbServiceInfo{ServiceName: service, DBType: "mariadb", ContainerName: container}
}
return nil
}
// composeExecEnv runs docker compose in the given stack directory with env vars.
func composeExecEnv(stackDir string, env map[string]string, args ...string) ([]byte, error) {
cmdArgs := append([]string{"compose"}, args...)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, "docker", cmdArgs...)
cmd.Dir = stackDir
cmd.Env = os.Environ()
for k, v := range env {
cmd.Env = append(cmd.Env, k+"="+v)
}
return cmd.CombinedOutput()
}
// getComposeContainerID returns the container ID for a compose service.
func getComposeContainerID(stackDir string, env map[string]string, service string) (string, error) {
// Wait a moment for the container to be created
time.Sleep(2 * time.Second)
out, err := composeExecEnv(stackDir, env, "ps", "-q", service)
if err != nil {
return "", fmt.Errorf("compose ps: %w", err)
}
id := strings.TrimSpace(string(out))
if id == "" {
return "", fmt.Errorf("no container found for service %s", service)
}
// Take the first line in case of multiple
if idx := strings.Index(id, "\n"); idx > 0 {
id = id[:idx]
}
return id, nil
}
// waitForDB polls until the database is ready to accept connections.
func waitForDB(containerID, dbType string, env map[string]string) error {
timeout := 60 * time.Second
start := time.Now()
for {
if time.Since(start) > timeout {
return fmt.Errorf("timeout waiting for %s to become ready", dbType)
}
var cmd *exec.Cmd
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
switch dbType {
case "postgres":
user := env["POSTGRES_USER"]
if user == "" {
user = "postgres"
}
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
"pg_isready", "-U", user)
case "mariadb":
password := env["MYSQL_ROOT_PASSWORD"]
if password == "" {
password = env["MARIADB_ROOT_PASSWORD"]
}
cmd = exec.CommandContext(ctx, "docker", "exec", containerID,
"mysqladmin", "ping", "-u", "root", "-p"+password)
default:
cancel()
return fmt.Errorf("unknown DB type: %s", dbType)
}
err := cmd.Run()
cancel()
if err == nil {
return nil
}
time.Sleep(2 * time.Second)
}
}
// importDBDump imports a (possibly gzipped) SQL dump into the running database container.
func importDBDump(containerID, dumpPath, dbType string, env map[string]string) error {
// Open the dump file
f, err := os.Open(dumpPath)
if err != nil {
return err
}
defer f.Close()
// If gzipped, wrap in gzip reader
var reader io.Reader = f
if strings.HasSuffix(dumpPath, ".gz") {
gr, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("opening gzip: %w", err)
}
defer gr.Close()
reader = gr
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
var cmd *exec.Cmd
switch dbType {
case "postgres":
user := env["POSTGRES_USER"]
if user == "" {
user = "postgres"
}
dbName := env["POSTGRES_DB"]
if dbName == "" {
dbName = user
}
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"psql", "-U", user, "-d", dbName)
case "mariadb":
password := env["MYSQL_ROOT_PASSWORD"]
if password == "" {
password = env["MARIADB_ROOT_PASSWORD"]
}
dbName := env["MYSQL_DATABASE"]
if dbName == "" {
dbName = env["MARIADB_DATABASE"]
}
cmd = exec.CommandContext(ctx, "docker", "exec", "-i", containerID,
"mysql", "-u", "root", "-p"+password, dbName)
default:
return fmt.Errorf("unknown DB type: %s", dbType)
}
cmd.Stdin = reader
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%s import failed: %s — %w", dbType, strings.TrimSpace(string(out)), err)
}
return nil
}
// extractTarGz extracts a tar.gz archive to a directory.
func extractTarGz(tgzPath, destDir string) error {
f, err := os.Open(tgzPath)
if err != nil {
return err
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gr.Close()
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target := filepath.Join(destDir, hdr.Name)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
outFile, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
os.Chmod(target, os.FileMode(hdr.Mode))
}
}
}
// extractTar extracts a plain tar archive to a directory.
func extractTar(tarPath, destDir string) error {
f, err := os.Open(tarPath)
if err != nil {
return err
}
defer f.Close()
tr := tar.NewReader(f)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target := filepath.Join(destDir, hdr.Name)
// Security: prevent path traversal
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
outFile, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(outFile, tr); err != nil {
outFile.Close()
return err
}
outFile.Close()
os.Chmod(target, os.FileMode(hdr.Mode))
}
}
}