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>
262 lines
7.7 KiB
Go
262 lines
7.7 KiB
Go
package sshx
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/arescom/dockmv/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
|
|
}
|