package dkr import ( "context" "fmt" "regexp" "sort" "strings" "github.com/arescom/dockmv/internal/spec" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" imagetypes "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" networktypes "github.com/docker/docker/api/types/network" volumetypes "github.com/docker/docker/api/types/volume" ) // Inventory is everything the UI needs to render the source host. type Inventory struct { Host string `json:"host"` DockerVersion string `json:"dockerVersion"` Containers []spec.Container `json:"containers"` Volumes []spec.Volume `json:"volumes"` Networks []spec.Network `json:"networks"` Warnings []string `json:"warnings,omitempty"` } // anonymousVolume matches the 64-hex names Docker generates for volumes that // were never explicitly named. var anonymousVolume = regexp.MustCompile(`^[0-9a-f]{64}$`) // Inventory reads every container on the daemon, plus the volumes and networks // they reference, and normalizes them into the transport spec. func (c *Client) Inventory(ctx context.Context) (*Inventory, error) { version, err := c.Ping(ctx) if err != nil { return nil, fmt.Errorf("connect to docker: %w", err) } info, err := c.api.Info(ctx) if err != nil { return nil, fmt.Errorf("read docker info: %w", err) } summaries, err := c.api.ContainerList(ctx, container.ListOptions{All: true}) if err != nil { return nil, fmt.Errorf("list containers: %w", err) } inv := &Inventory{Host: info.Name, DockerVersion: version} imgCache := map[string]*imagetypes.InspectResponse{} volNames := map[string]bool{} netNames := map[string]bool{} for _, s := range summaries { cs, err := c.inspectContainer(ctx, s.ID, imgCache) if err != nil { inv.Warnings = append(inv.Warnings, fmt.Sprintf("skipped container %s: %v", short(s.ID), err)) continue } for _, m := range cs.Mounts { if m.Kind == spec.MountVolume && m.Name != "" { volNames[m.Name] = true } } for _, e := range cs.Endpoints { netNames[e.Network] = true } inv.Containers = append(inv.Containers, *cs) } sort.Slice(inv.Containers, func(i, j int) bool { a, b := inv.Containers[i], inv.Containers[j] if a.ComposeProject != b.ComposeProject { return a.ComposeProject < b.ComposeProject } return a.Name < b.Name }) for name := range volNames { v, err := c.api.VolumeInspect(ctx, name) if err != nil { inv.Warnings = append(inv.Warnings, fmt.Sprintf("volume %s: %v", name, err)) continue } inv.Volumes = append(inv.Volumes, convertVolume(v)) } sort.Slice(inv.Volumes, func(i, j int) bool { return inv.Volumes[i].Name < inv.Volumes[j].Name }) for name := range netNames { if isBuiltinNetwork(name) { continue } n, err := c.api.NetworkInspect(ctx, name, networktypes.InspectOptions{}) if err != nil { inv.Warnings = append(inv.Warnings, fmt.Sprintf("network %s: %v", name, err)) continue } inv.Networks = append(inv.Networks, convertNetwork(n)) } sort.Slice(inv.Networks, func(i, j int) bool { return inv.Networks[i].Name < inv.Networks[j].Name }) return inv, nil } // InspectContainer normalizes one container by id or name. func (c *Client) InspectContainer(ctx context.Context, id string) (*spec.Container, error) { return c.inspectContainer(ctx, id, map[string]*imagetypes.InspectResponse{}) } func (c *Client) inspectContainer(ctx context.Context, id string, imgCache map[string]*imagetypes.InspectResponse) (*spec.Container, error) { j, err := c.api.ContainerInspect(ctx, id) if err != nil { return nil, err } if j.Config == nil || j.HostConfig == nil { return nil, fmt.Errorf("incomplete inspect payload") } out := &spec.Container{ ID: j.ID, Name: strings.TrimPrefix(j.Name, "/"), Image: j.Config.Image, ImageID: j.Image, } if out.Image == "" { out.Image = j.Image } if j.State != nil { out.State = j.State.Status } // Image config is used to strip everything the image already provides, so // the recreated container carries only genuine run-time overrides. img := c.imageConfig(ctx, j.Image, imgCache) if img == nil { out.Warnings = append(out.Warnings, "image config unavailable; env, command and labels are reproduced in full") } else if len(img.RepoDigests) > 0 { out.ImageDigest = img.RepoDigests[0] } cfg := j.Config out.Hostname = dropGeneratedHostname(cfg.Hostname, j.ID) out.Domainname = cfg.Domainname out.User = cfg.User out.WorkingDir = cfg.WorkingDir out.Tty = cfg.Tty out.OpenStdin = cfg.OpenStdin out.StopSignal = cfg.StopSignal out.StopTimeout = cfg.StopTimeout var imgEnv, imgCmd, imgEntry []string var imgLabels map[string]string if img != nil && img.Config != nil { imgEnv, imgLabels = img.Config.Env, img.Config.Labels imgCmd, imgEntry = img.Config.Cmd, img.Config.Entrypoint if img.Config.User == cfg.User { out.User = "" } if img.Config.WorkingDir == cfg.WorkingDir { out.WorkingDir = "" } } out.Env = subtractStrings(cfg.Env, imgEnv) out.Labels = subtractLabels(cfg.Labels, imgLabels) out.Cmd = cfg.Cmd out.CmdSet = !equalStrings(cfg.Cmd, imgCmd) out.Entrypoint = cfg.Entrypoint out.EntrypointSet = !equalStrings(cfg.Entrypoint, imgEntry) if p := cfg.Labels["com.docker.compose.project"]; p != "" { out.ComposeProject = p out.ComposeService = cfg.Labels["com.docker.compose.service"] } if cfg.Healthcheck != nil { var imgHC *container.HealthConfig if img != nil && img.Config != nil { imgHC = img.Config.Healthcheck } if !sameHealthcheck(cfg.Healthcheck, imgHC) { out.Healthcheck = &spec.Healthcheck{ Test: cfg.Healthcheck.Test, Interval: int64(cfg.Healthcheck.Interval), Timeout: int64(cfg.Healthcheck.Timeout), StartPeriod: int64(cfg.Healthcheck.StartPeriod), Retries: cfg.Healthcheck.Retries, } } } hc := j.HostConfig out.RestartPolicy = string(hc.RestartPolicy.Name) out.RestartMaxRetries = hc.RestartPolicy.MaximumRetryCount out.AutoRemove = hc.AutoRemove out.Privileged = hc.Privileged out.ReadonlyRootfs = hc.ReadonlyRootfs out.CapAdd = hc.CapAdd out.CapDrop = hc.CapDrop out.SecurityOpt = dropDefaultSecurityOpt(hc.SecurityOpt) out.GroupAdd = hc.GroupAdd out.Sysctls = hc.Sysctls out.Runtime = hc.Runtime out.PidMode = string(hc.PidMode) out.IpcMode = string(hc.IpcMode) out.UtsMode = string(hc.UTSMode) out.UsernsMode = string(hc.UsernsMode) out.CgroupnsMode = string(hc.CgroupnsMode) out.DNS = hc.DNS out.DNSSearch = hc.DNSSearch out.DNSOptions = hc.DNSOptions out.ExtraHosts = hc.ExtraHosts out.NetworkMode = string(hc.NetworkMode) out.PublishAll = hc.PublishAllPorts out.Init = hc.Init out.LogDriver = hc.LogConfig.Type out.LogOptions = hc.LogConfig.Config for _, d := range hc.Devices { out.Devices = append(out.Devices, spec.Device{ PathOnHost: d.PathOnHost, PathInContainer: d.PathInContainer, CgroupPermissions: d.CgroupPermissions, }) } for _, u := range hc.Ulimits { if u == nil { continue } out.Ulimits = append(out.Ulimits, spec.Ulimit{Name: u.Name, Soft: u.Soft, Hard: u.Hard}) } out.Resources = spec.Resources{ Memory: hc.Memory, MemoryReservation: hc.MemoryReservation, MemorySwap: hc.MemorySwap, MemorySwappiness: hc.MemorySwappiness, NanoCPUs: hc.NanoCPUs, CPUShares: hc.CPUShares, CPUPeriod: hc.CPUPeriod, CPUQuota: hc.CPUQuota, CpusetCpus: hc.CpusetCpus, CpusetMems: hc.CpusetMems, PidsLimit: hc.PidsLimit, OomKillDisable: hc.OomKillDisable, OomScoreAdj: hc.OomScoreAdj, ShmSize: hc.ShmSize, } for portProto, bindings := range hc.PortBindings { for _, b := range bindings { out.Ports = append(out.Ports, spec.PortBinding{ ContainerPort: string(portProto), HostIP: b.HostIP, HostPort: b.HostPort, }) } } sort.Slice(out.Ports, func(i, j int) bool { if out.Ports[i].ContainerPort != out.Ports[j].ContainerPort { return out.Ports[i].ContainerPort < out.Ports[j].ContainerPort } return out.Ports[i].HostPort < out.Ports[j].HostPort }) for p := range cfg.ExposedPorts { out.ExposedPorts = append(out.ExposedPorts, string(p)) } sort.Strings(out.ExposedPorts) out.Mounts = convertMounts(j.Mounts, hc.Tmpfs) out.Endpoints = convertEndpoints(j.NetworkSettings, out.NetworkMode, j.ID) if hc.AutoRemove { out.Warnings = append(out.Warnings, "source runs with --rm; the migrated container is created without it so it survives inspection") } if strings.HasPrefix(out.NetworkMode, "container:") { out.Warnings = append(out.Warnings, "shares another container's network namespace; migrate that container too") } for _, m := range out.Mounts { if m.Kind == spec.MountBind && isSensitiveBind(m.Source) { out.Warnings = append(out.Warnings, "binds host path "+m.Source+"; copying it is usually wrong, review before migrating") } } if len(hc.VolumesFrom) > 0 { out.Warnings = append(out.Warnings, "uses --volumes-from ("+strings.Join(hc.VolumesFrom, ", ")+"), which is not reproduced") } return out, nil } func (c *Client) imageConfig(ctx context.Context, id string, cache map[string]*imagetypes.InspectResponse) *imagetypes.InspectResponse { if v, ok := cache[id]; ok { return v } insp, err := c.api.ImageInspect(ctx, id) if err != nil { cache[id] = nil return nil } cache[id] = &insp return &insp } func convertMounts(mounts []container.MountPoint, tmpfs map[string]string) []spec.Mount { out := make([]spec.Mount, 0, len(mounts)+len(tmpfs)) for _, m := range mounts { sm := spec.Mount{ Destination: m.Destination, ReadOnly: !m.RW, Propagation: string(m.Propagation), SizeBytes: -1, } switch m.Type { case "volume": sm.Name = m.Name if anonymousVolume.MatchString(m.Name) { sm.Kind = spec.MountAnonymous } else { sm.Kind = spec.MountVolume } case "bind": sm.Kind = spec.MountBind sm.Source = m.Source case "tmpfs": sm.Kind = spec.MountTmpfs default: // npipe and unknown driver types carry no portable data. sm.Kind = spec.MountKind(m.Type) sm.Source = m.Source } out = append(out, sm) } for dest, opts := range tmpfs { if hasDestination(out, dest) { continue } out = append(out, spec.Mount{Kind: spec.MountTmpfs, Destination: dest, TmpfsOpts: opts, SizeBytes: 0}) } sort.Slice(out, func(i, j int) bool { return out[i].Destination < out[j].Destination }) return out } func convertEndpoints(ns *container.NetworkSettings, networkMode, containerID string) []spec.Endpoint { if ns == nil { return nil } out := make([]spec.Endpoint, 0, len(ns.Networks)) for name, ep := range ns.Networks { if ep == nil { continue } e := spec.Endpoint{ Network: name, Aliases: dropGeneratedAliases(ep.Aliases, containerID), Links: ep.Links, DriverOpts: ep.DriverOpts, } // The MAC address is normally derived by the daemon. Carrying it over // only makes sense alongside the static addressing it belongs to; // otherwise it risks colliding with an address on the target network. if ep.IPAMConfig != nil && (ep.IPAMConfig.IPv4Address != "" || ep.IPAMConfig.IPv6Address != "") { e.MacAddress = ep.MacAddress } if ep.IPAMConfig != nil { e.IPv4Address = ep.IPAMConfig.IPv4Address e.IPv6Address = ep.IPAMConfig.IPv6Address } out = append(out, e) } sort.Slice(out, func(i, j int) bool { // The network named by NetworkMode has to come first: it is the one // `docker create --network` can express. if out[i].Network == networkMode { return true } if out[j].Network == networkMode { return false } return out[i].Network < out[j].Network }) return out } func convertVolume(v volumetypes.Volume) spec.Volume { return spec.Volume{ Name: v.Name, Driver: v.Driver, DriverOpts: v.Options, Labels: v.Labels, } } func convertNetwork(n network.Inspect) spec.Network { out := spec.Network{ Name: n.Name, Driver: n.Driver, Scope: n.Scope, EnableIPv6: n.EnableIPv6, Internal: n.Internal, Attachable: n.Attachable, Ingress: n.Ingress, IPAMDriver: n.IPAM.Driver, Options: n.Options, Labels: n.Labels, } for _, p := range n.IPAM.Config { out.IPAMPools = append(out.IPAMPools, spec.IPAMPool{ Subnet: p.Subnet, IPRange: p.IPRange, Gateway: p.Gateway, AuxAddress: p.AuxAddress, }) } return out } // ImageInspectExists reports whether the daemon holds the given image. func (c *Client) ImageExists(ctx context.Context, ref string) bool { _, err := c.api.ImageInspect(ctx, ref) return err == nil } // ImageRepoDigests returns the registry digests of an image, used to decide // whether the target can simply pull it. func (c *Client) ImageRepoDigests(ctx context.Context, ref string) []string { insp, err := c.api.ImageInspect(ctx, ref) if err != nil { return nil } return insp.RepoDigests } var _ = image.InspectResponse{} func isBuiltinNetwork(name string) bool { switch name { case "bridge", "host", "none": return true } return false } // isSensitiveBind flags host paths that almost never should be copied wholesale // to another machine. func isSensitiveBind(p string) bool { p = strings.TrimSuffix(strings.ReplaceAll(p, `\`, "/"), "/") switch p { case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/", "/etc", "/var/run", "/run": return true } return strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/dev/") } // dropGeneratedHostname removes the hostname Docker derives from the container // id, which must not be pinned on the target. func dropGeneratedHostname(hostname, id string) string { if hostname == "" || strings.HasPrefix(id, hostname) { return "" } return hostname } // dropGeneratedAliases removes the short-container-id alias Docker attaches to // every endpoint by itself. Re-applying it would pin the target container to // the source container's id. func dropGeneratedAliases(aliases []string, containerID string) []string { out := make([]string, 0, len(aliases)) for _, a := range aliases { if len(a) == 12 && strings.HasPrefix(containerID, a) { continue } out = append(out, a) } sort.Strings(out) if len(out) == 0 { return nil } return out } // dropDefaultSecurityOpt removes the label=disable style entries Docker reports // on hosts without SELinux, which would fail to apply elsewhere. func dropDefaultSecurityOpt(opts []string) []string { out := make([]string, 0, len(opts)) for _, o := range opts { if strings.HasPrefix(o, "name=") { continue } out = append(out, o) } if len(out) == 0 { return nil } return out } func hasDestination(ms []spec.Mount, dest string) bool { for _, m := range ms { if m.Destination == dest { return true } } return false } func subtractStrings(all, base []string) []string { if len(base) == 0 { return all } seen := make(map[string]bool, len(base)) for _, b := range base { seen[b] = true } out := make([]string, 0, len(all)) for _, v := range all { if !seen[v] { out = append(out, v) } } if len(out) == 0 { return nil } return out } func subtractLabels(all, base map[string]string) map[string]string { out := map[string]string{} for k, v := range all { if bv, ok := base[k]; ok && bv == v { continue } out[k] = v } if len(out) == 0 { return nil } return out } func equalStrings(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func sameHealthcheck(a, b *container.HealthConfig) bool { if a == nil || b == nil { return a == b } return equalStrings(a.Test, b.Test) && a.Interval == b.Interval && a.Timeout == b.Timeout && a.StartPeriod == b.StartPeriod && a.Retries == b.Retries } func short(id string) string { if len(id) > 12 { return id[:12] } return id }