Initial push
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user