From 5aeba909f011737a20175a540f777a74174df002 Mon Sep 17 00:00:00 2001 From: "Jan Tapper [Onestein]" <56023504+Jan-Onestein@users.noreply.github.com> Date: Mon, 8 Jun 2026 17:24:05 +0200 Subject: [PATCH 1/5] Update main.go update client version detection --- main.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index c6c6ef4..d6f0ea5 100644 --- a/main.go +++ b/main.go @@ -33,7 +33,9 @@ func main() { var err error // cli, err = client.NewEnvClient() // cli, err = client.NewClientWithOpts(client.FromEnv) - cli, err = client.NewClientWithOpts(client.WithVersion("1.36")) + // cli, err = client.NewClientWithOpts(client.WithVersion("1.36")) + //FromEnv picks up the default Docker socket, and WithAPIVersionNegotiation() lets the client automatically negotiate the highest supported API version with the daemon + cli, err = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { panic(err) } From aee69037d9a44b94beb6bb786599114c16866ac2 Mon Sep 17 00:00:00 2001 From: "Jan Tapper [Onestein]" <56023504+Jan-Onestein@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:59:51 +0200 Subject: [PATCH 2/5] Update backup.go // Add mount-path to filelist --- backup.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backup.go b/backup.go index 18dcf3b..c5687bc 100644 --- a/backup.go +++ b/backup.go @@ -130,6 +130,14 @@ func backup(ID string) error { paths = []string{} + // Add mount-path to filelist - start fix + for _, mount := range conf.Mounts { + if mount.Type == "bind" || mount.Type == "volume" { + paths = append(paths, mount.Source) + } + } + // end fix + conf.Config.Image, err = getFullImageName(conf.Config.Image) if err != nil { return err From ce09a757c10d0f7f77d0b15a33b59530916502f6 Mon Sep 17 00:00:00 2001 From: "Jan Tapper [Onestein]" <56023504+Jan-Onestein@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:38:07 +0200 Subject: [PATCH 3/5] Update backup.go backups folder --- backup.go | 228 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 198 insertions(+), 30 deletions(-) diff --git a/backup.go b/backup.go index c5687bc..cc76e5b 100644 --- a/backup.go +++ b/backup.go @@ -4,15 +4,18 @@ import ( "archive/tar" "encoding/json" "fmt" + "io" "io/ioutil" "os" "os/exec" + "path/filepath" // "strings" + "syscall" "time" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" + "github.com/docker/go-connections/nat" "github.com/kennygrant/sanitize" "github.com/spf13/cobra" ) @@ -20,11 +23,10 @@ import ( // Backup is used to gather all of a container's metadata, so we can encode it // as JSON and store it type Backup struct { - Name string - Config *container.Config - HostConfig *container.HostConfig - NetworkSettings *types.NetworkSettings - Platform string + Name string + Config *container.Config + PortMap nat.PortMap + Mounts []types.MountPoint } var ( @@ -33,6 +35,7 @@ var ( optAll = false optStopped = false optVerbose = false + optOutput = "./backups" // paths []string tw *tar.Writer @@ -53,14 +56,144 @@ var ( } ) -func backupTar(filename string, backup Backup) error { +func collectFile(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if optVerbose { + fmt.Println("Adding", path) + } + + paths = append(paths, path) + return nil +} + +func collectFileTar(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode()&os.ModeSocket != 0 { + // ignore sockets + return nil + } + + if optVerbose { + fmt.Println("Adding", path) + } + + th, err := tar.FileInfoHeader(info, path) + if err != nil { + return err + } + + th.Name = path + if si, ok := info.Sys().(*syscall.Stat_t); ok { + th.Uid = int(si.Uid) + th.Gid = int(si.Gid) + } + + if err := tw.WriteHeader(th); err != nil { + return err + } + + if !info.Mode().IsRegular() { + return nil + } + if info.Mode().IsDir() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return err + } + + _, err = io.Copy(tw, file) + return err +} + +// mountFolderName bepaalt de submapnaam voor een mount: +// gebruikt de Docker volumenaam indien beschikbaar (named volume), +// anders het gesanitized destination-pad (bind mount). +func mountFolderName(m types.MountPoint) string { + if m.Name != "" { + return sanitize.Path(m.Name) + } + name := strings.TrimPrefix(m.Destination, "/") + name = strings.Replace(name, "/", "_", -1) + return sanitize.Path(name) +} + +// copyMount kopieert de volledige inhoud van src naar dst, +// met behoud van mapstructuur, bestandsrechten en symlinks. +func copyMount(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(linkTarget, target) + } + + if !info.Mode().IsRegular() { + // sockets, devices e.d. overslaan + return nil + } + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) + if err != nil { + return err + } + defer out.Close() + + if optVerbose { + fmt.Println("Copying", path, "->", target) + } + + _, err = io.Copy(out, in) + return err + }) +} + +func backupTar(backupRoot, filename string, backup Backup) error { b, err := json.MarshalIndent(backup, "", " ") if err != nil { return err } // fmt.Println(string(b)) - tarfile, err := os.Create(filename + ".tar") + if err := os.MkdirAll(backupRoot, 0755); err != nil { + return err + } + + tarPath := filepath.Join(backupRoot, filename+".tar") + tarfile, err := os.Create(tarPath) if err != nil { return err } @@ -82,8 +215,17 @@ func backupTar(filename string, backup Backup) error { return err } + for _, m := range backup.Mounts { + // fmt.Printf("Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) + + err := filepath.Walk(m.Source, collectFileTar) + if err != nil { + return err + } + } + tw.Close() - fmt.Println("Created backup:", filename+".tar") + fmt.Println("Created backup:", tarPath) return nil } @@ -94,7 +236,7 @@ func getFullImageName(imageName string) (string, error) { } // If the used image doesn't include tag information try to find one (if it exists). - images, err := cli.ImageList(ctx, image.ListOptions{}) + images, err := cli.ImageList(ctx, types.ImageListOptions{}) if err != nil { // Couldn't get image list, abort return imageName, err @@ -130,31 +272,32 @@ func backup(ID string) error { paths = []string{} - // Add mount-path to filelist - start fix - for _, mount := range conf.Mounts { - if mount.Type == "bind" || mount.Type == "volume" { - paths = append(paths, mount.Source) - } - } - // end fix - conf.Config.Image, err = getFullImageName(conf.Config.Image) if err != nil { return err } backup := Backup{ - Name: conf.Name, - Config: conf.Config, - HostConfig: conf.HostConfig, - NetworkSettings: conf.NetworkSettings, - Platform: conf.Platform, + Name: conf.Name, + PortMap: conf.HostConfig.PortBindings, + Config: conf.Config, + Mounts: conf.Mounts, } filename := sanitize.Path(fmt.Sprintf("%s-%s", conf.Config.Image, ID)) filename = strings.Replace(filename, "/", "_", -1) + + // Bepaal de doelmap: backups// + containerName := strings.TrimPrefix(conf.Name, "/") + timestamp := time.Now().Format("2006-01-02_15-04") + backupRoot := filepath.Join(optOutput, timestamp, containerName) + if optTar { - return backupTar(filename, backup) + return backupTar(backupRoot, filename, backup) + } + + if err := os.MkdirAll(backupRoot, 0755); err != nil { + return err } b, err := json.MarshalIndent(backup, "", " ") @@ -163,21 +306,45 @@ func backup(ID string) error { } // fmt.Println(string(b)) - err = ioutil.WriteFile(filename+".backup.json", b, 0600) + jsonPath := filepath.Join(backupRoot, filename+".backup.json") + err = ioutil.WriteFile(jsonPath, b, 0600) if err != nil { return err } - filelist, err := os.Create(filename + ".backup.files") + for _, m := range conf.Mounts { + // fmt.Printf("Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) + err := filepath.Walk(m.Source, collectFile) + if err != nil { + return err + } + + mountDir := filepath.Join(backupRoot, mountFolderName(m)) + if err := os.MkdirAll(mountDir, 0755); err != nil { + return err + } + if err := copyMount(m.Source, mountDir); err != nil { + return err + } + } + + filesPath := filepath.Join(backupRoot, filename+".backup.files") + filelist, err := os.Create(filesPath) if err != nil { return err } defer filelist.Close() - _, err = filelist.WriteString(filename + ".backup.json\n") + absJSONPath, err := filepath.Abs(jsonPath) + if err != nil { + return err + } + + _, err = filelist.WriteString(absJSONPath + "\n") if err != nil { return err } + for _, s := range paths { _, err := filelist.WriteString(s + "\n") if err != nil { @@ -185,11 +352,11 @@ func backup(ID string) error { } } - fmt.Println("Created backup:", filename+".backup.json") + fmt.Println("Created backup:", jsonPath) if optLaunch != "" { ol := strings.Replace(optLaunch, "%tag", filename, -1) - ol = strings.Replace(ol, "%list", filename+".backup.files", -1) + ol = strings.Replace(ol, "%list", filesPath, -1) fmt.Println("Launching external command and waiting for it to finish:") fmt.Println(ol) @@ -203,7 +370,7 @@ func backup(ID string) error { } func backupAll() error { - containers, err := cli.ContainerList(ctx, container.ListOptions{ + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ All: optStopped, }) if err != nil { @@ -226,5 +393,6 @@ func init() { backupCmd.Flags().BoolVarP(&optAll, "all", "a", false, "backup all running containers") backupCmd.Flags().BoolVarP(&optStopped, "stopped", "s", false, "in combination with --all: also backup stopped containers") backupCmd.Flags().BoolVarP(&optVerbose, "verbose", "v", false, "print detailed backup progress") + backupCmd.Flags().StringVarP(&optOutput, "output", "o", "./backups", "root folder for volume-mount backups") // RootCmd.AddCommand(backupCmd) } From 95eacb7b237dcb888c0a44fca9d279a21518baf4 Mon Sep 17 00:00:00 2001 From: "Jan Tapper [Onestein]" <56023504+Jan-Onestein@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:38:07 +0200 Subject: [PATCH 4/5] Update backup.go --- .gitignore | 6 + .idea/.gitignore | 5 + README.md | 2 +- backup.go | 228 ++++++++++++++++++++++++++---- docker-backup.py | 355 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- 6 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 docker-backup.py diff --git a/.gitignore b/.gitignore index 8e37167..8246de1 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ # Builds docker-backup /dist + +# Docker backup output +backups/ +*.backup.json +*.backup.files +*.tar \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/README.md b/README.md index 4c90519..ddcc3ac 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ and newer. ### From source - git clone https://github.com/muesli/docker-backup.git + git clone https://github.com/Jan-Onestein/docker-backup.git cd docker-backup go build diff --git a/backup.go b/backup.go index c5687bc..ec5e4bc 100644 --- a/backup.go +++ b/backup.go @@ -4,15 +4,18 @@ import ( "archive/tar" "encoding/json" "fmt" + "io" "io/ioutil" "os" "os/exec" + "path/filepath" // "strings" + "syscall" "time" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/image" + "github.com/docker/go-connections/nat" "github.com/kennygrant/sanitize" "github.com/spf13/cobra" ) @@ -20,11 +23,10 @@ import ( // Backup is used to gather all of a container's metadata, so we can encode it // as JSON and store it type Backup struct { - Name string - Config *container.Config - HostConfig *container.HostConfig - NetworkSettings *types.NetworkSettings - Platform string + Name string + Config *container.Config + PortMap nat.PortMap + Mounts []types.MountPoint } var ( @@ -33,6 +35,7 @@ var ( optAll = false optStopped = false optVerbose = false + optOutput = "./backups" // paths []string tw *tar.Writer @@ -53,14 +56,144 @@ var ( } ) -func backupTar(filename string, backup Backup) error { +func collectFile(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if optVerbose { + fmt.Println("Adding", path) + } + + paths = append(paths, path) + return nil +} + +func collectFileTar(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.Mode()&os.ModeSocket != 0 { + // ignore sockets + return nil + } + + if optVerbose { + fmt.Println("Adding", path) + } + + th, err := tar.FileInfoHeader(info, path) + if err != nil { + return err + } + + th.Name = path + if si, ok := info.Sys().(*syscall.Stat_t); ok { + th.Uid = int(si.Uid) + th.Gid = int(si.Gid) + } + + if err := tw.WriteHeader(th); err != nil { + return err + } + + if !info.Mode().IsRegular() { + return nil + } + if info.Mode().IsDir() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return err + } + + _, err = io.Copy(tw, file) + return err +} + +// mountFolderName get subfoldername for a mount: +// use the Docker volumename if availible (named volume), +// else get the sanitized destination-path (bind mount). +func mountFolderName(m types.MountPoint) string { + if m.Name != "" { + return sanitize.Path(m.Name) + } + name := strings.TrimPrefix(m.Destination, "/") + name = strings.Replace(name, "/", "_", -1) + return sanitize.Path(name) +} + +// copyMount copy the entire content from src to dst, +// including folderstructure, filerights and symlinks. +func copyMount(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if info.IsDir() { + return os.MkdirAll(target, info.Mode()) + } + + if info.Mode()&os.ModeSymlink != 0 { + linkTarget, err := os.Readlink(path) + if err != nil { + return err + } + return os.Symlink(linkTarget, target) + } + + if !info.Mode().IsRegular() { + // sockets, devices e.d. overslaan + return nil + } + + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode()) + if err != nil { + return err + } + defer out.Close() + + if optVerbose { + fmt.Println("Copying", path, "->", target) + } + + _, err = io.Copy(out, in) + return err + }) +} + +func backupTar(backupRoot, filename string, backup Backup) error { b, err := json.MarshalIndent(backup, "", " ") if err != nil { return err } // fmt.Println(string(b)) - tarfile, err := os.Create(filename + ".tar") + if err := os.MkdirAll(backupRoot, 0755); err != nil { + return err + } + + tarPath := filepath.Join(backupRoot, filename+".tar") + tarfile, err := os.Create(tarPath) if err != nil { return err } @@ -82,8 +215,17 @@ func backupTar(filename string, backup Backup) error { return err } + for _, m := range backup.Mounts { + // fmt.Printf("Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) + + err := filepath.Walk(m.Source, collectFileTar) + if err != nil { + return err + } + } + tw.Close() - fmt.Println("Created backup:", filename+".tar") + fmt.Println("Created backup:", tarPath) return nil } @@ -94,7 +236,7 @@ func getFullImageName(imageName string) (string, error) { } // If the used image doesn't include tag information try to find one (if it exists). - images, err := cli.ImageList(ctx, image.ListOptions{}) + images, err := cli.ImageList(ctx, types.ImageListOptions{}) if err != nil { // Couldn't get image list, abort return imageName, err @@ -130,31 +272,32 @@ func backup(ID string) error { paths = []string{} - // Add mount-path to filelist - start fix - for _, mount := range conf.Mounts { - if mount.Type == "bind" || mount.Type == "volume" { - paths = append(paths, mount.Source) - } - } - // end fix - conf.Config.Image, err = getFullImageName(conf.Config.Image) if err != nil { return err } backup := Backup{ - Name: conf.Name, - Config: conf.Config, - HostConfig: conf.HostConfig, - NetworkSettings: conf.NetworkSettings, - Platform: conf.Platform, + Name: conf.Name, + PortMap: conf.HostConfig.PortBindings, + Config: conf.Config, + Mounts: conf.Mounts, } filename := sanitize.Path(fmt.Sprintf("%s-%s", conf.Config.Image, ID)) filename = strings.Replace(filename, "/", "_", -1) + + // Determine target folder: backups// + containerName := strings.TrimPrefix(conf.Name, "/") + timestamp := time.Now().Format("2006-01-02_15-04") + backupRoot := filepath.Join(optOutput, timestamp, containerName) + if optTar { - return backupTar(filename, backup) + return backupTar(backupRoot, filename, backup) + } + + if err := os.MkdirAll(backupRoot, 0755); err != nil { + return err } b, err := json.MarshalIndent(backup, "", " ") @@ -163,21 +306,45 @@ func backup(ID string) error { } // fmt.Println(string(b)) - err = ioutil.WriteFile(filename+".backup.json", b, 0600) + jsonPath := filepath.Join(backupRoot, filename+".backup.json") + err = ioutil.WriteFile(jsonPath, b, 0600) if err != nil { return err } - filelist, err := os.Create(filename + ".backup.files") + for _, m := range conf.Mounts { + // fmt.Printf("Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) + err := filepath.Walk(m.Source, collectFile) + if err != nil { + return err + } + + mountDir := filepath.Join(backupRoot, mountFolderName(m)) + if err := os.MkdirAll(mountDir, 0755); err != nil { + return err + } + if err := copyMount(m.Source, mountDir); err != nil { + return err + } + } + + filesPath := filepath.Join(backupRoot, filename+".backup.files") + filelist, err := os.Create(filesPath) if err != nil { return err } defer filelist.Close() - _, err = filelist.WriteString(filename + ".backup.json\n") + absJSONPath, err := filepath.Abs(jsonPath) + if err != nil { + return err + } + + _, err = filelist.WriteString(absJSONPath + "\n") if err != nil { return err } + for _, s := range paths { _, err := filelist.WriteString(s + "\n") if err != nil { @@ -185,11 +352,11 @@ func backup(ID string) error { } } - fmt.Println("Created backup:", filename+".backup.json") + fmt.Println("Created backup:", jsonPath) if optLaunch != "" { ol := strings.Replace(optLaunch, "%tag", filename, -1) - ol = strings.Replace(ol, "%list", filename+".backup.files", -1) + ol = strings.Replace(ol, "%list", filesPath, -1) fmt.Println("Launching external command and waiting for it to finish:") fmt.Println(ol) @@ -203,7 +370,7 @@ func backup(ID string) error { } func backupAll() error { - containers, err := cli.ContainerList(ctx, container.ListOptions{ + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ All: optStopped, }) if err != nil { @@ -226,5 +393,6 @@ func init() { backupCmd.Flags().BoolVarP(&optAll, "all", "a", false, "backup all running containers") backupCmd.Flags().BoolVarP(&optStopped, "stopped", "s", false, "in combination with --all: also backup stopped containers") backupCmd.Flags().BoolVarP(&optVerbose, "verbose", "v", false, "print detailed backup progress") + backupCmd.Flags().StringVarP(&optOutput, "output", "o", "./backups", "root folder for volume-mount backups") // RootCmd.AddCommand(backupCmd) } diff --git a/docker-backup.py b/docker-backup.py new file mode 100644 index 0000000..f56e093 --- /dev/null +++ b/docker-backup.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +""" +docker_backup.py - Python re-implementatie van docker-backup (oorspronkelijk Go) + +Maakt backups van Docker containers: metadata (json) + een kopie van alle +volume-mounts, of optioneel een enkel .tar bestand. Kan een backup ook weer +terugzetten (restore). + +Vereist: python3 en de `docker` CLI in PATH. Praat via subprocess met de +Docker CLI -- geen extra pip-dependency nodig. + +Gebruik: + ./docker_backup.py backup + ./docker_backup.py backup --all [--stopped] + ./docker_backup.py backup --tar + ./docker_backup.py restore .json [--start] + ./docker_backup.py restore .tar [--start] +""" + +import argparse +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +from datetime import datetime +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run(cmd, capture=True, check=True): + """Voert een commando uit en geeft stdout terug (of gooit een RuntimeError).""" + result = subprocess.run(cmd, capture_output=capture, text=True) + if check and result.returncode != 0: + raise RuntimeError(f"Commando mislukt: {' '.join(cmd)}\n{result.stderr.strip()}") + return result.stdout.strip() if capture else None + + +def docker_inspect(ref): + """Geeft de volledige `docker inspect`-output terug als dict.""" + out = run(["docker", "inspect", ref]) + data = json.loads(out) + if not data: + raise RuntimeError(f"Kon container/image '{ref}' niet vinden.") + return data[0] + + +def sanitize(name): + """Maakt een string veilig als bestands-/mapnaam.""" + name = name.strip("/") + return re.sub(r"[^A-Za-z0-9._-]+", "_", name) + + +def mount_folder_name(mount): + """Submapnaam voor een mount: Docker volumenaam indien beschikbaar, + anders het gesanitized destination-pad (bind mount).""" + if mount.get("Name"): + return sanitize(mount["Name"]) + return sanitize(mount["Destination"]) + + +def get_full_image_name(image): + """Probeert een volledige image:tag te vinden als er geen tag is opgegeven.""" + if ":" in image: + return image + try: + info = docker_inspect(image) + tags = info.get("RepoTags") or [] + if tags: + return tags[0] + except RuntimeError: + pass + return image + + +# --------------------------------------------------------------------------- +# Backup +# --------------------------------------------------------------------------- + +def backup_root_for(container_name, output): + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M") + return Path(output) / timestamp / sanitize(container_name) + + +def do_backup(container_id, args): + info = docker_inspect(container_id) + name = info["Name"] + image = get_full_image_name(info["Config"]["Image"]) + mounts = info.get("Mounts", []) + + print(f"Creating backup of {name.lstrip('/')} ({image}, {container_id[:12]})") + + filename = sanitize(f"{image}-{container_id}") + backup_root = backup_root_for(name, args.output) + + if args.tar: + return backup_tar(info, filename, backup_root, args) + + backup_root.mkdir(parents=True, exist_ok=True) + + json_path = backup_root / f"{filename}.backup.json" + json_path.write_text(json.dumps(info, indent=2)) + + collected_paths = [] + for m in mounts: + src = m["Source"] + if args.verbose: + print(f"Mount ({m.get('Type')}) {src} -> {m['Destination']}") + + for root, _dirs, files in os.walk(src): + for f in files: + collected_paths.append(os.path.join(root, f)) + + dest_dir = backup_root / mount_folder_name(m) + shutil.copytree(src, dest_dir, dirs_exist_ok=True) + + files_path = backup_root / f"{filename}.backup.files" + with open(files_path, "w") as fl: + fl.write(str(json_path.resolve()) + "\n") + for p in collected_paths: + fl.write(p + "\n") + + print(f"Created backup: {json_path}") + + if args.launch: + cmd = args.launch.replace("%tag", filename).replace("%list", str(files_path)) + print("Launching external command and waiting for it to finish:") + print(cmd) + subprocess.run(cmd, shell=True, check=True) + + return json_path + + +def backup_tar(info, filename, backup_root, args): + backup_root.mkdir(parents=True, exist_ok=True) + tar_path = backup_root / f"{filename}.tar" + + with tarfile.open(tar_path, "w") as tf: + data = json.dumps(info, indent=2).encode() + ti = tarfile.TarInfo(name="container.json") + ti.size = len(data) + tf.addfile(ti, fileobj=io.BytesIO(data)) + + for m in info.get("Mounts", []): + src = m["Source"] + if args.verbose: + print(f"Mount ({m.get('Type')}) {src} -> {m['Destination']}") + tf.add(src, arcname=src.lstrip("/")) + + print(f"Created backup: {tar_path}") + return tar_path + + +# --------------------------------------------------------------------------- +# Restore +# --------------------------------------------------------------------------- + +def create_container_from_info(info, args): + """Maakt een nieuwe container op basis van de opgeslagen `docker inspect` + metadata. Poort-mappings, environment, entrypoint, cmd, working-dir en + labels worden hersteld. + + Let op (zelfde beperking als de originele Go-tool): mounts worden niet + 1-op-1 als bind-mount teruggezet. Alleen volumes die al in de image zelf + gedefinieerd staan (VOLUME-instructie) worden automatisch door Docker + aangemaakt -- de data daarvoor wordt na het aanmaken teruggekopieerd. + Overige bind-mounts moet je zelf opnieuw toevoegen (zie waarschuwingen + tijdens restore).""" + config = info["Config"] + host_config = info.get("HostConfig", {}) + name = info["Name"].lstrip("/") + image = config["Image"] + + try: + docker_inspect(image) + except RuntimeError: + print(f"Pulling image: {image}") + run(["docker", "pull", image], capture=False) + + cmd = ["docker", "create", "--name", name] + + for env in config.get("Env") or []: + cmd += ["-e", env] + + for key, val in (config.get("Labels") or {}).items(): + cmd += ["--label", f"{key}={val}"] + + for container_port, bindings in (host_config.get("PortBindings") or {}).items(): + proto = container_port.split("/")[-1] if "/" in container_port else "tcp" + port_only = container_port.split("/")[0] + for b in bindings or [{}]: + host_port = b.get("HostPort", "") + host_ip = b.get("HostIp", "") + spec = f"{host_port}:{port_only}/{proto}" + if host_ip: + spec = f"{host_ip}:{spec}" + cmd += ["-p", spec] + + if config.get("WorkingDir"): + cmd += ["-w", config["WorkingDir"]] + + if config.get("Entrypoint"): + cmd += ["--entrypoint", " ".join(config["Entrypoint"])] + + cmd.append(image) + + if config.get("Cmd"): + cmd += config["Cmd"] + + print("Restoring container:", name) + new_id = run(cmd) + print(f"Created container with ID: {new_id}") + return new_id + + +def match_new_mounts(old_mounts, new_info): + """Koppelt oude mounts aan nieuwe mounts op basis van Destination.""" + new_mounts = new_info.get("Mounts", []) + mapping = {} + for old in old_mounts: + for new in new_mounts: + if old["Destination"] == new["Destination"]: + mapping[old["Destination"]] = new + break + return mapping + + +def do_restore(backup_file, args): + path = Path(backup_file) + if path.suffix == ".json": + info = json.loads(path.read_text()) + return restore_from_info(info, path.parent, args) + if path.suffix == ".tar": + return restore_tar(path, args) + raise RuntimeError("Onbekend bestandstype, geef een .json of .tar bestand op") + + +def restore_from_info(info, backup_dir, args): + old_mounts = info.get("Mounts", []) + new_id = create_container_from_info(info, args) + new_info = docker_inspect(new_id) + mapping = match_new_mounts(old_mounts, new_info) + + for old in old_mounts: + new = mapping.get(old["Destination"]) + src_data = backup_dir / mount_folder_name(old) + + if not new: + print(f"Waarschuwing: geen automatisch aangemaakte mount voor " + f"{old['Destination']} -- data in '{src_data}' moet je zelf " + f"terugzetten (bv. handmatig bind-mounten).") + continue + if not src_data.exists(): + print(f"Waarschuwing: geen backupdata gevonden in {src_data}, sla over.") + continue + + print(f"Restoring: {src_data} -> {new['Source']}") + shutil.copytree(src_data, new["Source"], dirs_exist_ok=True) + + if args.start: + start_container(new_id) + return new_id + + +def restore_tar(tar_path, args): + with tarfile.open(tar_path, "r") as tf: + info = json.loads(tf.extractfile("container.json").read()) + old_mounts = info.get("Mounts", []) + + new_id = create_container_from_info(info, args) + new_info = docker_inspect(new_id) + mapping = match_new_mounts(old_mounts, new_info) + + with tempfile.TemporaryDirectory() as tmp: + tf.extractall(tmp) + + for old in old_mounts: + new = mapping.get(old["Destination"]) + extracted = Path(tmp) / old["Source"].lstrip("/") + + if not new: + print(f"Waarschuwing: geen automatisch aangemaakte mount voor " + f"{old['Destination']} -- data in '{extracted}' moet je " + f"zelf terugzetten.") + continue + if not extracted.exists(): + print(f"Waarschuwing: geen data gevonden voor {old['Destination']} in tar, sla over.") + continue + + print(f"Restoring: {extracted} -> {new['Source']}") + shutil.copytree(extracted, new["Source"], dirs_exist_ok=True) + + if args.start: + start_container(new_id) + return new_id + + +def start_container(container_id): + print(f"Starting container: {container_id[:12]}") + run(["docker", "start", container_id], capture=False) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Backup & restore Docker containers") + sub = parser.add_subparsers(dest="command", required=True) + + b = sub.add_parser("backup", help="maak een backup van een container") + b.add_argument("container", nargs="?", help="container ID of naam") + b.add_argument("-a", "--all", action="store_true", help="backup alle draaiende containers") + b.add_argument("-s", "--stopped", action="store_true", help="i.c.m. --all: ook gestopte containers meenemen") + b.add_argument("-t", "--tar", action="store_true", help="maak een .tar backup i.p.v. json + gekopieerde mounts") + b.add_argument("-o", "--output", default="./backups", help="root map voor backups (default: ./backups)") + b.add_argument("-l", "--launch", default=None, help="extern commando na backup; %%tag en %%list worden vervangen") + b.add_argument("-v", "--verbose", action="store_true", help="print gedetailleerde voortgang") + + r = sub.add_parser("restore", help="herstel een backup") + r.add_argument("backup_file", help=".json of .tar backup-bestand") + r.add_argument("-s", "--start", action="store_true", help="start de container na herstel") + + args = parser.parse_args() + + if args.command == "backup": + if args.all: + ps_cmd = ["docker", "ps", "-q"] + if args.stopped: + ps_cmd.append("-a") + ids = [i for i in run(ps_cmd).splitlines() if i] + for cid in ids: + do_backup(cid, args) + else: + if not args.container: + parser.error("geef een container ID/naam op, of gebruik --all") + do_backup(args.container, args) + + elif args.command == "restore": + do_restore(args.backup_file, args) + + +if __name__ == "__main__": + try: + main() + except RuntimeError as e: + print(f"Fout: {e}", file=sys.stderr) + sys.exit(1) diff --git a/go.mod b/go.mod index abdf44f..dcfe6f3 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/muesli/docker-backup +module github.com/Jan-Onestein/docker-backup go 1.23.0 From 82bbd4711cb4f86dde010e9ea6f5f160ceb5e19d Mon Sep 17 00:00:00 2001 From: jan Date: Tue, 7 Jul 2026 18:12:03 +0200 Subject: [PATCH 5/5] py fixes --all option content english --- docker-backup.py | 130 +++++++++++++++++++++++++++-------------------- 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/docker-backup.py b/docker-backup.py index f56e093..8fcb2c8 100644 --- a/docker-backup.py +++ b/docker-backup.py @@ -1,15 +1,15 @@ #!/usr/bin/env python3 """ -docker_backup.py - Python re-implementatie van docker-backup (oorspronkelijk Go) +docker_backup.py - Python re-implementation of docker-backup (originally Go) -Maakt backups van Docker containers: metadata (json) + een kopie van alle -volume-mounts, of optioneel een enkel .tar bestand. Kan een backup ook weer -terugzetten (restore). +Creates backups of Docker containers: metadata (json) + a copy of all +volume mounts, or optionally a single .tar file. Can also restore a +backup. -Vereist: python3 en de `docker` CLI in PATH. Praat via subprocess met de -Docker CLI -- geen extra pip-dependency nodig. +Requires: python3 and the `docker` CLI in PATH. Talks to the Docker CLI +via subprocess -- no extra pip dependency needed. -Gebruik: +Usage: ./docker_backup.py backup ./docker_backup.py backup --all [--stopped] ./docker_backup.py backup --tar @@ -30,44 +30,43 @@ from datetime import datetime from pathlib import Path - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def run(cmd, capture=True, check=True): - """Voert een commando uit en geeft stdout terug (of gooit een RuntimeError).""" + """Runs a command and returns stdout (or raises a RuntimeError).""" result = subprocess.run(cmd, capture_output=capture, text=True) if check and result.returncode != 0: - raise RuntimeError(f"Commando mislukt: {' '.join(cmd)}\n{result.stderr.strip()}") + raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{result.stderr.strip()}") return result.stdout.strip() if capture else None def docker_inspect(ref): - """Geeft de volledige `docker inspect`-output terug als dict.""" + """Returns the full `docker inspect` output as a dict.""" out = run(["docker", "inspect", ref]) data = json.loads(out) if not data: - raise RuntimeError(f"Kon container/image '{ref}' niet vinden.") + raise RuntimeError(f"Could not find container/image '{ref}'.") return data[0] def sanitize(name): - """Maakt een string veilig als bestands-/mapnaam.""" + """Makes a string safe to use as a file/directory name.""" name = name.strip("/") return re.sub(r"[^A-Za-z0-9._-]+", "_", name) def mount_folder_name(mount): - """Submapnaam voor een mount: Docker volumenaam indien beschikbaar, - anders het gesanitized destination-pad (bind mount).""" + """Subdirectory name for a mount: Docker volume name if available, + otherwise the sanitized destination path (bind mount).""" if mount.get("Name"): return sanitize(mount["Name"]) return sanitize(mount["Destination"]) def get_full_image_name(image): - """Probeert een volledige image:tag te vinden als er geen tag is opgegeven.""" + """Tries to find a full image:tag if no tag was specified.""" if ":" in image: return image try: @@ -114,10 +113,17 @@ def do_backup(container_id, args): if args.verbose: print(f"Mount ({m.get('Type')}) {src} -> {m['Destination']}") + # Some containers bind-mount special files instead of directories, + # e.g. /var/run/docker.sock (common for Portainer, Watchtower, Diun, + # Traefik, autoheal, etc.). shutil.copytree() requires a directory, + # so skip anything that isn't one instead of crashing the whole run. + if not os.path.isdir(src): + print(f"Warning: mount '{src}' is not a directory (socket/device?), skipping it.") + continue + for root, _dirs, files in os.walk(src): for f in files: collected_paths.append(os.path.join(root, f)) - dest_dir = backup_root / mount_folder_name(m) shutil.copytree(src, dest_dir, dirs_exist_ok=True) @@ -152,6 +158,13 @@ def backup_tar(info, filename, backup_root, args): src = m["Source"] if args.verbose: print(f"Mount ({m.get('Type')}) {src} -> {m['Destination']}") + + # Same reasoning as in do_backup(): skip sockets/devices/missing + # paths instead of letting tarfile blow up on a non-regular file. + if not os.path.exists(src): + print(f"Warning: mount '{src}' does not exist, skipping it.") + continue + tf.add(src, arcname=src.lstrip("/")) print(f"Created backup: {tar_path}") @@ -163,16 +176,15 @@ def backup_tar(info, filename, backup_root, args): # --------------------------------------------------------------------------- def create_container_from_info(info, args): - """Maakt een nieuwe container op basis van de opgeslagen `docker inspect` - metadata. Poort-mappings, environment, entrypoint, cmd, working-dir en - labels worden hersteld. - - Let op (zelfde beperking als de originele Go-tool): mounts worden niet - 1-op-1 als bind-mount teruggezet. Alleen volumes die al in de image zelf - gedefinieerd staan (VOLUME-instructie) worden automatisch door Docker - aangemaakt -- de data daarvoor wordt na het aanmaken teruggekopieerd. - Overige bind-mounts moet je zelf opnieuw toevoegen (zie waarschuwingen - tijdens restore).""" + """Creates a new container based on the stored `docker inspect` + metadata. Port mappings, environment, entrypoint, cmd, working dir and + labels are restored. + + Note (same limitation as the original Go tool): mounts are not + restored 1-to-1 as bind mounts. Only volumes already defined in the + image itself (VOLUME instruction) are automatically created by + Docker -- the data for those is copied back afterwards. Other bind + mounts need to be added back manually (see warnings during restore).""" config = info["Config"] host_config = info.get("HostConfig", {}) name = info["Name"].lstrip("/") @@ -221,7 +233,7 @@ def create_container_from_info(info, args): def match_new_mounts(old_mounts, new_info): - """Koppelt oude mounts aan nieuwe mounts op basis van Destination.""" + """Maps old mounts to new mounts based on Destination.""" new_mounts = new_info.get("Mounts", []) mapping = {} for old in old_mounts: @@ -239,7 +251,7 @@ def do_restore(backup_file, args): return restore_from_info(info, path.parent, args) if path.suffix == ".tar": return restore_tar(path, args) - raise RuntimeError("Onbekend bestandstype, geef een .json of .tar bestand op") + raise RuntimeError("Unknown file type, please provide a .json or .tar file") def restore_from_info(info, backup_dir, args): @@ -253,12 +265,13 @@ def restore_from_info(info, backup_dir, args): src_data = backup_dir / mount_folder_name(old) if not new: - print(f"Waarschuwing: geen automatisch aangemaakte mount voor " - f"{old['Destination']} -- data in '{src_data}' moet je zelf " - f"terugzetten (bv. handmatig bind-mounten).") + print(f"Warning: no automatically created mount for " + f"{old['Destination']} -- data in '{src_data}' must be " + f"restored manually (e.g. bind-mount it yourself).") continue + if not src_data.exists(): - print(f"Waarschuwing: geen backupdata gevonden in {src_data}, sla over.") + print(f"Warning: no backup data found in {src_data}, skipping.") continue print(f"Restoring: {src_data} -> {new['Source']}") @@ -266,6 +279,7 @@ def restore_from_info(info, backup_dir, args): if args.start: start_container(new_id) + return new_id @@ -273,7 +287,6 @@ def restore_tar(tar_path, args): with tarfile.open(tar_path, "r") as tf: info = json.loads(tf.extractfile("container.json").read()) old_mounts = info.get("Mounts", []) - new_id = create_container_from_info(info, args) new_info = docker_inspect(new_id) mapping = match_new_mounts(old_mounts, new_info) @@ -286,12 +299,13 @@ def restore_tar(tar_path, args): extracted = Path(tmp) / old["Source"].lstrip("/") if not new: - print(f"Waarschuwing: geen automatisch aangemaakte mount voor " - f"{old['Destination']} -- data in '{extracted}' moet je " - f"zelf terugzetten.") + print(f"Warning: no automatically created mount for " + f"{old['Destination']} -- data in '{extracted}' " + f"must be restored manually.") continue + if not extracted.exists(): - print(f"Waarschuwing: geen data gevonden voor {old['Destination']} in tar, sla over.") + print(f"Warning: no data found for {old['Destination']} in tar, skipping.") continue print(f"Restoring: {extracted} -> {new['Source']}") @@ -299,6 +313,7 @@ def restore_tar(tar_path, args): if args.start: start_container(new_id) + return new_id @@ -315,18 +330,18 @@ def main(): parser = argparse.ArgumentParser(description="Backup & restore Docker containers") sub = parser.add_subparsers(dest="command", required=True) - b = sub.add_parser("backup", help="maak een backup van een container") - b.add_argument("container", nargs="?", help="container ID of naam") - b.add_argument("-a", "--all", action="store_true", help="backup alle draaiende containers") - b.add_argument("-s", "--stopped", action="store_true", help="i.c.m. --all: ook gestopte containers meenemen") - b.add_argument("-t", "--tar", action="store_true", help="maak een .tar backup i.p.v. json + gekopieerde mounts") - b.add_argument("-o", "--output", default="./backups", help="root map voor backups (default: ./backups)") - b.add_argument("-l", "--launch", default=None, help="extern commando na backup; %%tag en %%list worden vervangen") - b.add_argument("-v", "--verbose", action="store_true", help="print gedetailleerde voortgang") + b = sub.add_parser("backup", help="create a backup of a container") + b.add_argument("container", nargs="?", help="container ID or name") + b.add_argument("-a", "--all", action="store_true", help="back up all running containers") + b.add_argument("-s", "--stopped", action="store_true", help="with --all: also include stopped containers") + b.add_argument("-t", "--tar", action="store_true", help="create a .tar backup instead of json + copied mounts") + b.add_argument("-o", "--output", default="./backups", help="root directory for backups (default: ./backups)") + b.add_argument("-l", "--launch", default=None, help="external command after backup; %%tag and %%list are substituted") + b.add_argument("-v", "--verbose", action="store_true", help="print detailed progress") - r = sub.add_parser("restore", help="herstel een backup") - r.add_argument("backup_file", help=".json of .tar backup-bestand") - r.add_argument("-s", "--start", action="store_true", help="start de container na herstel") + r = sub.add_parser("restore", help="restore a backup") + r.add_argument("backup_file", help=".json or .tar backup file") + r.add_argument("-s", "--start", action="store_true", help="start the container after restoring") args = parser.parse_args() @@ -337,12 +352,19 @@ def main(): ps_cmd.append("-a") ids = [i for i in run(ps_cmd).splitlines() if i] for cid in ids: - do_backup(cid, args) + # Isolate failures per container so one problematic container + # (e.g. one with a docker.sock bind mount, permission issues, + # or any other unexpected error) doesn't abort the whole + # --all run. + try: + do_backup(cid, args) + except Exception as e: + print(f"Error backing up container {cid}: {e}", file=sys.stderr) + continue else: if not args.container: - parser.error("geef een container ID/naam op, of gebruik --all") + parser.error("provide a container ID/name, or use --all") do_backup(args.container, args) - elif args.command == "restore": do_restore(args.backup_file, args) @@ -351,5 +373,5 @@ def main(): try: main() except RuntimeError as e: - print(f"Fout: {e}", file=sys.stderr) - sys.exit(1) + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) \ No newline at end of file