Initial push
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package spec
|
||||
|
||||
import "time"
|
||||
|
||||
// ImageMode decides how the container image reaches the target host.
|
||||
type ImageMode string
|
||||
|
||||
const (
|
||||
// ImageAuto pulls from a registry when the reference looks pullable and
|
||||
// falls back to streaming the image layers otherwise.
|
||||
ImageAuto ImageMode = "auto"
|
||||
// ImagePull always runs `docker pull` on the target.
|
||||
ImagePull ImageMode = "pull"
|
||||
// ImageStream always transfers `docker save` output.
|
||||
ImageStream ImageMode = "stream"
|
||||
// ImageSkip assumes the image is already present on the target.
|
||||
ImageSkip ImageMode = "skip"
|
||||
)
|
||||
|
||||
// ConflictPolicy decides what to do when the target already has a container,
|
||||
// volume or network with the same name.
|
||||
type ConflictPolicy string
|
||||
|
||||
const (
|
||||
ConflictFail ConflictPolicy = "fail" // abort the item
|
||||
ConflictSkip ConflictPolicy = "skip" // leave the target object untouched
|
||||
ConflictReplace ConflictPolicy = "replace" // remove the target object first
|
||||
ConflictRename ConflictPolicy = "rename" // create alongside with a suffix
|
||||
)
|
||||
|
||||
// ItemSelection is the per-container answer to "what do you want to migrate?".
|
||||
// Every data location is opted in or out individually.
|
||||
type ItemSelection struct {
|
||||
ContainerID string `json:"containerId"`
|
||||
|
||||
// Include is the master switch for this container.
|
||||
Include bool `json:"include"`
|
||||
|
||||
// NameOverride renames the container on the target.
|
||||
NameOverride string `json:"nameOverride,omitempty"`
|
||||
|
||||
// MigrateImage brings the image across; when false the container is
|
||||
// created assuming the image already exists on the target.
|
||||
MigrateImage bool `json:"migrateImage"`
|
||||
ImageMode ImageMode `json:"imageMode"`
|
||||
|
||||
// MigrateNetworks recreates user-defined networks and reattaches them.
|
||||
MigrateNetworks bool `json:"migrateNetworks"`
|
||||
KeepStaticIPs bool `json:"keepStaticIps"`
|
||||
MigratePorts bool `json:"migratePorts"`
|
||||
|
||||
// Mounts maps a container-side destination path to how it is handled.
|
||||
Mounts map[string]MountSelection `json:"mounts"`
|
||||
|
||||
// StartAfter starts the container on the target once restored.
|
||||
StartAfter bool `json:"startAfter"`
|
||||
// StopSourceDuringCopy stops the source container for the duration of the
|
||||
// data copy so the files are consistent, then restores its former state.
|
||||
StopSourceDuringCopy bool `json:"stopSourceDuringCopy"`
|
||||
// StopSourceAfter leaves the source container stopped once the migration
|
||||
// succeeded, so the two hosts do not both serve the same workload.
|
||||
StopSourceAfter bool `json:"stopSourceAfter"`
|
||||
}
|
||||
|
||||
// MountAction is what to do with one data location.
|
||||
type MountAction string
|
||||
|
||||
const (
|
||||
// MountActionCopy recreates the mount and copies its contents.
|
||||
MountActionCopy MountAction = "copy"
|
||||
// MountActionStructure recreates the mount (volume or host directory) but
|
||||
// leaves it empty.
|
||||
MountActionStructure MountAction = "structure"
|
||||
// MountActionSkip drops the mount from the target container entirely.
|
||||
MountActionSkip MountAction = "skip"
|
||||
)
|
||||
|
||||
// MountSelection is the per-mount answer, including an optional relocation of
|
||||
// a bind mount to a different path on the target host.
|
||||
type MountSelection struct {
|
||||
Action MountAction `json:"action"`
|
||||
// TargetSource relocates a bind mount on the target host. Empty keeps the
|
||||
// source path. Ignored for volumes.
|
||||
TargetSource string `json:"targetSource,omitempty"`
|
||||
// TargetName renames a named volume on the target. Empty keeps the name.
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
}
|
||||
|
||||
// Options are the settings shared by every item in one migration run.
|
||||
type Options struct {
|
||||
Conflict ConflictPolicy `json:"conflict"`
|
||||
RenameSuffix string `json:"renameSuffix,omitempty"` // used by ConflictRename, default "-migrated"
|
||||
|
||||
// Compress gzips data and image streams. Requires gzip on the target for
|
||||
// SSH mode; always safe for package mode.
|
||||
Compress bool `json:"compress"`
|
||||
// CompressLevel is 1..9, defaulting to 1 (fast) because these transfers
|
||||
// are usually bound by disk and network, not CPU.
|
||||
CompressLevel int `json:"compressLevel"`
|
||||
|
||||
// DryRun performs every check and prints every command without changing
|
||||
// anything on the target.
|
||||
DryRun bool `json:"dryRun"`
|
||||
|
||||
// Parallelism is how many containers migrate at once.
|
||||
Parallelism int `json:"parallelism"`
|
||||
|
||||
// VerifyAfter re-inspects each container on the target and compares the
|
||||
// resulting spec against the source.
|
||||
VerifyAfter bool `json:"verifyAfter"`
|
||||
}
|
||||
|
||||
// DefaultOptions returns the options used when the UI has not overridden them.
|
||||
func DefaultOptions() Options {
|
||||
return Options{
|
||||
Conflict: ConflictFail,
|
||||
RenameSuffix: "-migrated",
|
||||
Compress: true,
|
||||
CompressLevel: 1,
|
||||
Parallelism: 1,
|
||||
VerifyAfter: true,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSelection builds the "migrate everything" answer for a container,
|
||||
// which is what the UI presents before the user changes anything.
|
||||
func DefaultSelection(c *Container) ItemSelection {
|
||||
sel := ItemSelection{
|
||||
ContainerID: c.ID,
|
||||
Include: false,
|
||||
MigrateImage: true,
|
||||
ImageMode: ImageAuto,
|
||||
MigrateNetworks: true,
|
||||
KeepStaticIPs: false,
|
||||
MigratePorts: true,
|
||||
Mounts: map[string]MountSelection{},
|
||||
StartAfter: c.State == "running",
|
||||
StopSourceDuringCopy: true,
|
||||
StopSourceAfter: true,
|
||||
}
|
||||
for _, m := range c.Mounts {
|
||||
action := MountActionCopy
|
||||
if m.Kind == MountTmpfs {
|
||||
action = MountActionStructure
|
||||
}
|
||||
sel.Mounts[m.Destination] = MountSelection{Action: action}
|
||||
}
|
||||
return sel
|
||||
}
|
||||
|
||||
// Plan is a complete migration request: what to move, where, and how.
|
||||
type Plan struct {
|
||||
Items []ItemSelection `json:"items"`
|
||||
Options Options `json:"options"`
|
||||
// Target is the SSH connection id for host-to-host mode. Empty means the
|
||||
// plan produces an offline package instead.
|
||||
Target string `json:"target,omitempty"`
|
||||
// PackageName is the base name of the produced package (package mode).
|
||||
PackageName string `json:"packageName,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest is written into an offline migration package. It is descriptive:
|
||||
// the generated install.sh is self-contained and does not parse it.
|
||||
type Manifest struct {
|
||||
FormatVersion int `json:"formatVersion"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
SourceHost string `json:"sourceHost"`
|
||||
DockerVersion string `json:"dockerVersion"`
|
||||
|
||||
Containers []Container `json:"containers"`
|
||||
Volumes []Volume `json:"volumes"`
|
||||
Networks []Network `json:"networks"`
|
||||
Items []ItemSelection `json:"items"`
|
||||
Options Options `json:"options"`
|
||||
|
||||
// Payloads lists every data file in the package with its checksum, so the
|
||||
// installer can verify the archive survived the trip.
|
||||
Payloads []Payload `json:"payloads"`
|
||||
}
|
||||
|
||||
// Payload is one file inside a migration package.
|
||||
type Payload struct {
|
||||
Path string `json:"path"` // relative to the package root
|
||||
Kind string `json:"kind"` // "image" | "mount"
|
||||
Container string `json:"container,omitempty"`
|
||||
Destination string `json:"destination,omitempty"` // mount destination it restores
|
||||
Image string `json:"image,omitempty"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Compressed bool `json:"compressed"`
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package spec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShellQuote(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"simple": "simple",
|
||||
"a/b-c_1.2": "a/b-c_1.2",
|
||||
"": "''",
|
||||
"has space": "'has space'",
|
||||
"it's": `'it'\''s'`,
|
||||
"$(rm -rf /)": "'$(rm -rf /)'",
|
||||
"a;b": "'a;b'",
|
||||
"KEY=value": "KEY=value",
|
||||
"tag:1.0@sha256:ab": "tag:1.0@sha256:ab",
|
||||
"back`tick`": "'back`tick`'",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := ShellQuote(in); got != want {
|
||||
t.Errorf("ShellQuote(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateArgsFull checks that a container using most of the surface area of
|
||||
// docker run is rendered back into an equivalent create command.
|
||||
func TestCreateArgsFull(t *testing.T) {
|
||||
stopTimeout := 15
|
||||
initTrue := true
|
||||
c := &Container{
|
||||
Name: "web",
|
||||
Image: "nginx:1.27",
|
||||
Hostname: "web-1",
|
||||
User: "101:101",
|
||||
WorkingDir: "/srv",
|
||||
Env: []string{"TZ=Europe/Paris", "SECRET=a b"},
|
||||
Labels: map[string]string{"team": "infra", "com.docker.compose.project": "shop"},
|
||||
Cmd: []string{"nginx", "-g", "daemon off;"},
|
||||
CmdSet: true,
|
||||
Entrypoint: []string{"/entry.sh", "--flag"},
|
||||
EntrypointSet: true,
|
||||
RestartPolicy: "on-failure",
|
||||
RestartMaxRetries: 3,
|
||||
StopSignal: "SIGQUIT",
|
||||
StopTimeout: &stopTimeout,
|
||||
Init: &initTrue,
|
||||
Privileged: true,
|
||||
CapAdd: []string{"NET_ADMIN"},
|
||||
CapDrop: []string{"MKNOD"},
|
||||
Sysctls: map[string]string{"net.core.somaxconn": "1024"},
|
||||
DNS: []string{"1.1.1.1"},
|
||||
ExtraHosts: []string{"db:10.0.0.5"},
|
||||
NetworkMode: "frontend",
|
||||
Endpoints: []Endpoint{
|
||||
{Network: "frontend", Aliases: []string{"web", "www"}, IPv4Address: "172.20.0.9"},
|
||||
{Network: "backend", Aliases: []string{"web"}},
|
||||
},
|
||||
Ports: []PortBinding{
|
||||
{ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "8080"},
|
||||
{ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"},
|
||||
},
|
||||
ExposedPorts: []string{"80/tcp", "9000/tcp"},
|
||||
Mounts: []Mount{
|
||||
{Kind: MountVolume, Name: "html", Destination: "/usr/share/nginx/html", ReadOnly: true},
|
||||
{Kind: MountBind, Source: "/etc/nginx/conf.d", Destination: "/etc/nginx/conf.d"},
|
||||
{Kind: MountAnonymous, Name: strings.Repeat("a", 64), Destination: "/cache"},
|
||||
{Kind: MountTmpfs, Destination: "/run", TmpfsOpts: "size=64m"},
|
||||
},
|
||||
LogDriver: "json-file",
|
||||
LogOptions: map[string]string{"max-size": "10m"},
|
||||
Resources: Resources{Memory: 536870912, NanoCPUs: 1500000000, ShmSize: 67108864},
|
||||
}
|
||||
|
||||
got := strings.Join(c.CreateArgs(RenderOptions{}), " ")
|
||||
|
||||
mustContain := []string{
|
||||
"create --name web",
|
||||
"--hostname web-1",
|
||||
"--user 101:101",
|
||||
"--env TZ=Europe/Paris",
|
||||
"--label team=infra",
|
||||
"--restart on-failure:3",
|
||||
"--stop-timeout 15",
|
||||
"--init",
|
||||
"--privileged",
|
||||
"--cap-add NET_ADMIN",
|
||||
"--sysctl net.core.somaxconn=1024",
|
||||
"--add-host db:10.0.0.5",
|
||||
"--network frontend",
|
||||
"--network-alias web",
|
||||
"--publish 8080:80",
|
||||
"--publish 127.0.0.1:5353:53/udp",
|
||||
"--expose 9000/tcp",
|
||||
"--volume html:/usr/share/nginx/html:ro",
|
||||
"--volume /etc/nginx/conf.d:/etc/nginx/conf.d",
|
||||
"--tmpfs /run:size=64m",
|
||||
"--log-opt max-size=10m",
|
||||
"--memory 536870912",
|
||||
"--cpus 1.5",
|
||||
"--entrypoint /entry.sh",
|
||||
"nginx:1.27",
|
||||
}
|
||||
for _, want := range mustContain {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("create args missing %q\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Labels docker or compose manage themselves must not be re-applied.
|
||||
if strings.Contains(got, "com.docker.compose.project") {
|
||||
t.Errorf("compose-managed label was re-applied:\n%s", got)
|
||||
}
|
||||
// A port already published must not also be re-exposed.
|
||||
if strings.Contains(got, "--expose 80/tcp") {
|
||||
t.Errorf("published port was also exposed:\n%s", got)
|
||||
}
|
||||
// The default shm size carries no information and should be omitted.
|
||||
if strings.Contains(got, "--shm-size") {
|
||||
t.Errorf("default shm size was emitted:\n%s", got)
|
||||
}
|
||||
// The image must be the last flag-free token before the command.
|
||||
idx := strings.Index(got, "nginx:1.27")
|
||||
if idx < 0 || !strings.Contains(got[idx:], "--flag") {
|
||||
t.Errorf("entrypoint remainder and command must follow the image:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateArgsSecondNetworkNeedsConnect(t *testing.T) {
|
||||
c := &Container{
|
||||
Name: "app", Image: "app:1", NetworkMode: "a",
|
||||
Endpoints: []Endpoint{
|
||||
{Network: "a"},
|
||||
{Network: "b", Aliases: []string{"app-b"}, IPv4Address: "10.1.2.3"},
|
||||
},
|
||||
}
|
||||
args := c.CreateArgs(RenderOptions{})
|
||||
if n := strings.Count(strings.Join(args, " "), "--network "); n != 1 {
|
||||
t.Fatalf("docker create accepts one --network, got %d in %v", n, args)
|
||||
}
|
||||
connects := c.NetworkConnectArgs(RenderOptions{})
|
||||
if len(connects) != 1 {
|
||||
t.Fatalf("expected 1 network connect, got %d", len(connects))
|
||||
}
|
||||
joined := strings.Join(connects[0], " ")
|
||||
if !strings.Contains(joined, "network connect --alias app-b b app") {
|
||||
t.Errorf("unexpected connect args: %s", joined)
|
||||
}
|
||||
if strings.Contains(joined, "--ip ") {
|
||||
t.Errorf("static IP must not be applied unless requested: %s", joined)
|
||||
}
|
||||
|
||||
withIP := strings.Join(c.NetworkConnectArgs(RenderOptions{KeepStaticIPs: true})[0], " ")
|
||||
if !strings.Contains(withIP, "--ip 10.1.2.3") {
|
||||
t.Errorf("static IP was requested but not applied: %s", withIP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOptionsDropAndRename(t *testing.T) {
|
||||
c := &Container{
|
||||
Name: "db", Image: "postgres:16",
|
||||
Ports: []PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}},
|
||||
Mounts: []Mount{{Kind: MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"}},
|
||||
Endpoints: []Endpoint{{Network: "backend"}},
|
||||
}
|
||||
got := strings.Join(c.CreateArgs(RenderOptions{
|
||||
NameOverride: "db-new",
|
||||
SkipPorts: true,
|
||||
SkipNetworks: true,
|
||||
DropMounts: map[string]bool{"/var/lib/postgresql/data": true},
|
||||
}), " ")
|
||||
|
||||
if !strings.Contains(got, "--name db-new") {
|
||||
t.Errorf("name override not applied: %s", got)
|
||||
}
|
||||
for _, unwanted := range []string{"--publish", "--network", "--volume"} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Errorf("expected %s to be dropped: %s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCgroupnsPrivateIsNotCarried guards a cross-host hazard: "private" is
|
||||
// simply what a cgroup v2 host reports, and passing it explicitly makes the
|
||||
// create fail on a target whose kernel only has cgroup v1.
|
||||
func TestCgroupnsPrivateIsNotCarried(t *testing.T) {
|
||||
private := &Container{Name: "a", Image: "img", CgroupnsMode: "private"}
|
||||
if got := strings.Join(private.CreateArgs(RenderOptions{}), " "); strings.Contains(got, "--cgroupns") {
|
||||
t.Errorf("the default cgroup namespace must not be pinned: %s", got)
|
||||
}
|
||||
host := &Container{Name: "a", Image: "img", CgroupnsMode: "host"}
|
||||
if got := strings.Join(host.CreateArgs(RenderOptions{}), " "); !strings.Contains(got, "--cgroupns host") {
|
||||
t.Errorf("an explicit host cgroup namespace must be carried across: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRemoveIsNeverReapplied(t *testing.T) {
|
||||
c := &Container{Name: "job", Image: "busybox", AutoRemove: true}
|
||||
if strings.Contains(strings.Join(c.CreateArgs(RenderOptions{}), " "), "--rm") {
|
||||
t.Error("--rm must not be reapplied; the migrated container would delete itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeAndNetworkCreateArgs(t *testing.T) {
|
||||
v := Volume{
|
||||
Name: "pgdata", Driver: "local",
|
||||
DriverOpts: map[string]string{"type": "nfs", "device": ":/exports/pg"},
|
||||
Labels: map[string]string{"app": "shop", "com.docker.compose.project": "x"},
|
||||
}
|
||||
got := strings.Join(v.CreateArgs(), " ")
|
||||
for _, want := range []string{"volume create", "--opt device=:/exports/pg", "--opt type=nfs", "--label app=shop", "pgdata"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("volume args missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "--driver local") {
|
||||
t.Errorf("the default driver should be omitted: %s", got)
|
||||
}
|
||||
if strings.Contains(got, "compose.project") {
|
||||
t.Errorf("compose label must not be reapplied: %s", got)
|
||||
}
|
||||
|
||||
n := Network{
|
||||
Name: "backend", Driver: "bridge", Internal: true, Attachable: true,
|
||||
IPAMPools: []IPAMPool{{Subnet: "172.28.0.0/16", Gateway: "172.28.0.1"}},
|
||||
Options: map[string]string{"com.docker.network.bridge.name": "br-backend"},
|
||||
}
|
||||
gotNet := strings.Join(n.CreateArgs(), " ")
|
||||
for _, want := range []string{"network create", "--driver bridge", "--internal", "--attachable", "--subnet 172.28.0.0/16", "--gateway 172.28.0.1", "backend"} {
|
||||
if !strings.Contains(gotNet, want) {
|
||||
t.Errorf("network args missing %q: %s", want, gotNet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortBindingString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in PortBinding
|
||||
want string
|
||||
}{
|
||||
{PortBinding{ContainerPort: "80/tcp", HostPort: "8080"}, "8080:80"},
|
||||
{PortBinding{ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "80"}, "80:80"},
|
||||
{PortBinding{ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"}, "127.0.0.1:5353:53/udp"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.in.String(); got != c.want {
|
||||
t.Errorf("PortBinding%+v = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// Package spec defines a normalized, JSON-serializable description of a Docker
|
||||
// container and everything it needs to be recreated on another host.
|
||||
//
|
||||
// The spec is deliberately independent of the Docker SDK types: it is produced
|
||||
// on the source host, travels over SSH or inside an offline migration package,
|
||||
// and is consumed either by the SSH engine or by a generated shell script that
|
||||
// only has the docker CLI available.
|
||||
package spec
|
||||
|
||||
// MountKind classifies a data location attached to a container.
|
||||
type MountKind string
|
||||
|
||||
const (
|
||||
MountVolume MountKind = "volume" // named volume
|
||||
MountAnonymous MountKind = "anonymous" // volume with a generated name
|
||||
MountBind MountKind = "bind" // host directory or file
|
||||
MountTmpfs MountKind = "tmpfs" // in-memory, never carries data
|
||||
)
|
||||
|
||||
// Mount is one data location attached to a container.
|
||||
type Mount struct {
|
||||
Kind MountKind `json:"kind"`
|
||||
Name string `json:"name,omitempty"` // volume name (volume kind only)
|
||||
Source string `json:"source,omitempty"` // host path (bind kind only)
|
||||
Destination string `json:"destination"` // path inside the container
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
Propagation string `json:"propagation,omitempty"`
|
||||
TmpfsOpts string `json:"tmpfsOpts,omitempty"`
|
||||
|
||||
// SizeBytes is a best-effort measurement of the data at this location,
|
||||
// used to show progress and to warn about very large transfers. -1 = unknown.
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
}
|
||||
|
||||
// HasData reports whether this mount is worth copying. tmpfs never is.
|
||||
func (m Mount) HasData() bool { return m.Kind != MountTmpfs }
|
||||
|
||||
// Volume describes a named volume so it can be recreated with the same driver,
|
||||
// options and labels rather than falling back to a plain local volume.
|
||||
type Volume struct {
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
DriverOpts map[string]string `json:"driverOpts,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// IPAMPool is one subnet definition of a user-defined network.
|
||||
type IPAMPool struct {
|
||||
Subnet string `json:"subnet,omitempty"`
|
||||
IPRange string `json:"ipRange,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
AuxAddress map[string]string `json:"auxAddress,omitempty"`
|
||||
}
|
||||
|
||||
// Network describes a user-defined network to recreate on the target.
|
||||
type Network struct {
|
||||
Name string `json:"name"`
|
||||
Driver string `json:"driver"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
EnableIPv6 bool `json:"enableIPv6,omitempty"`
|
||||
Internal bool `json:"internal,omitempty"`
|
||||
Attachable bool `json:"attachable,omitempty"`
|
||||
Ingress bool `json:"ingress,omitempty"`
|
||||
IPAMDriver string `json:"ipamDriver,omitempty"`
|
||||
IPAMPools []IPAMPool `json:"ipamPools,omitempty"`
|
||||
Options map[string]string `json:"options,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
// Endpoint is a container's attachment to one network.
|
||||
type Endpoint struct {
|
||||
Network string `json:"network"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
IPv4Address string `json:"ipv4Address,omitempty"`
|
||||
IPv6Address string `json:"ipv6Address,omitempty"`
|
||||
MacAddress string `json:"macAddress,omitempty"`
|
||||
Links []string `json:"links,omitempty"`
|
||||
DriverOpts map[string]string `json:"driverOpts,omitempty"`
|
||||
}
|
||||
|
||||
// PortBinding maps a container port onto the host.
|
||||
type PortBinding struct {
|
||||
ContainerPort string `json:"containerPort"` // e.g. "80/tcp"
|
||||
HostIP string `json:"hostIp,omitempty"`
|
||||
HostPort string `json:"hostPort,omitempty"`
|
||||
}
|
||||
|
||||
// Healthcheck mirrors the container health configuration when it was
|
||||
// overridden at run time (an image-provided healthcheck is not re-emitted).
|
||||
type Healthcheck struct {
|
||||
Test []string `json:"test,omitempty"`
|
||||
Interval int64 `json:"interval,omitempty"` // nanoseconds
|
||||
Timeout int64 `json:"timeout,omitempty"` // nanoseconds
|
||||
StartPeriod int64 `json:"startPeriod,omitempty"` // nanoseconds
|
||||
Retries int `json:"retries,omitempty"`
|
||||
}
|
||||
|
||||
// Resources holds the cgroup limits applied to the container.
|
||||
type Resources struct {
|
||||
Memory int64 `json:"memory,omitempty"`
|
||||
MemoryReservation int64 `json:"memoryReservation,omitempty"`
|
||||
MemorySwap int64 `json:"memorySwap,omitempty"`
|
||||
MemorySwappiness *int64 `json:"memorySwappiness,omitempty"`
|
||||
NanoCPUs int64 `json:"nanoCpus,omitempty"`
|
||||
CPUShares int64 `json:"cpuShares,omitempty"`
|
||||
CPUPeriod int64 `json:"cpuPeriod,omitempty"`
|
||||
CPUQuota int64 `json:"cpuQuota,omitempty"`
|
||||
CpusetCpus string `json:"cpusetCpus,omitempty"`
|
||||
CpusetMems string `json:"cpusetMems,omitempty"`
|
||||
PidsLimit *int64 `json:"pidsLimit,omitempty"`
|
||||
OomKillDisable *bool `json:"oomKillDisable,omitempty"`
|
||||
OomScoreAdj int `json:"oomScoreAdj,omitempty"`
|
||||
ShmSize int64 `json:"shmSize,omitempty"`
|
||||
}
|
||||
|
||||
// Ulimit is a per-container resource limit.
|
||||
type Ulimit struct {
|
||||
Name string `json:"name"`
|
||||
Soft int64 `json:"soft"`
|
||||
Hard int64 `json:"hard"`
|
||||
}
|
||||
|
||||
// Device is a host device exposed to the container.
|
||||
type Device struct {
|
||||
PathOnHost string `json:"pathOnHost"`
|
||||
PathInContainer string `json:"pathInContainer"`
|
||||
CgroupPermissions string `json:"cgroupPermissions"`
|
||||
}
|
||||
|
||||
// Container is the full normalized description of one container.
|
||||
type Container struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"` // without the leading slash
|
||||
State string `json:"state"` // running, exited, ...
|
||||
|
||||
Image string `json:"image"` // reference as the user wrote it, e.g. nginx:1.27
|
||||
ImageID string `json:"imageId"` // sha256:...
|
||||
ImageDigest string `json:"imageDigest,omitempty"`
|
||||
|
||||
// Compose grouping, taken from the standard compose labels. Empty when the
|
||||
// container was not created by docker compose.
|
||||
ComposeProject string `json:"composeProject,omitempty"`
|
||||
ComposeService string `json:"composeService,omitempty"`
|
||||
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Domainname string `json:"domainname,omitempty"`
|
||||
User string `json:"user,omitempty"`
|
||||
WorkingDir string `json:"workingDir,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Cmd []string `json:"cmd,omitempty"`
|
||||
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||
EntrypointSet bool `json:"entrypointSet,omitempty"` // true when overridden at run time
|
||||
CmdSet bool `json:"cmdSet,omitempty"`
|
||||
Tty bool `json:"tty,omitempty"`
|
||||
OpenStdin bool `json:"openStdin,omitempty"`
|
||||
StopSignal string `json:"stopSignal,omitempty"`
|
||||
StopTimeout *int `json:"stopTimeout,omitempty"`
|
||||
Init *bool `json:"init,omitempty"`
|
||||
|
||||
RestartPolicy string `json:"restartPolicy,omitempty"`
|
||||
RestartMaxRetries int `json:"restartMaxRetries,omitempty"`
|
||||
AutoRemove bool `json:"autoRemove,omitempty"`
|
||||
|
||||
Privileged bool `json:"privileged,omitempty"`
|
||||
ReadonlyRootfs bool `json:"readonlyRootfs,omitempty"`
|
||||
CapAdd []string `json:"capAdd,omitempty"`
|
||||
CapDrop []string `json:"capDrop,omitempty"`
|
||||
SecurityOpt []string `json:"securityOpt,omitempty"`
|
||||
GroupAdd []string `json:"groupAdd,omitempty"`
|
||||
Sysctls map[string]string `json:"sysctls,omitempty"`
|
||||
Devices []Device `json:"devices,omitempty"`
|
||||
Ulimits []Ulimit `json:"ulimits,omitempty"`
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
|
||||
PidMode string `json:"pidMode,omitempty"`
|
||||
IpcMode string `json:"ipcMode,omitempty"`
|
||||
UtsMode string `json:"utsMode,omitempty"`
|
||||
UsernsMode string `json:"usernsMode,omitempty"`
|
||||
CgroupnsMode string `json:"cgroupnsMode,omitempty"`
|
||||
|
||||
DNS []string `json:"dns,omitempty"`
|
||||
DNSSearch []string `json:"dnsSearch,omitempty"`
|
||||
DNSOptions []string `json:"dnsOptions,omitempty"`
|
||||
ExtraHosts []string `json:"extraHosts,omitempty"`
|
||||
|
||||
NetworkMode string `json:"networkMode,omitempty"` // bridge, host, none, <name>, container:<id>
|
||||
Endpoints []Endpoint `json:"endpoints,omitempty"`
|
||||
Ports []PortBinding `json:"ports,omitempty"`
|
||||
ExposedPorts []string `json:"exposedPorts,omitempty"`
|
||||
PublishAll bool `json:"publishAll,omitempty"`
|
||||
|
||||
Mounts []Mount `json:"mounts,omitempty"`
|
||||
|
||||
LogDriver string `json:"logDriver,omitempty"`
|
||||
LogOptions map[string]string `json:"logOptions,omitempty"`
|
||||
|
||||
Healthcheck *Healthcheck `json:"healthcheck,omitempty"`
|
||||
Resources Resources `json:"resources"`
|
||||
|
||||
// Warnings collected while reading the container, surfaced in the UI.
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// DataMounts returns the mounts that actually carry data.
|
||||
func (c *Container) DataMounts() []Mount {
|
||||
out := make([]Mount, 0, len(c.Mounts))
|
||||
for _, m := range c.Mounts {
|
||||
if m.HasData() {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user