commit 273bde620f4565193fe0a60c6771dd34da9f440d Author: Ben Date: Sun Sep 3 05:26:36 2023 +0000 first commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5cdd159 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +# +# Copyright (C) 2006-2014 OpenWrt.org +# +# This is free software, licensed under the GNU General Public License v2. +# See /LICENSE for more information. +# + + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-docker-backup +LUCI_PKGARCH:=all +LUCI_DEPENDS:=+lsblk +docker +luci-lib-taskd + +include $(TOPDIR)/feeds/luci/luci.mk + +define Package/luci-app-docker-backup/conffiles +/root/etc/config/docker-backup +SECTION:=luci +CATEGORY:=LuCI +SUBMENU:=3. Applications +TITLE:=LuCI Docker container module by PrivateRouter +endef + +define Build/Prepare +endef + +define Build/Configure +endef + +define Build/Compile +endef + +define Package/install + $(INSTALL_DIR) $(1)./root/etc/config/ + $(INSTALL_DIR) $(1)./root/etc/uci-defaults/ + $(INSTALL_DIR) $(1)./root/usr/libexec/apps/docker-backup/ + $(INSTALL_DIR) $(1)./root/usr/share/rpcd/acl.d/ + $(INSTALL_DIR) $(1)./etc/config # add this line + + $(INSTALL_BIN) ./root/etc/config/docker-backup $(1)/etc/config/docker-backup + $(INSTALL_BIN) ./root/etc/uci-defaults/luci-app-docker-backup $(1)/etc/uci-defaults/luci-app-docker-backup + $(INSTALL_BIN) ./root/usr/libexec/apps/docker-backup/docker-backup.sh $(1)/usr/libexec/apps/docker-backup/docker-backup.sh + $(INSTALL_BIN) ./root/usr/libexec/apps/docker-backup/docker-backup $(1)/usr/libexec/apps/docker-backup/docker-backup + $(INSTALL_DATA) ./root/usr/libexec/apps/docker-backup/restore.go $(1)/usr/libexec/apps/docker-backup/restore.go + $(INSTALL_DATA) ./root/usr/libexec/apps/docker-backup/main.go $(1)/usr/libexec/apps/docker-backup/main.go + $(INSTALL_DATA) ./root/usr/libexec/apps/docker-backup/go.sum $(1)/usr/libexec/apps/docker-backup/go.sum + $(INSTALL_DATA) ./root/usr/libexec/apps/docker-backup/go.mod $(1)/usr/libexec/apps/docker-backup/go.mod + $(INSTALL_DATA) ./root/usr/libexec/apps/docker-backup/backup.go $(1)/usr/libexec/apps/docker-backup/backup.go + $(INSTALL_DATA) ./luasrc/model/cbi/luci-app-docker-backup/luci-app-docker-backup.lua $(1)/usr/lib/lua/luci/model/cbi/luci-app-docker-backup/luci-app-docker-backup.lua + $(INSTALL_DATA) ./luasrc/controller/docker-backup.lua $(1)/usr/lib/lua/luci/controller/docker-backup.lua + $(INSTALL_DATA) ./luasrc/view/docker-backup/status.htm $(1)/usr/lib/lua/luci/view/docker-backup/status.htm + $(INSTALL_DATA) ./luasrc/model/docker-backup.lua $(1)/usr/lib/lua/luci/model/docker-backup.lua + $(INSTALL_DATA) ./luasrc/model/cbi/docker-backup.lua $(1)/usr/lib/lua/luci/model/cbi/docker-backup.lua + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-docker-backup.json $(1)/usr/share/rpcd/acl.d/luci-app-docker-backup.json +endef + +$(eval $(call BuildPackage,$(PKG_NAME))) diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/luasrc/controller/docker-backup.lua b/luasrc/controller/docker-backup.lua new file mode 100644 index 0000000..ca3889a --- /dev/null +++ b/luasrc/controller/docker-backup.lua @@ -0,0 +1,7 @@ + +module("luci.controller.docker-backup", package.seeall) + +function index() + entry({"admin", "apps", "docker-backup"}, alias("admin", "apps", "docker-backup", "config"), _("docker-backup"), 30).dependent = true + entry({"admin", "apps", "docker-backup", "config"}, cbi("docker-backup")) +end diff --git a/luasrc/model/cbi/docker-backup.lua b/luasrc/model/cbi/docker-backup.lua new file mode 100644 index 0000000..2d9d184 --- /dev/null +++ b/luasrc/model/cbi/docker-backup.lua @@ -0,0 +1,50 @@ +--[[ +LuCI - Lua Configuration Interface +]]-- + +local taskd = require "luci.model.tasks" +local dockerbackup_model = require "luci.model.docker-backup" +local m, s, o + +m = taskd.docker_map("docker-backup", "docker-backup", "/usr/libexec/apps/docker-backup/docker-backup.sh", + translate("docker-backup"), + translate("Docker-Backup helps you easily backup and restore docker containers on any device running docker. Make sure images are pulled before restoring a container.")) + +s = m:section(TypedSection, "docker-backup", translate("Setup"), + translate("The default backup and restore directory is /opt/docker2/compose/docker-backup. Be sure to look for backup files in this location and place restore files in same location unless otherwise marked.") + .. "
" .. translate("Backup or restore a docker container ID:")) +s.addremove=false +s.anonymous=true + +o = s:option(Flag, "backup", translate("Enable Backup"), translate("Check only if you wish to back up a docker container ID or all docker containers.")) +o.default = 0 +o.rmempty = false + +o = s:option(Flag, "restore", translate("Enable Restore"), translate("Check only if you wish to restore a docker container from a given location.")) +o.default = 0 +o.rmempty = false + +o = s:option(Value, "restore_container", translate("Restore Container ID").."*") +o.default = "container" +o.datatype = "string" +o:depends("backup", 0) + +o = s:option(Value, "backup_container", translate("Backup Container ID").."*") +o.default = "container" +o.datatype = "string" +o:depends("restore", 0) + +local blocks = dockerbackup_model.blocks() +local home = dockerbackup_model.home() + +o = s:option(Value, "config_path", translate("Config path").."*") +o.default = "/opt/docker2/compose/docker-backup/" +o.rmempty = false +o.datatype = "string" + +for _, val in pairs(blocks) do + o:value(val, val) +end +o.default = home + +return m diff --git a/luasrc/model/docker-backup.lua b/luasrc/model/docker-backup.lua new file mode 100644 index 0000000..8d55b40 --- /dev/null +++ b/luasrc/model/docker-backup.lua @@ -0,0 +1,55 @@ +local util = require "luci.util" +local jsonc = require "luci.jsonc" + +local dockerbackup = {} + +dockerbackup.blocks = function() + local f = io.popen("lsblk -s -f -b -o NAME,FSSIZE,MOUNTPOINT --json", "r") + local vals = {} + if f then + local ret = f:read("*all") + f:close() + local obj = jsonc.parse(ret) + for _, val in pairs(obj["blockdevices"]) do + local fsize = val["fssize"] + if fsize ~= nil and string.len(fsize) > 10 and val["mountpoint"] then + -- fsize > 1G + vals[#vals+1] = val["mountpoint"] + end + end + end + return vals +end + +dockerbackup.home = function() + local uci = require "luci.model.uci".cursor() + local home_dirs = {} + home_dirs["main_dir"] = uci:get_first("quickstart", "main", "main_dir", "/root") + home_dirs["Configs"] = uci:get_first("quickstart", "main", "conf_dir", home_dirs["main_dir"].."/Configs") + home_dirs["Public"] = uci:get_first("quickstart", "main", "pub_dir", home_dirs["main_dir"].."/Public") + home_dirs["Downloads"] = uci:get_first("quickstart", "main", "dl_dir", home_dirs["Public"].."/Downloads") + home_dirs["Caches"] = uci:get_first("quickstart", "main", "tmp_dir", home_dirs["main_dir"].."/Caches") + return home_dirs +end + +dockerbackup.find_paths = function(blocks, home_dirs, path_name) + local default_path = '' + local configs = {} + + default_path = home_dirs[path_name] .. "/docker-backup" + if #blocks == 0 then + table.insert(configs, default_path) + else + for _, val in pairs(blocks) do + table.insert(configs, val .. "/" .. path_name .. "/docker-backup") + end + local without_conf_dir = "/root/" .. path_name .. "/docker-backup" + if default_path == without_conf_dir then + default_path = configs[1] + end + end + + return configs, default_path +end + +return dockerbackup diff --git a/luasrc/view/docker-backup/status.htm b/luasrc/view/docker-backup/status.htm new file mode 100644 index 0000000..5b61fe6 --- /dev/null +++ b/luasrc/view/docker-backup/status.htm @@ -0,0 +1,31 @@ +<% +local util = require "luci.util" +local container_status = util.trim(util.exec("/usr/libexec/apps/docker-backup/docker-backup.sh status")) +local container_install = (string.len(container_status) > 0) +local container_running = container_status == "running" +-%> +
+ +
+ <% if container_running then %> + + <% else %> + + <% end %> +
+
+<% +if container_running then + local port=util.trim(util.exec("/usr/libexec/apps/docker-backup.sh port")) + if port == "" then + port="80" + end +-%> +
+ +
+ + +
+
+<% end %> diff --git a/root/etc/config/docker-backup b/root/etc/config/docker-backup new file mode 100644 index 0000000..bcd611c --- /dev/null +++ b/root/etc/config/docker-backup @@ -0,0 +1,7 @@ +config docker-backup + option 'backup' '1' + option 'restore' '0' + option 'image' 'default' + option 'restore_container' '' + option 'backup_container' '' + option 'config_path' '/opt/docker2/compose/docker-backup/' diff --git a/root/etc/uci-defaults/luci-app-docker-backup b/root/etc/uci-defaults/luci-app-docker-backup new file mode 100644 index 0000000..9e2c424 --- /dev/null +++ b/root/etc/uci-defaults/luci-app-docker-backup @@ -0,0 +1,9 @@ +#!/bin/sh + +image_name=`uci get docker-backup.@docker-backup[0].image 2>/dev/null` +touch /etc/config/docker-backup + uci -q batch <<-EOF >/dev/null + set docker-backup.@docker-backup[0].image="default" + commit docker-backup +EOF +exit 0 diff --git a/root/usr/libexec/apps/docker-backup/LICENSE b/root/usr/libexec/apps/docker-backup/LICENSE new file mode 100644 index 0000000..8532c45 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Christian Muehlhaeuser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/root/usr/libexec/apps/docker-backup/README.md b/root/usr/libexec/apps/docker-backup/README.md new file mode 100644 index 0000000..4c90519 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/README.md @@ -0,0 +1,98 @@ +docker-backup +============= + +[![Latest Release](https://img.shields.io/github/release/muesli/docker-backup.svg)](https://github.com/muesli/docker-backup/releases) +[![Build Status](https://github.com/muesli/docker-backup/workflows/build/badge.svg)](https://github.com/muesli/docker-backup/actions) +[![Go ReportCard](https://goreportcard.com/badge/muesli/docker-backup)](https://goreportcard.com/report/muesli/docker-backup) +[![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/muesli/docker-backup) + +A tool to create & restore complete, self-contained backups of Docker containers + +# What's the issue + +Docker services usually have a bunch of volatile data volumes that need to be +backed up. Backing up an entire (file)system is easy, but often enough you just +want to create a backup of a single (or a few) containers, maybe to restore them +on another system later. + +Some services, such as databases, also need to be aware (flushed/synced/paused) +of an impending backup. The backup should be run on the Docker host, as you +don't want to have a backup client configured & running in every single +container either, since this would add a lot of maintenance & administration +overhead. + +`docker-backup` directly connects to Docker, analyzes a container's mounts & +volumes, and generates a list of dirs & files that need to be backed up on the +host system. This also collects all the metadata information associated with a +container, so it can be restored or cloned on a different host, including its +port-mappings and data volumes. + +The generated list can either be fed to an existing backup solution or +`docker-backup` can directly create a `.tar` image of your container, so you can +simply copy it to another machine. + +## Installation + +`docker-backup` requires Go 1.11 or higher. Make sure you have a working Go +environment. See the [install instructions](https://golang.org/doc/install.html). + +`docker-backup` works with Docker hosts running Docker 18.02 (API version 1.36) +and newer. + +### Packages + +- Arch Linux: [docker-backup](https://aur.archlinux.org/packages/docker-backup/) + +### From source + + git clone https://github.com/muesli/docker-backup.git + cd docker-backup + go build + +Run `docker-backup --help` to see a full list of options. + +## Usage + +### Creating a Backup + +To backup a single container start `docker-backup` with the `backup` command and +supply the ID of the container: + + docker-backup backup + +This will create a `.json` file with the container's metadata, as well as a file +containing all the volumes that need to be backed up with an external tool like +[restic](https://restic.net/) or [borgbackup](https://www.borgbackup.org/). + +If you want to directly create a `.tar` file containing all the container's +data, simply run: + + docker-backup backup --tar + +You can also backup all running containers on the host with the `--all` flag: + + docker-backup backup --all + +To backup all containers (regardless of their current running state), run: + + docker-backup backup --all --stopped + +With the help of `--launch` you can directly launch a backup program with the +generated file-list supplied as an argument: + + docker-backup backup --all --launch "restic -r /dest backup --password-file pwfile --tag %tag --files-from %list" + +### Restoring a Backup + +To restore a container, run `docker-backup` with the `restore` command: + + docker-backup restore + +`docker-backup` will automatically detect whether you supplied a `.tar` or +`.json` file and restore the container, including all its port-mappings and data +volumes. + +If you want to start the container once the restore has finished, add the +`--start` flag: + + docker-backup restore --start diff --git a/root/usr/libexec/apps/docker-backup/backup.go b/root/usr/libexec/apps/docker-backup/backup.go new file mode 100644 index 0000000..8cd5c27 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/backup.go @@ -0,0 +1,297 @@ +package main + +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/go-connections/nat" + "github.com/kennygrant/sanitize" + "github.com/spf13/cobra" +) + +// 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 + PortMap nat.PortMap + Mounts []types.MountPoint +} + +var ( + optLaunch = "" + optTar = false + optAll = false + optStopped = false + optVerbose = false + + paths []string + tw *tar.Writer + + backupCmd = &cobra.Command{ + Use: "backup [container-id]", + Short: "creates a backup of a container", + RunE: func(cmd *cobra.Command, args []string) error { + if optAll { + return backupAll() + } + + if len(args) < 1 { + return fmt.Errorf("backup requires the ID of a container") + } + return backup(args[0]) + }, + } +) + +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 +} + +func backupTar(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 != nil { + return err + } + tw = tar.NewWriter(tarfile) + + th := &tar.Header{ + Name: "container.json", + Size: int64(len(b)), + ModTime: time.Now(), + AccessTime: time.Now(), + ChangeTime: time.Now(), + Mode: 0600, + } + + if err := tw.WriteHeader(th); err != nil { + return err + } + if _, err := tw.Write(b); err != nil { + 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") + return nil +} + +func getFullImageName(imageName string) (string, error) { + // If the image already specifies a tag we can safely use as-is + if strings.Contains(imageName, ":") { + return imageName, nil + } + + // If the used image doesn't include tag information try to find one (if it exists). + images, err := cli.ImageList(ctx, types.ImageListOptions{}) + if err != nil { + // Couldn't get image list, abort + return imageName, err + } + + for _, image := range images { + if (!strings.Contains(imageName, image.ID)) || len(image.RepoTags) == 0 { + // unrelated image or image entry doesn't have any tags, move on + continue + } + + for _, tag := range image.RepoTags { + // use closer matching tag if it exists + if !strings.Contains(tag, imageName) { + continue + } + return tag, nil + } + // If none of the tags matches the base image name, return the first tag + return image.RepoTags[0], nil + } + + // There is no tag on the matching image, just have to go with what was provided + return imageName, nil +} + +func backup(ID string) error { + conf, err := cli.ContainerInspect(ctx, ID) + if err != nil { + return err + } + fmt.Printf("Creating backup of %s (%s, %s)\n", conf.Name[1:], conf.Config.Image, conf.ID[:12]) + + paths = []string{} + + conf.Config.Image, err = getFullImageName(conf.Config.Image) + if err != nil { + return err + } + + backup := Backup{ + 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) + if optTar { + return backupTar(filename, backup) + } + + b, err := json.MarshalIndent(backup, "", " ") + if err != nil { + return err + } + // fmt.Println(string(b)) + + err = ioutil.WriteFile(filename+".backup.json", b, 0600) + if err != nil { + return err + } + + 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 + } + } + + filelist, err := os.Create(filename + ".backup.files") + if err != nil { + return err + } + defer filelist.Close() + + _, err = filelist.WriteString(filename + ".backup.json\n") + if err != nil { + return err + } + for _, s := range paths { + _, err := filelist.WriteString(s + "\n") + if err != nil { + return err + } + } + + fmt.Println("Created backup:", filename+".backup.json") + + if optLaunch != "" { + ol := strings.Replace(optLaunch, "%tag", filename, -1) + ol = strings.Replace(ol, "%list", filename+".backup.files", -1) + + fmt.Println("Launching external command and waiting for it to finish:") + fmt.Println(ol) + + l := strings.Split(ol, " ") + cmd := exec.Command(l[0], l[1:]...) + return cmd.Run() + } + + return nil +} + +func backupAll() error { + containers, err := cli.ContainerList(ctx, types.ContainerListOptions{ + All: optStopped, + }) + if err != nil { + panic(err) + } + + for _, container := range containers { + err := backup(container.ID) + if err != nil { + return err + } + } + + return nil +} + +func init() { + backupCmd.Flags().StringVarP(&optLaunch, "launch", "l", "", "launch external program with file-list as argument") + backupCmd.Flags().BoolVarP(&optTar, "tar", "t", false, "create tar backups") + 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") + RootCmd.AddCommand(backupCmd) +} diff --git a/root/usr/libexec/apps/docker-backup/docker-backup b/root/usr/libexec/apps/docker-backup/docker-backup new file mode 100644 index 0000000..d9e82a8 Binary files /dev/null and b/root/usr/libexec/apps/docker-backup/docker-backup differ diff --git a/root/usr/libexec/apps/docker-backup/docker-backup.sh b/root/usr/libexec/apps/docker-backup/docker-backup.sh new file mode 100644 index 0000000..87e7c82 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/docker-backup.sh @@ -0,0 +1,73 @@ +#!/bin/sh +echo $PATH +ACTION=${1} +shift 1 + +usage() { + echo "usage: $0 sub-command" + echo "where sub-command is one of:" + echo " install Install the docker-backup" +} + +do_install() { + local backup=`uci get docker-backup.@docker-backup[0].backup 2>/dev/null` + local restore=`uci get docker-backup.@docker-backup[0].restore 2>/dev/null` + local restore_container=`uci get docker-backup.@docker-backup[0].restore_container 2>/dev/null` + local backup_container=`uci get docker-backup.@docker-backup[0].backup_container 2>/dev/null` + local config_path=`uci get docker-backup.@docker-backup[0].config_path 2>/dev/null` + + if [ "$backup" = "1" ]; then + echo "backing up containers!" + /usr/libexec/apps/docker-backup/docker-backup backup "$backup_container" + mv *.backup.files /opt/docker2/compose/docker-backup + mv *.backup.json /opt/docker2/compose/docker-backup + mv *.tar /opt/docker2/compose/docker-backup + fi + + if [ "$restore" = "1" ]; then + echo "restoring containers!" + result=`/usr/libexec/apps/docker-backup/docker-backup restore ${config_path}"$restore_container" 2>&1` + if echo "$result" | grep -q "No such image"; then + image_name=$(echo "$result" | sed -n 's/.*No such image: \(.*\)\s*/\1/p') + echo "Pulling missing image: $image_name" + /usr/bin/docker pull $image_name + while ! /usr/bin/docker image inspect $image_name >/dev/null 2>&1; do + echo "Waiting for image to download..." + sleep 1 + done + fi + + /usr/libexec/apps/docker-backup/docker-backup restore ${config_path}"$restore_container" + if [ $? -eq 0 ]; then + echo "Restore successful" + else + echo "Restore failed" + exit 1 + fi + fi +} + +case ${ACTION} in + "install") + do_install + ;; + "upgrade") + do_install + ;; + "rm") + opkg remove luci-app-docker-backup + ;; + "start" | "stop" | "restart") + do_install + ;; + "status") + do_install + ;; + "port") + do_install + ;; + *) + usage + exit 1 + ;; +esac diff --git a/root/usr/libexec/apps/docker-backup/go.mod b/root/usr/libexec/apps/docker-backup/go.mod new file mode 100644 index 0000000..192c840 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/go.mod @@ -0,0 +1,26 @@ +module github.com/muesli/docker-backup + +go 1.12 + +require ( + github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect + github.com/Microsoft/go-winio v0.4.16 // indirect + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/docker v0.7.3-0.20190503020752-619df5a8f60f + github.com/docker/go-connections v0.4.0 + github.com/docker/go-units v0.4.0 // indirect + github.com/gogo/protobuf v1.2.1 // indirect + github.com/google/go-cmp v0.5.5 // indirect + github.com/gorilla/mux v1.8.0 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/kennygrant/sanitize v1.2.4 + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0-rc1 // indirect + github.com/opencontainers/image-spec v1.0.1 // indirect + github.com/spf13/cobra v0.0.3 + github.com/spf13/pflag v1.0.3 // indirect + golang.org/x/net v0.0.0-20190502183928-7f726cade0ab // indirect + golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect + google.golang.org/grpc v1.20.1 // indirect + gotest.tools v2.2.0+incompatible // indirect +) diff --git a/root/usr/libexec/apps/docker-backup/go.sum b/root/usr/libexec/apps/docker-backup/go.sum new file mode 100644 index 0000000..f8324b6 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/go.sum @@ -0,0 +1,81 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v0.7.3-0.20190503020752-619df5a8f60f h1:Dtk1lVB9XfLuYUW+4mkWslWOBexBdVHD6IlsWu9R4nE= +github.com/docker/docker v0.7.3-0.20190503020752-619df5a8f60f/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= +github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190502183928-7f726cade0ab h1:9RfW3ktsOZxgo9YNbBAjq1FWzc/igwEcUzZz8IXgSbk= +golang.org/x/net v0.0.0-20190502183928-7f726cade0ab/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3 h1:7TYNF4UdlohbFwpNH04CoPMp1cHUZgO1Ebq5r2hIjfo= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba h1:O8mE0/t419eoIwhTFpKVkHiTs/Igowgfkj25AcZrtiE= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.20.1 h1:Hz2g2wirWK7H0qIIhGIqRGTuMwTE8HEKFnDZZ7lm9NU= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/root/usr/libexec/apps/docker-backup/main.go b/root/usr/libexec/apps/docker-backup/main.go new file mode 100644 index 0000000..c6c6ef4 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/main.go @@ -0,0 +1,45 @@ +/* + * A tool to create & restore full backups of Docker containers + * Copyright (c) 2019, Christian Muehlhaeuser + * + * For license see LICENSE + */ + +package main + +import ( + "context" + "fmt" + "os" + + "github.com/docker/docker/client" + "github.com/spf13/cobra" +) + +var ( + cli *client.Client + ctx = context.Background() + + // RootCmd is the core command used for cli-arg parsing + RootCmd = &cobra.Command{ + Use: "docker-backup", + Short: "docker-backup creates or restores backups of Docker containers", + SilenceErrors: true, + SilenceUsage: true, + } +) + +func main() { + var err error + // cli, err = client.NewEnvClient() + // cli, err = client.NewClientWithOpts(client.FromEnv) + cli, err = client.NewClientWithOpts(client.WithVersion("1.36")) + if err != nil { + panic(err) + } + + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(-1) + } +} diff --git a/root/usr/libexec/apps/docker-backup/restore.go b/root/usr/libexec/apps/docker-backup/restore.go new file mode 100644 index 0000000..451bd07 --- /dev/null +++ b/root/usr/libexec/apps/docker-backup/restore.go @@ -0,0 +1,236 @@ +package main + +import ( + "archive/tar" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "os" + "strings" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/spf13/cobra" +) + +var ( + optStart = false + + restoreCmd = &cobra.Command{ + Use: "restore ", + Short: "restores a backup of a container", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return fmt.Errorf("restore requires a .json or .tar backup") + } + + if strings.HasSuffix(args[0], ".json") { + return restore(args[0]) + } else if strings.HasSuffix(args[0], ".tar") { + return restoreTar(args[0]) + } + + return fmt.Errorf("Unknown file type, please provide a .tar or .json file") + }, + } +) + +func restoreTar(filename string) error { + tarfile, err := os.Open(filename) + if err != nil { + return err + } + defer tarfile.Close() + + tr := tar.NewReader(tarfile) + var b []byte + for { + th, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + switch th.Name { + case "container.json": + var err error + b, err = ioutil.ReadAll(tr) + if err != nil { + return err + } + } + } + + var backup Backup + err = json.Unmarshal(b, &backup) + if err != nil { + return err + } + + id, err := createContainer(backup) + if err != nil { + return err + } + + conf, err := cli.ContainerInspect(ctx, id) + if err != nil { + return err + } + + tt := map[string]string{} + for _, oldPath := range backup.Mounts { + for _, hostPath := range conf.Mounts { + if oldPath.Destination == hostPath.Destination { + tt[oldPath.Source] = hostPath.Source + break + } + } + } + + if _, err := tarfile.Seek(0, 0); err != nil { + return err + } + tr = tar.NewReader(tarfile) + for { + th, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if th.Name == "container.json" { + continue + } + + path := th.Name + fmt.Println("Restoring:", path) + for k, v := range tt { + if strings.HasPrefix(path, k) { + path = v + path[len(k):] + } + } + + if th.Typeflag == tar.TypeDir { + if err := os.MkdirAll(path, os.FileMode(th.Mode)); err != nil { + return err + } + } else { + file, err := os.Create(path) + if err != nil { + return err + } + if _, err := io.Copy(file, tr); err != nil { + return err + } + file.Close() + } + if err := os.Chmod(path, os.FileMode(th.Mode)); err != nil { + return err + } + if err := os.Chown(path, th.Uid, th.Gid); err != nil { + return err + } + fmt.Println("Created as:", path) + } + + if optStart { + return startContainer(id) + } + return nil +} + +func restore(filename string) error { + var backup Backup + b, err := ioutil.ReadFile(filename) + if err != nil { + return err + } + err = json.Unmarshal(b, &backup) + if err != nil { + return err + } + + id, err := createContainer(backup) + if err != nil { + return err + } + + if optStart { + return startContainer(id) + } + return nil +} + +func createContainer(backup Backup) (string, error) { + nameparts := strings.Split(backup.Name, "/") + name := nameparts[len(nameparts)-1] + fmt.Println("Restoring Container:", name) + + _, _, err := cli.ImageInspectWithRaw(ctx, backup.Config.Image) + if err != nil { + fmt.Println("Pulling Image:", backup.Config.Image) + _, err := cli.ImagePull(ctx, backup.Config.Image, types.ImagePullOptions{}) + if err != nil { + return "", err + } + } + // io.Copy(os.Stdout, reader) + + resp, err := cli.ContainerCreate(ctx, backup.Config, &container.HostConfig{ + PortBindings: backup.PortMap, + }, nil, name) + if err != nil { + return "", err + } + fmt.Println("Created Container with ID:", resp.ID) + + for _, m := range backup.Mounts { + fmt.Printf("Old Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) + } + + conf, err := cli.ContainerInspect(ctx, resp.ID) + if err != nil { + return "", err + } + for _, m := range conf.Mounts { + fmt.Printf("New Mount (type %s) %s -> %s\n", m.Type, m.Source, m.Destination) + } + + return resp.ID, nil +} + +func startContainer(id string) error { + fmt.Println("Starting container:", id[:12]) + + err := cli.ContainerStart(ctx, id, types.ContainerStartOptions{}) + if err != nil { + return err + } + + /* + statusCh, errCh := cli.ContainerWait(ctx, id, container.WaitConditionNotRunning) + select { + case err := <-errCh: + if err != nil { + return err + } + case <-statusCh: + } + + out, err := cli.ContainerLogs(ctx, id, types.ContainerLogsOptions{ShowStdout: true}) + if err != nil { + return err + } + io.Copy(os.Stdout, out) + */ + + return nil +} + +func init() { + restoreCmd.Flags().BoolVarP(&optStart, "start", "s", false, "start restored container") + RootCmd.AddCommand(restoreCmd) +} diff --git a/root/usr/share/rpcd/acl.d/luci-app-docker-backup.json b/root/usr/share/rpcd/acl.d/luci-app-docker-backup.json new file mode 100644 index 0000000..b8e7191 --- /dev/null +++ b/root/usr/share/rpcd/acl.d/luci-app-docker-backup.json @@ -0,0 +1,11 @@ +{ + "luci-app-docker-backup": { + "description": "Grant UCI access for luci-app-docker-backup", + "read": { + "uci": [ "docker-backup" ] + }, + "write": { + "uci": [ "docker-backup" ] + } + } +}