500 lines
13 KiB
Go
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
|
|
}
|