Files
DockMV/main.go
T
kawaandClaude Sonnet 5 34e18987a0 Rename product to dockmv (container, image, module, env vars)
Container/image/service name, Go module path, CLI binary name, and
DOCKER_MIGRATE_* env vars still used the old working name; the project
is branded DockMV everywhere else (README, logo, Gitea repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:56:26 +02:00

258 lines
6.6 KiB
Go

// Command dockmv 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/dockmv/internal/api"
"github.com/arescom/dockmv/internal/dkr"
"github.com/arescom/dockmv/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("dockmv %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, `dockmv - move Docker containers and their data between hosts
Usage:
dockmv [serve] [flags] start the web interface (default)
dockmv inspect [flags] print the source inventory as JSON
dockmv version print the version
Run "dockmv 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 <data-dir>/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: dockmv 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 dockmv %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: dockmv 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
// DOCKMV_DATA override.
func defaultDataDir() string {
if v := os.Getenv("DOCKMV_DATA"); v != "" {
return v
}
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "dockmv")
}
return ".dockmv"
}
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
}