Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+580
View File
@@ -0,0 +1,580 @@
package migrate
import (
"fmt"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// renderInstaller generates the shell script shipped inside a migration
// package. The script is self-contained: it never parses the manifest and
// depends on nothing but bash, gzip and the docker CLI, so it can be read and
// audited by whoever runs it on the target host.
func renderInstaller(prepared []*Prepared, man *spec.Manifest, savedImages map[string]string, pkgName string) string {
payload := map[string]spec.Payload{}
for _, p := range man.Payloads {
if p.Kind == "mount" {
payload[p.Container+"\x00"+p.Destination] = p
}
}
imagePayload := map[string]spec.Payload{}
for _, p := range man.Payloads {
if p.Kind == "image" {
imagePayload[p.Image] = p
}
}
var b strings.Builder
// w formats a line. Literal blocks must be passed as an argument, never as
// the format itself: shell text is full of % and would be mangled.
w := func(format string, args ...any) {
if len(args) == 0 {
b.WriteString(format)
b.WriteByte('\n')
return
}
fmt.Fprintf(&b, format+"\n", args...)
}
w("#!/usr/bin/env bash")
w("#")
w("# Migration package: %s", pkgName)
w("# Created: %s", man.CreatedAt.Format("2006-01-02 15:04:05 MST"))
w("# Source host: %s (docker %s)", orDash(man.SourceHost), orDash(man.DockerVersion))
w("# Containers: %d", len(prepared))
w("#")
w("# Run this on the TARGET host. It needs bash, gzip and a working docker CLI.")
w("# Nothing is written outside docker's own storage and the bind mount paths")
w("# listed below.")
w("#")
w("# Usage: ./install.sh [options]")
w("# --dry-run print every command without changing anything")
w("# --yes do not ask for confirmation")
w("# --no-start create the containers but leave them stopped")
w("# --conflict MODE fail (default) | skip | replace | rename")
w("# --rename-suffix S suffix used by --conflict rename (default -migrated)")
w("# --skip-verify do not checksum the payloads")
w("# --only NAME[,NAME...] restore only these containers")
w("# --docker CMD docker command to use (default: docker)")
w("# --sudo prefix docker with sudo -n")
w("")
w("set -euo pipefail")
w("")
w(`PKGDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`)
w("DRY_RUN=0")
w("ASSUME_YES=0")
w("NO_START=0")
w("VERIFY=1")
w("ONLY=\"\"")
w("DOCKER_BIN=docker")
w("USE_SUDO=0")
w("CONFLICT=%s", spec.ShellQuote(string(defaultConflict(man.Options.Conflict))))
w("RENAME_SUFFIX=%s", spec.ShellQuote(defaultSuffix(man.Options.RenameSuffix)))
w("")
w(`while [ $# -gt 0 ]; do`)
w(` case "$1" in`)
w(` --dry-run) DRY_RUN=1 ;;`)
w(` --yes|-y) ASSUME_YES=1 ;;`)
w(` --no-start) NO_START=1 ;;`)
w(` --skip-verify) VERIFY=0 ;;`)
w(` --conflict) shift; CONFLICT="${1:-}" ;;`)
w(` --rename-suffix) shift; RENAME_SUFFIX="${1:-}" ;;`)
w(` --only) shift; ONLY="${1:-}" ;;`)
w(` --docker) shift; DOCKER_BIN="${1:-docker}" ;;`)
w(` --sudo) USE_SUDO=1 ;;`)
w(` -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;`)
w(` *) echo "unknown option: $1" >&2; exit 2 ;;`)
w(` esac`)
w(` shift`)
w(`done`)
w("")
w(`case "$CONFLICT" in fail|skip|replace|rename) ;; *) echo "invalid --conflict: $CONFLICT" >&2; exit 2 ;; esac`)
w("")
w(`if [ "$USE_SUDO" = 1 ]; then DOCKER="sudo -n $DOCKER_BIN"; else DOCKER="$DOCKER_BIN"; fi`)
w("")
w(installerHelpers)
w("")
// Preflight.
w(`log "migration package: %s"`, escapeDoubleQuoted(pkgName))
w(`preflight`)
w("")
// Bind mount summary, so the operator sees what will touch the host
// filesystem before answering the prompt.
binds := collectBinds(prepared)
if len(binds) > 0 {
w(`echo "This package writes into the following host paths:"`)
for _, p := range binds {
w(`echo " %s"`, escapeDoubleQuoted(p))
}
w("")
}
w(`confirm`)
w("")
// Networks, created once.
nets := map[string]bool{}
var netBlock strings.Builder
for _, p := range prepared {
for _, n := range p.Networks {
if nets[n.Name] || isBuiltin(n.Name) {
continue
}
nets[n.Name] = true
fmt.Fprintf(&netBlock, "ensure_network %s %s\n",
spec.ShellQuote(n.Name), spec.ShellQuoteAll(n.CreateArgs()))
}
}
if netBlock.Len() > 0 {
w(`step "networks"`)
w("%s", strings.TrimRight(netBlock.String(), "\n"))
w("")
}
// One function per container keeps the flow readable and lets --only skip
// whole containers cleanly.
for i, p := range prepared {
w("%s", renderContainerFunc(i, p, payload, imagePayload, savedImages))
}
w(`FAILED=0`)
for i, p := range prepared {
name := p.ContainerName()
w(`if selected %s; then`, spec.ShellQuote(name))
w(` if ! migrate_%d; then err "container %s failed"; FAILED=$((FAILED+1)); fi`, i, escapeDoubleQuoted(name))
w(`else`)
w(` log "skipping %s (not in --only)"`, escapeDoubleQuoted(name))
w(`fi`)
}
w("")
w(`if [ "$FAILED" -gt 0 ]; then`)
w(` err "$FAILED container(s) failed"`)
w(` exit 1`)
w(`fi`)
w(`ok "migration complete"`)
w(`if [ "$DRY_RUN" = 1 ]; then log "this was a dry run; nothing was changed"; fi`)
return b.String()
}
func renderContainerFunc(idx int, p *Prepared, payload, imagePayload map[string]spec.Payload, savedImages map[string]string) string {
var b strings.Builder
w := func(format string, args ...any) {
if len(args) == 0 {
b.WriteString(format)
b.WriteByte('\n')
return
}
fmt.Fprintf(&b, format+"\n", args...)
}
name := p.ContainerName()
sel := p.Selection
// Every command below is checked explicitly with "|| return 1". This
// function is invoked from an `if !` test, which switches `set -e` off for
// its whole body, so an unchecked failure would otherwise be swallowed and
// the container reported as migrated when it was not.
w("migrate_%d() {", idx)
w(` local base=%s`, spec.ShellQuote(name))
w(` CNAME="$base"`)
w(` step "container $base"`)
for _, note := range p.Notes {
w(` warn %s`, spec.ShellQuote(note))
}
for _, note := range p.Source.Warnings {
w(` warn %s`, spec.ShellQuote(note))
}
// Conflict handling.
w(` if object_exists container "$CNAME"; then`)
w(` case "$CONFLICT" in`)
w(` skip) warn "container $CNAME already exists; skipping"; return 0 ;;`)
w(` replace) warn "removing existing container $CNAME"; run $DOCKER rm -f "$CNAME" || return 1 ;;`)
w(` rename) CNAME="$(free_name "$base")"; warn "creating $CNAME instead" ;;`)
w(` *) err "container $CNAME already exists; rerun with --conflict replace|rename|skip"; return 1 ;;`)
w(` esac`)
w(` fi`)
// Image.
image := p.Target.Image
switch {
case !sel.MigrateImage || sel.ImageMode == spec.ImageSkip:
w(` if ! object_exists image %s; then`, spec.ShellQuote(image))
w(` err "image %s is not present and this package does not carry it"; return 1`, escapeDoubleQuoted(image))
w(` fi`)
case sel.ImageMode == spec.ImagePull:
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
default:
if rel, ok := savedImages[image]; ok {
ip := imagePayload[image]
w(` ensure_image_load %s %s %s %s || return 1`,
spec.ShellQuote(image), spec.ShellQuote(rel),
spec.ShellQuote(ip.SHA256), boolArg(ip.Compressed))
} else {
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
}
}
// Named volumes.
for _, v := range p.Volumes {
w(` ensure_volume %s %s || return 1`, spec.ShellQuote(v.Name), spec.ShellQuoteAll(v.CreateArgs()))
}
// Bind mount directories, created before the container so docker does not
// invent them with unexpected ownership halfway through.
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !isSpecialBind(m.Source) {
w(` ensure_dir %s || return 1`, spec.ShellQuote(m.Source))
}
}
// Create. The name is substituted at run time so --conflict rename works.
createArgs := p.Target.CreateArgs(p.Render)
rest := createArgs
if len(rest) >= 3 && rest[0] == "create" && rest[1] == "--name" {
rest = rest[3:]
}
w(` log "creating container $CNAME"`)
w(` run $DOCKER create --name "$CNAME" %s || { err "could not create $CNAME"; return 1; }`, spec.ShellQuoteAll(rest))
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
// The rendered args end with (network, containerName); the name is
// replaced so a renamed container still gets attached.
if len(args) < 2 {
continue
}
head := args[:len(args)-1]
w(` run $DOCKER %s "$CNAME" || { err "could not attach $CNAME to a network"; return 1; }`, spec.ShellQuoteAll(head))
}
// Data.
for _, t := range p.Transfers {
pl, ok := payload[name+"\x00"+t.Destination]
if !ok {
w(` warn "no data archive for %s in this package; leaving it empty"`, escapeDoubleQuoted(t.Destination))
continue
}
w(` verify_payload %s %s || return 1`, spec.ShellQuote(pl.Path), spec.ShellQuote(pl.SHA256))
if t.ReadOnly {
w(` log "restoring %s (read-only mount, via staging container)"`, escapeDoubleQuoted(t.Label))
w(` seed_readonly "$CNAME" %s %s %s %s || return 1`,
spec.ShellQuote(p.Target.Image), spec.ShellQuote(t.Destination),
spec.ShellQuote(pl.Path), boolArg(pl.Compressed))
} else {
w(` log "restoring %s"`, escapeDoubleQuoted(t.Label))
w(` feed_archive %s %s "$CNAME" %s || { err "could not restore %s"; return 1; }`,
spec.ShellQuote(pl.Path), boolArg(pl.Compressed), spec.ShellQuote(t.RestoreInto),
escapeDoubleQuoted(t.Label))
}
}
if sel.StartAfter {
w(` if [ "$NO_START" = 1 ]; then`)
w(` log "leaving $CNAME stopped (--no-start)"`)
w(` else`)
w(` log "starting $CNAME"`)
w(` run $DOCKER start "$CNAME" || { err "could not start $CNAME"; return 1; }`)
w(` check_running "$CNAME" || return 1`)
w(` fi`)
} else {
w(` log "$CNAME created but not started (it was not running on the source)"`)
}
w(` ok "$CNAME done"`)
w(` return 0`)
w("}")
w("")
return b.String()
}
// installerHelpers is the fixed shell prelude shared by every generated
// installer.
const installerHelpers = `
if [ -t 1 ]; then C_R=$'\033[31m'; C_G=$'\033[32m'; C_Y=$'\033[33m'; C_B=$'\033[1m'; C_0=$'\033[0m'
else C_R=""; C_G=""; C_Y=""; C_B=""; C_0=""; fi
log() { printf '%s\n' " $*"; }
step() { printf '\n%s\n' "${C_B}==> $*${C_0}"; }
ok() { printf '%s\n' " ${C_G}ok${C_0} $*"; }
warn() { printf '%s\n' " ${C_Y}warning${C_0} $*" >&2; }
err() { printf '%s\n' " ${C_R}error${C_0} $*" >&2; }
die() { err "$*"; exit 1; }
# run echoes a command and executes it, unless this is a dry run.
#
# It discards the command's own stdout itself. Callers must not add their own
# >/dev/null: that would also hide the "would run" line, leaving a dry run
# showing none of the commands it was about to execute.
run() {
if [ "$DRY_RUN" = 1 ]; then
printf ' would run:'; printf ' %q' "$@"; printf '\n'
return 0
fi
"$@" >/dev/null
}
preflight() {
command -v "$DOCKER_BIN" >/dev/null 2>&1 || die "$DOCKER_BIN is not on PATH"
if ! $DOCKER version >/dev/null 2>&1; then
die "cannot talk to the docker daemon (try --sudo, or add your user to the docker group)"
fi
command -v gzip >/dev/null 2>&1 || warn "gzip is missing; compressed payloads cannot be restored"
local srv
srv="$($DOCKER version --format '{{.Server.Version}}' 2>/dev/null || echo unknown)"
log "docker server $srv on $(uname -s) $(uname -m)"
}
confirm() {
[ "$ASSUME_YES" = 1 ] && return 0
[ "$DRY_RUN" = 1 ] && return 0
printf '%s' "Proceed? [y/N] "
local ans; read -r ans </dev/tty || ans=""
case "$ans" in y|Y|yes|YES) return 0 ;; *) echo "aborted"; exit 1 ;; esac
}
selected() {
[ -z "$ONLY" ] && return 0
local want
IFS=, read -ra want <<< "$ONLY"
local n
for n in "${want[@]}"; do [ "$n" = "$1" ] && return 0; done
return 1
}
object_exists() { # kind name
$DOCKER "$1" inspect "$2" >/dev/null 2>&1
}
free_name() { # base -> an unused container name
local base="$1" candidate="$1$RENAME_SUFFIX" i=2
while object_exists container "$candidate"; do
candidate="$base$RENAME_SUFFIX-$i"; i=$((i+1))
[ "$i" -gt 50 ] && die "no free name based on $base"
done
printf '%s' "$candidate"
}
ensure_network() { # name, then the full docker network create argv
local name="$1"; shift
if object_exists network "$name"; then
log "network $name already exists; reusing it"
return 0
fi
log "creating network $name"
run $DOCKER "$@" || { err "could not create network $name"; return 1; }
}
ensure_volume() { # name, then the full docker volume create argv
local name="$1"; shift
if object_exists volume "$name"; then
warn "volume $name already exists; restored data will be merged into it"
return 0
fi
log "creating volume $name"
run $DOCKER "$@" || { err "could not create volume $name"; return 1; }
}
ensure_dir() { # host path for a bind mount
if [ -e "$1" ]; then return 0; fi
log "creating host directory $1"
if [ "$DRY_RUN" = 1 ]; then printf ' would run: mkdir -p %q\n' "$1"; return 0; fi
mkdir -p "$1" 2>/dev/null || sudo mkdir -p "$1" || { err "cannot create $1"; return 1; }
}
ensure_image_pull() { # ref
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
log "pulling image $1"
run $DOCKER pull "$1" || { err "could not pull $1"; return 1; }
}
ensure_image_load() { # ref relpath sha256 compressed
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
verify_payload "$2" "$3" || return 1
log "loading image $1 from $2"
if [ "$DRY_RUN" = 1 ]; then printf ' would run: docker load < %q\n' "$2"; return 0; fi
if [ "$4" = 1 ]; then gzip -dc -- "$PKGDIR/$2" | $DOCKER load >/dev/null
else $DOCKER load >/dev/null < "$PKGDIR/$2"; fi
}
verify_payload() { # relpath sha256
[ -f "$PKGDIR/$1" ] || { err "payload missing from package: $1"; return 1; }
[ "$VERIFY" = 1 ] || return 0
if ! command -v sha256sum >/dev/null 2>&1; then
warn "sha256sum not available; skipping checksum verification"
VERIFY=0
return 0
fi
local got
got="$(sha256sum "$PKGDIR/$1" | cut -d' ' -f1)"
if [ "$got" != "$2" ]; then
err "checksum mismatch for $1 (package is corrupt or truncated)"
return 1
fi
}
feed_archive() { # relpath compressed container extract_into
if [ "$DRY_RUN" = 1 ]; then
printf ' would restore %q into %s:%s\n' "$1" "$3" "$4"
return 0
fi
if [ "$2" = 1 ]; then
gzip -dc -- "$PKGDIR/$1" | $DOCKER cp -a - "$3:$4"
else
$DOCKER cp -a - "$3:$4" < "$PKGDIR/$1"
fi
}
# resolve_mount prints the volume name, or the host path, backing a mount
# destination in a container that already exists.
resolve_mount() { # container destination
local d n s
while IFS='|' read -r d n s; do
if [ "$d" = "$2" ]; then
if [ -n "$n" ]; then printf '%s' "$n"; else printf '%s' "$s"; fi
return 0
fi
done < <($DOCKER inspect --format '{{range .Mounts}}{{.Destination}}|{{.Name}}|{{.Source}}{{"\n"}}{{end}}' "$1")
return 1
}
# seed_readonly fills a mount the container declares read-only. The same volume
# or host path is attached writable to a throwaway container, which is created
# but never started, and removed straight after.
seed_readonly() { # container image destination relpath compressed
if [ "$DRY_RUN" = 1 ]; then
printf ' would seed read-only mount %s from %q via a staging container\n' "$3" "$4"
return 0
fi
local store base stage mountat
store="$(resolve_mount "$1" "$3")" || { err "cannot resolve storage behind $3"; return 1; }
base="${3##*/}"
stage="dm-stage-$$-${RANDOM}"
mountat="/__docker_migrate/$base"
$DOCKER create --name "$stage" --volume "$store:$mountat" "$2" >/dev/null \
|| { err "could not create staging container for $3"; return 1; }
local rc=0
if [ "$5" = 1 ]; then
gzip -dc -- "$PKGDIR/$4" | $DOCKER cp -a - "$stage:/__docker_migrate" || rc=$?
else
$DOCKER cp -a - "$stage:/__docker_migrate" < "$PKGDIR/$4" || rc=$?
fi
$DOCKER rm -f "$stage" >/dev/null 2>&1 || true
if [ "$rc" != 0 ]; then
err "failed to seed read-only mount $3"
return 1
fi
}
check_running() { # container
[ "$DRY_RUN" = 1 ] && return 0
sleep 2
local st
st="$($DOCKER inspect --format '{{.State.Status}}' "$1" 2>/dev/null || echo missing)"
if [ "$st" != "running" ]; then
err "$1 is not running (status: $st); last log lines:"
$DOCKER logs --tail 20 "$1" 2>&1 | sed 's/^/ /' || true
return 1
fi
}
`
func renderReadme(name string, prepared []*Prepared, opts spec.Options) string {
var b strings.Builder
fmt.Fprintf(&b, "Docker migration package: %s\n", name)
fmt.Fprintf(&b, "%s\n\n", strings.Repeat("=", 27+len(name)))
b.WriteString("How to use this package\n")
b.WriteString("-----------------------\n")
b.WriteString("1. Copy this whole directory (or tar file) to the target host.\n")
b.WriteString("2. On the target host, unpack it if needed and run:\n\n")
b.WriteString(" ./install.sh --dry-run # review every command first\n")
b.WriteString(" ./install.sh # actually restore\n\n")
b.WriteString("The target host needs: bash, gzip, and a working docker CLI.\n")
b.WriteString("Nothing else is installed and no network access is required unless a\n")
b.WriteString("container's image is set to be pulled instead of carried.\n\n")
b.WriteString("Contents\n")
b.WriteString("--------\n")
b.WriteString(" install.sh self-contained restore script (read it, it is plain bash)\n")
b.WriteString(" manifest.json machine-readable description of everything in here\n")
b.WriteString(" images/ docker image archives\n")
b.WriteString(" data/ volume and bind mount contents, one tar per mount\n\n")
b.WriteString("Containers in this package\n")
b.WriteString("--------------------------\n")
for _, p := range prepared {
fmt.Fprintf(&b, " %s (image %s)\n", p.ContainerName(), p.Target.Image)
for _, t := range p.Transfers {
fmt.Fprintf(&b, " data: %s\n", t.Label)
}
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind {
fmt.Fprintf(&b, " writes host path: %s\n", m.Source)
}
}
}
if opts.DryRun {
b.WriteString("\nNOTE: this package was built in dry-run mode and contains no data archives.\n")
}
return b.String()
}
func collectBinds(prepared []*Prepared) []string {
seen := map[string]bool{}
var out []string
for _, p := range prepared {
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !seen[m.Source] {
seen[m.Source] = true
out = append(out, m.Source)
}
}
}
return out
}
// isSpecialBind reports paths that must never be created by the installer,
// because they are kernel or daemon sockets rather than data directories.
func isSpecialBind(p string) bool {
switch p {
case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/":
return true
}
return strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/dev/")
}
func boolArg(b bool) string {
if b {
return "1"
}
return "0"
}
func defaultConflict(c spec.ConflictPolicy) spec.ConflictPolicy {
if c == "" {
return spec.ConflictFail
}
return c
}
func defaultSuffix(s string) string {
if s == "" {
return "-migrated"
}
return s
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// escapeDoubleQuoted makes a value safe to interpolate inside a double-quoted
// shell string in the generated script.
func escapeDoubleQuoted(s string) string {
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`)
return r.Replace(s)
}
+323
View File
@@ -0,0 +1,323 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/arescom/docker-migrate/internal/spec"
)
func buildPrepared(t *testing.T, c *spec.Container, vols []spec.Volume, nets []spec.Network) *Prepared {
t.Helper()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, vols, nets)
if err != nil {
t.Fatal(err)
}
return p
}
func fixture(t *testing.T) ([]*Prepared, *spec.Manifest, map[string]string) {
t.Helper()
c := &spec.Container{
ID: "id1", Name: "shop-db", State: "running", Image: "postgres:16",
Env: []string{"POSTGRES_PASSWORD=p'w\"d $(whoami)"},
Labels: map[string]string{"note": "a;b`c`"},
Mounts: []spec.Mount{
{Kind: spec.MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"},
{Kind: spec.MountBind, Source: "/srv/shop/initdb", Destination: "/docker-entrypoint-initdb.d", ReadOnly: true},
},
Endpoints: []spec.Endpoint{{Network: "shopnet"}},
Ports: []spec.PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}},
}
p := buildPrepared(t,
c,
[]spec.Volume{{Name: "pgdata", Driver: "local"}},
[]spec.Network{{Name: "shopnet", Driver: "bridge"}},
)
man := &spec.Manifest{
FormatVersion: 1,
CreatedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
SourceHost: "old-host",
DockerVersion: "27.0.0",
Options: spec.DefaultOptions(),
Payloads: []spec.Payload{
{Path: "images/postgres_16.tar.gz", Kind: "image", Image: "postgres:16", SHA256: "aa", Compressed: true},
{Path: "data/shop-db/00-var_lib_postgresql_data.tar.gz", Kind: "mount",
Container: "shop-db", Destination: "/var/lib/postgresql/data", SHA256: "bb", Compressed: true},
{Path: "data/shop-db/01-docker-entrypoint-initdb.d.tar.gz", Kind: "mount",
Container: "shop-db", Destination: "/docker-entrypoint-initdb.d", SHA256: "cc", Compressed: true},
},
}
return []*Prepared{p}, man, map[string]string{"postgres:16": "images/postgres_16.tar.gz"}
}
func TestInstallerIsValidBash(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
path := filepath.Join(t.TempDir(), "install.sh")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
out, err := exec.Command(bash, "-n", path).CombinedOutput()
if err != nil {
t.Fatalf("generated installer is not valid bash: %v\n%s\n---\n%s", err, out, numbered(script))
}
}
// TestInstallerRunsCleanlyInDryRun executes the generated script against a
// stub docker, which is the closest thing to a real run that does not need a
// docker daemon.
func TestInstallerDryRunExecutes(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
// The installer checks that every payload is present even on a dry run, so
// that an incomplete package is reported before anything is changed.
writePayloads(t, dir, man)
binDir := writeStubDocker(t, dir, false)
env := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
run := func(args ...string) (string, error) {
cmd := exec.Command(bash, append([]string{"./install.sh"}, args...)...)
cmd.Dir = dir
cmd.Env = env
out, err := cmd.CombinedOutput()
return string(out), err
}
// The payload contents here are placeholders, so checksums are skipped;
// the real checksums are exercised by the end-to-end test.
text, err := run("--dry-run", "--yes", "--skip-verify")
if err != nil {
t.Fatalf("dry run failed: %v\n%s", err, text)
}
for _, want := range []string{"shop-db", "would run", "migration complete", "creating network shopnet"} {
if !strings.Contains(text, want) {
t.Errorf("dry run output missing %q:\n%s", want, text)
}
}
// A package whose payload does not match its checksum must be refused,
// rather than restoring truncated data.
corrupt, err := run("--dry-run", "--yes")
if err == nil {
t.Errorf("a payload with a bad checksum was accepted:\n%s", corrupt)
} else if !strings.Contains(corrupt, "checksum mismatch") {
t.Errorf("expected a checksum mismatch error, got:\n%s", corrupt)
}
}
// TestInstallerReportsFailedStart guards against the worst failure mode there
// is: reporting a successful migration when the container never started.
//
// The per-container work runs inside a function invoked from an `if !` test,
// which disables `set -e` for that whole function body, so every command has to
// be checked explicitly or its failure is silently discarded.
func TestInstallerReportsFailedStart(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
writePayloads(t, dir, man)
binDir := writeStubDocker(t, dir, true)
cmd := exec.Command(bash, "./install.sh", "--yes", "--skip-verify")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
text := string(out)
if err == nil {
t.Fatalf("the installer exited 0 even though the container never started:\n%s", text)
}
if strings.Contains(text, "migration complete") {
t.Errorf("the installer claimed the migration completed:\n%s", text)
}
for _, want := range []string{"could not start", "container(s) failed"} {
if !strings.Contains(text, want) {
t.Errorf("expected the output to contain %q:\n%s", want, text)
}
}
}
// TestInstallerQuotesHostileValues makes sure values taken from container
// metadata cannot break out of the generated script.
func TestInstallerQuotesHostileValues(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
// The password contains a quote, a double quote and a command
// substitution; none of it may appear unquoted.
if strings.Contains(script, "POSTGRES_PASSWORD=p'w\"d $(whoami)") {
t.Error("environment value was interpolated without quoting")
}
if !strings.Contains(script, `'POSTGRES_PASSWORD=p'\''w"d $(whoami)'`) {
t.Errorf("environment value is not quoted as expected:\n%s", grepLines(script, "POSTGRES_PASSWORD"))
}
}
func TestInstallerHandlesReadOnlyMountThroughStaging(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
if !strings.Contains(script, "seed_readonly") {
t.Error("read-only mount must be seeded through a staging container")
}
// The writable volume is fed into the real container directly. Shell-safe
// paths are emitted without quotes, which is what ShellQuote does.
want := `feed_archive data/shop-db/00-var_lib_postgresql_data.tar.gz 1 "$CNAME" /var/lib/postgresql`
if !strings.Contains(script, want) {
t.Errorf("writable volume restore command is wrong:\nwant a line containing: %s\ngot:\n%s",
want, grepLines(script, "feed_archive"))
}
// Restoring must target the parent directory, never the mount point itself,
// because the archive entries are already rooted at the last segment.
if strings.Contains(script, `"$CNAME" /var/lib/postgresql/data`) {
t.Error("archive is being extracted into the mount point instead of its parent")
}
}
func TestInstallerVerifiesChecksums(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
// Every payload in the manifest must be checksummed before it is fed to
// docker, so a truncated package fails loudly instead of restoring garbage.
for _, p := range man.Payloads {
var want string
if p.Kind == "image" {
want = "ensure_image_load " + spec.ShellQuote(p.Image) + " " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
} else {
want = "verify_payload " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
}
if !strings.Contains(script, want) {
t.Errorf("payload %s is not verified\nwant a line containing: %s\ngot:\n%s",
p.Path, want, grepLines(script, "verify_payload"))
}
}
}
func numbered(s string) string {
var b strings.Builder
for i, line := range strings.Split(s, "\n") {
b.WriteString(strings.TrimRight(line, "\r"))
b.WriteByte('\n')
if i > 200 {
b.WriteString("...\n")
break
}
}
return b.String()
}
func grepLines(s, needle string) string {
var out []string
for _, l := range strings.Split(s, "\n") {
if strings.Contains(l, needle) {
out = append(out, l)
}
}
return strings.Join(out, "\n")
}
// writePayloads materialises every payload the manifest references as a real
// gzipped tar, so the generated installer's gzip and docker cp steps behave the
// way they would with a genuine package.
func writePayloads(t *testing.T, dir string, man *spec.Manifest) {
t.Helper()
for _, p := range man.Payloads {
full := filepath.Join(dir, filepath.FromSlash(p.Path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
body := []byte("payload for " + p.Path + "\n")
if err := tw.WriteHeader(&tar.Header{
Name: "placeholder.txt", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
}
// writeStubDocker installs a fake docker CLI on PATH and returns its directory.
//
// It models just enough of the real thing for the installer to run without a
// daemon: nothing exists yet except the image, and `docker inspect --format`
// answers the mount lookup the read-only seeding path depends on. When
// failStart is set, `docker start` fails the way it does on a target whose
// published port is already taken.
func writeStubDocker(t *testing.T, dir string, failStart bool) string {
t.Helper()
startCase := ""
if failStart {
startCase = ` start) echo "Bind for 0.0.0.0:5432 failed: port is already allocated" >&2; exit 1 ;;` + "\n"
}
stub := `#!/usr/bin/env bash
# object existence probes: "docker <kind> inspect <name>"
case "$1 $2" in
"image inspect") exit 0 ;;
"container inspect"|"volume inspect"|"network inspect") exit 1 ;;
esac
case "$1" in
version) echo 27.0.0 ;;
# resolve_mount calls "docker inspect --format <tmpl> <container>"
inspect) echo "/docker-entrypoint-initdb.d|stub-volume|" ;;
` + startCase + `esac
exit 0
`
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
return binDir
}
+440
View File
@@ -0,0 +1,440 @@
package migrate
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/spec"
)
// PackageFormat selects how the finished package is laid out on disk.
type PackageFormat string
const (
// FormatDir leaves an unpacked directory, easiest to inspect and to copy
// onto a USB stick that is already mounted.
FormatDir PackageFormat = "dir"
// FormatTar produces a single .tar file, easiest to move around.
FormatTar PackageFormat = "tar"
)
// Packager writes a self-contained migration package: the container specs, the
// data archives, optionally the images, and a shell installer that replays it
// all on a target that has nothing but docker.
type Packager struct {
Src *dkr.Client
Containers []spec.Container
Volumes []spec.Volume
Networks []spec.Network
Plan spec.Plan
// OutputDir is the directory packages are created under.
OutputDir string
// Format selects a directory or a single tar file.
Format PackageFormat
// SourceHost is recorded in the manifest.
SourceHost string
}
// Result describes the produced package.
type Result struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Name string `json:"name"`
}
// Run builds the package, reporting progress into j.
func (p *Packager) Run(ctx context.Context, j *job.Job) (*Result, error) {
opts := p.Plan.Options
name := p.Plan.PackageName
if name == "" {
name = "docker-migration-" + time.Now().Format("20060102-150405")
}
name = sanitize(name)
root := filepath.Join(p.OutputDir, name)
if _, err := os.Stat(root); err == nil {
return nil, fmt.Errorf("package %s already exists in %s", name, p.OutputDir)
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create package directory: %w", err)
}
cleanup := true
defer func() {
if cleanup {
os.RemoveAll(root)
}
}()
byID := map[string]*spec.Container{}
for i := range p.Containers {
byID[p.Containers[i].ID] = &p.Containers[i]
}
var prepared []*Prepared
for _, sel := range p.Plan.Items {
if !sel.Include {
continue
}
pr, err := Prepare(byID[sel.ContainerID], sel, p.Volumes, p.Networks)
if err != nil {
return nil, fmt.Errorf("container %s: %w", sel.ContainerID, err)
}
prepared = append(prepared, pr)
}
if len(prepared) == 0 {
return nil, errors.New("nothing selected to migrate")
}
man := spec.Manifest{
FormatVersion: 1,
CreatedAt: time.Now(),
CreatedBy: "docker-migrate",
SourceHost: p.SourceHost,
Options: opts,
Items: p.Plan.Items,
}
if v, err := p.Src.Ping(ctx); err == nil {
man.DockerVersion = v
}
savedImages := map[string]string{} // image ref -> payload path
for _, pr := range prepared {
item := j.AddItem(pr.Source.ID, pr.Source.Name)
for _, n := range pr.Source.Warnings {
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
}
for _, n := range pr.Notes {
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
}
err := p.packOne(ctx, j, item, root, pr, opts, &man, savedImages)
if err != nil {
j.SetItemState(item, job.StateFailed, err)
return nil, fmt.Errorf("%s: %w", pr.Source.Name, err)
}
j.SetItemState(item, job.StateSucceeded, nil)
man.Containers = append(man.Containers, *pr.Target)
man.Volumes = append(man.Volumes, pr.Volumes...)
for _, n := range pr.Networks {
if !hasNetwork(man.Networks, n.Name) {
man.Networks = append(man.Networks, n)
}
}
}
if err := writeJSON(filepath.Join(root, "manifest.json"), man); err != nil {
return nil, err
}
installer := renderInstaller(prepared, &man, savedImages, name)
if err := os.WriteFile(filepath.Join(root, "install.sh"), []byte(installer), 0o755); err != nil {
return nil, fmt.Errorf("write installer: %w", err)
}
if err := os.WriteFile(filepath.Join(root, "README.txt"), []byte(renderReadme(name, prepared, opts)), 0o644); err != nil {
return nil, fmt.Errorf("write readme: %w", err)
}
j.Logf(job.LevelInfo, "", "wrote installer, manifest and readme")
if p.Format == FormatTar {
tarPath := root + ".tar"
j.Logf(job.LevelInfo, "", "packing %s into a single archive", name)
size, err := tarDirectory(ctx, root, tarPath, name)
if err != nil {
os.Remove(tarPath)
return nil, fmt.Errorf("create package archive: %w", err)
}
os.RemoveAll(root)
cleanup = false
j.SetArtifact(tarPath, size)
return &Result{Path: tarPath, Bytes: size, Name: name + ".tar"}, nil
}
size, _ := dirSize(root)
cleanup = false
j.SetArtifact(root, size)
return &Result{Path: root, Bytes: size, Name: name}, nil
}
func (p *Packager) packOne(
ctx context.Context, j *job.Job, item *job.Item, root string,
pr *Prepared, opts spec.Options, man *spec.Manifest, savedImages map[string]string,
) error {
compress := opts.Compress
level := opts.CompressLevel
// Image.
if pr.Selection.MigrateImage && pr.Selection.ImageMode != spec.ImageSkip && pr.Selection.ImageMode != spec.ImagePull {
ref := pr.Target.Image
if _, done := savedImages[ref]; !done {
size := p.Src.ImageSizeBytes(ctx, ref)
st := j.AddStep(item, "image", "save image "+ref, size)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run: image not written")
} else {
rel := filepath.ToSlash(filepath.Join("images", sanitize(ref)+tarExt(compress)))
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
func() (io.ReadCloser, error) { return p.Src.SaveImage(ctx, ref) })
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("save image %s: %w", ref, err)
}
payload.Kind, payload.Image = "image", ref
man.Payloads = append(man.Payloads, *payload)
savedImages[ref] = rel
}
} else {
st := j.AddStep(item, "image", "image "+ref+" already in package", 0)
j.StartStep(st)
j.SkipStep(st, "shared with another container")
}
} else if pr.Selection.ImageMode == spec.ImagePull {
j.Logf(job.LevelInfo, item.ID, "%s: image %s will be pulled by the installer", pr.Source.Name, pr.Target.Image)
}
// Data. The source container is stopped for the duration when asked.
if len(pr.Transfers) == 0 {
return nil
}
restore, err := p.quiesce(ctx, j, item, pr, opts)
if err != nil {
return err
}
defer func() {
if restore != nil {
restore()
}
}()
for i, t := range pr.Transfers {
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run: data not written")
continue
}
rel := filepath.ToSlash(filepath.Join("data", sanitize(pr.ContainerName()),
fmt.Sprintf("%02d-%s%s", i, sanitize(strings.Trim(t.Destination, "/")), tarExt(compress))))
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
func() (io.ReadCloser, error) { return p.Src.CopyOut(ctx, pr.Source.ID, t.SourcePath) })
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("archive %s: %w", t.Label, err)
}
payload.Kind = "mount"
payload.Container = pr.ContainerName()
payload.Destination = t.Destination
man.Payloads = append(man.Payloads, *payload)
}
return nil
}
// streamToFile copies a stream to a file inside the package, optionally
// gzipping it, while counting bytes and computing a checksum.
func (p *Packager) streamToFile(
ctx context.Context, j *job.Job, st *job.Step,
absPath, relPath string, compress bool, level int,
open func() (io.ReadCloser, error),
) (*spec.Payload, error) {
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return nil, err
}
src, err := open()
if err != nil {
return nil, err
}
defer src.Close()
f, err := os.Create(absPath)
if err != nil {
return nil, err
}
defer f.Close()
hash := sha256.New()
// The checksum covers the bytes as stored, so the installer can verify the
// file it is about to feed to docker.
out := io.MultiWriter(f, hash)
counted := job.NewCountingReader(src, j, st)
var copyErr error
if compress {
gz, gerr := gzip.NewWriterLevel(out, gzipLevel(nil, level))
if gerr != nil {
return nil, gerr
}
_, copyErr = io.Copy(gz, counted)
if cerr := gz.Close(); copyErr == nil {
copyErr = cerr
}
} else {
_, copyErr = io.Copy(out, counted)
}
counted.Flush()
if copyErr != nil {
return nil, copyErr
}
if err := f.Sync(); err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
return nil, err
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return &spec.Payload{
Path: relPath,
Bytes: info.Size(),
SHA256: hex.EncodeToString(hash.Sum(nil)),
Compressed: compress,
}, nil
}
func (p *Packager) quiesce(ctx context.Context, j *job.Job, item *job.Item, pr *Prepared, opts spec.Options) (func(), error) {
if opts.DryRun {
return nil, nil
}
wasRunning := pr.Source.State == "running"
if !pr.Selection.StopSourceDuringCopy {
if wasRunning {
j.AddItemWarning(item,
"archiving %s while it is running; data written during the copy may be inconsistent", pr.Source.Name)
}
return nil, nil
}
if !wasRunning {
return nil, nil
}
st := j.AddStep(item, "quiesce", "stop source "+pr.Source.Name, 0)
j.StartStep(st)
err := p.Src.Stop(ctx, pr.Source.ID, 30*time.Second)
j.FinishStep(st, err)
if err != nil {
return nil, fmt.Errorf("stop source container: %w", err)
}
return func() {
// Building a package does not move the workload anywhere, so the source
// is always put back the way it was found.
if err := p.Src.Start(context.WithoutCancel(ctx), pr.Source.ID); err != nil {
j.AddItemWarning(item, "could not restart source container: %v", err)
} else {
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", pr.Source.Name)
}
}, nil
}
func tarExt(compress bool) string {
if compress {
return ".tar.gz"
}
return ".tar"
}
func writeJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
func hasNetwork(ns []spec.Network, name string) bool {
for _, n := range ns {
if n.Name == name {
return true
}
}
return false
}
// tarDirectory packs a package directory into a single tar file, keeping the
// directory name as the archive's top-level entry.
func tarDirectory(ctx context.Context, dir, dest, prefix string) (int64, error) {
f, err := os.Create(dest)
if err != nil {
return 0, err
}
defer f.Close()
tw := tar.NewWriter(f)
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
name := prefix
if rel != "." {
name = prefix + "/" + filepath.ToSlash(rel)
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = name
if info.IsDir() {
hdr.Name += "/"
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if info.IsDir() {
return nil
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(tw, src)
return err
})
if err != nil {
tw.Close()
return 0, err
}
if err := tw.Close(); err != nil {
return 0, err
}
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
func dirSize(dir string) (int64, error) {
var total int64
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}
+235
View File
@@ -0,0 +1,235 @@
// Package migrate turns a plan into work: either commands executed on a target
// host over SSH, or a self-contained package that can be carried to the target
// on a disk.
package migrate
import (
"fmt"
"path"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// Prepared is one container resolved against the user's selection: the spec as
// it will exist on the target, plus the list of data locations to transfer.
type Prepared struct {
// Source is the container as read from the source host.
Source *spec.Container
// Target is the same container rewritten for the target: renamed mounts,
// relocated binds, dropped mounts and an optional new container name.
Target *spec.Container
// Selection is the user's answer for this container.
Selection spec.ItemSelection
// Transfers are the mounts whose contents must be copied, in target terms.
Transfers []Transfer
// Volumes are the named volumes to create on the target.
Volumes []spec.Volume
// Networks are the user-defined networks to create on the target.
Networks []spec.Network
// Render carries the flags that shape the generated docker create command.
Render spec.RenderOptions
// Notes are advisories to show next to this container.
Notes []string
}
// Transfer is one data location to copy from source to target.
type Transfer struct {
// SourcePath is the path inside the source container to read from.
SourcePath string
// Destination is the path inside the target container the data belongs at.
Destination string
// RestoreInto is the directory the tar archive is extracted into, which is
// the parent of Destination.
RestoreInto string
// Kind describes what is behind the destination on the target.
Kind spec.MountKind
// ReadOnly means the target container mounts this read-only, so the copy
// has to go through a staging container.
ReadOnly bool
// VolumeName is the named volume behind the destination, when known.
VolumeName string
// BindSource is the host path behind the destination, for bind mounts.
BindSource string
// SizeBytes is the best-effort size, or -1.
SizeBytes int64
// Label is a human description used in the progress UI.
Label string
}
// ContainerName returns the name the container will have on the target.
func (p *Prepared) ContainerName() string {
if p.Render.NameOverride != "" {
return p.Render.NameOverride
}
return p.Source.Name
}
// Prepare resolves a plan item against the source inventory.
func Prepare(
src *spec.Container,
sel spec.ItemSelection,
allVolumes []spec.Volume,
allNetworks []spec.Network,
) (*Prepared, error) {
if src == nil {
return nil, fmt.Errorf("container not found in source inventory")
}
p := &Prepared{Source: src, Selection: sel}
target := *src // shallow copy; mounts are rebuilt below
p.Render = spec.RenderOptions{
NameOverride: sel.NameOverride,
KeepStaticIPs: sel.MigrateNetworks && sel.KeepStaticIPs,
SkipNetworks: !sel.MigrateNetworks,
SkipPorts: !sel.MigratePorts,
DropMounts: map[string]bool{},
}
volByName := map[string]spec.Volume{}
for _, v := range allVolumes {
volByName[v.Name] = v
}
netByName := map[string]spec.Network{}
for _, n := range allNetworks {
netByName[n.Name] = n
}
var mounts []spec.Mount
seenVolume := map[string]bool{}
for _, m := range src.Mounts {
ms, ok := sel.Mounts[m.Destination]
if !ok {
// A mount the UI never asked about defaults to being copied, so
// data is never silently left behind.
ms = spec.MountSelection{Action: spec.MountActionCopy}
if m.Kind == spec.MountTmpfs {
ms.Action = spec.MountActionStructure
}
}
if ms.Action == spec.MountActionSkip {
p.Render.DropMounts[m.Destination] = true
p.Notes = append(p.Notes, "mount "+m.Destination+" is not migrated")
continue
}
tm := m
switch m.Kind {
case spec.MountVolume:
if ms.TargetName != "" {
tm.Name = ms.TargetName
}
if v, ok := volByName[m.Name]; ok && !seenVolume[tm.Name] {
v.Name = tm.Name
p.Volumes = append(p.Volumes, v)
seenVolume[tm.Name] = true
}
case spec.MountAnonymous:
// Anonymous volumes are recreated as fresh anonymous volumes on
// the target; their generated name carries no meaning and the data
// is restored through the container path, not the volume name.
p.Render.AnonymousVolumesAsAnonymous = true
case spec.MountBind:
if ms.TargetSource != "" {
tm.Source = ms.TargetSource
}
}
mounts = append(mounts, tm)
if ms.Action != spec.MountActionCopy || !m.HasData() {
continue
}
if isRootPath(m.Destination) {
p.Notes = append(p.Notes, "refusing to copy mount at "+m.Destination+": copying a container root is not supported")
continue
}
t := Transfer{
SourcePath: m.Destination,
Destination: tm.Destination,
RestoreInto: parentDir(tm.Destination),
Kind: tm.Kind,
ReadOnly: tm.ReadOnly,
VolumeName: tm.Name,
BindSource: tm.Source,
SizeBytes: m.SizeBytes,
}
switch tm.Kind {
case spec.MountVolume:
t.Label = "volume " + tm.Name + " -> " + tm.Destination
case spec.MountAnonymous:
t.Label = "anonymous volume -> " + tm.Destination
case spec.MountBind:
t.Label = "bind " + tm.Source + " -> " + tm.Destination
default:
t.Label = string(tm.Kind) + " -> " + tm.Destination
}
p.Transfers = append(p.Transfers, t)
}
target.Mounts = mounts
if sel.MigrateNetworks {
for _, ep := range src.Endpoints {
if n, ok := netByName[ep.Network]; ok {
p.Networks = append(p.Networks, n)
}
}
}
if !sel.MigrateImage {
p.Notes = append(p.Notes, "image is assumed to already exist on the target")
}
if sel.MigrateNetworks && sel.KeepStaticIPs {
p.Notes = append(p.Notes, "static IP addresses are reapplied; they must fit the target subnets")
}
p.Target = &target
return p, nil
}
// StagingMountPath is where a read-only destination is mounted inside the
// temporary staging container used to seed it.
const stagingRoot = "/__docker_migrate"
// StagingPaths returns the mount point and the extraction directory used when
// seeding a read-only mount through a staging container. The volume is mounted
// under a directory named after the destination's last segment so the archive,
// whose entries are rooted at that same segment, lands exactly on top of it.
func StagingPaths(destination string) (mountAt string, extractInto string) {
return path.Join(stagingRoot, path.Base(strings.TrimSuffix(destination, "/"))), stagingRoot
}
// StagingName is the throwaway container name used to seed one read-only mount.
func StagingName(container string, index int) string {
return fmt.Sprintf("dm-stage-%s-%d", sanitize(container), index)
}
func parentDir(p string) string {
d := path.Dir(strings.TrimSuffix(p, "/"))
if d == "" || d == "." {
return "/"
}
return d
}
func isRootPath(p string) bool {
p = strings.TrimSuffix(p, "/")
return p == "" || p == "/"
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if len(out) > 40 {
out = out[:40]
}
return out
}
+144
View File
@@ -0,0 +1,144 @@
package migrate
import (
"strings"
"testing"
"github.com/arescom/docker-migrate/internal/spec"
)
func sample() *spec.Container {
return &spec.Container{
ID: "abc123", Name: "app", State: "running", Image: "app:1.0",
Mounts: []spec.Mount{
{Kind: spec.MountVolume, Name: "appdata", Destination: "/data", SizeBytes: 4096},
{Kind: spec.MountBind, Source: "/srv/app/conf", Destination: "/etc/app", ReadOnly: true},
{Kind: spec.MountAnonymous, Name: strings.Repeat("f", 64), Destination: "/tmp/cache"},
{Kind: spec.MountTmpfs, Destination: "/run"},
},
Endpoints: []spec.Endpoint{{Network: "appnet"}},
}
}
func TestPrepareDefaultsCopyEverything(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel,
[]spec.Volume{{Name: "appdata", Driver: "local"}},
[]spec.Network{{Name: "appnet", Driver: "bridge"}})
if err != nil {
t.Fatal(err)
}
// tmpfs carries no data, so exactly the three real locations transfer.
if len(p.Transfers) != 3 {
t.Fatalf("expected 3 transfers, got %d: %+v", len(p.Transfers), p.Transfers)
}
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata" {
t.Errorf("named volume not scheduled for creation: %+v", p.Volumes)
}
if len(p.Networks) != 1 {
t.Errorf("network not scheduled for creation: %+v", p.Networks)
}
byDest := map[string]Transfer{}
for _, tr := range p.Transfers {
byDest[tr.Destination] = tr
}
if got := byDest["/data"].RestoreInto; got != "/" {
t.Errorf("/data must be restored into /, got %q", got)
}
if got := byDest["/etc/app"].RestoreInto; got != "/etc" {
t.Errorf("/etc/app must be restored into /etc, got %q", got)
}
if !byDest["/etc/app"].ReadOnly {
t.Error("read-only bind must be flagged so it is seeded through a staging container")
}
}
func TestPrepareSkipAndRelocate(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
sel.Mounts["/tmp/cache"] = spec.MountSelection{Action: spec.MountActionSkip}
sel.Mounts["/etc/app"] = spec.MountSelection{Action: spec.MountActionCopy, TargetSource: "/opt/app/conf"}
sel.Mounts["/data"] = spec.MountSelection{Action: spec.MountActionStructure, TargetName: "appdata2"}
p, err := Prepare(c, sel, []spec.Volume{{Name: "appdata", Driver: "local"}}, nil)
if err != nil {
t.Fatal(err)
}
if !p.Render.DropMounts["/tmp/cache"] {
t.Error("skipped mount must be dropped from the create command")
}
// structure-only means the volume is created but no data is copied.
for _, tr := range p.Transfers {
if tr.Destination == "/data" {
t.Error("a structure-only mount must not be transferred")
}
}
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata2" {
t.Errorf("renamed volume not applied: %+v", p.Volumes)
}
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
if !strings.Contains(args, "--volume /opt/app/conf:/etc/app:ro") {
t.Errorf("relocated bind not applied: %s", args)
}
if !strings.Contains(args, "--volume appdata2:/data") {
t.Errorf("renamed volume not applied to create args: %s", args)
}
if strings.Contains(args, "/tmp/cache") {
t.Errorf("skipped mount still present: %s", args)
}
}
func TestPrepareAnonymousVolumeIsRecreatedFresh(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, nil, nil)
if err != nil {
t.Fatal(err)
}
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
if strings.Contains(args, strings.Repeat("f", 64)) {
t.Errorf("the generated volume name must not be pinned on the target: %s", args)
}
if !strings.Contains(args, "--volume /tmp/cache") {
t.Errorf("anonymous volume must still be declared: %s", args)
}
}
func TestStagingPathsLandArchiveOnTheMountPoint(t *testing.T) {
// A tar produced from /var/lib/postgresql/data has entries rooted at
// "data/", so the staging container must mount the volume at
// <root>/data and extract into <root>.
mountAt, into := StagingPaths("/var/lib/postgresql/data")
if mountAt != into+"/data" {
t.Fatalf("mount point %q is not directly under the extraction dir %q", mountAt, into)
}
}
func TestPrepareRefusesRootMount(t *testing.T) {
c := &spec.Container{
ID: "x", Name: "weird", Image: "img",
Mounts: []spec.Mount{{Kind: spec.MountBind, Source: "/", Destination: "/"}},
}
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(p.Transfers) != 0 {
t.Errorf("a mount at / must not be copied: %+v", p.Transfers)
}
if len(p.Notes) == 0 {
t.Error("refusing to copy / should be reported to the operator")
}
}
+732
View File
@@ -0,0 +1,732 @@
package migrate
import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/arescom/docker-migrate/internal/sshx"
)
// SSHRunner migrates containers straight from the local daemon to a target
// host over one SSH connection. Nothing is written to disk on either side:
// tar streams go from the source daemon into a remote `docker cp`.
type SSHRunner struct {
Src *dkr.Client
Dst *sshx.RemoteDocker
Containers []spec.Container
Volumes []spec.Volume
Networks []spec.Network
Plan spec.Plan
}
// Run executes the whole plan, reporting into j.
func (r *SSHRunner) Run(ctx context.Context, j *job.Job) error {
opts := r.Plan.Options
if opts.Parallelism < 1 {
opts.Parallelism = 1
}
pre, err := r.Dst.Preflight(ctx)
if err != nil {
return fmt.Errorf("target preflight: %w", err)
}
for _, p := range pre.Problems {
j.Logf(job.LevelWarn, "", "target: %s", p)
}
if pre.ServerVersion == "" {
return errors.New("target host cannot run docker; see the warnings above")
}
j.Logf(job.LevelInfo, "", "target docker %s (%s/%s), free space on %s: %s",
pre.ServerVersion, pre.OS, pre.Arch, pre.DockerRoot, humanBytes(pre.DiskFreeBytes))
compress := opts.Compress && pre.HasGzip
if opts.Compress && !pre.HasGzip {
j.Logf(job.LevelWarn, "", "gzip missing on target; sending data uncompressed")
}
if opts.DryRun {
j.Logf(job.LevelInfo, "", "dry run: no command below is executed on the target")
}
byID := map[string]*spec.Container{}
for i := range r.Containers {
byID[r.Containers[i].ID] = &r.Containers[i]
}
// Prepare everything up front so a bad selection fails before any change.
var prepared []*Prepared
for _, sel := range r.Plan.Items {
if !sel.Include {
continue
}
p, err := Prepare(byID[sel.ContainerID], sel, r.Volumes, r.Networks)
if err != nil {
return fmt.Errorf("container %s: %w", sel.ContainerID, err)
}
prepared = append(prepared, p)
}
if len(prepared) == 0 {
return errors.New("nothing selected to migrate")
}
// Networks are shared between containers, so they are created once, before
// the per-container work fans out.
if err := r.ensureNetworks(ctx, j, prepared, opts); err != nil {
return err
}
sem := make(chan struct{}, opts.Parallelism)
var wg sync.WaitGroup
var mu sync.Mutex
var failures int
for _, p := range prepared {
p := p
item := j.AddItem(p.Source.ID, p.Source.Name)
for _, n := range p.Source.Warnings {
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
}
for _, n := range p.Notes {
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
}
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
j.SetItemState(item, job.StateCanceled, nil)
return
}
defer func() { <-sem }()
err := r.migrateOne(ctx, j, item, p, opts, compress)
switch {
case err == nil:
j.SetItemState(item, job.StateSucceeded, nil)
case errors.Is(err, errSkipped):
j.SetItemState(item, job.StateSkipped, err)
case errors.Is(err, context.Canceled):
j.SetItemState(item, job.StateCanceled, err)
default:
j.SetItemState(item, job.StateFailed, err)
j.Logf(job.LevelError, item.ID, "%s: %v", p.Source.Name, err)
mu.Lock()
failures++
mu.Unlock()
}
}()
}
wg.Wait()
if ctx.Err() != nil {
return context.Canceled
}
if failures > 0 {
return fmt.Errorf("%d of %d containers failed to migrate", failures, len(prepared))
}
return nil
}
var errSkipped = errors.New("skipped")
func (r *SSHRunner) migrateOne(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
name := p.ContainerName()
// 1. Name conflict on the target.
stepConflict := j.AddStep(item, "conflict", "check target for an existing "+name, 0)
j.StartStep(stepConflict)
newName, action, err := r.resolveConflict(ctx, j, name, opts)
if err != nil {
j.FinishStep(stepConflict, err)
return err
}
if action == "skip" {
j.SkipStep(stepConflict, "container already exists on target")
return fmt.Errorf("%w: %s already exists on the target", errSkipped, name)
}
if newName != name {
j.Logf(job.LevelWarn, item.ID, "%s already exists on target; creating %s instead", name, newName)
p.Render.NameOverride = newName
name = newName
}
j.FinishStep(stepConflict, nil)
// 2. Image.
if err := r.ensureImage(ctx, j, item, p, opts, compress); err != nil {
return err
}
// 3. Named volumes.
if err := r.ensureVolumes(ctx, j, item, p, opts); err != nil {
return err
}
// 4. Create the container, stopped. Creating it before the data copy is
// what makes volumes and bind directories exist with the right identity.
stepCreate := j.AddStep(item, "create", "create container "+name, 0)
j.StartStep(stepCreate)
createArgs := p.Target.CreateArgs(p.Render)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(createArgs...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, createArgs...); err != nil {
j.FinishStep(stepCreate, err)
return fmt.Errorf("create container on target: %w", err)
}
}
j.FinishStep(stepCreate, nil)
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
st := j.AddStep(item, "netconnect", "attach "+args[len(args)-2], 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, args...); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("attach extra network: %w", err)
}
}
j.FinishStep(st, nil)
}
// 5. Data. The source container is stopped first when asked, so the files
// are not changing underneath the copy.
if len(p.Transfers) > 0 {
restore, err := r.quiesceSource(ctx, j, item, p, opts)
if err != nil {
return err
}
copyErr := r.transferAll(ctx, j, item, p, opts, compress, name)
if restore != nil {
restore()
}
if copyErr != nil {
return copyErr
}
} else if p.Selection.StopSourceAfter && !opts.DryRun {
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
j.AddItemWarning(item, "could not stop source container: %v", err)
}
}
// 6. Start.
if p.Selection.StartAfter {
st := j.AddStep(item, "start", "start "+name+" on target", 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("start", name))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, "start", name); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("start container on target: %w", err)
}
}
j.FinishStep(st, nil)
}
// 7. Verify.
if opts.VerifyAfter && !opts.DryRun {
st := j.AddStep(item, "verify", "verify "+name+" on target", 0)
j.StartStep(st)
err := r.verify(ctx, j, item, p, name)
j.FinishStep(st, err)
if err != nil {
return err
}
}
return nil
}
// quiesceSource stops the source container when the selection asks for a
// consistent copy, and returns the function that puts it back the way the
// operator wants it afterwards.
func (r *SSHRunner) quiesceSource(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) (func(), error) {
if opts.DryRun {
return nil, nil
}
wasRunning := p.Source.State == "running"
if !p.Selection.StopSourceDuringCopy {
if wasRunning {
j.AddItemWarning(item,
"copying %s while it is running; data written during the copy may be inconsistent", p.Source.Name)
}
return func() {
if p.Selection.StopSourceAfter && wasRunning {
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
j.AddItemWarning(item, "could not stop source container: %v", err)
}
}
}, nil
}
if wasRunning {
st := j.AddStep(item, "quiesce", "stop source "+p.Source.Name, 0)
j.StartStep(st)
err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second)
j.FinishStep(st, err)
if err != nil {
return nil, fmt.Errorf("stop source container: %w", err)
}
}
return func() {
if !wasRunning || p.Selection.StopSourceAfter {
return
}
if err := r.Src.Start(context.WithoutCancel(ctx), p.Source.ID); err != nil {
j.AddItemWarning(item, "could not restart source container: %v", err)
} else {
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", p.Source.Name)
}
}, nil
}
func (r *SSHRunner) transferAll(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool, targetName string) error {
// Ask the target what it actually created, so read-only mounts can be
// seeded through a staging container that mounts the same volume writable.
var resolved map[string]targetMount
if !opts.DryRun && anyReadOnlyTransfer(p.Transfers) {
var err error
resolved, err = r.inspectTargetMounts(ctx, targetName)
if err != nil {
return fmt.Errorf("inspect target mounts: %w", err)
}
}
for i, t := range p.Transfers {
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
j.StartStep(st)
if opts.DryRun {
j.Logf(job.LevelCmd, item.ID, "target: %s (fed with a tar stream of %s from %s)",
r.Dst.Cmd("cp", "-a", "-", targetName+":"+t.RestoreInto), t.SourcePath, p.Source.Name)
j.SkipStep(st, "dry run")
continue
}
err := r.transferOne(ctx, j, item, st, p, t, i, opts, compress, targetName, resolved)
j.FinishStep(st, err)
if err != nil {
return err
}
}
return nil
}
func (r *SSHRunner) transferOne(
ctx context.Context, j *job.Job, item *job.Item, st *job.Step, p *Prepared,
t Transfer, index int, opts spec.Options, compress bool, targetName string, resolved map[string]targetMount,
) error {
// Pick where the archive is extracted. A writable mount is filled through
// the real container; a read-only one goes through a staging container that
// mounts the same volume or host path writable.
cpTarget := targetName
extractInto := t.RestoreInto
var cleanup func()
if t.ReadOnly {
tm, ok := resolved[t.Destination]
if !ok {
return fmt.Errorf("target does not report a mount at %s", t.Destination)
}
stageName := StagingName(targetName, index)
mountAt, into := StagingPaths(t.Destination)
var source string
switch {
case tm.Name != "":
source = tm.Name
case tm.Source != "":
source = tm.Source
default:
return fmt.Errorf("cannot resolve the storage behind read-only mount %s", t.Destination)
}
_, _ = r.Dst.Run(ctx, "rm", "-f", stageName)
args := []string{"create", "--name", stageName, "--volume", source + ":" + mountAt, p.Target.Image}
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if _, err := r.Dst.Run(ctx, args...); err != nil {
return fmt.Errorf("create staging container for read-only mount %s: %w", t.Destination, err)
}
cleanup = func() { _, _ = r.Dst.Run(context.WithoutCancel(ctx), "rm", "-f", stageName) }
cpTarget, extractInto = stageName, into
j.Logf(job.LevelInfo, item.ID, "seeding read-only mount %s through staging container %s", t.Destination, stageName)
}
if cleanup != nil {
defer cleanup()
}
src, err := r.Src.CopyOut(ctx, p.Source.ID, t.SourcePath)
if err != nil {
return err
}
defer src.Close()
counted := job.NewCountingReader(src, j, st)
body := io.Reader(counted)
if compress {
pr, pw := io.Pipe()
go func() {
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
if gerr != nil {
pw.CloseWithError(gerr)
return
}
_, cerr := io.Copy(gz, counted)
if closeErr := gz.Close(); cerr == nil {
cerr = closeErr
}
pw.CloseWithError(cerr)
}()
body = pr
}
cpArgs := []string{"cp", "-a", "-", cpTarget + ":" + extractInto}
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(cpArgs...))
if err := r.Dst.Feed(ctx, body, compress, cpArgs...); err != nil {
return fmt.Errorf("copy %s: %w", t.Label, err)
}
counted.Flush()
return nil
}
type targetMount struct {
Kind string
Name string
Source string
}
// inspectTargetMounts reads back the mounts the target actually created,
// which is the only way to learn the generated name of an anonymous volume.
func (r *SSHRunner) inspectTargetMounts(ctx context.Context, name string) (map[string]targetMount, error) {
const format = `{{range .Mounts}}{{.Type}}` + "\t" + `{{.Name}}` + "\t" + `{{.Source}}` + "\t" + `{{.Destination}}{{"\n"}}{{end}}`
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
if err != nil {
return nil, err
}
res := map[string]targetMount{}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(strings.TrimRight(line, "\r"), "\t")
if len(f) != 4 {
continue
}
res[f[3]] = targetMount{Kind: f[0], Name: f[1], Source: f[2]}
}
return res, nil
}
func (r *SSHRunner) ensureImage(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
ref := p.Target.Image
sel := p.Selection
if !sel.MigrateImage || sel.ImageMode == spec.ImageSkip {
st := j.AddStep(item, "image", "check image "+ref+" on target", 0)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
ok, err := r.Dst.Exists(ctx, "image", ref)
j.FinishStep(st, err)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("image %s is not on the target and image migration is disabled", ref)
}
return nil
}
mode := sel.ImageMode
if mode == "" {
mode = spec.ImageAuto
}
if mode == spec.ImageAuto && !opts.DryRun {
if ok, err := r.Dst.Exists(ctx, "image", ref); err == nil && ok {
st := j.AddStep(item, "image", "image "+ref+" already on target", 0)
j.StartStep(st)
j.SkipStep(st, "already present")
return nil
}
}
tryPull := mode == spec.ImagePull ||
(mode == spec.ImageAuto && looksPullable(ref) && len(r.Src.ImageRepoDigests(ctx, ref)) > 0)
if tryPull {
st := j.AddStep(item, "image", "pull "+ref+" on target", 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("pull", ref))
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
_, err := r.Dst.Run(ctx, "pull", ref)
if err == nil {
j.FinishStep(st, nil)
return nil
}
if mode == spec.ImagePull {
j.FinishStep(st, err)
return fmt.Errorf("pull image on target: %w", err)
}
j.SkipStep(st, "pull failed, falling back to streaming the image")
j.Logf(job.LevelWarn, item.ID, "pull of %s failed on target (%v); streaming layers instead", ref, err)
}
size := r.Src.ImageSizeBytes(ctx, ref)
st := j.AddStep(item, "image", "stream image "+ref, size)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("load"))
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
src, err := r.Src.SaveImage(ctx, ref)
if err != nil {
j.FinishStep(st, err)
return err
}
defer src.Close()
counted := job.NewCountingReader(src, j, st)
body := io.Reader(counted)
if compress {
pr, pw := io.Pipe()
go func() {
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
if gerr != nil {
pw.CloseWithError(gerr)
return
}
_, cerr := io.Copy(gz, counted)
if closeErr := gz.Close(); cerr == nil {
cerr = closeErr
}
pw.CloseWithError(cerr)
}()
body = pr
}
err = r.Dst.Feed(ctx, body, compress, "load")
counted.Flush()
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("load image on target: %w", err)
}
return nil
}
func (r *SSHRunner) ensureVolumes(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) error {
for _, v := range p.Volumes {
st := j.AddStep(item, "volume-"+v.Name, "ensure volume "+v.Name, 0)
j.StartStep(st)
if !opts.DryRun {
exists, err := r.Dst.Exists(ctx, "volume", v.Name)
if err != nil {
j.FinishStep(st, err)
return err
}
if exists {
switch opts.Conflict {
case spec.ConflictReplace:
j.Logf(job.LevelWarn, item.ID, "removing existing volume %s on target", v.Name)
if _, err := r.Dst.Run(ctx, "volume", "rm", "-f", v.Name); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("remove existing volume %s: %w", v.Name, err)
}
default:
// Reusing an existing volume is the safe default: the copy
// below writes into it without destroying anything else.
j.SkipStep(st, "volume already exists on target and is reused")
j.AddItemWarning(item, "volume %s already exists on the target; its current contents will be merged with the copied data", v.Name)
continue
}
}
}
args := v.CreateArgs()
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, args...); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("create volume %s: %w", v.Name, err)
}
}
j.FinishStep(st, nil)
}
return nil
}
func (r *SSHRunner) ensureNetworks(ctx context.Context, j *job.Job, prepared []*Prepared, opts spec.Options) error {
seen := map[string]bool{}
for _, p := range prepared {
for _, n := range p.Networks {
if seen[n.Name] || isBuiltin(n.Name) {
continue
}
seen[n.Name] = true
if !opts.DryRun {
exists, err := r.Dst.Exists(ctx, "network", n.Name)
if err != nil {
return err
}
if exists {
j.Logf(job.LevelInfo, "", "network %s already exists on target; reusing it", n.Name)
continue
}
}
args := n.CreateArgs()
j.Logf(job.LevelCmd, "", "target: %s", r.Dst.Cmd(args...))
if opts.DryRun {
continue
}
if _, err := r.Dst.Run(ctx, args...); err != nil {
return fmt.Errorf("create network %s: %w", n.Name, err)
}
j.Logf(job.LevelInfo, "", "created network %s on target", n.Name)
}
}
return nil
}
// resolveConflict decides what to do about an existing container on the target
// and returns the name to use.
func (r *SSHRunner) resolveConflict(ctx context.Context, j *job.Job, name string, opts spec.Options) (string, string, error) {
exists, err := r.Dst.Exists(ctx, "container", name)
if err != nil {
return "", "", err
}
if !exists {
return name, "create", nil
}
switch opts.Conflict {
case spec.ConflictSkip:
return name, "skip", nil
case spec.ConflictReplace:
j.Logf(job.LevelWarn, "", "removing existing container %s on target", name)
if opts.DryRun {
return name, "create", nil
}
if _, err := r.Dst.Run(ctx, "rm", "-f", name); err != nil {
return "", "", fmt.Errorf("remove existing container %s: %w", name, err)
}
return name, "create", nil
case spec.ConflictRename:
suffix := opts.RenameSuffix
if suffix == "" {
suffix = "-migrated"
}
candidate := name + suffix
for i := 2; ; i++ {
ok, err := r.Dst.Exists(ctx, "container", candidate)
if err != nil {
return "", "", err
}
if !ok {
return candidate, "create", nil
}
candidate = fmt.Sprintf("%s%s-%d", name, suffix, i)
if i > 50 {
return "", "", fmt.Errorf("could not find a free name based on %s", name)
}
}
default:
return "", "", fmt.Errorf("container %s already exists on the target", name)
}
}
func (r *SSHRunner) verify(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, name string) error {
const format = `{{.State.Status}}` + "\t" + `{{.Config.Image}}` + "\t" + `{{len .Mounts}}`
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
if err != nil {
return fmt.Errorf("container %s is not inspectable on the target: %w", name, err)
}
f := strings.Split(strings.TrimSpace(out), "\t")
if len(f) != 3 {
return fmt.Errorf("unexpected inspect output for %s", name)
}
status, image, mountCount := f[0], f[1], f[2]
wantMounts := 0
for _, m := range p.Target.Mounts {
if !p.Render.DropMounts[m.Destination] {
wantMounts++
}
}
if fmt.Sprint(wantMounts) != mountCount {
j.AddItemWarning(item, "target container %s reports %s mounts, expected %d", name, mountCount, wantMounts)
}
if image != p.Target.Image {
j.AddItemWarning(item, "target container %s runs image %s, expected %s", name, image, p.Target.Image)
}
if p.Selection.StartAfter && status != "running" {
logs, _ := r.Dst.Run(ctx, "logs", "--tail", "20", name)
if s := strings.TrimSpace(logs); s != "" {
j.Logf(job.LevelError, item.ID, "last logs from %s:\n%s", name, s)
}
return fmt.Errorf("container %s did not stay running on the target (status %s)", name, status)
}
j.Logf(job.LevelInfo, item.ID, "verified %s on target: status=%s image=%s mounts=%s", name, status, image, mountCount)
return nil
}
func anyReadOnlyTransfer(ts []Transfer) bool {
for _, t := range ts {
if t.ReadOnly {
return true
}
}
return false
}
func gzipLevel(_ *Prepared, level int) int {
if level < gzip.BestSpeed || level > gzip.BestCompression {
return gzip.BestSpeed
}
return level
}
// looksPullable reports whether a reference is a name a registry could serve,
// as opposed to a bare image id or a locally built, never-pushed tag.
func looksPullable(ref string) bool {
if ref == "" || strings.HasPrefix(ref, "sha256:") {
return false
}
if len(ref) == 64 && !strings.ContainsAny(ref, ":/.-_") {
return false
}
return true
}
func isBuiltin(name string) bool {
switch name {
case "bridge", "host", "none":
return true
}
return false
}
func humanBytes(n int64) string {
if n <= 0 {
return "unknown"
}
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}