571 lines
16 KiB
Go
571 lines
16 KiB
Go
package dkr
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/arescom/docker-migrate/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
|
|
}
|