Files
2026-08-11 09:00:01 +02:00

500 lines
13 KiB
Go

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
}