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>
236 lines
7.1 KiB
Go
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
|
|
}
|