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
+192
View File
@@ -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"`
}
+499
View File
@@ -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
}
+252
View File
@@ -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)
}
}
}
+214
View File
@@ -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
}