package sshx import ( "context" "encoding/json" "fmt" "io" "strings" "github.com/arescom/docker-migrate/internal/spec" ) // RemoteDocker drives the docker CLI on a target host over SSH. It assumes // nothing beyond sshd, docker and a POSIX shell; gzip is used only when // compression is enabled and is probed for first. type RemoteDocker struct { c *Client binary string sudo bool } // NewRemoteDocker wraps a connection. func NewRemoteDocker(c *Client) *RemoteDocker { cfg := c.Config() bin := cfg.DockerCmd if bin == "" { bin = "docker" } return &RemoteDocker{c: c, binary: bin, sudo: cfg.Sudo} } // Cmd renders a docker invocation as a shell command line, correctly quoted. func (r *RemoteDocker) Cmd(args ...string) string { var b strings.Builder if r.sudo { b.WriteString("sudo -n ") } b.WriteString(spec.ShellQuote(r.binary)) if len(args) > 0 { b.WriteString(" ") b.WriteString(spec.ShellQuoteAll(args)) } return b.String() } // Run executes a docker command and returns its stdout, failing on non-zero. func (r *RemoteDocker) Run(ctx context.Context, args ...string) (string, error) { return r.c.RunCheck(ctx, r.Cmd(args...)) } // Try executes a docker command and reports success without treating a // non-zero exit as an error. Used for existence probes. func (r *RemoteDocker) Try(ctx context.Context, args ...string) (string, bool, error) { res, err := r.c.Run(ctx, r.Cmd(args...)) if err != nil { return "", false, err } return res.Stdout, res.ExitCode == 0, nil } // Feed pipes a local reader into a docker command's stdin. When decompress is // set the remote side runs `gzip -dc` ahead of docker, so the bytes on the // wire are compressed. func (r *RemoteDocker) Feed(ctx context.Context, src io.Reader, decompress bool, args ...string) error { cmd := r.Cmd(args...) if decompress { cmd = "gzip -dc | " + cmd } _, err := r.c.Stream(ctx, cmd, src, nil) return err } // Preflight is what the target host was found to support. type Preflight struct { DockerVersion string `json:"dockerVersion"` ServerVersion string `json:"serverVersion"` OS string `json:"os"` Arch string `json:"arch"` HasGzip bool `json:"hasGzip"` DiskFreeBytes int64 `json:"diskFreeBytes"` DockerRoot string `json:"dockerRoot"` Problems []string `json:"problems,omitempty"` } // Preflight checks everything the migration depends on before any data moves. func (r *RemoteDocker) Preflight(ctx context.Context) (*Preflight, error) { p := &Preflight{} out, ok, err := r.Try(ctx, "version", "--format", "{{.Client.Version}}|{{.Server.Version}}|{{.Server.Os}}|{{.Server.Arch}}") if err != nil { return nil, err } if !ok { res, _ := r.c.Run(ctx, r.Cmd("version")) msg := strings.TrimSpace(res.Stderr) if strings.Contains(msg, "permission denied") { p.Problems = append(p.Problems, "the login user cannot talk to the docker daemon; add it to the docker group or enable sudo for this connection") } else if msg != "" { p.Problems = append(p.Problems, "docker is not usable on the target: "+firstLine(msg)) } else { p.Problems = append(p.Problems, "docker is not installed or not on PATH on the target") } return p, nil } parts := strings.Split(strings.TrimSpace(out), "|") if len(parts) == 4 { p.DockerVersion, p.ServerVersion, p.OS, p.Arch = parts[0], parts[1], parts[2], parts[3] } if p.OS != "" && p.OS != "linux" { p.Problems = append(p.Problems, "target daemon runs "+p.OS+" containers; only linux targets are supported") } res, err := r.c.Run(ctx, "command -v gzip >/dev/null 2>&1") if err != nil { return nil, err } p.HasGzip = res.ExitCode == 0 if !p.HasGzip { p.Problems = append(p.Problems, "gzip is missing on the target; transfers will run uncompressed") } if root, err2 := r.Run(ctx, "info", "--format", "{{.DockerRootDir}}"); err2 == nil { p.DockerRoot = strings.TrimSpace(root) if p.DockerRoot != "" { // POSIX df in 1K blocks; the fourth column is available space. cmd := "df -Pk " + spec.ShellQuote(p.DockerRoot) + " | awk 'NR==2 {print $4}'" if dres, derr := r.c.Run(ctx, cmd); derr == nil && dres.ExitCode == 0 { var kb int64 if _, serr := fmt.Sscanf(strings.TrimSpace(dres.Stdout), "%d", &kb); serr == nil { p.DiskFreeBytes = kb * 1024 } } } } return p, nil } // TargetContainer is a container that already exists on the target host. type TargetContainer struct { ID string `json:"id"` Name string `json:"name"` Image string `json:"image"` State string `json:"state"` Status string `json:"status"` Ports string `json:"ports"` } // TargetInventory is the current state of the target host, used to show it // side by side with the source and to detect name conflicts up front. type TargetInventory struct { Host string `json:"host"` Containers []TargetContainer `json:"containers"` Volumes []string `json:"volumes"` Networks []string `json:"networks"` Preflight *Preflight `json:"preflight"` } // Inventory reads the target host's containers, volumes and networks. func (r *RemoteDocker) Inventory(ctx context.Context) (*TargetInventory, error) { pre, err := r.Preflight(ctx) if err != nil { return nil, err } inv := &TargetInventory{Preflight: pre} if pre.ServerVersion == "" { return inv, nil } if host, err := r.c.RunCheck(ctx, "hostname"); err == nil { inv.Host = strings.TrimSpace(host) } out, err := r.Run(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}") if err != nil { return nil, err } for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) if line == "" { continue } var raw struct { ID, Names, Image, State, Status, Ports string } if json.Unmarshal([]byte(line), &raw) != nil { continue } // A container attached to several networks is reported with a // comma-separated name list; the first is its real name. name := raw.Names if i := strings.Index(name, ","); i >= 0 { name = name[:i] } inv.Containers = append(inv.Containers, TargetContainer{ ID: raw.ID, Name: name, Image: raw.Image, State: raw.State, Status: raw.Status, Ports: raw.Ports, }) } if out, err := r.Run(ctx, "volume", "ls", "--format", "{{.Name}}"); err == nil { inv.Volumes = nonEmptyLines(out) } if out, err := r.Run(ctx, "network", "ls", "--format", "{{.Name}}"); err == nil { inv.Networks = nonEmptyLines(out) } return inv, nil } // Exists reports whether an object of the given kind is present on the target. func (r *RemoteDocker) Exists(ctx context.Context, kind, name string) (bool, error) { var args []string switch kind { case "container": args = []string{"container", "inspect", name} case "volume": args = []string{"volume", "inspect", name} case "network": args = []string{"network", "inspect", name} case "image": args = []string{"image", "inspect", name} default: return false, fmt.Errorf("unknown object kind %q", kind) } res, err := r.c.Run(ctx, r.Cmd(args...)+" >/dev/null 2>&1") if err != nil { return false, err } return res.ExitCode == 0, nil } // MkdirAll creates a directory on the target host, for bind mount sources that // need to exist with the right ownership before the container starts. func (r *RemoteDocker) MkdirAll(ctx context.Context, path string) error { cmd := "mkdir -p " + spec.ShellQuote(path) if r.sudo { cmd = "sudo -n " + cmd } _, err := r.c.RunCheck(ctx, cmd) return err } // Client exposes the underlying SSH connection for raw shell work. func (r *RemoteDocker) Client() *Client { return r.c } func nonEmptyLines(s string) []string { var out []string for _, l := range strings.Split(s, "\n") { if l = strings.TrimSpace(l); l != "" { out = append(out, l) } } return out } func firstLine(s string) string { if i := strings.IndexByte(s, '\n'); i >= 0 { return s[:i] } return s }