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
+678
View File
@@ -0,0 +1,678 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/migrate"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/arescom/docker-migrate/internal/sshx"
"github.com/arescom/docker-migrate/internal/store"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
body := map[string]any{
"ok": true,
"dockerHost": s.docker.Endpoint,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "",
}
if v, err := s.docker.Ping(ctx); err != nil {
body["ok"] = false
body["dockerError"] = err.Error()
} else {
body["dockerVersion"] = v
}
writeJSON(w, http.StatusOK, body)
}
// handleSource returns the full inventory of the source daemon, plus the
// default selection for every container so the UI can render immediately.
func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
defaults := make(map[string]spec.ItemSelection, len(inv.Containers))
for i := range inv.Containers {
defaults[inv.Containers[i].ID] = spec.DefaultSelection(&inv.Containers[i])
}
writeJSON(w, http.StatusOK, map[string]any{
"inventory": inv,
"defaults": defaults,
"options": spec.DefaultOptions(),
})
}
// handleSourceSizes measures volume sizes, which is slow enough that the UI
// asks for it separately.
func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
sizes, err := s.docker.VolumeSizes(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"volumes": sizes})
}
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.conns.List())
}
func (s *Server) handleSaveConnection(w http.ResponseWriter, r *http.Request) {
var cfg sshx.Config
if err := decode(r, &cfg); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
saved, err := s.conns.Save(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
if err := s.conns.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleProbe reads the target's host key so the operator can compare the
// fingerprint before trusting it.
func (s *Server) handleProbe(w http.ResponseWriter, r *http.Request) {
cfg, err := s.conns.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
info, err := sshx.Probe(ctx, cfg, s.hosts)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, info)
}
// handleTrust records the host key. The fingerprint the operator approved is
// echoed back and re-checked, so approving one key cannot trust another.
func (s *Server) handleTrust(w http.ResponseWriter, r *http.Request) {
cfg, err := s.conns.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
var body struct {
Fingerprint string `json:"fingerprint"`
}
if err := decode(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := sshx.TrustFromProbe(ctx, cfg, s.hosts, body.Fingerprint); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"trusted": true})
}
func (s *Server) handleTestConnection(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
defer closeFn()
pre, err := rd.Preflight(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, pre)
}
func (s *Server) handleTargetInventory(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
defer closeFn()
inv, err := rd.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, inv)
}
// PreviewItem is what the UI shows when the operator asks "what will this do?".
type PreviewItem struct {
ContainerID string `json:"containerId"`
Name string `json:"name"`
TargetName string `json:"targetName"`
Image string `json:"image"`
Commands []string `json:"commands"`
Transfers []string `json:"transfers"`
Notes []string `json:"notes"`
Warnings []string `json:"warnings"`
TotalBytes int64 `json:"totalBytes"`
}
// handlePreview renders the exact docker commands a plan would run, without
// touching either host.
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
var plan spec.Plan
if err := decode(r, &plan); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
byID := map[string]*spec.Container{}
for i := range inv.Containers {
byID[inv.Containers[i].ID] = &inv.Containers[i]
}
out := []PreviewItem{}
netSeen := map[string]bool{}
var netCmds []string
for _, sel := range plan.Items {
if !sel.Include {
continue
}
p, err := migrate.Prepare(byID[sel.ContainerID], sel, inv.Volumes, inv.Networks)
if err != nil {
writeError(w, http.StatusBadRequest, "container %s: %v", sel.ContainerID, err)
return
}
item := PreviewItem{
ContainerID: p.Source.ID,
Name: p.Source.Name,
TargetName: p.ContainerName(),
Image: p.Target.Image,
Notes: p.Notes,
Warnings: p.Source.Warnings,
}
for _, n := range p.Networks {
if !netSeen[n.Name] {
netSeen[n.Name] = true
netCmds = append(netCmds, "docker "+spec.ShellQuoteAll(n.CreateArgs()))
}
}
for _, v := range p.Volumes {
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(v.CreateArgs()))
}
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(p.Target.CreateArgs(p.Render)))
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(args))
}
for _, t := range p.Transfers {
item.Transfers = append(item.Transfers, t.Label)
item.Commands = append(item.Commands,
fmt.Sprintf("docker cp -a - %s:%s # contents of %s", p.ContainerName(), t.RestoreInto, t.SourcePath))
if t.SizeBytes > 0 {
item.TotalBytes += t.SizeBytes
}
}
if p.Selection.StartAfter {
item.Commands = append(item.Commands, "docker start "+spec.ShellQuote(p.ContainerName()))
}
out = append(out, item)
}
writeJSON(w, http.StatusOK, map[string]any{
"networkCommands": netCmds,
"items": out,
})
}
type sshMigrateRequest struct {
ConnectionID string `json:"connectionId"`
Plan spec.Plan `json:"plan"`
}
func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
var req sshMigrateRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if req.ConnectionID == "" {
writeError(w, http.StatusBadRequest, "connectionId is required")
return
}
if countIncluded(req.Plan) == 0 {
writeError(w, http.StatusBadRequest, "no containers selected")
return
}
cfg, err := s.conns.Get(req.ConnectionID)
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
// The inventory is re-read now so the plan is applied to current state
// rather than to whatever the browser last loaded.
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
cancel()
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
// Dial before the job starts so a bad credential is an immediate error in
// the UI instead of a failed job.
dialCtx, dialCancel := context.WithTimeout(r.Context(), 45*time.Second)
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
dialCancel()
if err != nil {
s.writeDialError(w, err)
return
}
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
runner := &migrate.SSHRunner{
Src: s.docker,
Dst: sshx.NewRemoteDocker(client),
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
Plan: req.Plan,
}
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
defer client.Close()
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
return runner.Run(ctx, j)
})
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
type packageRequest struct {
Plan spec.Plan `json:"plan"`
Format migrate.PackageFormat `json:"format"`
}
func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
var req packageRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if countIncluded(req.Plan) == 0 {
writeError(w, http.StatusBadRequest, "no containers selected")
return
}
if req.Format == "" {
req.Format = migrate.FormatTar
}
if req.Format != migrate.FormatTar && req.Format != migrate.FormatDir {
writeError(w, http.StatusBadRequest, "format must be %q or %q", migrate.FormatTar, migrate.FormatDir)
return
}
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
cancel()
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
packager := &migrate.Packager{
Src: s.docker,
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
Plan: req.Plan,
OutputDir: s.cfg.PackageDir,
Format: req.Format,
SourceHost: inv.Host,
}
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
res, err := packager.Run(ctx, j)
if err != nil {
return err
}
j.Logf(job.LevelInfo, "", "package ready: %s (%s)", res.Name, humanBytes(res.Bytes))
return nil
})
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.jobs.List())
}
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeJSON(w, http.StatusOK, j.Snapshot())
}
// handleJobEvents streams job snapshots over server-sent events, coalescing
// rapid updates so a fast transfer does not saturate the browser.
func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
changes, unsubscribe := j.Subscribe()
defer unsubscribe()
send := func() bool {
b, err := json.Marshal(j.Snapshot())
if err != nil {
return false
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
}
flusher.Flush()
return true
}
if !send() {
return
}
// Updates are batched: at most one frame every 250ms while work is busy.
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
keepalive := time.NewTicker(20 * time.Second)
defer keepalive.Stop()
dirty := false
for {
select {
case <-r.Context().Done():
return
case _, open := <-changes:
if !open {
return
}
dirty = true
case <-ticker.C:
if !dirty {
continue
}
dirty = false
if !send() {
return
}
if j.Snapshot().State.Terminal() {
fmt.Fprint(w, "event: done\ndata: {}\n\n")
flusher.Flush()
return
}
case <-keepalive.C:
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
case <-j.Done():
// Drain one last snapshot so the client sees the final state.
send()
fmt.Fprint(w, "event: done\ndata: {}\n\n")
flusher.Flush()
return
}
}
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
j.Cancel()
writeJSON(w, http.StatusOK, map[string]any{"canceled": true})
}
func (s *Server) handleDeleteJob(w http.ResponseWriter, r *http.Request) {
if err := s.jobs.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// PackageInfo describes one built package on disk.
type PackageInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
IsDir bool `json:"isDir"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Server) handleListPackages(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.cfg.PackageDir)
if err != nil {
writeError(w, http.StatusInternalServerError, "read package directory: %v", err)
return
}
out := []PackageInfo{}
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
p := PackageInfo{
Name: e.Name(),
Path: filepath.Join(s.cfg.PackageDir, e.Name()),
IsDir: e.IsDir(),
CreatedAt: info.ModTime(),
}
if e.IsDir() {
p.Bytes, _ = dirBytes(p.Path)
} else {
p.Bytes = info.Size()
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
writeJSON(w, http.StatusOK, out)
}
// handleDownloadPackage streams a package file. Directory packages are not
// downloadable as-is; the operator picks the tar format for that.
func (s *Server) handleDownloadPackage(w http.ResponseWriter, r *http.Request) {
path, err := s.packagePath(r.PathValue("name"))
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
info, err := os.Stat(path)
if err != nil {
writeError(w, http.StatusNotFound, "package not found")
return
}
if info.IsDir() {
writeError(w, http.StatusBadRequest,
"this package is a directory; copy it from %s, or rebuild with the tar format", path)
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/x-tar")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(path)))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
func (s *Server) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
path, err := s.packagePath(r.PathValue("name"))
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if err := os.RemoveAll(path); err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// packagePath resolves a package name against the package directory, refusing
// anything that would escape it.
func (s *Server) packagePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return "", errors.New("invalid package name")
}
base, err := filepath.Abs(s.cfg.PackageDir)
if err != nil {
return "", err
}
full := filepath.Join(base, name)
if !strings.HasPrefix(full, base+string(os.PathSeparator)) {
return "", errors.New("invalid package name")
}
return full, nil
}
// dialTarget opens a short-lived connection for an interactive request.
func (s *Server) dialTarget(ctx context.Context, id string) (*sshx.RemoteDocker, func(), error) {
cfg, err := s.conns.Get(id)
if err != nil {
return nil, nil, err
}
client, err := sshx.Dial(ctx, cfg, s.hosts)
if err != nil {
return nil, nil, err
}
return sshx.NewRemoteDocker(client), func() { client.Close() }, nil
}
// writeDialError turns an untrusted host key into a structured response the UI
// can turn into a trust prompt.
func (s *Server) writeDialError(w http.ResponseWriter, err error) {
var hk *sshx.HostKeyError
if errors.As(err, &hk) {
writeJSON(w, http.StatusPreconditionRequired, map[string]any{
"error": err.Error(),
"hostKey": hk.Fingerprint,
"keyType": hk.KeyType,
"changed": hk.Changed,
"needsTrust": true,
"host": hk.Host,
})
return
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeError(w, http.StatusBadGateway, "%v", err)
}
func countIncluded(p spec.Plan) int {
n := 0
for _, i := range p.Items {
if i.Include {
n++
}
}
return n
}
func dirBytes(dir string) (int64, error) {
var total int64
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
var _ = dkr.RestorePath
+246
View File
@@ -0,0 +1,246 @@
// Package api exposes the migration tool over HTTP and serves the web UI.
package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/sshx"
"github.com/arescom/docker-migrate/internal/store"
)
// Config configures the HTTP server.
type Config struct {
// Addr is the listen address, e.g. 127.0.0.1:8080.
Addr string
// Token, when set, must be presented on every API request.
Token string
// DataDir holds connections and the known-hosts file.
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
// DockerHost overrides the source daemon address.
DockerHost string
// UI is the embedded web app; nil disables the UI.
UI fs.FS
// Logger receives request and error logs.
Logger *slog.Logger
}
// Server ties the Docker client, connection store and job manager to HTTP.
type Server struct {
cfg Config
log *slog.Logger
docker *dkr.Client
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
}
// New builds the server and everything it owns.
func New(cfg Config) (*Server, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if cfg.PackageDir == "" {
cfg.PackageDir = filepath.Join(cfg.DataDir, "packages")
}
if err := os.MkdirAll(cfg.PackageDir, 0o755); err != nil {
return nil, fmt.Errorf("create package directory: %w", err)
}
docker, err := dkr.New(cfg.DockerHost)
if err != nil {
return nil, err
}
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
if err != nil {
return nil, err
}
hosts, err := sshx.NewKnownHosts(filepath.Join(cfg.DataDir, "known_hosts"))
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg, log: cfg.Logger, docker: docker,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// Close releases the Docker connection.
func (s *Server) Close() error { return s.docker.Close() }
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.logging(s.auth(s.mux)))
}
func (s *Server) routes() {
m := s.mux
m.HandleFunc("GET /api/health", s.handleHealth)
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
m.HandleFunc("GET /api/connections", s.handleListConnections)
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
m.HandleFunc("POST /api/connections/{id}/probe", s.handleProbe)
m.HandleFunc("POST /api/connections/{id}/trust", s.handleTrust)
m.HandleFunc("POST /api/connections/{id}/test", s.handleTestConnection)
m.HandleFunc("GET /api/connections/{id}/inventory", s.handleTargetInventory)
m.HandleFunc("POST /api/plan/preview", s.handlePreview)
m.HandleFunc("POST /api/migrate/ssh", s.handleMigrateSSH)
m.HandleFunc("POST /api/migrate/package", s.handleBuildPackage)
m.HandleFunc("GET /api/jobs", s.handleListJobs)
m.HandleFunc("GET /api/jobs/{id}", s.handleGetJob)
m.HandleFunc("GET /api/jobs/{id}/events", s.handleJobEvents)
m.HandleFunc("POST /api/jobs/{id}/cancel", s.handleCancelJob)
m.HandleFunc("DELETE /api/jobs/{id}", s.handleDeleteJob)
m.HandleFunc("GET /api/packages", s.handleListPackages)
m.HandleFunc("GET /api/packages/{name}/download", s.handleDownloadPackage)
m.HandleFunc("DELETE /api/packages/{name}", s.handleDeletePackage)
if s.cfg.UI != nil {
m.Handle("/", s.spaHandler())
}
}
// auth enforces the shared token on the API. The token may also be passed as a
// query parameter, because EventSource cannot set headers and neither can a
// download link.
//
// Static assets are deliberately served without it. A browser opening
// /?token=… does not carry the query string over to /assets/app.js, so gating
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
// bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
if s.cfg.Token == "" {
return next
}
want := []byte(s.cfg.Token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-Auth-Token")
if got == "" {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got = strings.TrimPrefix(h, "Bearer ")
}
}
if got == "" {
got = r.URL.Query().Get("token")
}
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
writeError(w, http.StatusUnauthorized, "invalid or missing token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
if strings.HasPrefix(r.URL.Path, "/api/") {
s.log.Debug("request", "method", r.Method, "path", r.URL.Path,
"status", sw.status, "duration", time.Since(start).Round(time.Millisecond))
}
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic serving request", "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
// Flush forwards to the wrapped writer so server-sent events keep streaming.
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// spaHandler serves the built web app, falling back to index.html so client
// side routing works on a hard refresh.
func (s *Server) spaHandler() http.Handler {
files := http.FileServer(http.FS(s.cfg.UI))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(s.cfg.UI, p); err != nil {
r = r.Clone(r.Context())
r.URL.Path = "/"
w.Header().Set("Cache-Control", "no-store")
} else if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
// The response is already partially written; nothing useful is left to do.
return
}
}
type errorBody struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, code int, format string, args ...any) {
writeJSON(w, code, errorBody{Error: fmt.Sprintf(format, args...)})
}
func decode(r *http.Request, v any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 8<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
// Package dkr wraps the Docker Engine API with the operations the migration
// tool needs: reading a full container inventory, streaming data out of a
// container's mounts, and streaming image layers.
package dkr
import (
"context"
"fmt"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
)
// Client is a connection to one Docker daemon.
type Client struct {
api *client.Client
// Endpoint is the daemon address, shown in the UI.
Endpoint string
}
// New connects to the daemon described by the standard DOCKER_* environment
// variables, or to host when it is non-empty (e.g. unix:///var/run/docker.sock
// or tcp://10.0.0.5:2375).
func New(host string) (*Client, error) {
opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()}
if host != "" {
opts = append(opts, client.WithHost(host))
}
api, err := client.NewClientWithOpts(opts...)
if err != nil {
return nil, fmt.Errorf("create docker client: %w", err)
}
return &Client{api: api, Endpoint: api.DaemonHost()}, nil
}
// API exposes the underlying SDK client for callers that need an operation
// this package does not wrap.
func (c *Client) API() *client.Client { return c.api }
// Close releases the daemon connection.
func (c *Client) Close() error { return c.api.Close() }
// Info returns daemon information, and doubles as a connectivity check.
func (c *Client) Info(ctx context.Context) (system.Info, error) {
return c.api.Info(ctx)
}
// Ping verifies the daemon is reachable and returns its version string.
func (c *Client) Ping(ctx context.Context) (string, error) {
v, err := c.api.ServerVersion(ctx)
if err != nil {
return "", err
}
return v.Version, nil
}
+160
View File
@@ -0,0 +1,160 @@
package dkr
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/spec"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
)
// CopyOut streams the contents of a path inside a container as an uncompressed
// tar archive.
//
// This is the single mechanism used for every kind of data location. It works
// for named volumes, anonymous volumes and bind mounts alike, because the
// daemon resolves the mount and produces the tar itself: no helper image is
// needed, the container's own image needs no tar binary, and the container does
// not have to be running.
//
// The archive entries are rooted at the last path segment, matching `docker cp`
// semantics. Restoring therefore targets the parent directory; use RestorePath.
func (c *Client) CopyOut(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
rc, _, err := c.api.CopyFromContainer(ctx, containerID, srcPath)
if err != nil {
return nil, fmt.Errorf("read %s from %s: %w", srcPath, short(containerID), err)
}
return rc, nil
}
// CopyIn writes an uncompressed tar archive into a path inside a container.
func (c *Client) CopyIn(ctx context.Context, containerID, dstPath string, r io.Reader) error {
err := c.api.CopyToContainer(ctx, containerID, dstPath, r, container.CopyToContainerOptions{
AllowOverwriteDirWithFile: false,
CopyUIDGID: true,
})
if err != nil {
return fmt.Errorf("write %s into %s: %w", dstPath, short(containerID), err)
}
return nil
}
// RestorePath is the directory an archive produced by CopyOut must be extracted
// into so that the contents land back at the original destination.
func RestorePath(destination string) string {
d := path.Dir(strings.TrimSuffix(destination, "/"))
if d == "" || d == "." {
return "/"
}
return d
}
// SaveImage streams `docker save` output for one or more image references.
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
rc, err := c.api.ImageSave(ctx, refs)
if err != nil {
return nil, fmt.Errorf("save image %s: %w", strings.Join(refs, ","), err)
}
return rc, nil
}
// ImageSizeBytes returns the on-disk size of an image, used to estimate how
// long a streamed transfer will take.
func (c *Client) ImageSizeBytes(ctx context.Context, ref string) int64 {
insp, err := c.api.ImageInspect(ctx, ref)
if err != nil {
return -1
}
return insp.Size
}
// State returns the current status of a container, e.g. "running".
func (c *Client) State(ctx context.Context, id string) (string, error) {
j, err := c.api.ContainerInspect(ctx, id)
if err != nil {
return "", err
}
if j.State == nil {
return "", errors.New("no state in inspect payload")
}
return j.State.Status, nil
}
// Stop stops a container and waits for it to settle. A container that is
// already stopped is left alone.
func (c *Client) Stop(ctx context.Context, id string, timeout time.Duration) error {
st, err := c.State(ctx, id)
if err != nil {
return err
}
if st != "running" && st != "restarting" && st != "paused" {
return nil
}
secs := int(timeout.Seconds())
if err := c.api.ContainerStop(ctx, id, container.StopOptions{Timeout: &secs}); err != nil {
return fmt.Errorf("stop %s: %w", short(id), err)
}
return nil
}
// Start starts a container.
func (c *Client) Start(ctx context.Context, id string) error {
if err := c.api.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
return fmt.Errorf("start %s: %w", short(id), err)
}
return nil
}
// VolumeSizes measures every local volume in one daemon round trip. It can be
// slow on hosts with a lot of data, so the UI asks for it explicitly rather
// than including it in the inventory.
func (c *Client) VolumeSizes(ctx context.Context) (map[string]int64, error) {
du, err := c.api.DiskUsage(ctx, dockertypes.DiskUsageOptions{
Types: []dockertypes.DiskUsageObject{dockertypes.VolumeObject},
})
if err != nil {
return nil, fmt.Errorf("compute disk usage: %w", err)
}
out := map[string]int64{}
for _, v := range du.Volumes {
if v == nil || v.UsageData == nil {
continue
}
out[v.Name] = v.UsageData.Size
}
return out, nil
}
// MeasureMounts fills in the SizeBytes of every mount it can determine.
// Volume sizes come from the daemon; bind mount sizes are measured by walking
// the path from inside the container, which works even when the daemon is
// remote.
func (c *Client) MeasureMounts(ctx context.Context, containers []spec.Container) error {
sizes, err := c.VolumeSizes(ctx)
if err != nil {
return err
}
for i := range containers {
for j := range containers[i].Mounts {
m := &containers[i].Mounts[j]
switch m.Kind {
case spec.MountVolume, spec.MountAnonymous:
if s, ok := sizes[m.Name]; ok {
m.SizeBytes = s
}
case spec.MountTmpfs:
m.SizeBytes = 0
}
}
}
return nil
}
var _ = image.InspectResponse{}
+570
View File
@@ -0,0 +1,570 @@
package dkr
import (
"context"
"fmt"
"regexp"
"sort"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
networktypes "github.com/docker/docker/api/types/network"
volumetypes "github.com/docker/docker/api/types/volume"
)
// Inventory is everything the UI needs to render the source host.
type Inventory struct {
Host string `json:"host"`
DockerVersion string `json:"dockerVersion"`
Containers []spec.Container `json:"containers"`
Volumes []spec.Volume `json:"volumes"`
Networks []spec.Network `json:"networks"`
Warnings []string `json:"warnings,omitempty"`
}
// anonymousVolume matches the 64-hex names Docker generates for volumes that
// were never explicitly named.
var anonymousVolume = regexp.MustCompile(`^[0-9a-f]{64}$`)
// Inventory reads every container on the daemon, plus the volumes and networks
// they reference, and normalizes them into the transport spec.
func (c *Client) Inventory(ctx context.Context) (*Inventory, error) {
version, err := c.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("connect to docker: %w", err)
}
info, err := c.api.Info(ctx)
if err != nil {
return nil, fmt.Errorf("read docker info: %w", err)
}
summaries, err := c.api.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
inv := &Inventory{Host: info.Name, DockerVersion: version}
imgCache := map[string]*imagetypes.InspectResponse{}
volNames := map[string]bool{}
netNames := map[string]bool{}
for _, s := range summaries {
cs, err := c.inspectContainer(ctx, s.ID, imgCache)
if err != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("skipped container %s: %v", short(s.ID), err))
continue
}
for _, m := range cs.Mounts {
if m.Kind == spec.MountVolume && m.Name != "" {
volNames[m.Name] = true
}
}
for _, e := range cs.Endpoints {
netNames[e.Network] = true
}
inv.Containers = append(inv.Containers, *cs)
}
sort.Slice(inv.Containers, func(i, j int) bool {
a, b := inv.Containers[i], inv.Containers[j]
if a.ComposeProject != b.ComposeProject {
return a.ComposeProject < b.ComposeProject
}
return a.Name < b.Name
})
for name := range volNames {
v, err := c.api.VolumeInspect(ctx, name)
if err != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("volume %s: %v", name, err))
continue
}
inv.Volumes = append(inv.Volumes, convertVolume(v))
}
sort.Slice(inv.Volumes, func(i, j int) bool { return inv.Volumes[i].Name < inv.Volumes[j].Name })
for name := range netNames {
if isBuiltinNetwork(name) {
continue
}
n, err := c.api.NetworkInspect(ctx, name, networktypes.InspectOptions{})
if err != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("network %s: %v", name, err))
continue
}
inv.Networks = append(inv.Networks, convertNetwork(n))
}
sort.Slice(inv.Networks, func(i, j int) bool { return inv.Networks[i].Name < inv.Networks[j].Name })
return inv, nil
}
// InspectContainer normalizes one container by id or name.
func (c *Client) InspectContainer(ctx context.Context, id string) (*spec.Container, error) {
return c.inspectContainer(ctx, id, map[string]*imagetypes.InspectResponse{})
}
func (c *Client) inspectContainer(ctx context.Context, id string, imgCache map[string]*imagetypes.InspectResponse) (*spec.Container, error) {
j, err := c.api.ContainerInspect(ctx, id)
if err != nil {
return nil, err
}
if j.Config == nil || j.HostConfig == nil {
return nil, fmt.Errorf("incomplete inspect payload")
}
out := &spec.Container{
ID: j.ID,
Name: strings.TrimPrefix(j.Name, "/"),
Image: j.Config.Image,
ImageID: j.Image,
}
if out.Image == "" {
out.Image = j.Image
}
if j.State != nil {
out.State = j.State.Status
}
// Image config is used to strip everything the image already provides, so
// the recreated container carries only genuine run-time overrides.
img := c.imageConfig(ctx, j.Image, imgCache)
if img == nil {
out.Warnings = append(out.Warnings,
"image config unavailable; env, command and labels are reproduced in full")
} else if len(img.RepoDigests) > 0 {
out.ImageDigest = img.RepoDigests[0]
}
cfg := j.Config
out.Hostname = dropGeneratedHostname(cfg.Hostname, j.ID)
out.Domainname = cfg.Domainname
out.User = cfg.User
out.WorkingDir = cfg.WorkingDir
out.Tty = cfg.Tty
out.OpenStdin = cfg.OpenStdin
out.StopSignal = cfg.StopSignal
out.StopTimeout = cfg.StopTimeout
var imgEnv, imgCmd, imgEntry []string
var imgLabels map[string]string
if img != nil && img.Config != nil {
imgEnv, imgLabels = img.Config.Env, img.Config.Labels
imgCmd, imgEntry = img.Config.Cmd, img.Config.Entrypoint
if img.Config.User == cfg.User {
out.User = ""
}
if img.Config.WorkingDir == cfg.WorkingDir {
out.WorkingDir = ""
}
}
out.Env = subtractStrings(cfg.Env, imgEnv)
out.Labels = subtractLabels(cfg.Labels, imgLabels)
out.Cmd = cfg.Cmd
out.CmdSet = !equalStrings(cfg.Cmd, imgCmd)
out.Entrypoint = cfg.Entrypoint
out.EntrypointSet = !equalStrings(cfg.Entrypoint, imgEntry)
if p := cfg.Labels["com.docker.compose.project"]; p != "" {
out.ComposeProject = p
out.ComposeService = cfg.Labels["com.docker.compose.service"]
}
if cfg.Healthcheck != nil {
var imgHC *container.HealthConfig
if img != nil && img.Config != nil {
imgHC = img.Config.Healthcheck
}
if !sameHealthcheck(cfg.Healthcheck, imgHC) {
out.Healthcheck = &spec.Healthcheck{
Test: cfg.Healthcheck.Test,
Interval: int64(cfg.Healthcheck.Interval),
Timeout: int64(cfg.Healthcheck.Timeout),
StartPeriod: int64(cfg.Healthcheck.StartPeriod),
Retries: cfg.Healthcheck.Retries,
}
}
}
hc := j.HostConfig
out.RestartPolicy = string(hc.RestartPolicy.Name)
out.RestartMaxRetries = hc.RestartPolicy.MaximumRetryCount
out.AutoRemove = hc.AutoRemove
out.Privileged = hc.Privileged
out.ReadonlyRootfs = hc.ReadonlyRootfs
out.CapAdd = hc.CapAdd
out.CapDrop = hc.CapDrop
out.SecurityOpt = dropDefaultSecurityOpt(hc.SecurityOpt)
out.GroupAdd = hc.GroupAdd
out.Sysctls = hc.Sysctls
out.Runtime = hc.Runtime
out.PidMode = string(hc.PidMode)
out.IpcMode = string(hc.IpcMode)
out.UtsMode = string(hc.UTSMode)
out.UsernsMode = string(hc.UsernsMode)
out.CgroupnsMode = string(hc.CgroupnsMode)
out.DNS = hc.DNS
out.DNSSearch = hc.DNSSearch
out.DNSOptions = hc.DNSOptions
out.ExtraHosts = hc.ExtraHosts
out.NetworkMode = string(hc.NetworkMode)
out.PublishAll = hc.PublishAllPorts
out.Init = hc.Init
out.LogDriver = hc.LogConfig.Type
out.LogOptions = hc.LogConfig.Config
for _, d := range hc.Devices {
out.Devices = append(out.Devices, spec.Device{
PathOnHost: d.PathOnHost,
PathInContainer: d.PathInContainer,
CgroupPermissions: d.CgroupPermissions,
})
}
for _, u := range hc.Ulimits {
if u == nil {
continue
}
out.Ulimits = append(out.Ulimits, spec.Ulimit{Name: u.Name, Soft: u.Soft, Hard: u.Hard})
}
out.Resources = spec.Resources{
Memory: hc.Memory,
MemoryReservation: hc.MemoryReservation,
MemorySwap: hc.MemorySwap,
MemorySwappiness: hc.MemorySwappiness,
NanoCPUs: hc.NanoCPUs,
CPUShares: hc.CPUShares,
CPUPeriod: hc.CPUPeriod,
CPUQuota: hc.CPUQuota,
CpusetCpus: hc.CpusetCpus,
CpusetMems: hc.CpusetMems,
PidsLimit: hc.PidsLimit,
OomKillDisable: hc.OomKillDisable,
OomScoreAdj: hc.OomScoreAdj,
ShmSize: hc.ShmSize,
}
for portProto, bindings := range hc.PortBindings {
for _, b := range bindings {
out.Ports = append(out.Ports, spec.PortBinding{
ContainerPort: string(portProto),
HostIP: b.HostIP,
HostPort: b.HostPort,
})
}
}
sort.Slice(out.Ports, func(i, j int) bool {
if out.Ports[i].ContainerPort != out.Ports[j].ContainerPort {
return out.Ports[i].ContainerPort < out.Ports[j].ContainerPort
}
return out.Ports[i].HostPort < out.Ports[j].HostPort
})
for p := range cfg.ExposedPorts {
out.ExposedPorts = append(out.ExposedPorts, string(p))
}
sort.Strings(out.ExposedPorts)
out.Mounts = convertMounts(j.Mounts, hc.Tmpfs)
out.Endpoints = convertEndpoints(j.NetworkSettings, out.NetworkMode, j.ID)
if hc.AutoRemove {
out.Warnings = append(out.Warnings,
"source runs with --rm; the migrated container is created without it so it survives inspection")
}
if strings.HasPrefix(out.NetworkMode, "container:") {
out.Warnings = append(out.Warnings,
"shares another container's network namespace; migrate that container too")
}
for _, m := range out.Mounts {
if m.Kind == spec.MountBind && isSensitiveBind(m.Source) {
out.Warnings = append(out.Warnings,
"binds host path "+m.Source+"; copying it is usually wrong, review before migrating")
}
}
if len(hc.VolumesFrom) > 0 {
out.Warnings = append(out.Warnings,
"uses --volumes-from ("+strings.Join(hc.VolumesFrom, ", ")+"), which is not reproduced")
}
return out, nil
}
func (c *Client) imageConfig(ctx context.Context, id string, cache map[string]*imagetypes.InspectResponse) *imagetypes.InspectResponse {
if v, ok := cache[id]; ok {
return v
}
insp, err := c.api.ImageInspect(ctx, id)
if err != nil {
cache[id] = nil
return nil
}
cache[id] = &insp
return &insp
}
func convertMounts(mounts []container.MountPoint, tmpfs map[string]string) []spec.Mount {
out := make([]spec.Mount, 0, len(mounts)+len(tmpfs))
for _, m := range mounts {
sm := spec.Mount{
Destination: m.Destination,
ReadOnly: !m.RW,
Propagation: string(m.Propagation),
SizeBytes: -1,
}
switch m.Type {
case "volume":
sm.Name = m.Name
if anonymousVolume.MatchString(m.Name) {
sm.Kind = spec.MountAnonymous
} else {
sm.Kind = spec.MountVolume
}
case "bind":
sm.Kind = spec.MountBind
sm.Source = m.Source
case "tmpfs":
sm.Kind = spec.MountTmpfs
default:
// npipe and unknown driver types carry no portable data.
sm.Kind = spec.MountKind(m.Type)
sm.Source = m.Source
}
out = append(out, sm)
}
for dest, opts := range tmpfs {
if hasDestination(out, dest) {
continue
}
out = append(out, spec.Mount{Kind: spec.MountTmpfs, Destination: dest, TmpfsOpts: opts, SizeBytes: 0})
}
sort.Slice(out, func(i, j int) bool { return out[i].Destination < out[j].Destination })
return out
}
func convertEndpoints(ns *container.NetworkSettings, networkMode, containerID string) []spec.Endpoint {
if ns == nil {
return nil
}
out := make([]spec.Endpoint, 0, len(ns.Networks))
for name, ep := range ns.Networks {
if ep == nil {
continue
}
e := spec.Endpoint{
Network: name,
Aliases: dropGeneratedAliases(ep.Aliases, containerID),
Links: ep.Links,
DriverOpts: ep.DriverOpts,
}
// The MAC address is normally derived by the daemon. Carrying it over
// only makes sense alongside the static addressing it belongs to;
// otherwise it risks colliding with an address on the target network.
if ep.IPAMConfig != nil && (ep.IPAMConfig.IPv4Address != "" || ep.IPAMConfig.IPv6Address != "") {
e.MacAddress = ep.MacAddress
}
if ep.IPAMConfig != nil {
e.IPv4Address = ep.IPAMConfig.IPv4Address
e.IPv6Address = ep.IPAMConfig.IPv6Address
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
// The network named by NetworkMode has to come first: it is the one
// `docker create --network` can express.
if out[i].Network == networkMode {
return true
}
if out[j].Network == networkMode {
return false
}
return out[i].Network < out[j].Network
})
return out
}
func convertVolume(v volumetypes.Volume) spec.Volume {
return spec.Volume{
Name: v.Name,
Driver: v.Driver,
DriverOpts: v.Options,
Labels: v.Labels,
}
}
func convertNetwork(n network.Inspect) spec.Network {
out := spec.Network{
Name: n.Name,
Driver: n.Driver,
Scope: n.Scope,
EnableIPv6: n.EnableIPv6,
Internal: n.Internal,
Attachable: n.Attachable,
Ingress: n.Ingress,
IPAMDriver: n.IPAM.Driver,
Options: n.Options,
Labels: n.Labels,
}
for _, p := range n.IPAM.Config {
out.IPAMPools = append(out.IPAMPools, spec.IPAMPool{
Subnet: p.Subnet,
IPRange: p.IPRange,
Gateway: p.Gateway,
AuxAddress: p.AuxAddress,
})
}
return out
}
// ImageInspectExists reports whether the daemon holds the given image.
func (c *Client) ImageExists(ctx context.Context, ref string) bool {
_, err := c.api.ImageInspect(ctx, ref)
return err == nil
}
// ImageRepoDigests returns the registry digests of an image, used to decide
// whether the target can simply pull it.
func (c *Client) ImageRepoDigests(ctx context.Context, ref string) []string {
insp, err := c.api.ImageInspect(ctx, ref)
if err != nil {
return nil
}
return insp.RepoDigests
}
var _ = image.InspectResponse{}
func isBuiltinNetwork(name string) bool {
switch name {
case "bridge", "host", "none":
return true
}
return false
}
// isSensitiveBind flags host paths that almost never should be copied wholesale
// to another machine.
func isSensitiveBind(p string) bool {
p = strings.TrimSuffix(strings.ReplaceAll(p, `\`, "/"), "/")
switch p {
case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/", "/etc", "/var/run", "/run":
return true
}
return strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/dev/")
}
// dropGeneratedHostname removes the hostname Docker derives from the container
// id, which must not be pinned on the target.
func dropGeneratedHostname(hostname, id string) string {
if hostname == "" || strings.HasPrefix(id, hostname) {
return ""
}
return hostname
}
// dropGeneratedAliases removes the short-container-id alias Docker attaches to
// every endpoint by itself. Re-applying it would pin the target container to
// the source container's id.
func dropGeneratedAliases(aliases []string, containerID string) []string {
out := make([]string, 0, len(aliases))
for _, a := range aliases {
if len(a) == 12 && strings.HasPrefix(containerID, a) {
continue
}
out = append(out, a)
}
sort.Strings(out)
if len(out) == 0 {
return nil
}
return out
}
// dropDefaultSecurityOpt removes the label=disable style entries Docker reports
// on hosts without SELinux, which would fail to apply elsewhere.
func dropDefaultSecurityOpt(opts []string) []string {
out := make([]string, 0, len(opts))
for _, o := range opts {
if strings.HasPrefix(o, "name=") {
continue
}
out = append(out, o)
}
if len(out) == 0 {
return nil
}
return out
}
func hasDestination(ms []spec.Mount, dest string) bool {
for _, m := range ms {
if m.Destination == dest {
return true
}
}
return false
}
func subtractStrings(all, base []string) []string {
if len(base) == 0 {
return all
}
seen := make(map[string]bool, len(base))
for _, b := range base {
seen[b] = true
}
out := make([]string, 0, len(all))
for _, v := range all {
if !seen[v] {
out = append(out, v)
}
}
if len(out) == 0 {
return nil
}
return out
}
func subtractLabels(all, base map[string]string) map[string]string {
out := map[string]string{}
for k, v := range all {
if bv, ok := base[k]; ok && bv == v {
continue
}
out[k] = v
}
if len(out) == 0 {
return nil
}
return out
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func sameHealthcheck(a, b *container.HealthConfig) bool {
if a == nil || b == nil {
return a == b
}
return equalStrings(a.Test, b.Test) && a.Interval == b.Interval &&
a.Timeout == b.Timeout && a.StartPeriod == b.StartPeriod && a.Retries == b.Retries
}
func short(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
+49
View File
@@ -0,0 +1,49 @@
package dkr
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/arescom/docker-migrate/internal/spec"
)
// TestLiveInventory is a smoke test against whatever daemon the environment
// points at. It is skipped unless DOCKER_MIGRATE_LIVE_TEST is set, because it
// needs a real Docker host.
func TestLiveInventory(t *testing.T) {
if os.Getenv("DOCKER_MIGRATE_LIVE_TEST") == "" {
t.Skip("set DOCKER_MIGRATE_LIVE_TEST=1 to run against the local daemon")
}
c, err := New("")
if err != nil {
t.Fatal(err)
}
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
inv, err := c.Inventory(ctx)
if err != nil {
t.Fatal(err)
}
t.Logf("host=%s docker=%s containers=%d volumes=%d networks=%d warnings=%v",
inv.Host, inv.DockerVersion, len(inv.Containers), len(inv.Volumes), len(inv.Networks), inv.Warnings)
for i := range inv.Containers {
ct := &inv.Containers[i]
args := ct.CreateArgs(spec.RenderOptions{})
t.Logf("%s [%s] -> docker %s", ct.Name, ct.State, spec.ShellQuoteAll(args))
for _, m := range ct.DataMounts() {
t.Logf(" mount %-8s %-40s restore-into %s", m.Kind, m.Destination, RestorePath(m.Destination))
}
for _, w := range ct.Warnings {
t.Logf(" warn: %s", w)
}
}
b, _ := json.MarshalIndent(inv, "", " ")
t.Logf("inventory bytes: %d", len(b))
}
+348
View File
@@ -0,0 +1,348 @@
// Package job tracks long-running migrations and streams their progress to
// the UI. A job is a tree: job -> per-container item -> per-operation step,
// with byte counters on the steps that move data.
package job
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
// State is the lifecycle of a job, item or step.
type State string
const (
StatePending State = "pending"
StateRunning State = "running"
StateSucceeded State = "succeeded"
StateFailed State = "failed"
StateSkipped State = "skipped"
StateCanceled State = "canceled"
)
// Terminal reports whether no further transitions are expected.
func (s State) Terminal() bool {
switch s {
case StateSucceeded, StateFailed, StateSkipped, StateCanceled:
return true
}
return false
}
// Kind distinguishes the two migration modes.
type Kind string
const (
KindSSH Kind = "ssh"
KindPackage Kind = "package"
KindRestore Kind = "restore"
)
// Level classifies a log line.
type Level string
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
LevelCmd Level = "cmd" // a command that was (or would be) run on a host
)
// LogEntry is one line in the job log.
type LogEntry struct {
Seq int64 `json:"seq"`
At time.Time `json:"at"`
Level Level `json:"level"`
Item string `json:"item,omitempty"`
Message string `json:"message"`
}
// Step is one unit of work inside an item, e.g. "transfer volume pgdata".
type Step struct {
ID string `json:"id"`
Label string `json:"label"`
State State `json:"state"`
BytesDone int64 `json:"bytesDone"`
BytesTotal int64 `json:"bytesTotal"` // -1 when unknown
Error string `json:"error,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
EndedAt *time.Time `json:"endedAt,omitempty"`
}
// Item is the migration of one container.
type Item struct {
ID string `json:"id"` // container id on the source
Name string `json:"name"`
State State `json:"state"`
Error string `json:"error,omitempty"`
Steps []*Step `json:"steps"`
Warnings []string `json:"warnings,omitempty"`
}
// Snapshot is the serializable view of a job handed to the UI.
type Snapshot struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Title string `json:"title"`
State State `json:"state"`
DryRun bool `json:"dryRun"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"`
EndedAt *time.Time `json:"endedAt,omitempty"`
Items []*Item `json:"items"`
Log []LogEntry `json:"log"`
BytesDone int64 `json:"bytesDone"`
BytesTotal int64 `json:"bytesTotal"`
// Artifact is the produced package path, for package jobs.
Artifact string `json:"artifact,omitempty"`
// ArtifactBytes is the package size on disk.
ArtifactBytes int64 `json:"artifactBytes,omitempty"`
Revision int64 `json:"revision"`
}
// Job is a running or finished migration.
type Job struct {
mu sync.RWMutex
snap Snapshot
seq int64
revision int64
maxLog int
cancel context.CancelFunc
done chan struct{}
subsMu sync.Mutex
subs map[int]chan struct{}
nextID int
}
func newJob(id string, kind Kind, title string, dryRun bool) *Job {
return &Job{
snap: Snapshot{
ID: id, Kind: kind, Title: title, State: StatePending,
DryRun: dryRun, CreatedAt: time.Now(), Items: []*Item{}, Log: []LogEntry{},
BytesTotal: 0,
},
maxLog: 5000,
done: make(chan struct{}),
subs: map[int]chan struct{}{},
}
}
// ID returns the job identifier.
func (j *Job) ID() string { return j.snap.ID }
// Done is closed once the job reaches a terminal state.
func (j *Job) Done() <-chan struct{} { return j.done }
// Snapshot returns a deep-enough copy for JSON serialization.
func (j *Job) Snapshot() Snapshot {
j.mu.RLock()
defer j.mu.RUnlock()
s := j.snap
s.Items = make([]*Item, len(j.snap.Items))
var done, total int64
for i, it := range j.snap.Items {
cp := *it
cp.Steps = make([]*Step, len(it.Steps))
for k, st := range it.Steps {
sc := *st
cp.Steps[k] = &sc
done += sc.BytesDone
if sc.BytesTotal > 0 {
total += sc.BytesTotal
}
}
s.Items[i] = &cp
}
s.Log = append([]LogEntry(nil), j.snap.Log...)
s.BytesDone, s.BytesTotal = done, total
s.Revision = atomic.LoadInt64(&j.revision)
return s
}
// Subscribe returns a channel that receives a signal whenever the job changes,
// plus a function to unsubscribe.
func (j *Job) Subscribe() (<-chan struct{}, func()) {
j.subsMu.Lock()
defer j.subsMu.Unlock()
id := j.nextID
j.nextID++
ch := make(chan struct{}, 1)
j.subs[id] = ch
return ch, func() {
j.subsMu.Lock()
defer j.subsMu.Unlock()
if c, ok := j.subs[id]; ok {
delete(j.subs, id)
close(c)
}
}
}
func (j *Job) touch() {
atomic.AddInt64(&j.revision, 1)
j.subsMu.Lock()
for _, ch := range j.subs {
select {
case ch <- struct{}{}:
default: // a signal is already pending; the reader will see the latest state
}
}
j.subsMu.Unlock()
}
// Logf appends a line to the job log.
func (j *Job) Logf(level Level, item, format string, args ...any) {
j.mu.Lock()
j.seq++
e := LogEntry{Seq: j.seq, At: time.Now(), Level: level, Item: item, Message: fmt.Sprintf(format, args...)}
j.snap.Log = append(j.snap.Log, e)
if len(j.snap.Log) > j.maxLog {
j.snap.Log = j.snap.Log[len(j.snap.Log)-j.maxLog:]
}
j.mu.Unlock()
j.touch()
}
// AddItem registers a container in the job and returns its handle.
func (j *Job) AddItem(id, name string) *Item {
j.mu.Lock()
it := &Item{ID: id, Name: name, State: StatePending, Steps: []*Step{}}
j.snap.Items = append(j.snap.Items, it)
j.mu.Unlock()
j.touch()
return it
}
// AddStep registers a unit of work under an item. bytesTotal may be -1 when
// the size is not known ahead of time.
func (j *Job) AddStep(it *Item, id, label string, bytesTotal int64) *Step {
j.mu.Lock()
st := &Step{ID: id, Label: label, State: StatePending, BytesTotal: bytesTotal}
it.Steps = append(it.Steps, st)
j.mu.Unlock()
j.touch()
return st
}
// StartStep marks a step as running.
func (j *Job) StartStep(st *Step) {
now := time.Now()
j.mu.Lock()
st.State = StateRunning
st.StartedAt = &now
j.mu.Unlock()
j.touch()
}
// FinishStep closes a step, recording an error when one occurred.
func (j *Job) FinishStep(st *Step, err error) {
now := time.Now()
j.mu.Lock()
st.EndedAt = &now
if err != nil {
st.State = StateFailed
st.Error = err.Error()
} else {
st.State = StateSucceeded
if st.BytesTotal < 0 {
st.BytesTotal = st.BytesDone
}
}
j.mu.Unlock()
j.touch()
}
// SkipStep marks a step as deliberately not performed.
func (j *Job) SkipStep(st *Step, reason string) {
now := time.Now()
j.mu.Lock()
st.State = StateSkipped
st.EndedAt = &now
st.Error = reason
j.mu.Unlock()
j.touch()
}
// AddBytes advances a step's byte counter. It is safe to call at high rates
// from the transfer goroutine.
func (j *Job) AddBytes(st *Step, n int64) {
j.mu.Lock()
st.BytesDone += n
j.mu.Unlock()
j.touch()
}
// SetItemState transitions an item.
func (j *Job) SetItemState(it *Item, s State, err error) {
j.mu.Lock()
it.State = s
if err != nil {
it.Error = err.Error()
}
j.mu.Unlock()
j.touch()
}
// AddItemWarning attaches a non-fatal note to an item.
func (j *Job) AddItemWarning(it *Item, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
j.mu.Lock()
it.Warnings = append(it.Warnings, msg)
j.mu.Unlock()
j.Logf(LevelWarn, it.ID, "%s", msg)
}
// SetArtifact records the produced package.
func (j *Job) SetArtifact(path string, bytes int64) {
j.mu.Lock()
j.snap.Artifact = path
j.snap.ArtifactBytes = bytes
j.mu.Unlock()
j.touch()
}
func (j *Job) start() {
now := time.Now()
j.mu.Lock()
j.snap.State = StateRunning
j.snap.StartedAt = &now
j.mu.Unlock()
j.touch()
}
func (j *Job) finish(err error) {
now := time.Now()
j.mu.Lock()
if j.snap.State.Terminal() {
j.mu.Unlock()
return
}
j.snap.EndedAt = &now
switch {
case err == nil:
j.snap.State = StateSucceeded
case err == context.Canceled:
j.snap.State = StateCanceled
j.snap.Error = "canceled by operator"
default:
j.snap.State = StateFailed
j.snap.Error = err.Error()
}
j.mu.Unlock()
j.touch()
close(j.done)
}
// Cancel asks the job to stop. Work already in flight unwinds through context
// cancellation.
func (j *Job) Cancel() {
if j.cancel != nil {
j.cancel()
}
}
+183
View File
@@ -0,0 +1,183 @@
package job
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"sort"
"sync"
"time"
)
// Manager owns every job in the process.
type Manager struct {
mu sync.RWMutex
jobs map[string]*Job
// keep bounds how many finished jobs are retained.
keep int
}
// NewManager creates an empty job manager.
func NewManager() *Manager {
return &Manager{jobs: map[string]*Job{}, keep: 50}
}
// ErrNotFound is returned for an unknown job id.
var ErrNotFound = errors.New("job not found")
// Run creates a job and executes fn in the background. fn receives a context
// that is canceled when the job is canceled, and the job handle for progress
// reporting.
func (m *Manager) Run(parent context.Context, kind Kind, title string, dryRun bool, fn func(context.Context, *Job) error) *Job {
j := newJob(newID(), kind, title, dryRun)
ctx, cancel := context.WithCancel(parent)
j.cancel = cancel
m.mu.Lock()
m.jobs[j.snap.ID] = j
m.mu.Unlock()
m.prune()
go func() {
defer cancel()
j.start()
err := fn(ctx, j)
if err == nil && ctx.Err() != nil {
err = context.Canceled
}
if errors.Is(err, context.Canceled) {
err = context.Canceled
}
j.finish(err)
}()
return j
}
// Get returns a job by id.
func (m *Manager) Get(id string) (*Job, error) {
m.mu.RLock()
defer m.mu.RUnlock()
j, ok := m.jobs[id]
if !ok {
return nil, ErrNotFound
}
return j, nil
}
// List returns every job, newest first.
func (m *Manager) List() []Snapshot {
m.mu.RLock()
jobs := make([]*Job, 0, len(m.jobs))
for _, j := range m.jobs {
jobs = append(jobs, j)
}
m.mu.RUnlock()
out := make([]Snapshot, 0, len(jobs))
for _, j := range jobs {
s := j.Snapshot()
// The list view does not need the full log.
if len(s.Log) > 5 {
s.Log = s.Log[len(s.Log)-5:]
}
out = append(out, s)
}
sort.Slice(out, func(i, k int) bool { return out[i].CreatedAt.After(out[k].CreatedAt) })
return out
}
// Delete removes a finished job. A running job is canceled instead.
func (m *Manager) Delete(id string) error {
m.mu.Lock()
j, ok := m.jobs[id]
if !ok {
m.mu.Unlock()
return ErrNotFound
}
if !j.Snapshot().State.Terminal() {
m.mu.Unlock()
j.Cancel()
return nil
}
delete(m.jobs, id)
m.mu.Unlock()
return nil
}
// prune drops the oldest finished jobs once the retention limit is exceeded.
func (m *Manager) prune() {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.jobs) <= m.keep {
return
}
type entry struct {
id string
at time.Time
}
var finished []entry
for id, j := range m.jobs {
s := j.Snapshot()
if s.State.Terminal() {
finished = append(finished, entry{id, s.CreatedAt})
}
}
sort.Slice(finished, func(i, k int) bool { return finished[i].at.Before(finished[k].at) })
for i := 0; i < len(finished) && len(m.jobs) > m.keep; i++ {
delete(m.jobs, finished[i].id)
}
}
func newID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return hex.EncodeToString([]byte(time.Now().Format("150405.000000")))
}
return hex.EncodeToString(b)
}
// CountingReader wraps a reader and reports every read to a job step, which is
// how transfer progress reaches the UI.
type CountingReader struct {
R io.Reader
Job *Job
St *Step
pending int64
lastFlush time.Time
}
// NewCountingReader builds a progress-reporting reader.
func NewCountingReader(r io.Reader, j *Job, st *Step) *CountingReader {
return &CountingReader{R: r, Job: j, St: st, lastFlush: time.Now()}
}
// Read implements io.Reader, batching counter updates so a fast transfer does
// not flood subscribers with notifications.
func (c *CountingReader) Read(p []byte) (int, error) {
n, err := c.R.Read(p)
if n > 0 {
c.pending += int64(n)
if c.pending >= 4<<20 || time.Since(c.lastFlush) > 200*time.Millisecond {
c.flush()
}
}
if err != nil {
c.flush()
}
return n, err
}
// Flush pushes any buffered byte count to the job.
func (c *CountingReader) Flush() { c.flush() }
func (c *CountingReader) flush() {
if c.pending == 0 {
return
}
c.Job.AddBytes(c.St, c.pending)
c.pending = 0
c.lastFlush = time.Now()
}
+580
View File
@@ -0,0 +1,580 @@
package migrate
import (
"fmt"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// renderInstaller generates the shell script shipped inside a migration
// package. The script is self-contained: it never parses the manifest and
// depends on nothing but bash, gzip and the docker CLI, so it can be read and
// audited by whoever runs it on the target host.
func renderInstaller(prepared []*Prepared, man *spec.Manifest, savedImages map[string]string, pkgName string) string {
payload := map[string]spec.Payload{}
for _, p := range man.Payloads {
if p.Kind == "mount" {
payload[p.Container+"\x00"+p.Destination] = p
}
}
imagePayload := map[string]spec.Payload{}
for _, p := range man.Payloads {
if p.Kind == "image" {
imagePayload[p.Image] = p
}
}
var b strings.Builder
// w formats a line. Literal blocks must be passed as an argument, never as
// the format itself: shell text is full of % and would be mangled.
w := func(format string, args ...any) {
if len(args) == 0 {
b.WriteString(format)
b.WriteByte('\n')
return
}
fmt.Fprintf(&b, format+"\n", args...)
}
w("#!/usr/bin/env bash")
w("#")
w("# Migration package: %s", pkgName)
w("# Created: %s", man.CreatedAt.Format("2006-01-02 15:04:05 MST"))
w("# Source host: %s (docker %s)", orDash(man.SourceHost), orDash(man.DockerVersion))
w("# Containers: %d", len(prepared))
w("#")
w("# Run this on the TARGET host. It needs bash, gzip and a working docker CLI.")
w("# Nothing is written outside docker's own storage and the bind mount paths")
w("# listed below.")
w("#")
w("# Usage: ./install.sh [options]")
w("# --dry-run print every command without changing anything")
w("# --yes do not ask for confirmation")
w("# --no-start create the containers but leave them stopped")
w("# --conflict MODE fail (default) | skip | replace | rename")
w("# --rename-suffix S suffix used by --conflict rename (default -migrated)")
w("# --skip-verify do not checksum the payloads")
w("# --only NAME[,NAME...] restore only these containers")
w("# --docker CMD docker command to use (default: docker)")
w("# --sudo prefix docker with sudo -n")
w("")
w("set -euo pipefail")
w("")
w(`PKGDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`)
w("DRY_RUN=0")
w("ASSUME_YES=0")
w("NO_START=0")
w("VERIFY=1")
w("ONLY=\"\"")
w("DOCKER_BIN=docker")
w("USE_SUDO=0")
w("CONFLICT=%s", spec.ShellQuote(string(defaultConflict(man.Options.Conflict))))
w("RENAME_SUFFIX=%s", spec.ShellQuote(defaultSuffix(man.Options.RenameSuffix)))
w("")
w(`while [ $# -gt 0 ]; do`)
w(` case "$1" in`)
w(` --dry-run) DRY_RUN=1 ;;`)
w(` --yes|-y) ASSUME_YES=1 ;;`)
w(` --no-start) NO_START=1 ;;`)
w(` --skip-verify) VERIFY=0 ;;`)
w(` --conflict) shift; CONFLICT="${1:-}" ;;`)
w(` --rename-suffix) shift; RENAME_SUFFIX="${1:-}" ;;`)
w(` --only) shift; ONLY="${1:-}" ;;`)
w(` --docker) shift; DOCKER_BIN="${1:-docker}" ;;`)
w(` --sudo) USE_SUDO=1 ;;`)
w(` -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;`)
w(` *) echo "unknown option: $1" >&2; exit 2 ;;`)
w(` esac`)
w(` shift`)
w(`done`)
w("")
w(`case "$CONFLICT" in fail|skip|replace|rename) ;; *) echo "invalid --conflict: $CONFLICT" >&2; exit 2 ;; esac`)
w("")
w(`if [ "$USE_SUDO" = 1 ]; then DOCKER="sudo -n $DOCKER_BIN"; else DOCKER="$DOCKER_BIN"; fi`)
w("")
w(installerHelpers)
w("")
// Preflight.
w(`log "migration package: %s"`, escapeDoubleQuoted(pkgName))
w(`preflight`)
w("")
// Bind mount summary, so the operator sees what will touch the host
// filesystem before answering the prompt.
binds := collectBinds(prepared)
if len(binds) > 0 {
w(`echo "This package writes into the following host paths:"`)
for _, p := range binds {
w(`echo " %s"`, escapeDoubleQuoted(p))
}
w("")
}
w(`confirm`)
w("")
// Networks, created once.
nets := map[string]bool{}
var netBlock strings.Builder
for _, p := range prepared {
for _, n := range p.Networks {
if nets[n.Name] || isBuiltin(n.Name) {
continue
}
nets[n.Name] = true
fmt.Fprintf(&netBlock, "ensure_network %s %s\n",
spec.ShellQuote(n.Name), spec.ShellQuoteAll(n.CreateArgs()))
}
}
if netBlock.Len() > 0 {
w(`step "networks"`)
w("%s", strings.TrimRight(netBlock.String(), "\n"))
w("")
}
// One function per container keeps the flow readable and lets --only skip
// whole containers cleanly.
for i, p := range prepared {
w("%s", renderContainerFunc(i, p, payload, imagePayload, savedImages))
}
w(`FAILED=0`)
for i, p := range prepared {
name := p.ContainerName()
w(`if selected %s; then`, spec.ShellQuote(name))
w(` if ! migrate_%d; then err "container %s failed"; FAILED=$((FAILED+1)); fi`, i, escapeDoubleQuoted(name))
w(`else`)
w(` log "skipping %s (not in --only)"`, escapeDoubleQuoted(name))
w(`fi`)
}
w("")
w(`if [ "$FAILED" -gt 0 ]; then`)
w(` err "$FAILED container(s) failed"`)
w(` exit 1`)
w(`fi`)
w(`ok "migration complete"`)
w(`if [ "$DRY_RUN" = 1 ]; then log "this was a dry run; nothing was changed"; fi`)
return b.String()
}
func renderContainerFunc(idx int, p *Prepared, payload, imagePayload map[string]spec.Payload, savedImages map[string]string) string {
var b strings.Builder
w := func(format string, args ...any) {
if len(args) == 0 {
b.WriteString(format)
b.WriteByte('\n')
return
}
fmt.Fprintf(&b, format+"\n", args...)
}
name := p.ContainerName()
sel := p.Selection
// Every command below is checked explicitly with "|| return 1". This
// function is invoked from an `if !` test, which switches `set -e` off for
// its whole body, so an unchecked failure would otherwise be swallowed and
// the container reported as migrated when it was not.
w("migrate_%d() {", idx)
w(` local base=%s`, spec.ShellQuote(name))
w(` CNAME="$base"`)
w(` step "container $base"`)
for _, note := range p.Notes {
w(` warn %s`, spec.ShellQuote(note))
}
for _, note := range p.Source.Warnings {
w(` warn %s`, spec.ShellQuote(note))
}
// Conflict handling.
w(` if object_exists container "$CNAME"; then`)
w(` case "$CONFLICT" in`)
w(` skip) warn "container $CNAME already exists; skipping"; return 0 ;;`)
w(` replace) warn "removing existing container $CNAME"; run $DOCKER rm -f "$CNAME" || return 1 ;;`)
w(` rename) CNAME="$(free_name "$base")"; warn "creating $CNAME instead" ;;`)
w(` *) err "container $CNAME already exists; rerun with --conflict replace|rename|skip"; return 1 ;;`)
w(` esac`)
w(` fi`)
// Image.
image := p.Target.Image
switch {
case !sel.MigrateImage || sel.ImageMode == spec.ImageSkip:
w(` if ! object_exists image %s; then`, spec.ShellQuote(image))
w(` err "image %s is not present and this package does not carry it"; return 1`, escapeDoubleQuoted(image))
w(` fi`)
case sel.ImageMode == spec.ImagePull:
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
default:
if rel, ok := savedImages[image]; ok {
ip := imagePayload[image]
w(` ensure_image_load %s %s %s %s || return 1`,
spec.ShellQuote(image), spec.ShellQuote(rel),
spec.ShellQuote(ip.SHA256), boolArg(ip.Compressed))
} else {
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
}
}
// Named volumes.
for _, v := range p.Volumes {
w(` ensure_volume %s %s || return 1`, spec.ShellQuote(v.Name), spec.ShellQuoteAll(v.CreateArgs()))
}
// Bind mount directories, created before the container so docker does not
// invent them with unexpected ownership halfway through.
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !isSpecialBind(m.Source) {
w(` ensure_dir %s || return 1`, spec.ShellQuote(m.Source))
}
}
// Create. The name is substituted at run time so --conflict rename works.
createArgs := p.Target.CreateArgs(p.Render)
rest := createArgs
if len(rest) >= 3 && rest[0] == "create" && rest[1] == "--name" {
rest = rest[3:]
}
w(` log "creating container $CNAME"`)
w(` run $DOCKER create --name "$CNAME" %s || { err "could not create $CNAME"; return 1; }`, spec.ShellQuoteAll(rest))
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
// The rendered args end with (network, containerName); the name is
// replaced so a renamed container still gets attached.
if len(args) < 2 {
continue
}
head := args[:len(args)-1]
w(` run $DOCKER %s "$CNAME" || { err "could not attach $CNAME to a network"; return 1; }`, spec.ShellQuoteAll(head))
}
// Data.
for _, t := range p.Transfers {
pl, ok := payload[name+"\x00"+t.Destination]
if !ok {
w(` warn "no data archive for %s in this package; leaving it empty"`, escapeDoubleQuoted(t.Destination))
continue
}
w(` verify_payload %s %s || return 1`, spec.ShellQuote(pl.Path), spec.ShellQuote(pl.SHA256))
if t.ReadOnly {
w(` log "restoring %s (read-only mount, via staging container)"`, escapeDoubleQuoted(t.Label))
w(` seed_readonly "$CNAME" %s %s %s %s || return 1`,
spec.ShellQuote(p.Target.Image), spec.ShellQuote(t.Destination),
spec.ShellQuote(pl.Path), boolArg(pl.Compressed))
} else {
w(` log "restoring %s"`, escapeDoubleQuoted(t.Label))
w(` feed_archive %s %s "$CNAME" %s || { err "could not restore %s"; return 1; }`,
spec.ShellQuote(pl.Path), boolArg(pl.Compressed), spec.ShellQuote(t.RestoreInto),
escapeDoubleQuoted(t.Label))
}
}
if sel.StartAfter {
w(` if [ "$NO_START" = 1 ]; then`)
w(` log "leaving $CNAME stopped (--no-start)"`)
w(` else`)
w(` log "starting $CNAME"`)
w(` run $DOCKER start "$CNAME" || { err "could not start $CNAME"; return 1; }`)
w(` check_running "$CNAME" || return 1`)
w(` fi`)
} else {
w(` log "$CNAME created but not started (it was not running on the source)"`)
}
w(` ok "$CNAME done"`)
w(` return 0`)
w("}")
w("")
return b.String()
}
// installerHelpers is the fixed shell prelude shared by every generated
// installer.
const installerHelpers = `
if [ -t 1 ]; then C_R=$'\033[31m'; C_G=$'\033[32m'; C_Y=$'\033[33m'; C_B=$'\033[1m'; C_0=$'\033[0m'
else C_R=""; C_G=""; C_Y=""; C_B=""; C_0=""; fi
log() { printf '%s\n' " $*"; }
step() { printf '\n%s\n' "${C_B}==> $*${C_0}"; }
ok() { printf '%s\n' " ${C_G}ok${C_0} $*"; }
warn() { printf '%s\n' " ${C_Y}warning${C_0} $*" >&2; }
err() { printf '%s\n' " ${C_R}error${C_0} $*" >&2; }
die() { err "$*"; exit 1; }
# run echoes a command and executes it, unless this is a dry run.
#
# It discards the command's own stdout itself. Callers must not add their own
# >/dev/null: that would also hide the "would run" line, leaving a dry run
# showing none of the commands it was about to execute.
run() {
if [ "$DRY_RUN" = 1 ]; then
printf ' would run:'; printf ' %q' "$@"; printf '\n'
return 0
fi
"$@" >/dev/null
}
preflight() {
command -v "$DOCKER_BIN" >/dev/null 2>&1 || die "$DOCKER_BIN is not on PATH"
if ! $DOCKER version >/dev/null 2>&1; then
die "cannot talk to the docker daemon (try --sudo, or add your user to the docker group)"
fi
command -v gzip >/dev/null 2>&1 || warn "gzip is missing; compressed payloads cannot be restored"
local srv
srv="$($DOCKER version --format '{{.Server.Version}}' 2>/dev/null || echo unknown)"
log "docker server $srv on $(uname -s) $(uname -m)"
}
confirm() {
[ "$ASSUME_YES" = 1 ] && return 0
[ "$DRY_RUN" = 1 ] && return 0
printf '%s' "Proceed? [y/N] "
local ans; read -r ans </dev/tty || ans=""
case "$ans" in y|Y|yes|YES) return 0 ;; *) echo "aborted"; exit 1 ;; esac
}
selected() {
[ -z "$ONLY" ] && return 0
local want
IFS=, read -ra want <<< "$ONLY"
local n
for n in "${want[@]}"; do [ "$n" = "$1" ] && return 0; done
return 1
}
object_exists() { # kind name
$DOCKER "$1" inspect "$2" >/dev/null 2>&1
}
free_name() { # base -> an unused container name
local base="$1" candidate="$1$RENAME_SUFFIX" i=2
while object_exists container "$candidate"; do
candidate="$base$RENAME_SUFFIX-$i"; i=$((i+1))
[ "$i" -gt 50 ] && die "no free name based on $base"
done
printf '%s' "$candidate"
}
ensure_network() { # name, then the full docker network create argv
local name="$1"; shift
if object_exists network "$name"; then
log "network $name already exists; reusing it"
return 0
fi
log "creating network $name"
run $DOCKER "$@" || { err "could not create network $name"; return 1; }
}
ensure_volume() { # name, then the full docker volume create argv
local name="$1"; shift
if object_exists volume "$name"; then
warn "volume $name already exists; restored data will be merged into it"
return 0
fi
log "creating volume $name"
run $DOCKER "$@" || { err "could not create volume $name"; return 1; }
}
ensure_dir() { # host path for a bind mount
if [ -e "$1" ]; then return 0; fi
log "creating host directory $1"
if [ "$DRY_RUN" = 1 ]; then printf ' would run: mkdir -p %q\n' "$1"; return 0; fi
mkdir -p "$1" 2>/dev/null || sudo mkdir -p "$1" || { err "cannot create $1"; return 1; }
}
ensure_image_pull() { # ref
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
log "pulling image $1"
run $DOCKER pull "$1" || { err "could not pull $1"; return 1; }
}
ensure_image_load() { # ref relpath sha256 compressed
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
verify_payload "$2" "$3" || return 1
log "loading image $1 from $2"
if [ "$DRY_RUN" = 1 ]; then printf ' would run: docker load < %q\n' "$2"; return 0; fi
if [ "$4" = 1 ]; then gzip -dc -- "$PKGDIR/$2" | $DOCKER load >/dev/null
else $DOCKER load >/dev/null < "$PKGDIR/$2"; fi
}
verify_payload() { # relpath sha256
[ -f "$PKGDIR/$1" ] || { err "payload missing from package: $1"; return 1; }
[ "$VERIFY" = 1 ] || return 0
if ! command -v sha256sum >/dev/null 2>&1; then
warn "sha256sum not available; skipping checksum verification"
VERIFY=0
return 0
fi
local got
got="$(sha256sum "$PKGDIR/$1" | cut -d' ' -f1)"
if [ "$got" != "$2" ]; then
err "checksum mismatch for $1 (package is corrupt or truncated)"
return 1
fi
}
feed_archive() { # relpath compressed container extract_into
if [ "$DRY_RUN" = 1 ]; then
printf ' would restore %q into %s:%s\n' "$1" "$3" "$4"
return 0
fi
if [ "$2" = 1 ]; then
gzip -dc -- "$PKGDIR/$1" | $DOCKER cp -a - "$3:$4"
else
$DOCKER cp -a - "$3:$4" < "$PKGDIR/$1"
fi
}
# resolve_mount prints the volume name, or the host path, backing a mount
# destination in a container that already exists.
resolve_mount() { # container destination
local d n s
while IFS='|' read -r d n s; do
if [ "$d" = "$2" ]; then
if [ -n "$n" ]; then printf '%s' "$n"; else printf '%s' "$s"; fi
return 0
fi
done < <($DOCKER inspect --format '{{range .Mounts}}{{.Destination}}|{{.Name}}|{{.Source}}{{"\n"}}{{end}}' "$1")
return 1
}
# seed_readonly fills a mount the container declares read-only. The same volume
# or host path is attached writable to a throwaway container, which is created
# but never started, and removed straight after.
seed_readonly() { # container image destination relpath compressed
if [ "$DRY_RUN" = 1 ]; then
printf ' would seed read-only mount %s from %q via a staging container\n' "$3" "$4"
return 0
fi
local store base stage mountat
store="$(resolve_mount "$1" "$3")" || { err "cannot resolve storage behind $3"; return 1; }
base="${3##*/}"
stage="dm-stage-$$-${RANDOM}"
mountat="/__docker_migrate/$base"
$DOCKER create --name "$stage" --volume "$store:$mountat" "$2" >/dev/null \
|| { err "could not create staging container for $3"; return 1; }
local rc=0
if [ "$5" = 1 ]; then
gzip -dc -- "$PKGDIR/$4" | $DOCKER cp -a - "$stage:/__docker_migrate" || rc=$?
else
$DOCKER cp -a - "$stage:/__docker_migrate" < "$PKGDIR/$4" || rc=$?
fi
$DOCKER rm -f "$stage" >/dev/null 2>&1 || true
if [ "$rc" != 0 ]; then
err "failed to seed read-only mount $3"
return 1
fi
}
check_running() { # container
[ "$DRY_RUN" = 1 ] && return 0
sleep 2
local st
st="$($DOCKER inspect --format '{{.State.Status}}' "$1" 2>/dev/null || echo missing)"
if [ "$st" != "running" ]; then
err "$1 is not running (status: $st); last log lines:"
$DOCKER logs --tail 20 "$1" 2>&1 | sed 's/^/ /' || true
return 1
fi
}
`
func renderReadme(name string, prepared []*Prepared, opts spec.Options) string {
var b strings.Builder
fmt.Fprintf(&b, "Docker migration package: %s\n", name)
fmt.Fprintf(&b, "%s\n\n", strings.Repeat("=", 27+len(name)))
b.WriteString("How to use this package\n")
b.WriteString("-----------------------\n")
b.WriteString("1. Copy this whole directory (or tar file) to the target host.\n")
b.WriteString("2. On the target host, unpack it if needed and run:\n\n")
b.WriteString(" ./install.sh --dry-run # review every command first\n")
b.WriteString(" ./install.sh # actually restore\n\n")
b.WriteString("The target host needs: bash, gzip, and a working docker CLI.\n")
b.WriteString("Nothing else is installed and no network access is required unless a\n")
b.WriteString("container's image is set to be pulled instead of carried.\n\n")
b.WriteString("Contents\n")
b.WriteString("--------\n")
b.WriteString(" install.sh self-contained restore script (read it, it is plain bash)\n")
b.WriteString(" manifest.json machine-readable description of everything in here\n")
b.WriteString(" images/ docker image archives\n")
b.WriteString(" data/ volume and bind mount contents, one tar per mount\n\n")
b.WriteString("Containers in this package\n")
b.WriteString("--------------------------\n")
for _, p := range prepared {
fmt.Fprintf(&b, " %s (image %s)\n", p.ContainerName(), p.Target.Image)
for _, t := range p.Transfers {
fmt.Fprintf(&b, " data: %s\n", t.Label)
}
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind {
fmt.Fprintf(&b, " writes host path: %s\n", m.Source)
}
}
}
if opts.DryRun {
b.WriteString("\nNOTE: this package was built in dry-run mode and contains no data archives.\n")
}
return b.String()
}
func collectBinds(prepared []*Prepared) []string {
seen := map[string]bool{}
var out []string
for _, p := range prepared {
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !seen[m.Source] {
seen[m.Source] = true
out = append(out, m.Source)
}
}
}
return out
}
// isSpecialBind reports paths that must never be created by the installer,
// because they are kernel or daemon sockets rather than data directories.
func isSpecialBind(p string) bool {
switch p {
case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/":
return true
}
return strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/dev/")
}
func boolArg(b bool) string {
if b {
return "1"
}
return "0"
}
func defaultConflict(c spec.ConflictPolicy) spec.ConflictPolicy {
if c == "" {
return spec.ConflictFail
}
return c
}
func defaultSuffix(s string) string {
if s == "" {
return "-migrated"
}
return s
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// escapeDoubleQuoted makes a value safe to interpolate inside a double-quoted
// shell string in the generated script.
func escapeDoubleQuoted(s string) string {
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`)
return r.Replace(s)
}
+323
View File
@@ -0,0 +1,323 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/arescom/docker-migrate/internal/spec"
)
func buildPrepared(t *testing.T, c *spec.Container, vols []spec.Volume, nets []spec.Network) *Prepared {
t.Helper()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, vols, nets)
if err != nil {
t.Fatal(err)
}
return p
}
func fixture(t *testing.T) ([]*Prepared, *spec.Manifest, map[string]string) {
t.Helper()
c := &spec.Container{
ID: "id1", Name: "shop-db", State: "running", Image: "postgres:16",
Env: []string{"POSTGRES_PASSWORD=p'w\"d $(whoami)"},
Labels: map[string]string{"note": "a;b`c`"},
Mounts: []spec.Mount{
{Kind: spec.MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"},
{Kind: spec.MountBind, Source: "/srv/shop/initdb", Destination: "/docker-entrypoint-initdb.d", ReadOnly: true},
},
Endpoints: []spec.Endpoint{{Network: "shopnet"}},
Ports: []spec.PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}},
}
p := buildPrepared(t,
c,
[]spec.Volume{{Name: "pgdata", Driver: "local"}},
[]spec.Network{{Name: "shopnet", Driver: "bridge"}},
)
man := &spec.Manifest{
FormatVersion: 1,
CreatedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
SourceHost: "old-host",
DockerVersion: "27.0.0",
Options: spec.DefaultOptions(),
Payloads: []spec.Payload{
{Path: "images/postgres_16.tar.gz", Kind: "image", Image: "postgres:16", SHA256: "aa", Compressed: true},
{Path: "data/shop-db/00-var_lib_postgresql_data.tar.gz", Kind: "mount",
Container: "shop-db", Destination: "/var/lib/postgresql/data", SHA256: "bb", Compressed: true},
{Path: "data/shop-db/01-docker-entrypoint-initdb.d.tar.gz", Kind: "mount",
Container: "shop-db", Destination: "/docker-entrypoint-initdb.d", SHA256: "cc", Compressed: true},
},
}
return []*Prepared{p}, man, map[string]string{"postgres:16": "images/postgres_16.tar.gz"}
}
func TestInstallerIsValidBash(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
path := filepath.Join(t.TempDir(), "install.sh")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
out, err := exec.Command(bash, "-n", path).CombinedOutput()
if err != nil {
t.Fatalf("generated installer is not valid bash: %v\n%s\n---\n%s", err, out, numbered(script))
}
}
// TestInstallerRunsCleanlyInDryRun executes the generated script against a
// stub docker, which is the closest thing to a real run that does not need a
// docker daemon.
func TestInstallerDryRunExecutes(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
// The installer checks that every payload is present even on a dry run, so
// that an incomplete package is reported before anything is changed.
writePayloads(t, dir, man)
binDir := writeStubDocker(t, dir, false)
env := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
run := func(args ...string) (string, error) {
cmd := exec.Command(bash, append([]string{"./install.sh"}, args...)...)
cmd.Dir = dir
cmd.Env = env
out, err := cmd.CombinedOutput()
return string(out), err
}
// The payload contents here are placeholders, so checksums are skipped;
// the real checksums are exercised by the end-to-end test.
text, err := run("--dry-run", "--yes", "--skip-verify")
if err != nil {
t.Fatalf("dry run failed: %v\n%s", err, text)
}
for _, want := range []string{"shop-db", "would run", "migration complete", "creating network shopnet"} {
if !strings.Contains(text, want) {
t.Errorf("dry run output missing %q:\n%s", want, text)
}
}
// A package whose payload does not match its checksum must be refused,
// rather than restoring truncated data.
corrupt, err := run("--dry-run", "--yes")
if err == nil {
t.Errorf("a payload with a bad checksum was accepted:\n%s", corrupt)
} else if !strings.Contains(corrupt, "checksum mismatch") {
t.Errorf("expected a checksum mismatch error, got:\n%s", corrupt)
}
}
// TestInstallerReportsFailedStart guards against the worst failure mode there
// is: reporting a successful migration when the container never started.
//
// The per-container work runs inside a function invoked from an `if !` test,
// which disables `set -e` for that whole function body, so every command has to
// be checked explicitly or its failure is silently discarded.
func TestInstallerReportsFailedStart(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
writePayloads(t, dir, man)
binDir := writeStubDocker(t, dir, true)
cmd := exec.Command(bash, "./install.sh", "--yes", "--skip-verify")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
text := string(out)
if err == nil {
t.Fatalf("the installer exited 0 even though the container never started:\n%s", text)
}
if strings.Contains(text, "migration complete") {
t.Errorf("the installer claimed the migration completed:\n%s", text)
}
for _, want := range []string{"could not start", "container(s) failed"} {
if !strings.Contains(text, want) {
t.Errorf("expected the output to contain %q:\n%s", want, text)
}
}
}
// TestInstallerQuotesHostileValues makes sure values taken from container
// metadata cannot break out of the generated script.
func TestInstallerQuotesHostileValues(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
// The password contains a quote, a double quote and a command
// substitution; none of it may appear unquoted.
if strings.Contains(script, "POSTGRES_PASSWORD=p'w\"d $(whoami)") {
t.Error("environment value was interpolated without quoting")
}
if !strings.Contains(script, `'POSTGRES_PASSWORD=p'\''w"d $(whoami)'`) {
t.Errorf("environment value is not quoted as expected:\n%s", grepLines(script, "POSTGRES_PASSWORD"))
}
}
func TestInstallerHandlesReadOnlyMountThroughStaging(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
if !strings.Contains(script, "seed_readonly") {
t.Error("read-only mount must be seeded through a staging container")
}
// The writable volume is fed into the real container directly. Shell-safe
// paths are emitted without quotes, which is what ShellQuote does.
want := `feed_archive data/shop-db/00-var_lib_postgresql_data.tar.gz 1 "$CNAME" /var/lib/postgresql`
if !strings.Contains(script, want) {
t.Errorf("writable volume restore command is wrong:\nwant a line containing: %s\ngot:\n%s",
want, grepLines(script, "feed_archive"))
}
// Restoring must target the parent directory, never the mount point itself,
// because the archive entries are already rooted at the last segment.
if strings.Contains(script, `"$CNAME" /var/lib/postgresql/data`) {
t.Error("archive is being extracted into the mount point instead of its parent")
}
}
func TestInstallerVerifiesChecksums(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
// Every payload in the manifest must be checksummed before it is fed to
// docker, so a truncated package fails loudly instead of restoring garbage.
for _, p := range man.Payloads {
var want string
if p.Kind == "image" {
want = "ensure_image_load " + spec.ShellQuote(p.Image) + " " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
} else {
want = "verify_payload " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
}
if !strings.Contains(script, want) {
t.Errorf("payload %s is not verified\nwant a line containing: %s\ngot:\n%s",
p.Path, want, grepLines(script, "verify_payload"))
}
}
}
func numbered(s string) string {
var b strings.Builder
for i, line := range strings.Split(s, "\n") {
b.WriteString(strings.TrimRight(line, "\r"))
b.WriteByte('\n')
if i > 200 {
b.WriteString("...\n")
break
}
}
return b.String()
}
func grepLines(s, needle string) string {
var out []string
for _, l := range strings.Split(s, "\n") {
if strings.Contains(l, needle) {
out = append(out, l)
}
}
return strings.Join(out, "\n")
}
// writePayloads materialises every payload the manifest references as a real
// gzipped tar, so the generated installer's gzip and docker cp steps behave the
// way they would with a genuine package.
func writePayloads(t *testing.T, dir string, man *spec.Manifest) {
t.Helper()
for _, p := range man.Payloads {
full := filepath.Join(dir, filepath.FromSlash(p.Path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
body := []byte("payload for " + p.Path + "\n")
if err := tw.WriteHeader(&tar.Header{
Name: "placeholder.txt", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
}
// writeStubDocker installs a fake docker CLI on PATH and returns its directory.
//
// It models just enough of the real thing for the installer to run without a
// daemon: nothing exists yet except the image, and `docker inspect --format`
// answers the mount lookup the read-only seeding path depends on. When
// failStart is set, `docker start` fails the way it does on a target whose
// published port is already taken.
func writeStubDocker(t *testing.T, dir string, failStart bool) string {
t.Helper()
startCase := ""
if failStart {
startCase = ` start) echo "Bind for 0.0.0.0:5432 failed: port is already allocated" >&2; exit 1 ;;` + "\n"
}
stub := `#!/usr/bin/env bash
# object existence probes: "docker <kind> inspect <name>"
case "$1 $2" in
"image inspect") exit 0 ;;
"container inspect"|"volume inspect"|"network inspect") exit 1 ;;
esac
case "$1" in
version) echo 27.0.0 ;;
# resolve_mount calls "docker inspect --format <tmpl> <container>"
inspect) echo "/docker-entrypoint-initdb.d|stub-volume|" ;;
` + startCase + `esac
exit 0
`
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
return binDir
}
+440
View File
@@ -0,0 +1,440 @@
package migrate
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/spec"
)
// PackageFormat selects how the finished package is laid out on disk.
type PackageFormat string
const (
// FormatDir leaves an unpacked directory, easiest to inspect and to copy
// onto a USB stick that is already mounted.
FormatDir PackageFormat = "dir"
// FormatTar produces a single .tar file, easiest to move around.
FormatTar PackageFormat = "tar"
)
// Packager writes a self-contained migration package: the container specs, the
// data archives, optionally the images, and a shell installer that replays it
// all on a target that has nothing but docker.
type Packager struct {
Src *dkr.Client
Containers []spec.Container
Volumes []spec.Volume
Networks []spec.Network
Plan spec.Plan
// OutputDir is the directory packages are created under.
OutputDir string
// Format selects a directory or a single tar file.
Format PackageFormat
// SourceHost is recorded in the manifest.
SourceHost string
}
// Result describes the produced package.
type Result struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Name string `json:"name"`
}
// Run builds the package, reporting progress into j.
func (p *Packager) Run(ctx context.Context, j *job.Job) (*Result, error) {
opts := p.Plan.Options
name := p.Plan.PackageName
if name == "" {
name = "docker-migration-" + time.Now().Format("20060102-150405")
}
name = sanitize(name)
root := filepath.Join(p.OutputDir, name)
if _, err := os.Stat(root); err == nil {
return nil, fmt.Errorf("package %s already exists in %s", name, p.OutputDir)
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create package directory: %w", err)
}
cleanup := true
defer func() {
if cleanup {
os.RemoveAll(root)
}
}()
byID := map[string]*spec.Container{}
for i := range p.Containers {
byID[p.Containers[i].ID] = &p.Containers[i]
}
var prepared []*Prepared
for _, sel := range p.Plan.Items {
if !sel.Include {
continue
}
pr, err := Prepare(byID[sel.ContainerID], sel, p.Volumes, p.Networks)
if err != nil {
return nil, fmt.Errorf("container %s: %w", sel.ContainerID, err)
}
prepared = append(prepared, pr)
}
if len(prepared) == 0 {
return nil, errors.New("nothing selected to migrate")
}
man := spec.Manifest{
FormatVersion: 1,
CreatedAt: time.Now(),
CreatedBy: "docker-migrate",
SourceHost: p.SourceHost,
Options: opts,
Items: p.Plan.Items,
}
if v, err := p.Src.Ping(ctx); err == nil {
man.DockerVersion = v
}
savedImages := map[string]string{} // image ref -> payload path
for _, pr := range prepared {
item := j.AddItem(pr.Source.ID, pr.Source.Name)
for _, n := range pr.Source.Warnings {
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
}
for _, n := range pr.Notes {
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
}
err := p.packOne(ctx, j, item, root, pr, opts, &man, savedImages)
if err != nil {
j.SetItemState(item, job.StateFailed, err)
return nil, fmt.Errorf("%s: %w", pr.Source.Name, err)
}
j.SetItemState(item, job.StateSucceeded, nil)
man.Containers = append(man.Containers, *pr.Target)
man.Volumes = append(man.Volumes, pr.Volumes...)
for _, n := range pr.Networks {
if !hasNetwork(man.Networks, n.Name) {
man.Networks = append(man.Networks, n)
}
}
}
if err := writeJSON(filepath.Join(root, "manifest.json"), man); err != nil {
return nil, err
}
installer := renderInstaller(prepared, &man, savedImages, name)
if err := os.WriteFile(filepath.Join(root, "install.sh"), []byte(installer), 0o755); err != nil {
return nil, fmt.Errorf("write installer: %w", err)
}
if err := os.WriteFile(filepath.Join(root, "README.txt"), []byte(renderReadme(name, prepared, opts)), 0o644); err != nil {
return nil, fmt.Errorf("write readme: %w", err)
}
j.Logf(job.LevelInfo, "", "wrote installer, manifest and readme")
if p.Format == FormatTar {
tarPath := root + ".tar"
j.Logf(job.LevelInfo, "", "packing %s into a single archive", name)
size, err := tarDirectory(ctx, root, tarPath, name)
if err != nil {
os.Remove(tarPath)
return nil, fmt.Errorf("create package archive: %w", err)
}
os.RemoveAll(root)
cleanup = false
j.SetArtifact(tarPath, size)
return &Result{Path: tarPath, Bytes: size, Name: name + ".tar"}, nil
}
size, _ := dirSize(root)
cleanup = false
j.SetArtifact(root, size)
return &Result{Path: root, Bytes: size, Name: name}, nil
}
func (p *Packager) packOne(
ctx context.Context, j *job.Job, item *job.Item, root string,
pr *Prepared, opts spec.Options, man *spec.Manifest, savedImages map[string]string,
) error {
compress := opts.Compress
level := opts.CompressLevel
// Image.
if pr.Selection.MigrateImage && pr.Selection.ImageMode != spec.ImageSkip && pr.Selection.ImageMode != spec.ImagePull {
ref := pr.Target.Image
if _, done := savedImages[ref]; !done {
size := p.Src.ImageSizeBytes(ctx, ref)
st := j.AddStep(item, "image", "save image "+ref, size)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run: image not written")
} else {
rel := filepath.ToSlash(filepath.Join("images", sanitize(ref)+tarExt(compress)))
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
func() (io.ReadCloser, error) { return p.Src.SaveImage(ctx, ref) })
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("save image %s: %w", ref, err)
}
payload.Kind, payload.Image = "image", ref
man.Payloads = append(man.Payloads, *payload)
savedImages[ref] = rel
}
} else {
st := j.AddStep(item, "image", "image "+ref+" already in package", 0)
j.StartStep(st)
j.SkipStep(st, "shared with another container")
}
} else if pr.Selection.ImageMode == spec.ImagePull {
j.Logf(job.LevelInfo, item.ID, "%s: image %s will be pulled by the installer", pr.Source.Name, pr.Target.Image)
}
// Data. The source container is stopped for the duration when asked.
if len(pr.Transfers) == 0 {
return nil
}
restore, err := p.quiesce(ctx, j, item, pr, opts)
if err != nil {
return err
}
defer func() {
if restore != nil {
restore()
}
}()
for i, t := range pr.Transfers {
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run: data not written")
continue
}
rel := filepath.ToSlash(filepath.Join("data", sanitize(pr.ContainerName()),
fmt.Sprintf("%02d-%s%s", i, sanitize(strings.Trim(t.Destination, "/")), tarExt(compress))))
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
func() (io.ReadCloser, error) { return p.Src.CopyOut(ctx, pr.Source.ID, t.SourcePath) })
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("archive %s: %w", t.Label, err)
}
payload.Kind = "mount"
payload.Container = pr.ContainerName()
payload.Destination = t.Destination
man.Payloads = append(man.Payloads, *payload)
}
return nil
}
// streamToFile copies a stream to a file inside the package, optionally
// gzipping it, while counting bytes and computing a checksum.
func (p *Packager) streamToFile(
ctx context.Context, j *job.Job, st *job.Step,
absPath, relPath string, compress bool, level int,
open func() (io.ReadCloser, error),
) (*spec.Payload, error) {
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return nil, err
}
src, err := open()
if err != nil {
return nil, err
}
defer src.Close()
f, err := os.Create(absPath)
if err != nil {
return nil, err
}
defer f.Close()
hash := sha256.New()
// The checksum covers the bytes as stored, so the installer can verify the
// file it is about to feed to docker.
out := io.MultiWriter(f, hash)
counted := job.NewCountingReader(src, j, st)
var copyErr error
if compress {
gz, gerr := gzip.NewWriterLevel(out, gzipLevel(nil, level))
if gerr != nil {
return nil, gerr
}
_, copyErr = io.Copy(gz, counted)
if cerr := gz.Close(); copyErr == nil {
copyErr = cerr
}
} else {
_, copyErr = io.Copy(out, counted)
}
counted.Flush()
if copyErr != nil {
return nil, copyErr
}
if err := f.Sync(); err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
return nil, err
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return &spec.Payload{
Path: relPath,
Bytes: info.Size(),
SHA256: hex.EncodeToString(hash.Sum(nil)),
Compressed: compress,
}, nil
}
func (p *Packager) quiesce(ctx context.Context, j *job.Job, item *job.Item, pr *Prepared, opts spec.Options) (func(), error) {
if opts.DryRun {
return nil, nil
}
wasRunning := pr.Source.State == "running"
if !pr.Selection.StopSourceDuringCopy {
if wasRunning {
j.AddItemWarning(item,
"archiving %s while it is running; data written during the copy may be inconsistent", pr.Source.Name)
}
return nil, nil
}
if !wasRunning {
return nil, nil
}
st := j.AddStep(item, "quiesce", "stop source "+pr.Source.Name, 0)
j.StartStep(st)
err := p.Src.Stop(ctx, pr.Source.ID, 30*time.Second)
j.FinishStep(st, err)
if err != nil {
return nil, fmt.Errorf("stop source container: %w", err)
}
return func() {
// Building a package does not move the workload anywhere, so the source
// is always put back the way it was found.
if err := p.Src.Start(context.WithoutCancel(ctx), pr.Source.ID); err != nil {
j.AddItemWarning(item, "could not restart source container: %v", err)
} else {
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", pr.Source.Name)
}
}, nil
}
func tarExt(compress bool) string {
if compress {
return ".tar.gz"
}
return ".tar"
}
func writeJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
func hasNetwork(ns []spec.Network, name string) bool {
for _, n := range ns {
if n.Name == name {
return true
}
}
return false
}
// tarDirectory packs a package directory into a single tar file, keeping the
// directory name as the archive's top-level entry.
func tarDirectory(ctx context.Context, dir, dest, prefix string) (int64, error) {
f, err := os.Create(dest)
if err != nil {
return 0, err
}
defer f.Close()
tw := tar.NewWriter(f)
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
name := prefix
if rel != "." {
name = prefix + "/" + filepath.ToSlash(rel)
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = name
if info.IsDir() {
hdr.Name += "/"
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if info.IsDir() {
return nil
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(tw, src)
return err
})
if err != nil {
tw.Close()
return 0, err
}
if err := tw.Close(); err != nil {
return 0, err
}
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
func dirSize(dir string) (int64, error) {
var total int64
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}
+235
View File
@@ -0,0 +1,235 @@
// Package migrate turns a plan into work: either commands executed on a target
// host over SSH, or a self-contained package that can be carried to the target
// on a disk.
package migrate
import (
"fmt"
"path"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// Prepared is one container resolved against the user's selection: the spec as
// it will exist on the target, plus the list of data locations to transfer.
type Prepared struct {
// Source is the container as read from the source host.
Source *spec.Container
// Target is the same container rewritten for the target: renamed mounts,
// relocated binds, dropped mounts and an optional new container name.
Target *spec.Container
// Selection is the user's answer for this container.
Selection spec.ItemSelection
// Transfers are the mounts whose contents must be copied, in target terms.
Transfers []Transfer
// Volumes are the named volumes to create on the target.
Volumes []spec.Volume
// Networks are the user-defined networks to create on the target.
Networks []spec.Network
// Render carries the flags that shape the generated docker create command.
Render spec.RenderOptions
// Notes are advisories to show next to this container.
Notes []string
}
// Transfer is one data location to copy from source to target.
type Transfer struct {
// SourcePath is the path inside the source container to read from.
SourcePath string
// Destination is the path inside the target container the data belongs at.
Destination string
// RestoreInto is the directory the tar archive is extracted into, which is
// the parent of Destination.
RestoreInto string
// Kind describes what is behind the destination on the target.
Kind spec.MountKind
// ReadOnly means the target container mounts this read-only, so the copy
// has to go through a staging container.
ReadOnly bool
// VolumeName is the named volume behind the destination, when known.
VolumeName string
// BindSource is the host path behind the destination, for bind mounts.
BindSource string
// SizeBytes is the best-effort size, or -1.
SizeBytes int64
// Label is a human description used in the progress UI.
Label string
}
// ContainerName returns the name the container will have on the target.
func (p *Prepared) ContainerName() string {
if p.Render.NameOverride != "" {
return p.Render.NameOverride
}
return p.Source.Name
}
// Prepare resolves a plan item against the source inventory.
func Prepare(
src *spec.Container,
sel spec.ItemSelection,
allVolumes []spec.Volume,
allNetworks []spec.Network,
) (*Prepared, error) {
if src == nil {
return nil, fmt.Errorf("container not found in source inventory")
}
p := &Prepared{Source: src, Selection: sel}
target := *src // shallow copy; mounts are rebuilt below
p.Render = spec.RenderOptions{
NameOverride: sel.NameOverride,
KeepStaticIPs: sel.MigrateNetworks && sel.KeepStaticIPs,
SkipNetworks: !sel.MigrateNetworks,
SkipPorts: !sel.MigratePorts,
DropMounts: map[string]bool{},
}
volByName := map[string]spec.Volume{}
for _, v := range allVolumes {
volByName[v.Name] = v
}
netByName := map[string]spec.Network{}
for _, n := range allNetworks {
netByName[n.Name] = n
}
var mounts []spec.Mount
seenVolume := map[string]bool{}
for _, m := range src.Mounts {
ms, ok := sel.Mounts[m.Destination]
if !ok {
// A mount the UI never asked about defaults to being copied, so
// data is never silently left behind.
ms = spec.MountSelection{Action: spec.MountActionCopy}
if m.Kind == spec.MountTmpfs {
ms.Action = spec.MountActionStructure
}
}
if ms.Action == spec.MountActionSkip {
p.Render.DropMounts[m.Destination] = true
p.Notes = append(p.Notes, "mount "+m.Destination+" is not migrated")
continue
}
tm := m
switch m.Kind {
case spec.MountVolume:
if ms.TargetName != "" {
tm.Name = ms.TargetName
}
if v, ok := volByName[m.Name]; ok && !seenVolume[tm.Name] {
v.Name = tm.Name
p.Volumes = append(p.Volumes, v)
seenVolume[tm.Name] = true
}
case spec.MountAnonymous:
// Anonymous volumes are recreated as fresh anonymous volumes on
// the target; their generated name carries no meaning and the data
// is restored through the container path, not the volume name.
p.Render.AnonymousVolumesAsAnonymous = true
case spec.MountBind:
if ms.TargetSource != "" {
tm.Source = ms.TargetSource
}
}
mounts = append(mounts, tm)
if ms.Action != spec.MountActionCopy || !m.HasData() {
continue
}
if isRootPath(m.Destination) {
p.Notes = append(p.Notes, "refusing to copy mount at "+m.Destination+": copying a container root is not supported")
continue
}
t := Transfer{
SourcePath: m.Destination,
Destination: tm.Destination,
RestoreInto: parentDir(tm.Destination),
Kind: tm.Kind,
ReadOnly: tm.ReadOnly,
VolumeName: tm.Name,
BindSource: tm.Source,
SizeBytes: m.SizeBytes,
}
switch tm.Kind {
case spec.MountVolume:
t.Label = "volume " + tm.Name + " -> " + tm.Destination
case spec.MountAnonymous:
t.Label = "anonymous volume -> " + tm.Destination
case spec.MountBind:
t.Label = "bind " + tm.Source + " -> " + tm.Destination
default:
t.Label = string(tm.Kind) + " -> " + tm.Destination
}
p.Transfers = append(p.Transfers, t)
}
target.Mounts = mounts
if sel.MigrateNetworks {
for _, ep := range src.Endpoints {
if n, ok := netByName[ep.Network]; ok {
p.Networks = append(p.Networks, n)
}
}
}
if !sel.MigrateImage {
p.Notes = append(p.Notes, "image is assumed to already exist on the target")
}
if sel.MigrateNetworks && sel.KeepStaticIPs {
p.Notes = append(p.Notes, "static IP addresses are reapplied; they must fit the target subnets")
}
p.Target = &target
return p, nil
}
// StagingMountPath is where a read-only destination is mounted inside the
// temporary staging container used to seed it.
const stagingRoot = "/__docker_migrate"
// StagingPaths returns the mount point and the extraction directory used when
// seeding a read-only mount through a staging container. The volume is mounted
// under a directory named after the destination's last segment so the archive,
// whose entries are rooted at that same segment, lands exactly on top of it.
func StagingPaths(destination string) (mountAt string, extractInto string) {
return path.Join(stagingRoot, path.Base(strings.TrimSuffix(destination, "/"))), stagingRoot
}
// StagingName is the throwaway container name used to seed one read-only mount.
func StagingName(container string, index int) string {
return fmt.Sprintf("dm-stage-%s-%d", sanitize(container), index)
}
func parentDir(p string) string {
d := path.Dir(strings.TrimSuffix(p, "/"))
if d == "" || d == "." {
return "/"
}
return d
}
func isRootPath(p string) bool {
p = strings.TrimSuffix(p, "/")
return p == "" || p == "/"
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if len(out) > 40 {
out = out[:40]
}
return out
}
+144
View File
@@ -0,0 +1,144 @@
package migrate
import (
"strings"
"testing"
"github.com/arescom/docker-migrate/internal/spec"
)
func sample() *spec.Container {
return &spec.Container{
ID: "abc123", Name: "app", State: "running", Image: "app:1.0",
Mounts: []spec.Mount{
{Kind: spec.MountVolume, Name: "appdata", Destination: "/data", SizeBytes: 4096},
{Kind: spec.MountBind, Source: "/srv/app/conf", Destination: "/etc/app", ReadOnly: true},
{Kind: spec.MountAnonymous, Name: strings.Repeat("f", 64), Destination: "/tmp/cache"},
{Kind: spec.MountTmpfs, Destination: "/run"},
},
Endpoints: []spec.Endpoint{{Network: "appnet"}},
}
}
func TestPrepareDefaultsCopyEverything(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel,
[]spec.Volume{{Name: "appdata", Driver: "local"}},
[]spec.Network{{Name: "appnet", Driver: "bridge"}})
if err != nil {
t.Fatal(err)
}
// tmpfs carries no data, so exactly the three real locations transfer.
if len(p.Transfers) != 3 {
t.Fatalf("expected 3 transfers, got %d: %+v", len(p.Transfers), p.Transfers)
}
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata" {
t.Errorf("named volume not scheduled for creation: %+v", p.Volumes)
}
if len(p.Networks) != 1 {
t.Errorf("network not scheduled for creation: %+v", p.Networks)
}
byDest := map[string]Transfer{}
for _, tr := range p.Transfers {
byDest[tr.Destination] = tr
}
if got := byDest["/data"].RestoreInto; got != "/" {
t.Errorf("/data must be restored into /, got %q", got)
}
if got := byDest["/etc/app"].RestoreInto; got != "/etc" {
t.Errorf("/etc/app must be restored into /etc, got %q", got)
}
if !byDest["/etc/app"].ReadOnly {
t.Error("read-only bind must be flagged so it is seeded through a staging container")
}
}
func TestPrepareSkipAndRelocate(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
sel.Mounts["/tmp/cache"] = spec.MountSelection{Action: spec.MountActionSkip}
sel.Mounts["/etc/app"] = spec.MountSelection{Action: spec.MountActionCopy, TargetSource: "/opt/app/conf"}
sel.Mounts["/data"] = spec.MountSelection{Action: spec.MountActionStructure, TargetName: "appdata2"}
p, err := Prepare(c, sel, []spec.Volume{{Name: "appdata", Driver: "local"}}, nil)
if err != nil {
t.Fatal(err)
}
if !p.Render.DropMounts["/tmp/cache"] {
t.Error("skipped mount must be dropped from the create command")
}
// structure-only means the volume is created but no data is copied.
for _, tr := range p.Transfers {
if tr.Destination == "/data" {
t.Error("a structure-only mount must not be transferred")
}
}
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata2" {
t.Errorf("renamed volume not applied: %+v", p.Volumes)
}
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
if !strings.Contains(args, "--volume /opt/app/conf:/etc/app:ro") {
t.Errorf("relocated bind not applied: %s", args)
}
if !strings.Contains(args, "--volume appdata2:/data") {
t.Errorf("renamed volume not applied to create args: %s", args)
}
if strings.Contains(args, "/tmp/cache") {
t.Errorf("skipped mount still present: %s", args)
}
}
func TestPrepareAnonymousVolumeIsRecreatedFresh(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, nil, nil)
if err != nil {
t.Fatal(err)
}
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
if strings.Contains(args, strings.Repeat("f", 64)) {
t.Errorf("the generated volume name must not be pinned on the target: %s", args)
}
if !strings.Contains(args, "--volume /tmp/cache") {
t.Errorf("anonymous volume must still be declared: %s", args)
}
}
func TestStagingPathsLandArchiveOnTheMountPoint(t *testing.T) {
// A tar produced from /var/lib/postgresql/data has entries rooted at
// "data/", so the staging container must mount the volume at
// <root>/data and extract into <root>.
mountAt, into := StagingPaths("/var/lib/postgresql/data")
if mountAt != into+"/data" {
t.Fatalf("mount point %q is not directly under the extraction dir %q", mountAt, into)
}
}
func TestPrepareRefusesRootMount(t *testing.T) {
c := &spec.Container{
ID: "x", Name: "weird", Image: "img",
Mounts: []spec.Mount{{Kind: spec.MountBind, Source: "/", Destination: "/"}},
}
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(p.Transfers) != 0 {
t.Errorf("a mount at / must not be copied: %+v", p.Transfers)
}
if len(p.Notes) == 0 {
t.Error("refusing to copy / should be reported to the operator")
}
}
+732
View File
@@ -0,0 +1,732 @@
package migrate
import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/arescom/docker-migrate/internal/sshx"
)
// SSHRunner migrates containers straight from the local daemon to a target
// host over one SSH connection. Nothing is written to disk on either side:
// tar streams go from the source daemon into a remote `docker cp`.
type SSHRunner struct {
Src *dkr.Client
Dst *sshx.RemoteDocker
Containers []spec.Container
Volumes []spec.Volume
Networks []spec.Network
Plan spec.Plan
}
// Run executes the whole plan, reporting into j.
func (r *SSHRunner) Run(ctx context.Context, j *job.Job) error {
opts := r.Plan.Options
if opts.Parallelism < 1 {
opts.Parallelism = 1
}
pre, err := r.Dst.Preflight(ctx)
if err != nil {
return fmt.Errorf("target preflight: %w", err)
}
for _, p := range pre.Problems {
j.Logf(job.LevelWarn, "", "target: %s", p)
}
if pre.ServerVersion == "" {
return errors.New("target host cannot run docker; see the warnings above")
}
j.Logf(job.LevelInfo, "", "target docker %s (%s/%s), free space on %s: %s",
pre.ServerVersion, pre.OS, pre.Arch, pre.DockerRoot, humanBytes(pre.DiskFreeBytes))
compress := opts.Compress && pre.HasGzip
if opts.Compress && !pre.HasGzip {
j.Logf(job.LevelWarn, "", "gzip missing on target; sending data uncompressed")
}
if opts.DryRun {
j.Logf(job.LevelInfo, "", "dry run: no command below is executed on the target")
}
byID := map[string]*spec.Container{}
for i := range r.Containers {
byID[r.Containers[i].ID] = &r.Containers[i]
}
// Prepare everything up front so a bad selection fails before any change.
var prepared []*Prepared
for _, sel := range r.Plan.Items {
if !sel.Include {
continue
}
p, err := Prepare(byID[sel.ContainerID], sel, r.Volumes, r.Networks)
if err != nil {
return fmt.Errorf("container %s: %w", sel.ContainerID, err)
}
prepared = append(prepared, p)
}
if len(prepared) == 0 {
return errors.New("nothing selected to migrate")
}
// Networks are shared between containers, so they are created once, before
// the per-container work fans out.
if err := r.ensureNetworks(ctx, j, prepared, opts); err != nil {
return err
}
sem := make(chan struct{}, opts.Parallelism)
var wg sync.WaitGroup
var mu sync.Mutex
var failures int
for _, p := range prepared {
p := p
item := j.AddItem(p.Source.ID, p.Source.Name)
for _, n := range p.Source.Warnings {
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
}
for _, n := range p.Notes {
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
}
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
j.SetItemState(item, job.StateCanceled, nil)
return
}
defer func() { <-sem }()
err := r.migrateOne(ctx, j, item, p, opts, compress)
switch {
case err == nil:
j.SetItemState(item, job.StateSucceeded, nil)
case errors.Is(err, errSkipped):
j.SetItemState(item, job.StateSkipped, err)
case errors.Is(err, context.Canceled):
j.SetItemState(item, job.StateCanceled, err)
default:
j.SetItemState(item, job.StateFailed, err)
j.Logf(job.LevelError, item.ID, "%s: %v", p.Source.Name, err)
mu.Lock()
failures++
mu.Unlock()
}
}()
}
wg.Wait()
if ctx.Err() != nil {
return context.Canceled
}
if failures > 0 {
return fmt.Errorf("%d of %d containers failed to migrate", failures, len(prepared))
}
return nil
}
var errSkipped = errors.New("skipped")
func (r *SSHRunner) migrateOne(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
name := p.ContainerName()
// 1. Name conflict on the target.
stepConflict := j.AddStep(item, "conflict", "check target for an existing "+name, 0)
j.StartStep(stepConflict)
newName, action, err := r.resolveConflict(ctx, j, name, opts)
if err != nil {
j.FinishStep(stepConflict, err)
return err
}
if action == "skip" {
j.SkipStep(stepConflict, "container already exists on target")
return fmt.Errorf("%w: %s already exists on the target", errSkipped, name)
}
if newName != name {
j.Logf(job.LevelWarn, item.ID, "%s already exists on target; creating %s instead", name, newName)
p.Render.NameOverride = newName
name = newName
}
j.FinishStep(stepConflict, nil)
// 2. Image.
if err := r.ensureImage(ctx, j, item, p, opts, compress); err != nil {
return err
}
// 3. Named volumes.
if err := r.ensureVolumes(ctx, j, item, p, opts); err != nil {
return err
}
// 4. Create the container, stopped. Creating it before the data copy is
// what makes volumes and bind directories exist with the right identity.
stepCreate := j.AddStep(item, "create", "create container "+name, 0)
j.StartStep(stepCreate)
createArgs := p.Target.CreateArgs(p.Render)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(createArgs...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, createArgs...); err != nil {
j.FinishStep(stepCreate, err)
return fmt.Errorf("create container on target: %w", err)
}
}
j.FinishStep(stepCreate, nil)
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
st := j.AddStep(item, "netconnect", "attach "+args[len(args)-2], 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, args...); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("attach extra network: %w", err)
}
}
j.FinishStep(st, nil)
}
// 5. Data. The source container is stopped first when asked, so the files
// are not changing underneath the copy.
if len(p.Transfers) > 0 {
restore, err := r.quiesceSource(ctx, j, item, p, opts)
if err != nil {
return err
}
copyErr := r.transferAll(ctx, j, item, p, opts, compress, name)
if restore != nil {
restore()
}
if copyErr != nil {
return copyErr
}
} else if p.Selection.StopSourceAfter && !opts.DryRun {
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
j.AddItemWarning(item, "could not stop source container: %v", err)
}
}
// 6. Start.
if p.Selection.StartAfter {
st := j.AddStep(item, "start", "start "+name+" on target", 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("start", name))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, "start", name); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("start container on target: %w", err)
}
}
j.FinishStep(st, nil)
}
// 7. Verify.
if opts.VerifyAfter && !opts.DryRun {
st := j.AddStep(item, "verify", "verify "+name+" on target", 0)
j.StartStep(st)
err := r.verify(ctx, j, item, p, name)
j.FinishStep(st, err)
if err != nil {
return err
}
}
return nil
}
// quiesceSource stops the source container when the selection asks for a
// consistent copy, and returns the function that puts it back the way the
// operator wants it afterwards.
func (r *SSHRunner) quiesceSource(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) (func(), error) {
if opts.DryRun {
return nil, nil
}
wasRunning := p.Source.State == "running"
if !p.Selection.StopSourceDuringCopy {
if wasRunning {
j.AddItemWarning(item,
"copying %s while it is running; data written during the copy may be inconsistent", p.Source.Name)
}
return func() {
if p.Selection.StopSourceAfter && wasRunning {
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
j.AddItemWarning(item, "could not stop source container: %v", err)
}
}
}, nil
}
if wasRunning {
st := j.AddStep(item, "quiesce", "stop source "+p.Source.Name, 0)
j.StartStep(st)
err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second)
j.FinishStep(st, err)
if err != nil {
return nil, fmt.Errorf("stop source container: %w", err)
}
}
return func() {
if !wasRunning || p.Selection.StopSourceAfter {
return
}
if err := r.Src.Start(context.WithoutCancel(ctx), p.Source.ID); err != nil {
j.AddItemWarning(item, "could not restart source container: %v", err)
} else {
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", p.Source.Name)
}
}, nil
}
func (r *SSHRunner) transferAll(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool, targetName string) error {
// Ask the target what it actually created, so read-only mounts can be
// seeded through a staging container that mounts the same volume writable.
var resolved map[string]targetMount
if !opts.DryRun && anyReadOnlyTransfer(p.Transfers) {
var err error
resolved, err = r.inspectTargetMounts(ctx, targetName)
if err != nil {
return fmt.Errorf("inspect target mounts: %w", err)
}
}
for i, t := range p.Transfers {
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
j.StartStep(st)
if opts.DryRun {
j.Logf(job.LevelCmd, item.ID, "target: %s (fed with a tar stream of %s from %s)",
r.Dst.Cmd("cp", "-a", "-", targetName+":"+t.RestoreInto), t.SourcePath, p.Source.Name)
j.SkipStep(st, "dry run")
continue
}
err := r.transferOne(ctx, j, item, st, p, t, i, opts, compress, targetName, resolved)
j.FinishStep(st, err)
if err != nil {
return err
}
}
return nil
}
func (r *SSHRunner) transferOne(
ctx context.Context, j *job.Job, item *job.Item, st *job.Step, p *Prepared,
t Transfer, index int, opts spec.Options, compress bool, targetName string, resolved map[string]targetMount,
) error {
// Pick where the archive is extracted. A writable mount is filled through
// the real container; a read-only one goes through a staging container that
// mounts the same volume or host path writable.
cpTarget := targetName
extractInto := t.RestoreInto
var cleanup func()
if t.ReadOnly {
tm, ok := resolved[t.Destination]
if !ok {
return fmt.Errorf("target does not report a mount at %s", t.Destination)
}
stageName := StagingName(targetName, index)
mountAt, into := StagingPaths(t.Destination)
var source string
switch {
case tm.Name != "":
source = tm.Name
case tm.Source != "":
source = tm.Source
default:
return fmt.Errorf("cannot resolve the storage behind read-only mount %s", t.Destination)
}
_, _ = r.Dst.Run(ctx, "rm", "-f", stageName)
args := []string{"create", "--name", stageName, "--volume", source + ":" + mountAt, p.Target.Image}
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if _, err := r.Dst.Run(ctx, args...); err != nil {
return fmt.Errorf("create staging container for read-only mount %s: %w", t.Destination, err)
}
cleanup = func() { _, _ = r.Dst.Run(context.WithoutCancel(ctx), "rm", "-f", stageName) }
cpTarget, extractInto = stageName, into
j.Logf(job.LevelInfo, item.ID, "seeding read-only mount %s through staging container %s", t.Destination, stageName)
}
if cleanup != nil {
defer cleanup()
}
src, err := r.Src.CopyOut(ctx, p.Source.ID, t.SourcePath)
if err != nil {
return err
}
defer src.Close()
counted := job.NewCountingReader(src, j, st)
body := io.Reader(counted)
if compress {
pr, pw := io.Pipe()
go func() {
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
if gerr != nil {
pw.CloseWithError(gerr)
return
}
_, cerr := io.Copy(gz, counted)
if closeErr := gz.Close(); cerr == nil {
cerr = closeErr
}
pw.CloseWithError(cerr)
}()
body = pr
}
cpArgs := []string{"cp", "-a", "-", cpTarget + ":" + extractInto}
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(cpArgs...))
if err := r.Dst.Feed(ctx, body, compress, cpArgs...); err != nil {
return fmt.Errorf("copy %s: %w", t.Label, err)
}
counted.Flush()
return nil
}
type targetMount struct {
Kind string
Name string
Source string
}
// inspectTargetMounts reads back the mounts the target actually created,
// which is the only way to learn the generated name of an anonymous volume.
func (r *SSHRunner) inspectTargetMounts(ctx context.Context, name string) (map[string]targetMount, error) {
const format = `{{range .Mounts}}{{.Type}}` + "\t" + `{{.Name}}` + "\t" + `{{.Source}}` + "\t" + `{{.Destination}}{{"\n"}}{{end}}`
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
if err != nil {
return nil, err
}
res := map[string]targetMount{}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(strings.TrimRight(line, "\r"), "\t")
if len(f) != 4 {
continue
}
res[f[3]] = targetMount{Kind: f[0], Name: f[1], Source: f[2]}
}
return res, nil
}
func (r *SSHRunner) ensureImage(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
ref := p.Target.Image
sel := p.Selection
if !sel.MigrateImage || sel.ImageMode == spec.ImageSkip {
st := j.AddStep(item, "image", "check image "+ref+" on target", 0)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
ok, err := r.Dst.Exists(ctx, "image", ref)
j.FinishStep(st, err)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("image %s is not on the target and image migration is disabled", ref)
}
return nil
}
mode := sel.ImageMode
if mode == "" {
mode = spec.ImageAuto
}
if mode == spec.ImageAuto && !opts.DryRun {
if ok, err := r.Dst.Exists(ctx, "image", ref); err == nil && ok {
st := j.AddStep(item, "image", "image "+ref+" already on target", 0)
j.StartStep(st)
j.SkipStep(st, "already present")
return nil
}
}
tryPull := mode == spec.ImagePull ||
(mode == spec.ImageAuto && looksPullable(ref) && len(r.Src.ImageRepoDigests(ctx, ref)) > 0)
if tryPull {
st := j.AddStep(item, "image", "pull "+ref+" on target", 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("pull", ref))
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
_, err := r.Dst.Run(ctx, "pull", ref)
if err == nil {
j.FinishStep(st, nil)
return nil
}
if mode == spec.ImagePull {
j.FinishStep(st, err)
return fmt.Errorf("pull image on target: %w", err)
}
j.SkipStep(st, "pull failed, falling back to streaming the image")
j.Logf(job.LevelWarn, item.ID, "pull of %s failed on target (%v); streaming layers instead", ref, err)
}
size := r.Src.ImageSizeBytes(ctx, ref)
st := j.AddStep(item, "image", "stream image "+ref, size)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("load"))
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
src, err := r.Src.SaveImage(ctx, ref)
if err != nil {
j.FinishStep(st, err)
return err
}
defer src.Close()
counted := job.NewCountingReader(src, j, st)
body := io.Reader(counted)
if compress {
pr, pw := io.Pipe()
go func() {
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
if gerr != nil {
pw.CloseWithError(gerr)
return
}
_, cerr := io.Copy(gz, counted)
if closeErr := gz.Close(); cerr == nil {
cerr = closeErr
}
pw.CloseWithError(cerr)
}()
body = pr
}
err = r.Dst.Feed(ctx, body, compress, "load")
counted.Flush()
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("load image on target: %w", err)
}
return nil
}
func (r *SSHRunner) ensureVolumes(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) error {
for _, v := range p.Volumes {
st := j.AddStep(item, "volume-"+v.Name, "ensure volume "+v.Name, 0)
j.StartStep(st)
if !opts.DryRun {
exists, err := r.Dst.Exists(ctx, "volume", v.Name)
if err != nil {
j.FinishStep(st, err)
return err
}
if exists {
switch opts.Conflict {
case spec.ConflictReplace:
j.Logf(job.LevelWarn, item.ID, "removing existing volume %s on target", v.Name)
if _, err := r.Dst.Run(ctx, "volume", "rm", "-f", v.Name); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("remove existing volume %s: %w", v.Name, err)
}
default:
// Reusing an existing volume is the safe default: the copy
// below writes into it without destroying anything else.
j.SkipStep(st, "volume already exists on target and is reused")
j.AddItemWarning(item, "volume %s already exists on the target; its current contents will be merged with the copied data", v.Name)
continue
}
}
}
args := v.CreateArgs()
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, args...); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("create volume %s: %w", v.Name, err)
}
}
j.FinishStep(st, nil)
}
return nil
}
func (r *SSHRunner) ensureNetworks(ctx context.Context, j *job.Job, prepared []*Prepared, opts spec.Options) error {
seen := map[string]bool{}
for _, p := range prepared {
for _, n := range p.Networks {
if seen[n.Name] || isBuiltin(n.Name) {
continue
}
seen[n.Name] = true
if !opts.DryRun {
exists, err := r.Dst.Exists(ctx, "network", n.Name)
if err != nil {
return err
}
if exists {
j.Logf(job.LevelInfo, "", "network %s already exists on target; reusing it", n.Name)
continue
}
}
args := n.CreateArgs()
j.Logf(job.LevelCmd, "", "target: %s", r.Dst.Cmd(args...))
if opts.DryRun {
continue
}
if _, err := r.Dst.Run(ctx, args...); err != nil {
return fmt.Errorf("create network %s: %w", n.Name, err)
}
j.Logf(job.LevelInfo, "", "created network %s on target", n.Name)
}
}
return nil
}
// resolveConflict decides what to do about an existing container on the target
// and returns the name to use.
func (r *SSHRunner) resolveConflict(ctx context.Context, j *job.Job, name string, opts spec.Options) (string, string, error) {
exists, err := r.Dst.Exists(ctx, "container", name)
if err != nil {
return "", "", err
}
if !exists {
return name, "create", nil
}
switch opts.Conflict {
case spec.ConflictSkip:
return name, "skip", nil
case spec.ConflictReplace:
j.Logf(job.LevelWarn, "", "removing existing container %s on target", name)
if opts.DryRun {
return name, "create", nil
}
if _, err := r.Dst.Run(ctx, "rm", "-f", name); err != nil {
return "", "", fmt.Errorf("remove existing container %s: %w", name, err)
}
return name, "create", nil
case spec.ConflictRename:
suffix := opts.RenameSuffix
if suffix == "" {
suffix = "-migrated"
}
candidate := name + suffix
for i := 2; ; i++ {
ok, err := r.Dst.Exists(ctx, "container", candidate)
if err != nil {
return "", "", err
}
if !ok {
return candidate, "create", nil
}
candidate = fmt.Sprintf("%s%s-%d", name, suffix, i)
if i > 50 {
return "", "", fmt.Errorf("could not find a free name based on %s", name)
}
}
default:
return "", "", fmt.Errorf("container %s already exists on the target", name)
}
}
func (r *SSHRunner) verify(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, name string) error {
const format = `{{.State.Status}}` + "\t" + `{{.Config.Image}}` + "\t" + `{{len .Mounts}}`
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
if err != nil {
return fmt.Errorf("container %s is not inspectable on the target: %w", name, err)
}
f := strings.Split(strings.TrimSpace(out), "\t")
if len(f) != 3 {
return fmt.Errorf("unexpected inspect output for %s", name)
}
status, image, mountCount := f[0], f[1], f[2]
wantMounts := 0
for _, m := range p.Target.Mounts {
if !p.Render.DropMounts[m.Destination] {
wantMounts++
}
}
if fmt.Sprint(wantMounts) != mountCount {
j.AddItemWarning(item, "target container %s reports %s mounts, expected %d", name, mountCount, wantMounts)
}
if image != p.Target.Image {
j.AddItemWarning(item, "target container %s runs image %s, expected %s", name, image, p.Target.Image)
}
if p.Selection.StartAfter && status != "running" {
logs, _ := r.Dst.Run(ctx, "logs", "--tail", "20", name)
if s := strings.TrimSpace(logs); s != "" {
j.Logf(job.LevelError, item.ID, "last logs from %s:\n%s", name, s)
}
return fmt.Errorf("container %s did not stay running on the target (status %s)", name, status)
}
j.Logf(job.LevelInfo, item.ID, "verified %s on target: status=%s image=%s mounts=%s", name, status, image, mountCount)
return nil
}
func anyReadOnlyTransfer(ts []Transfer) bool {
for _, t := range ts {
if t.ReadOnly {
return true
}
}
return false
}
func gzipLevel(_ *Prepared, level int) int {
if level < gzip.BestSpeed || level > gzip.BestCompression {
return gzip.BestSpeed
}
return level
}
// looksPullable reports whether a reference is a name a registry could serve,
// as opposed to a bare image id or a locally built, never-pushed tag.
func looksPullable(ref string) bool {
if ref == "" || strings.HasPrefix(ref, "sha256:") {
return false
}
if len(ref) == 64 && !strings.ContainsAny(ref, ":/.-_") {
return false
}
return true
}
func isBuiltin(name string) bool {
switch name {
case "bridge", "host", "none":
return true
}
return false
}
func humanBytes(n int64) string {
if n <= 0 {
return "unknown"
}
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+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
}
+298
View File
@@ -0,0 +1,298 @@
// Package sshx provides the SSH transport used to drive a target host that has
// nothing installed but sshd and the docker CLI.
package sshx
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
)
// AuthMethod selects how to authenticate against the target host.
type AuthMethod string
const (
AuthPassword AuthMethod = "password"
AuthKey AuthMethod = "key"
AuthAgent AuthMethod = "agent"
)
// Config describes one target host.
type Config struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Auth AuthMethod `json:"auth"`
// Password is used with AuthPassword, and as the passphrase fallback when
// a key is encrypted.
Password string `json:"password,omitempty"`
// PrivateKey holds PEM key material for AuthKey. PrivateKeyPath is read
// from disk instead when PrivateKey is empty.
PrivateKey string `json:"privateKey,omitempty"`
PrivateKeyPath string `json:"privateKeyPath,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
// Sudo prefixes every docker command with sudo -n, for hosts where the
// login user is not in the docker group.
Sudo bool `json:"sudo"`
// DockerCmd overrides the docker binary, e.g. "podman" or an absolute path.
DockerCmd string `json:"dockerCmd,omitempty"`
// SaveSecrets persists the password and key material to the connection
// store. When false the secrets live only for the current process.
SaveSecrets bool `json:"saveSecrets"`
// Timeout is the TCP/handshake timeout. Zero means 20s.
Timeout time.Duration `json:"-"`
}
func (c Config) addr() string {
port := c.Port
if port == 0 {
port = 22
}
return net.JoinHostPort(c.Host, strconv.Itoa(port))
}
// Client is a live SSH connection to a target host.
type Client struct {
cfg Config
conn *ssh.Client
}
// HostKeyError reports that the target's host key is unknown or has changed.
// The UI shows the fingerprint and asks the operator to confirm before the key
// is written to the known-hosts store.
type HostKeyError struct {
Host string
Fingerprint string
KeyType string
Changed bool // true when a different key was already trusted
}
func (e *HostKeyError) Error() string {
if e.Changed {
return fmt.Sprintf("host key for %s CHANGED (%s %s); refusing to connect", e.Host, e.KeyType, e.Fingerprint)
}
return fmt.Sprintf("host key for %s is not trusted yet (%s %s)", e.Host, e.KeyType, e.Fingerprint)
}
// Dial opens a connection, verifying the host key against the known-hosts
// store. It returns a *HostKeyError when the operator has to make a trust
// decision first.
func Dial(ctx context.Context, cfg Config, hk *KnownHosts) (*Client, error) {
auths, err := authMethods(cfg)
if err != nil {
return nil, err
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 20 * time.Second
}
var hkErr *HostKeyError
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Auth: auths,
Timeout: timeout,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := hk.Check(hostname, remote, key)
var he *HostKeyError
if errors.As(err, &he) {
hkErr = he
}
return err
},
}
d := net.Dialer{Timeout: timeout}
rawConn, err := d.DialContext(ctx, "tcp", cfg.addr())
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
}
sshConn, chans, reqs, err := ssh.NewClientConn(rawConn, cfg.addr(), clientCfg)
if err != nil {
rawConn.Close()
if hkErr != nil {
return nil, hkErr
}
return nil, fmt.Errorf("ssh handshake with %s: %w", cfg.addr(), err)
}
return &Client{cfg: cfg, conn: ssh.NewClient(sshConn, chans, reqs)}, nil
}
// Close terminates the connection.
func (c *Client) Close() error { return c.conn.Close() }
// Config returns the configuration this client was dialled with.
func (c *Client) Config() Config { return c.cfg }
// Result is the outcome of a remote command.
type Result struct {
Stdout string
Stderr string
ExitCode int
}
// Run executes a command line on the remote host and collects its output.
// The command is passed to the remote login shell, so it may contain pipes.
func (c *Client) Run(ctx context.Context, cmdline string) (*Result, error) {
var stdout, stderr bytes.Buffer
code, err := c.run(ctx, cmdline, nil, &stdout, &stderr)
res := &Result{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: code}
return res, err
}
// RunCheck executes a command and turns a non-zero exit into an error that
// carries the remote stderr, which is what the operator needs to see.
func (c *Client) RunCheck(ctx context.Context, cmdline string) (string, error) {
res, err := c.Run(ctx, cmdline)
if err != nil {
return res.Stdout, err
}
if res.ExitCode != 0 {
msg := strings.TrimSpace(res.Stderr)
if msg == "" {
msg = strings.TrimSpace(res.Stdout)
}
return res.Stdout, fmt.Errorf("remote command failed (exit %d): %s", res.ExitCode, msg)
}
return res.Stdout, nil
}
// Stream executes a command, feeding it stdin and writing its stdout to out.
// This is how bulk data crosses the wire: the tar stream produced locally is
// piped straight into a remote `docker cp` without ever touching disk.
func (c *Client) Stream(ctx context.Context, cmdline string, stdin io.Reader, stdout io.Writer) (*Result, error) {
var stderr bytes.Buffer
if stdout == nil {
stdout = io.Discard
}
code, err := c.run(ctx, cmdline, stdin, stdout, &stderr)
res := &Result{Stderr: stderr.String(), ExitCode: code}
if err != nil {
return res, err
}
if code != 0 {
return res, fmt.Errorf("remote command failed (exit %d): %s", code, strings.TrimSpace(stderr.String()))
}
return res, nil
}
func (c *Client) run(ctx context.Context, cmdline string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
sess, err := c.conn.NewSession()
if err != nil {
return -1, fmt.Errorf("open ssh session: %w", err)
}
defer sess.Close()
sess.Stdout = stdout
sess.Stderr = stderr
if stdin != nil {
sess.Stdin = stdin
}
done := make(chan error, 1)
go func() { done <- sess.Run(cmdline) }()
select {
case <-ctx.Done():
_ = sess.Signal(ssh.SIGTERM)
_ = sess.Close()
return -1, ctx.Err()
case err := <-done:
if err == nil {
return 0, nil
}
var ee *ssh.ExitError
if errors.As(err, &ee) {
return ee.ExitStatus(), nil
}
return -1, err
}
}
func authMethods(cfg Config) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
switch cfg.Auth {
case AuthPassword:
if cfg.Password == "" {
return nil, errors.New("password authentication selected but no password supplied")
}
methods = append(methods,
ssh.Password(cfg.Password),
// Many sshd setups answer with keyboard-interactive instead of the
// plain password method.
ssh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range answers {
answers[i] = cfg.Password
}
return answers, nil
}),
)
case AuthKey:
pem := []byte(cfg.PrivateKey)
if len(pem) == 0 {
if cfg.PrivateKeyPath == "" {
return nil, errors.New("key authentication selected but no key supplied")
}
b, err := os.ReadFile(cfg.PrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("read private key: %w", err)
}
pem = b
}
var signer ssh.Signer
var err error
passphrase := cfg.Passphrase
if passphrase == "" {
passphrase = cfg.Password
}
if passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(pem, []byte(passphrase))
} else {
signer, err = ssh.ParsePrivateKey(pem)
}
if err != nil {
var pm *ssh.PassphraseMissingError
if errors.As(err, &pm) {
return nil, errors.New("private key is encrypted; supply the passphrase")
}
return nil, fmt.Errorf("parse private key: %w", err)
}
methods = append(methods, ssh.PublicKeys(signer))
case AuthAgent:
sock := os.Getenv("SSH_AUTH_SOCK")
if sock == "" {
return nil, errors.New("agent authentication selected but SSH_AUTH_SOCK is not set")
}
conn, err := net.Dial("unix", sock)
if err != nil {
return nil, fmt.Errorf("connect to ssh agent: %w", err)
}
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
default:
return nil, fmt.Errorf("unknown auth method %q", cfg.Auth)
}
return methods, nil
}
// Fingerprint renders a public key the way OpenSSH shows it.
func Fingerprint(key ssh.PublicKey) string { return ssh.FingerprintSHA256(key) }
var _ = knownhosts.Normalize
+261
View File
@@ -0,0 +1,261 @@
package sshx
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// RemoteDocker drives the docker CLI on a target host over SSH. It assumes
// nothing beyond sshd, docker and a POSIX shell; gzip is used only when
// compression is enabled and is probed for first.
type RemoteDocker struct {
c *Client
binary string
sudo bool
}
// NewRemoteDocker wraps a connection.
func NewRemoteDocker(c *Client) *RemoteDocker {
cfg := c.Config()
bin := cfg.DockerCmd
if bin == "" {
bin = "docker"
}
return &RemoteDocker{c: c, binary: bin, sudo: cfg.Sudo}
}
// Cmd renders a docker invocation as a shell command line, correctly quoted.
func (r *RemoteDocker) Cmd(args ...string) string {
var b strings.Builder
if r.sudo {
b.WriteString("sudo -n ")
}
b.WriteString(spec.ShellQuote(r.binary))
if len(args) > 0 {
b.WriteString(" ")
b.WriteString(spec.ShellQuoteAll(args))
}
return b.String()
}
// Run executes a docker command and returns its stdout, failing on non-zero.
func (r *RemoteDocker) Run(ctx context.Context, args ...string) (string, error) {
return r.c.RunCheck(ctx, r.Cmd(args...))
}
// Try executes a docker command and reports success without treating a
// non-zero exit as an error. Used for existence probes.
func (r *RemoteDocker) Try(ctx context.Context, args ...string) (string, bool, error) {
res, err := r.c.Run(ctx, r.Cmd(args...))
if err != nil {
return "", false, err
}
return res.Stdout, res.ExitCode == 0, nil
}
// Feed pipes a local reader into a docker command's stdin. When decompress is
// set the remote side runs `gzip -dc` ahead of docker, so the bytes on the
// wire are compressed.
func (r *RemoteDocker) Feed(ctx context.Context, src io.Reader, decompress bool, args ...string) error {
cmd := r.Cmd(args...)
if decompress {
cmd = "gzip -dc | " + cmd
}
_, err := r.c.Stream(ctx, cmd, src, nil)
return err
}
// Preflight is what the target host was found to support.
type Preflight struct {
DockerVersion string `json:"dockerVersion"`
ServerVersion string `json:"serverVersion"`
OS string `json:"os"`
Arch string `json:"arch"`
HasGzip bool `json:"hasGzip"`
DiskFreeBytes int64 `json:"diskFreeBytes"`
DockerRoot string `json:"dockerRoot"`
Problems []string `json:"problems,omitempty"`
}
// Preflight checks everything the migration depends on before any data moves.
func (r *RemoteDocker) Preflight(ctx context.Context) (*Preflight, error) {
p := &Preflight{}
out, ok, err := r.Try(ctx, "version", "--format", "{{.Client.Version}}|{{.Server.Version}}|{{.Server.Os}}|{{.Server.Arch}}")
if err != nil {
return nil, err
}
if !ok {
res, _ := r.c.Run(ctx, r.Cmd("version"))
msg := strings.TrimSpace(res.Stderr)
if strings.Contains(msg, "permission denied") {
p.Problems = append(p.Problems,
"the login user cannot talk to the docker daemon; add it to the docker group or enable sudo for this connection")
} else if msg != "" {
p.Problems = append(p.Problems, "docker is not usable on the target: "+firstLine(msg))
} else {
p.Problems = append(p.Problems, "docker is not installed or not on PATH on the target")
}
return p, nil
}
parts := strings.Split(strings.TrimSpace(out), "|")
if len(parts) == 4 {
p.DockerVersion, p.ServerVersion, p.OS, p.Arch = parts[0], parts[1], parts[2], parts[3]
}
if p.OS != "" && p.OS != "linux" {
p.Problems = append(p.Problems, "target daemon runs "+p.OS+" containers; only linux targets are supported")
}
res, err := r.c.Run(ctx, "command -v gzip >/dev/null 2>&1")
if err != nil {
return nil, err
}
p.HasGzip = res.ExitCode == 0
if !p.HasGzip {
p.Problems = append(p.Problems, "gzip is missing on the target; transfers will run uncompressed")
}
if root, err2 := r.Run(ctx, "info", "--format", "{{.DockerRootDir}}"); err2 == nil {
p.DockerRoot = strings.TrimSpace(root)
if p.DockerRoot != "" {
// POSIX df in 1K blocks; the fourth column is available space.
cmd := "df -Pk " + spec.ShellQuote(p.DockerRoot) + " | awk 'NR==2 {print $4}'"
if dres, derr := r.c.Run(ctx, cmd); derr == nil && dres.ExitCode == 0 {
var kb int64
if _, serr := fmt.Sscanf(strings.TrimSpace(dres.Stdout), "%d", &kb); serr == nil {
p.DiskFreeBytes = kb * 1024
}
}
}
}
return p, nil
}
// TargetContainer is a container that already exists on the target host.
type TargetContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Ports string `json:"ports"`
}
// TargetInventory is the current state of the target host, used to show it
// side by side with the source and to detect name conflicts up front.
type TargetInventory struct {
Host string `json:"host"`
Containers []TargetContainer `json:"containers"`
Volumes []string `json:"volumes"`
Networks []string `json:"networks"`
Preflight *Preflight `json:"preflight"`
}
// Inventory reads the target host's containers, volumes and networks.
func (r *RemoteDocker) Inventory(ctx context.Context) (*TargetInventory, error) {
pre, err := r.Preflight(ctx)
if err != nil {
return nil, err
}
inv := &TargetInventory{Preflight: pre}
if pre.ServerVersion == "" {
return inv, nil
}
if host, err := r.c.RunCheck(ctx, "hostname"); err == nil {
inv.Host = strings.TrimSpace(host)
}
out, err := r.Run(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
if err != nil {
return nil, err
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var raw struct {
ID, Names, Image, State, Status, Ports string
}
if json.Unmarshal([]byte(line), &raw) != nil {
continue
}
// A container attached to several networks is reported with a
// comma-separated name list; the first is its real name.
name := raw.Names
if i := strings.Index(name, ","); i >= 0 {
name = name[:i]
}
inv.Containers = append(inv.Containers, TargetContainer{
ID: raw.ID, Name: name, Image: raw.Image,
State: raw.State, Status: raw.Status, Ports: raw.Ports,
})
}
if out, err := r.Run(ctx, "volume", "ls", "--format", "{{.Name}}"); err == nil {
inv.Volumes = nonEmptyLines(out)
}
if out, err := r.Run(ctx, "network", "ls", "--format", "{{.Name}}"); err == nil {
inv.Networks = nonEmptyLines(out)
}
return inv, nil
}
// Exists reports whether an object of the given kind is present on the target.
func (r *RemoteDocker) Exists(ctx context.Context, kind, name string) (bool, error) {
var args []string
switch kind {
case "container":
args = []string{"container", "inspect", name}
case "volume":
args = []string{"volume", "inspect", name}
case "network":
args = []string{"network", "inspect", name}
case "image":
args = []string{"image", "inspect", name}
default:
return false, fmt.Errorf("unknown object kind %q", kind)
}
res, err := r.c.Run(ctx, r.Cmd(args...)+" >/dev/null 2>&1")
if err != nil {
return false, err
}
return res.ExitCode == 0, nil
}
// MkdirAll creates a directory on the target host, for bind mount sources that
// need to exist with the right ownership before the container starts.
func (r *RemoteDocker) MkdirAll(ctx context.Context, path string) error {
cmd := "mkdir -p " + spec.ShellQuote(path)
if r.sudo {
cmd = "sudo -n " + cmd
}
_, err := r.c.RunCheck(ctx, cmd)
return err
}
// Client exposes the underlying SSH connection for raw shell work.
func (r *RemoteDocker) Client() *Client { return r.c }
func nonEmptyLines(s string) []string {
var out []string
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
+231
View File
@@ -0,0 +1,231 @@
package sshx
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// KnownHosts is the trust store for target host keys. It behaves like OpenSSH:
// an unknown key is refused until the operator confirms the fingerprint, and a
// changed key is refused outright.
type KnownHosts struct {
path string
mu sync.Mutex
}
// NewKnownHosts opens (and creates if needed) the store at path.
func NewKnownHosts(path string) (*KnownHosts, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create key store directory: %w", err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("open known hosts file: %w", err)
}
f.Close()
return &KnownHosts{path: path}, nil
}
// Path returns the on-disk location of the store.
func (k *KnownHosts) Path() string { return k.path }
// Check implements the ssh.HostKeyCallback contract.
func (k *KnownHosts) Check(hostname string, remote net.Addr, key ssh.PublicKey) error {
k.mu.Lock()
defer k.mu.Unlock()
cb, err := knownhosts.New(k.path)
if err != nil {
return fmt.Errorf("read known hosts: %w", err)
}
err = cb(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) {
return &HostKeyError{
Host: hostname,
Fingerprint: ssh.FingerprintSHA256(key),
KeyType: key.Type(),
Changed: len(keyErr.Want) > 0,
}
}
return err
}
// Trust records a host key so later connections succeed.
func (k *KnownHosts) Trust(hostname string, key ssh.PublicKey) error {
k.mu.Lock()
defer k.mu.Unlock()
f, err := os.OpenFile(k.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open known hosts for write: %w", err)
}
defer f.Close()
line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
if _, err := f.WriteString(line + "\n"); err != nil {
return fmt.Errorf("record host key: %w", err)
}
return nil
}
// Forget removes every entry for a host, so a changed key can be re-approved.
func (k *KnownHosts) Forget(hostname string) error {
k.mu.Lock()
defer k.mu.Unlock()
b, err := os.ReadFile(k.path)
if err != nil {
return err
}
want := knownhosts.Normalize(hostname)
var kept []byte
for _, line := range splitLines(b) {
if len(line) == 0 || line[0] == '#' {
kept = append(kept, line...)
kept = append(kept, '\n')
continue
}
_, hosts, _, _, _, perr := ssh.ParseKnownHosts(append(line, '\n'))
if perr == nil && containsHost(hosts, want) {
continue
}
kept = append(kept, line...)
kept = append(kept, '\n')
}
return os.WriteFile(k.path, kept, 0o600)
}
// HostKeyInfo is the fingerprint presented by a host, shown to the operator
// before they decide to trust it.
type HostKeyInfo struct {
Host string `json:"host"`
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
Trusted bool `json:"trusted"`
Changed bool `json:"changed"`
}
// Probe opens a TCP connection just far enough to read the host key, without
// authenticating. Used by the "check fingerprint" step in the UI.
func Probe(ctx context.Context, cfg Config, hk *KnownHosts) (*HostKeyInfo, error) {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 15 * time.Second
}
var captured ssh.PublicKey
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Timeout: timeout,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
captured = key
// Stop the handshake here: reading the key is all this needs.
return errProbeDone
},
}
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", cfg.addr())
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
}
defer conn.Close()
_, _, _, err = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
if captured == nil {
return nil, fmt.Errorf("read host key from %s: %w", cfg.addr(), err)
}
info := &HostKeyInfo{
Host: cfg.addr(),
KeyType: captured.Type(),
Fingerprint: ssh.FingerprintSHA256(captured),
}
switch checkErr := hk.Check(cfg.addr(), conn.RemoteAddr(), captured).(type) {
case nil:
info.Trusted = true
case *HostKeyError:
info.Changed = checkErr.Changed
}
return info, nil
}
// TrustFromProbe re-reads the host key and stores it. Taking the key from a
// fresh handshake rather than from client-supplied input means the UI can only
// approve a fingerprint it actually saw.
func TrustFromProbe(ctx context.Context, cfg Config, hk *KnownHosts, expectFingerprint string) error {
info, err := Probe(ctx, cfg, hk)
if err != nil {
return err
}
if expectFingerprint != "" && info.Fingerprint != expectFingerprint {
return fmt.Errorf("host key changed between check and approval (%s vs %s); aborting",
expectFingerprint, info.Fingerprint)
}
var captured ssh.PublicKey
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Timeout: 15 * time.Second,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
captured = key
return errProbeDone
},
}
conn, err := net.DialTimeout("tcp", cfg.addr(), 15*time.Second)
if err != nil {
return err
}
defer conn.Close()
_, _, _, _ = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
if captured == nil {
return errors.New("could not read host key")
}
if ssh.FingerprintSHA256(captured) != info.Fingerprint {
return errors.New("host key is unstable; aborting")
}
if info.Changed {
if err := hk.Forget(cfg.addr()); err != nil {
return fmt.Errorf("drop previous host key: %w", err)
}
}
return hk.Trust(cfg.addr(), captured)
}
var errProbeDone = errors.New("host key captured")
func splitLines(b []byte) [][]byte {
var out [][]byte
start := 0
for i := 0; i < len(b); i++ {
if b[i] == '\n' {
line := b[start:i]
if n := len(line); n > 0 && line[n-1] == '\r' {
line = line[:n-1]
}
out = append(out, line)
start = i + 1
}
}
if start < len(b) {
out = append(out, b[start:])
}
return out
}
func containsHost(hosts []string, want string) bool {
for _, h := range hosts {
if knownhosts.Normalize(h) == want {
return true
}
}
return false
}
+209
View File
@@ -0,0 +1,209 @@
// Package store persists target host connections between runs.
package store
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"github.com/arescom/docker-migrate/internal/sshx"
)
// ErrNotFound is returned for an unknown connection id.
var ErrNotFound = errors.New("connection not found")
// Connections is a small JSON-backed collection of target hosts.
//
// Secrets are only written when the operator opts in per connection. The file
// is created with owner-only permissions either way.
type Connections struct {
path string
mu sync.RWMutex
// items holds the persisted form.
items map[string]sshx.Config
// secrets holds credentials for connections that opted out of persistence,
// so they survive for the lifetime of the process but never hit disk.
secrets map[string]secret
}
type secret struct {
Password string
PrivateKey string
Passphrase string
}
// NewConnections loads (or creates) the connection file at path.
func NewConnections(path string) (*Connections, error) {
c := &Connections{path: path, items: map[string]sshx.Config{}, secrets: map[string]secret{}}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, fmt.Errorf("read connections: %w", err)
}
var list []sshx.Config
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse connections file %s: %w", path, err)
}
for _, cfg := range list {
c.items[cfg.ID] = cfg
}
return c, nil
}
// List returns every connection with secrets stripped, newest name order.
func (c *Connections) List() []sshx.Config {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]sshx.Config, 0, len(c.items))
for _, cfg := range c.items {
out = append(out, redact(cfg))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Get returns a connection ready to dial, with secrets filled back in.
func (c *Connections) Get(id string) (sshx.Config, error) {
c.mu.RLock()
defer c.mu.RUnlock()
cfg, ok := c.items[id]
if !ok {
return sshx.Config{}, ErrNotFound
}
if s, ok := c.secrets[id]; ok {
if cfg.Password == "" {
cfg.Password = s.Password
}
if cfg.PrivateKey == "" {
cfg.PrivateKey = s.PrivateKey
}
if cfg.Passphrase == "" {
cfg.Passphrase = s.Passphrase
}
}
return cfg, nil
}
// Save inserts or updates a connection and returns the stored, redacted form.
//
// When SaveSecrets is false the credentials are kept in memory only; an update
// that omits credentials keeps whatever was already held, so the UI can edit a
// connection without re-entering a password.
func (c *Connections) Save(cfg sshx.Config) (sshx.Config, error) {
if cfg.Host == "" {
return sshx.Config{}, errors.New("host is required")
}
if cfg.User == "" {
return sshx.Config{}, errors.New("user is required")
}
if cfg.Port == 0 {
cfg.Port = 22
}
if cfg.Name == "" {
cfg.Name = cfg.Host
}
c.mu.Lock()
defer c.mu.Unlock()
if cfg.ID == "" {
cfg.ID = newID()
}
prev, existed := c.items[cfg.ID]
prevSecret := c.secrets[cfg.ID]
// Carry forward credentials the caller did not resend.
if cfg.Password == "" {
cfg.Password = firstNonEmpty(prev.Password, prevSecret.Password)
}
if cfg.PrivateKey == "" {
cfg.PrivateKey = firstNonEmpty(prev.PrivateKey, prevSecret.PrivateKey)
}
if cfg.Passphrase == "" {
cfg.Passphrase = firstNonEmpty(prev.Passphrase, prevSecret.Passphrase)
}
_ = existed
if cfg.SaveSecrets {
delete(c.secrets, cfg.ID)
c.items[cfg.ID] = cfg
} else {
c.secrets[cfg.ID] = secret{
Password: cfg.Password,
PrivateKey: cfg.PrivateKey,
Passphrase: cfg.Passphrase,
}
c.items[cfg.ID] = redact(cfg)
}
if err := c.flush(); err != nil {
return sshx.Config{}, err
}
return redact(c.items[cfg.ID]), nil
}
// Delete removes a connection.
func (c *Connections) Delete(id string) error {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.items[id]; !ok {
return ErrNotFound
}
delete(c.items, id)
delete(c.secrets, id)
return c.flush()
}
// flush writes the file. The caller must hold the write lock.
func (c *Connections) flush() error {
list := make([]sshx.Config, 0, len(c.items))
for _, cfg := range c.items {
list = append(list, cfg)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write connections: %w", err)
}
if err := os.Rename(tmp, c.path); err != nil {
return fmt.Errorf("replace connections file: %w", err)
}
return nil
}
func redact(cfg sshx.Config) sshx.Config {
cfg.Password = ""
cfg.PrivateKey = ""
cfg.Passphrase = ""
return cfg
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func newID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>docker-migrate</title>
<script type="module" crossorigin src="/assets/index-BJYzrqMo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BYoxln0e.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
// Package webui embeds the built web application into the binary so the tool
// ships as a single file with no runtime assets to install.
package webui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// FS returns the built web app rooted at its index.html, or nil when the
// frontend has not been built into this binary.
func FS() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return nil
}
return sub
}
// Built reports whether a real UI is embedded, as opposed to the placeholder
// that keeps the package compiling before the frontend is built.
func Built() bool {
sub := FS()
if sub == nil {
return false
}
_, err := fs.Stat(sub, "assets")
return err == nil
}