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