Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+570
View File
@@ -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
}