Initial push
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user