Files
DockMV/internal/migrate/prepare.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

236 lines
7.1 KiB
Go

// 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/dockmv/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 = "/__dockmv"
// 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
}