Files
DockMV/internal/migrate/installer.go
T
kawaandClaude Sonnet 5 34e18987a0 Rename product to dockmv (container, image, module, env vars)
Container/image/service name, Go module path, CLI binary name, and
DOCKER_MIGRATE_* env vars still used the old working name; the project
is branded DockMV everywhere else (README, logo, Gitea repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:56:26 +02:00

581 lines
19 KiB
Go

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/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="/__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)
}