package spec import ( "fmt" "regexp" "sort" "strconv" "strings" ) // ShellQuote wraps s so that a POSIX shell passes it through as a single // literal argument. Single quotes inside are escaped the usual way. func ShellQuote(s string) string { if s == "" { return "''" } if safeArg.MatchString(s) { return s } return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } var safeArg = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`) // ShellQuoteAll quotes every argument and joins them with spaces. func ShellQuoteAll(args []string) string { parts := make([]string, len(args)) for i, a := range args { parts[i] = ShellQuote(a) } return strings.Join(parts, " ") } // RenderOptions tunes how a container spec is turned into a create command. type RenderOptions struct { // NameOverride replaces the container name on the target (empty = keep). NameOverride string // KeepStaticIPs re-applies the source IP addresses. Off by default because // the target subnets are often different. KeepStaticIPs bool // SkipNetworks drops all network flags, leaving the container on the // default bridge. Used when the user opts out of network migration. SkipNetworks bool // SkipPorts drops published port flags (useful when the target already // runs something on those ports). SkipPorts bool // DropMounts omits mount flags for destinations listed here, so a // container can be migrated without one of its data locations. DropMounts map[string]bool // AnonymousVolumesAsAnonymous recreates generated-name volumes as fresh // anonymous volumes instead of pinning the source name. AnonymousVolumesAsAnonymous bool } // CreateArgs renders the full `docker create ...` argument list for a // container, excluding the leading "docker". The first attached network is // applied here; any additional networks need NetworkConnectArgs afterwards // because `docker create` accepts only one --network. func (c *Container) CreateArgs(o RenderOptions) []string { name := c.Name if o.NameOverride != "" { name = o.NameOverride } a := []string{"create", "--name", name} add := func(v ...string) { a = append(a, v...) } flag := func(f, v string) { if v != "" { add(f, v) } } flag("--hostname", c.Hostname) flag("--domainname", c.Domainname) flag("--user", c.User) flag("--workdir", c.WorkingDir) for _, e := range c.Env { add("--env", e) } for _, k := range sortedKeys(c.Labels) { if isManagedLabel(k) { continue } add("--label", k+"="+c.Labels[k]) } if c.Tty { add("--tty") } if c.OpenStdin { add("--interactive") } flag("--stop-signal", c.StopSignal) if c.StopTimeout != nil { add("--stop-timeout", strconv.Itoa(*c.StopTimeout)) } if c.Init != nil && *c.Init { add("--init") } if c.RestartPolicy != "" && c.RestartPolicy != "no" { if c.RestartPolicy == "on-failure" && c.RestartMaxRetries > 0 { add("--restart", fmt.Sprintf("on-failure:%d", c.RestartMaxRetries)) } else { add("--restart", c.RestartPolicy) } } // --rm is intentionally never re-applied: an auto-removing container would // vanish before the operator can verify the migration. if c.Privileged { add("--privileged") } if c.ReadonlyRootfs { add("--read-only") } for _, v := range c.CapAdd { add("--cap-add", v) } for _, v := range c.CapDrop { add("--cap-drop", v) } for _, v := range c.SecurityOpt { add("--security-opt", v) } for _, v := range c.GroupAdd { add("--group-add", v) } for _, k := range sortedKeys(c.Sysctls) { add("--sysctl", k+"="+c.Sysctls[k]) } for _, d := range c.Devices { v := d.PathOnHost if d.PathInContainer != "" && d.PathInContainer != d.PathOnHost { v += ":" + d.PathInContainer } if p := d.CgroupPermissions; p != "" && p != "rwm" { if !strings.Contains(v, ":") { v += ":" + d.PathOnHost } v += ":" + p } add("--device", v) } for _, u := range c.Ulimits { add("--ulimit", fmt.Sprintf("%s=%d:%d", u.Name, u.Soft, u.Hard)) } if c.Runtime != "" && c.Runtime != "runc" { add("--runtime", c.Runtime) } flag("--pid", nonDefault(c.PidMode, "")) flag("--ipc", nonDefault(c.IpcMode, "private", "shareable")) flag("--uts", nonDefault(c.UtsMode, "")) flag("--userns", nonDefault(c.UsernsMode, "")) // "private" is what a cgroup v2 host reports by default, and passing it // explicitly breaks on a target whose kernel only has cgroup v1. Only the // deliberate "host" override is worth carrying across. flag("--cgroupns", nonDefault(c.CgroupnsMode, "private")) for _, v := range c.DNS { add("--dns", v) } for _, v := range c.DNSSearch { add("--dns-search", v) } for _, v := range c.DNSOptions { add("--dns-option", v) } for _, v := range c.ExtraHosts { add("--add-host", v) } // Networking. Only the first endpoint can be expressed here. if !o.SkipNetworks { switch { case strings.HasPrefix(c.NetworkMode, "container:"): add("--network", c.NetworkMode) case c.NetworkMode == "host" || c.NetworkMode == "none": add("--network", c.NetworkMode) case len(c.Endpoints) > 0: ep := c.Endpoints[0] add("--network", ep.Network) for _, al := range ep.Aliases { add("--network-alias", al) } if o.KeepStaticIPs { flag("--ip", ep.IPv4Address) flag("--ip6", ep.IPv6Address) } flag("--mac-address", ep.MacAddress) case c.NetworkMode != "" && c.NetworkMode != "default": add("--network", c.NetworkMode) } } if !o.SkipPorts && c.NetworkMode != "host" { for _, p := range c.Ports { add("--publish", p.String()) } if c.PublishAll { add("--publish-all") } } for _, e := range c.ExposedPorts { if !c.isPublished(e) { add("--expose", e) } } for _, m := range c.Mounts { if o.DropMounts[m.Destination] { continue } switch m.Kind { case MountTmpfs: if m.TmpfsOpts != "" { add("--tmpfs", m.Destination+":"+m.TmpfsOpts) } else { add("--tmpfs", m.Destination) } case MountAnonymous: if o.AnonymousVolumesAsAnonymous || m.Name == "" { add("--volume", m.Destination+roSuffix(m)) } else { add("--volume", m.Name+":"+m.Destination+roSuffix(m)) } case MountVolume: add("--volume", m.Name+":"+m.Destination+roSuffix(m)) case MountBind: v := m.Source + ":" + m.Destination + roSuffix(m) if m.Propagation != "" && m.Propagation != "rprivate" { if roSuffix(m) == "" { v += ":" + m.Propagation } else { v += "," + m.Propagation } } add("--volume", v) } } if c.LogDriver != "" && c.LogDriver != "json-file" { add("--log-driver", c.LogDriver) } for _, k := range sortedKeys(c.LogOptions) { add("--log-opt", k+"="+c.LogOptions[k]) } if h := c.Healthcheck; h != nil && len(h.Test) > 0 { switch h.Test[0] { case "NONE": add("--no-healthcheck") case "CMD": add("--health-cmd", ShellQuoteAll(h.Test[1:])) case "CMD-SHELL": if len(h.Test) > 1 { add("--health-cmd", h.Test[1]) } } if h.Interval > 0 { add("--health-interval", durStr(h.Interval)) } if h.Timeout > 0 { add("--health-timeout", durStr(h.Timeout)) } if h.StartPeriod > 0 { add("--health-start-period", durStr(h.StartPeriod)) } if h.Retries > 0 { add("--health-retries", strconv.Itoa(h.Retries)) } } r := c.Resources if r.Memory > 0 { add("--memory", strconv.FormatInt(r.Memory, 10)) } if r.MemoryReservation > 0 { add("--memory-reservation", strconv.FormatInt(r.MemoryReservation, 10)) } if r.MemorySwap != 0 { add("--memory-swap", strconv.FormatInt(r.MemorySwap, 10)) } if r.MemorySwappiness != nil && *r.MemorySwappiness >= 0 { add("--memory-swappiness", strconv.FormatInt(*r.MemorySwappiness, 10)) } if r.NanoCPUs > 0 { add("--cpus", strconv.FormatFloat(float64(r.NanoCPUs)/1e9, 'f', -1, 64)) } if r.CPUShares > 0 { add("--cpu-shares", strconv.FormatInt(r.CPUShares, 10)) } if r.CPUPeriod > 0 { add("--cpu-period", strconv.FormatInt(r.CPUPeriod, 10)) } if r.CPUQuota > 0 { add("--cpu-quota", strconv.FormatInt(r.CPUQuota, 10)) } flag("--cpuset-cpus", r.CpusetCpus) flag("--cpuset-mems", r.CpusetMems) if r.PidsLimit != nil && *r.PidsLimit > 0 { add("--pids-limit", strconv.FormatInt(*r.PidsLimit, 10)) } if r.OomKillDisable != nil && *r.OomKillDisable { add("--oom-kill-disable") } if r.OomScoreAdj != 0 { add("--oom-score-adj", strconv.Itoa(r.OomScoreAdj)) } if r.ShmSize > 0 && r.ShmSize != 67108864 { add("--shm-size", strconv.FormatInt(r.ShmSize, 10)) } if c.EntrypointSet && len(c.Entrypoint) > 0 { // docker only accepts a single --entrypoint token; extra words are // prepended to the command instead. add("--entrypoint", c.Entrypoint[0]) } add(c.Image) if c.EntrypointSet && len(c.Entrypoint) > 1 { add(c.Entrypoint[1:]...) } if c.CmdSet { add(c.Cmd...) } return a } // NetworkConnectArgs renders `docker network connect ...` for every endpoint // beyond the first, which `docker create` could not express. func (c *Container) NetworkConnectArgs(o RenderOptions) [][]string { if o.SkipNetworks || len(c.Endpoints) < 2 { return nil } name := c.Name if o.NameOverride != "" { name = o.NameOverride } var out [][]string for _, ep := range c.Endpoints[1:] { a := []string{"network", "connect"} for _, al := range ep.Aliases { a = append(a, "--alias", al) } if o.KeepStaticIPs { if ep.IPv4Address != "" { a = append(a, "--ip", ep.IPv4Address) } if ep.IPv6Address != "" { a = append(a, "--ip6", ep.IPv6Address) } } for _, l := range ep.Links { a = append(a, "--link", l) } out = append(out, append(a, ep.Network, name)) } return out } // CreateArgs renders `docker volume create ...` for a named volume. func (v Volume) CreateArgs() []string { a := []string{"volume", "create"} if v.Driver != "" && v.Driver != "local" { a = append(a, "--driver", v.Driver) } for _, k := range sortedKeys(v.DriverOpts) { a = append(a, "--opt", k+"="+v.DriverOpts[k]) } for _, k := range sortedKeys(v.Labels) { if isManagedLabel(k) { continue } a = append(a, "--label", k+"="+v.Labels[k]) } return append(a, v.Name) } // CreateArgs renders `docker network create ...` for a user-defined network. func (n Network) CreateArgs() []string { a := []string{"network", "create"} if n.Driver != "" { a = append(a, "--driver", n.Driver) } if n.EnableIPv6 { a = append(a, "--ipv6") } if n.Internal { a = append(a, "--internal") } if n.Attachable { a = append(a, "--attachable") } if n.IPAMDriver != "" && n.IPAMDriver != "default" { a = append(a, "--ipam-driver", n.IPAMDriver) } for _, p := range n.IPAMPools { if p.Subnet != "" { a = append(a, "--subnet", p.Subnet) } if p.IPRange != "" { a = append(a, "--ip-range", p.IPRange) } if p.Gateway != "" { a = append(a, "--gateway", p.Gateway) } for _, k := range sortedKeys(p.AuxAddress) { a = append(a, "--aux-address", k+"="+p.AuxAddress[k]) } } for _, k := range sortedKeys(n.Options) { a = append(a, "--opt", k+"="+n.Options[k]) } for _, k := range sortedKeys(n.Labels) { if isManagedLabel(k) { continue } a = append(a, "--label", k+"="+n.Labels[k]) } return append(a, n.Name) } // String renders a port binding in `docker publish` syntax. func (p PortBinding) String() string { port := p.ContainerPort proto := "tcp" if i := strings.LastIndex(port, "/"); i >= 0 { proto, port = port[i+1:], port[:i] } var b strings.Builder if p.HostIP != "" && p.HostIP != "0.0.0.0" { b.WriteString(p.HostIP + ":") } b.WriteString(p.HostPort + ":" + port) if proto != "tcp" { b.WriteString("/" + proto) } return b.String() } func (c *Container) isPublished(exposed string) bool { for _, p := range c.Ports { if p.ContainerPort == exposed { return true } } return false } func roSuffix(m Mount) string { if m.ReadOnly { return ":ro" } return "" } // nonDefault returns v unless it is one of the values Docker would have chosen // anyway, in which case emitting a flag adds noise without changing behaviour. func nonDefault(v string, defaults ...string) string { if v == "" || v == "default" { return "" } for _, d := range defaults { if v == d { return "" } } return v } // isManagedLabel filters out labels Docker or compose maintain themselves; // re-applying them would make the target look like it belongs to a compose // project that is not actually there. func isManagedLabel(k string) bool { return strings.HasPrefix(k, "com.docker.compose.") || strings.HasPrefix(k, "com.docker.swarm.") || strings.HasPrefix(k, "desktop.docker.io/") } func durStr(ns int64) string { if ns%1e9 == 0 { return strconv.FormatInt(ns/1e9, 10) + "s" } return strconv.FormatInt(ns/1e6, 10) + "ms" } func sortedKeys(m map[string]string) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out }