// 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 }