package migrate import ( "fmt" "strings" "github.com/arescom/dockmv/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/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="/__dockmv/$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:/__dockmv" || rc=$? else $DOCKER cp -a - "$stage:/__dockmv" < "$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) }