// Command docker-migrate moves Docker containers, together with their volumes // and bind mounts, from one host to another. // // It runs either as a web application (the default) or as a one-shot inventory // dump for scripting. It needs no agent on the target: everything is driven // through the target's own docker CLI over SSH, or through a self-contained // package that is carried to the target by hand. package main import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "flag" "fmt" "log/slog" "net" "net/http" "os" "os/signal" "path/filepath" "runtime" "strings" "syscall" "time" "github.com/arescom/docker-migrate/internal/api" "github.com/arescom/docker-migrate/internal/dkr" "github.com/arescom/docker-migrate/internal/webui" ) // version is overridden at build time with -ldflags "-X main.version=...". var version = "dev" func main() { if err := run(os.Args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { os.Exit(2) } fmt.Fprintln(os.Stderr, "error: "+err.Error()) os.Exit(1) } } func run(args []string) error { cmd := "serve" if len(args) > 0 && !strings.HasPrefix(args[0], "-") { cmd, args = args[0], args[1:] } switch cmd { case "serve": return serve(args) case "inspect": return inspect(args) case "version": fmt.Printf("docker-migrate %s (%s %s/%s)\n", version, runtime.Version(), runtime.GOOS, runtime.GOARCH) return nil case "help", "-h", "--help": usage() return nil default: usage() return fmt.Errorf("unknown command %q", cmd) } } func usage() { fmt.Fprint(os.Stderr, `docker-migrate - move Docker containers and their data between hosts Usage: docker-migrate [serve] [flags] start the web interface (default) docker-migrate inspect [flags] print the source inventory as JSON docker-migrate version print the version Run "docker-migrate serve -h" for the server flags. `) } func serve(args []string) error { fs := flag.NewFlagSet("serve", flag.ContinueOnError) addr := fs.String("addr", "127.0.0.1:8080", "address to listen on; use 0.0.0.0:8080 to expose it on the network") token := fs.String("token", "", "require this token on every request; \"auto\" generates one") dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys") pkgDir := fs.String("package-dir", "", "directory for migration packages (default /packages)") dockerHost := fs.String("docker-host", "", "source docker daemon (default: the DOCKER_HOST environment)") verbose := fs.Bool("v", false, "verbose logging") fs.Usage = func() { fmt.Fprintln(os.Stderr, "Usage: docker-migrate serve [flags]\n\nFlags:") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { return err } level := slog.LevelInfo if *verbose { level = slog.LevelDebug } logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) authToken := *token if authToken == "auto" { authToken = randomToken() } // Anything but a loopback bind is reachable by other machines. This tool // can stop containers and read every volume on the host, so it refuses to // be exposed without a token. if authToken == "" && !isLoopback(*addr) { authToken = randomToken() logger.Warn("listening on a non-loopback address; generated an access token") } cfg := api.Config{ Addr: *addr, Token: authToken, DataDir: *dataDir, PackageDir: *pkgDir, DockerHost: *dockerHost, UI: webui.FS(), Logger: logger, } srv, err := api.New(cfg) if err != nil { return err } defer srv.Close() if !webui.Built() { logger.Warn("web UI is not embedded in this binary; only the HTTP API is available") } httpSrv := &http.Server{ Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 15 * time.Second, // Migrations stream for as long as the data takes; no write timeout. IdleTimeout: 120 * time.Second, } ln, err := net.Listen("tcp", *addr) if err != nil { return fmt.Errorf("listen on %s: %w", *addr, err) } url := "http://" + displayAddr(ln.Addr().String()) if authToken != "" { url += "/?token=" + authToken } fmt.Fprintf(os.Stderr, "\n docker-migrate %s\n open %s\n data: %s\n\n", version, url, *dataDir) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() errCh := make(chan error, 1) go func() { if err := httpSrv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { errCh <- err } }() select { case err := <-errCh: return err case <-ctx.Done(): logger.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return httpSrv.Shutdown(shutdownCtx) } } func inspect(args []string) error { fs := flag.NewFlagSet("inspect", flag.ContinueOnError) dockerHost := fs.String("docker-host", "", "docker daemon to read (default: the DOCKER_HOST environment)") sizes := fs.Bool("sizes", false, "also measure volume sizes (slower)") fs.Usage = func() { fmt.Fprintln(os.Stderr, "Usage: docker-migrate inspect [flags]\n\nFlags:") fs.PrintDefaults() } if err := fs.Parse(args); err != nil { return err } c, err := dkr.New(*dockerHost) if err != nil { return err } defer c.Close() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() inv, err := c.Inventory(ctx) if err != nil { return err } if *sizes { if err := c.MeasureMounts(ctx, inv.Containers); err != nil { fmt.Fprintln(os.Stderr, "warning: could not measure volume sizes: "+err.Error()) } } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(inv) } // defaultDataDir picks a per-user location, honouring the container-friendly // DOCKER_MIGRATE_DATA override. func defaultDataDir() string { if v := os.Getenv("DOCKER_MIGRATE_DATA"); v != "" { return v } if dir, err := os.UserConfigDir(); err == nil { return filepath.Join(dir, "docker-migrate") } return ".docker-migrate" } func randomToken() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("t%d", time.Now().UnixNano()) } return hex.EncodeToString(b) } func isLoopback(addr string) bool { host, _, err := net.SplitHostPort(addr) if err != nil { return false } if host == "" { return false // an empty host binds every interface } if host == "localhost" { return true } ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // displayAddr turns a wildcard bind into something clickable. func displayAddr(addr string) string { host, port, err := net.SplitHostPort(addr) if err != nil { return addr } if host == "" || host == "0.0.0.0" || host == "::" { return "localhost:" + port } if strings.Contains(host, ":") { return "[" + host + "]:" + port } return host + ":" + port }