Files
DockMV/internal/sshx/docker.go
T
2026-08-11 09:00:01 +02:00

262 lines
7.8 KiB
Go

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
}