Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.gitignore
dist/
docker-migrate
docker-migrate.exe
web/node_modules
internal/webui/dist
*.test
*.test.exe
README.md
+18
View File
@@ -0,0 +1,18 @@
# Binaries
/docker-migrate
/docker-migrate.exe
/dist/
*.test
*.test.exe
# Frontend
web/node_modules/
# Local data
*.log
.DS_Store
# NOTE: internal/webui/dist IS committed on purpose. It is the embedded web UI,
# and keeping it in the tree means `go build` alone produces a working binary
# without a Node toolchain. Regenerate it with `make ui`.
web/tsconfig.tsbuildinfo
+39
View File
@@ -0,0 +1,39 @@
# Build the web UI first; vite writes straight into the Go package that
# embeds it, so the Go stage picks it up without extra wiring.
FROM node:22-alpine AS ui
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY web/ ./
RUN npm run build
FROM golang:1.26-alpine AS build
ARG VERSION=dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui /src/internal/webui/dist ./internal/webui/dist
# A static binary keeps the runtime image free of a libc and lets the same
# artifact be copied onto a bare-metal host.
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/docker-migrate .
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata && \
adduser -D -u 10001 migrate
COPY --from=build /out/docker-migrate /usr/local/bin/docker-migrate
# The container talks to the Docker socket mounted from the host, which is
# owned by root:docker. It therefore runs as root by default; set --user to
# override when the socket permissions on your host allow it.
ENV DOCKER_MIGRATE_DATA=/data
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD wget -qO- http://127.0.0.1:8080/api/health >/dev/null || exit 1
ENTRYPOINT ["docker-migrate"]
CMD ["serve", "--addr", "0.0.0.0:8080"]
+57
View File
@@ -0,0 +1,57 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
BIN := docker-migrate
OUT := dist
.PHONY: all ui build run test vet fmt docker clean release help
help:
@echo "make ui build the web interface into internal/webui/dist"
@echo "make build build the binary for this platform (implies ui)"
@echo "make run build and start the server on 127.0.0.1:8080"
@echo "make test run the Go tests"
@echo "make docker build the container image"
@echo "make release cross-compile linux/amd64, linux/arm64, darwin/arm64, windows/amd64"
all: build
ui:
cd web && npm ci --no-audit --no-fund && npm run build
build: ui
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) .
# Build without rebuilding the UI, for a quick backend iteration.
build-go:
CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN) .
run: build
./$(BIN) serve --addr 127.0.0.1:8080 -v
test:
go test ./...
vet:
go vet ./...
fmt:
go fmt ./...
cd web && npx tsc -b --noEmit
docker:
docker build --build-arg VERSION=$(VERSION) -t docker-migrate:$(VERSION) -t docker-migrate:latest .
release: ui
@mkdir -p $(OUT)
@set -e; for target in linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64; do \
os=$${target%/*}; arch=$${target#*/}; \
ext=""; [ "$$os" = "windows" ] && ext=".exe"; \
echo "building $$os/$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" \
-o $(OUT)/$(BIN)-$$os-$$arch$$ext .; \
done
@ls -lh $(OUT)
clean:
rm -rf $(BIN) $(BIN).exe $(OUT) internal/webui/dist/assets
rm -rf web/node_modules
+315
View File
@@ -0,0 +1,315 @@
# docker-migrate
Move Docker containers — and their data — from one host to another, from a web UI, in a few clicks.
It handles the whole container, not just the image: named volumes, anonymous volumes, bind mounts,
user-defined networks, published ports, environment, capabilities, restart policy, healthchecks and
resource limits. You pick what travels, per container and per mount.
Two ways to move things:
| Mode | What happens | When to use it |
| --- | --- | --- |
| **Host to host over SSH** | The source connects to the target over SSH and streams everything straight into the target's `docker cp` / `docker load`. Nothing touches disk in between. | The two hosts can reach each other. |
| **Migration package** | Builds a self-contained folder or `.tar` holding the data, the images, and a plain-bash `install.sh`. Carry it on a disk, run the script on the target. | Air-gapped targets, or when you want the move reviewed and replayed later. |
The target needs **nothing installed**: no agent, no Python, no Go. Just `sshd`, a working `docker`
CLI, `bash` and `gzip`.
---
## Quick start
### Run it in a container (recommended)
On the **source** host:
```bash
git clone <this repo> docker-migrate && cd docker-migrate
docker compose up -d --build
docker compose logs docker-migrate # prints the URL, including the access token
```
Then open the printed URL. It binds to `127.0.0.1` only; reach it from your laptop with a tunnel:
```bash
ssh -L 8080:127.0.0.1:8080 you@source-host
```
### Run it bare-metal
The binary is fully static and embeds the web UI, so there is nothing to install alongside it.
```bash
make build # needs Go 1.25+ and Node 20+ ... or just `go build .` if you skip the UI rebuild
./docker-migrate serve
```
Prebuilt for several platforms:
```bash
make release # dist/docker-migrate-linux-amd64, -linux-arm64, -darwin-arm64, -windows-amd64
```
`docker-migrate` needs access to the Docker socket on the source host, so run it as a user in the
`docker` group (or as root).
---
## Using it
1. **Containers tab** — everything on the source host, grouped by compose project.
Tick the ones to move. Use *select all*, *select running*, or the compose-project checkbox for
batch selection.
2. **Expand a row** (`▸`) to choose per-container details: the name on the target, whether the image
is pulled or transferred, whether networks and ports come along, and — per mount — whether to
**copy the data**, **create it empty**, or **not mount it at all**. Bind mounts can be relocated to
a different path on the target; named volumes can be renamed.
3. **Apply to selected** in the toolbar does the same thing to every selected container at once
(*copy all data*, *skip binds*, *image: pull on target*, …).
4. **Right panel** — add the target host, hit *connect*, review the options, then either
**migrate over SSH** or **build a package**.
5. **Preview the commands** shows the exact `docker` invocations that will run on the target. Nothing
is hidden.
6. **Jobs tab** — live progress per container and per mount, with the full command log.
Start with **dry run** ticked. It performs every check and prints every command without changing
anything on the target.
---
## How the data is actually moved
The interesting part is that there is exactly **one** mechanism for every kind of data location:
```
source daemon ──CopyFromContainer(/mount/path)──▶ tar stream ──gzip──▶ ssh ──▶ docker cp -a - ctr:/parent
```
The source container's own mount path is read through the Docker archive API — the same thing
`docker cp` uses. That means:
- named volumes, anonymous volumes and bind mounts are all handled identically;
- no helper image is pulled, and the container's image does not need `tar` inside it;
- it works whether the container is running or stopped;
- file ownership, permissions, symlinks and hardlinks are preserved (`docker cp -a`).
On the target the container is **created first, started last**. Creating it is what makes Docker
materialise the named volumes and bind directories; the data is then copied into the stopped
container, and only then is it started.
A mount the container declares **read-only** cannot be written through the container itself. For
those, a throwaway container is created (never started) with the same volume attached writable, the
data is copied into it, and it is removed straight after.
### What is faithfully reproduced
Image (by pull or by layer transfer), command, entrypoint, environment, labels, working directory,
user, hostname, published and exposed ports, all mount types, user-defined networks with their
subnets and the container's aliases, DNS settings, extra hosts, capabilities, devices, sysctls,
ulimits, security options, restart policy, stop signal and timeout, healthcheck, log driver and
options, memory/CPU/pids limits, privileged, read-only rootfs, init, and the PID/IPC/UTS/userns
modes.
Settings that come from the **image** are deliberately not re-emitted — the recreated container
carries only genuine run-time overrides, so it stays readable and keeps working when the image is
later updated.
### What it will not do for you
- **`--rm` is never reapplied.** A migrated container that deletes itself cannot be inspected.
- **`--volumes-from` is not reproduced.** You are warned; migrate the other container and mount
explicitly.
- **`--network container:other`** requires the other container to be migrated too. You are warned.
- **Swarm services** are out of scope. This tool moves plain containers.
- **Live databases**: copying a running database's files gives you a crash-consistent snapshot at
best. The default is to stop the source container while copying — leave it on. For anything you
really care about, take a dump instead and migrate that.
- **Cross-architecture moves**: an `amd64` image will not run on an `arm64` target. The preflight
shows the target's architecture; check it.
---
## Safety
The tool can stop containers and read every volume on the host, so it is treated as a privileged
admin tool:
- It binds to **`127.0.0.1` by default**. Binding anywhere else automatically generates an access
token and prints it.
- **SSH host keys are verified** exactly like OpenSSH. An unknown key is refused until you approve
the fingerprint in the UI; a *changed* key is refused outright until you explicitly replace it.
Trusted keys go to `<data-dir>/known_hosts`.
- **Credentials are not persisted unless you ask.** By default the password or key lives in memory
for the session. Ticking *remember* writes it to `<data-dir>/connections.json`, mode `0600`.
- **Nothing on the target is overwritten by default.** If a container name already exists the item
fails; you choose *skip*, *rename* or *replace* explicitly. An existing **volume** is reused and
merged into, never silently deleted, unless you pick *replace*.
- Every command that runs on the target is echoed into the job log.
- Bind mounts of `/var/run/docker.sock`, `/proc`, `/sys`, `/dev` and `/` are flagged, and a mount at
`/` is refused outright.
---
## The migration package
`build package` produces:
```
shop-migration/
install.sh self-contained bash; read it, it is the whole contract
manifest.json machine-readable description of everything inside
README.txt instructions for whoever runs it
images/ docker image archives (.tar.gz)
data/ one archive per mount, per container
```
On the target:
```bash
./install.sh --dry-run # print every command, change nothing
./install.sh # restore
```
The installer never parses the manifest — every command is written out literally, so it can be read
and audited before running. It checksums each payload before feeding it to Docker, and supports:
```
--dry-run print every command without changing anything
--yes do not ask for confirmation
--no-start create the containers but leave them stopped
--conflict MODE fail (default) | skip | replace | rename
--rename-suffix S suffix used by --conflict rename
--skip-verify do not checksum the payloads
--only NAME[,NAME...] restore only these containers
--docker CMD docker command to use
--sudo prefix docker with sudo -n
```
---
## Target host requirements
| Requirement | Why |
| --- | --- |
| `sshd`, reachable from the source | transport |
| `docker` CLI + a working daemon | everything |
| the login user can use docker | either in the `docker` group, or tick *run docker through `sudo -n`* |
| `gzip` | compressed transfers; without it the tool falls back to uncompressed |
| `bash` | only for the migration package installer |
The **connect** button runs a preflight and tells you which of these are missing, plus the target's
free disk space and architecture.
---
## Command line
```
docker-migrate [serve] [flags] start the web interface (default)
docker-migrate inspect [flags] print the source inventory as JSON
docker-migrate version
```
`serve` flags:
```
--addr string address to listen on (default "127.0.0.1:8080")
--token string require this token on every request; "auto" generates one
--data-dir string connections and trusted host keys (default: OS config dir)
--package-dir string where migration packages are written (default <data-dir>/packages)
--docker-host string source docker daemon (default: the DOCKER_HOST environment)
-v verbose logging
```
`inspect` is handy for scripting and for reporting bugs:
```bash
docker-migrate inspect --sizes | jq '.containers[] | {name, image, mounts}'
```
---
## HTTP API
Everything the UI does is available over HTTP. Pass the token as `X-Auth-Token` when one is set.
```
GET /api/health
GET /api/source inventory + default selections
GET /api/source/sizes volume sizes (slow)
GET /api/connections
POST /api/connections
DELETE /api/connections/{id}
POST /api/connections/{id}/probe read the SSH host key fingerprint
POST /api/connections/{id}/trust approve that fingerprint
POST /api/connections/{id}/test preflight the target
GET /api/connections/{id}/inventory what is already on the target
POST /api/plan/preview render the commands, run nothing
POST /api/migrate/ssh start a host-to-host migration
POST /api/migrate/package start a package build
GET /api/jobs, /api/jobs/{id}
GET /api/jobs/{id}/events server-sent events, live progress
POST /api/jobs/{id}/cancel
GET /api/packages, /api/packages/{name}/download
```
---
## Development
```
web/ React + TypeScript UI (vite)
internal/spec/ the transport model: a container, and how to render it back into docker flags
internal/dkr/ source Docker daemon: inventory, archive streams, image save
internal/sshx/ SSH transport, host key trust, driving the target's docker CLI
internal/migrate/ the two engines: SSH streaming, and package + installer generation
internal/job/ progress tracking for long-running work
internal/api/ HTTP handlers and SSE
internal/webui/ the built UI, embedded into the binary
```
```bash
make test # go test ./...
make vet
make ui # rebuild the embedded UI
cd web && npm run dev # UI dev server on :5173, proxying /api to :8080
```
### Verifying it works
On a Linux host with Docker (a VM is fine):
```bash
go test ./... # unit tests: command rendering, plan resolution,
# and the generated installer, checked with bash
go test -tags e2e ./test/... -v # end to end, against the real daemon:
# creates a container with a named volume, a
# read-only bind mount and an anonymous volume,
# writes files into all three, builds a package,
# runs the generated install.sh, then reads the
# files back out of the restored container
```
The e2e tests restore onto the same daemon under a suffixed name and clean up after
themselves, so a single machine is enough:
- `TestPackageRoundTrip` — builds a package and runs the generated `install.sh` for real.
- `TestSSHMigration` — drives the host-to-host engine over a genuine SSH connection to
`127.0.0.1`, so the whole transport (ssh, gzip streaming, the target's docker CLI, the
staging container for read-only mounts, the verify step) is exercised. It needs
`DM_SSH_HOST`, `DM_SSH_USER` and `DM_SSH_KEY`, and skips without them:
```bash
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dm_loop
cat ~/.ssh/dm_loop.pub >> ~/.ssh/authorized_keys
DM_SSH_HOST=127.0.0.1 DM_SSH_USER=root DM_SSH_KEY=~/.ssh/dm_loop \
go test -tags e2e ./test/... -run TestSSHMigration -v
```
Both were run against Debian 13 with Docker 29.7.2, alongside a browser pass over the web
UI covering the trust prompt, a batch migration and a package build.
`internal/webui/dist` is committed so that a plain `go build .` produces a working binary without a
Node toolchain. Rerun `make ui` after changing anything under `web/`.
+37
View File
@@ -0,0 +1,37 @@
# docker-migrate, running on the SOURCE host.
#
# docker compose up -d
# docker compose logs docker-migrate # the URL and access token are printed here
#
# The container needs the Docker socket to read containers and stream their
# data. That is equivalent to root on this host, so the UI is protected by a
# generated token and should not be published to an untrusted network.
services:
docker-migrate:
build:
context: .
args:
VERSION: ${VERSION:-dev}
image: docker-migrate:latest
container_name: docker-migrate
restart: unless-stopped
ports:
# Bind to loopback only. Use an SSH tunnel to reach it from elsewhere:
# ssh -L 8080:127.0.0.1:8080 you@this-host
- "127.0.0.1:8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Connections, trusted SSH host keys and built packages.
- migrate-data:/data
environment:
# Set a fixed token to keep the same URL across restarts.
# DOCKER_MIGRATE_TOKEN: change-me
TZ: ${TZ:-UTC}
command:
- serve
- --addr=0.0.0.0:8080
- --token=auto
volumes:
migrate-data:
+39
View File
@@ -0,0 +1,39 @@
module github.com/arescom/docker-migrate
go 1.25.0
require (
github.com/docker/docker v28.3.3+incompatible
golang.org/x/crypto v0.54.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/time v0.15.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
+134
View File
@@ -0,0 +1,134 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+678
View File
@@ -0,0 +1,678 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/migrate"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/arescom/docker-migrate/internal/sshx"
"github.com/arescom/docker-migrate/internal/store"
)
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
body := map[string]any{
"ok": true,
"dockerHost": s.docker.Endpoint,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "",
}
if v, err := s.docker.Ping(ctx); err != nil {
body["ok"] = false
body["dockerError"] = err.Error()
} else {
body["dockerVersion"] = v
}
writeJSON(w, http.StatusOK, body)
}
// handleSource returns the full inventory of the source daemon, plus the
// default selection for every container so the UI can render immediately.
func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
defaults := make(map[string]spec.ItemSelection, len(inv.Containers))
for i := range inv.Containers {
defaults[inv.Containers[i].ID] = spec.DefaultSelection(&inv.Containers[i])
}
writeJSON(w, http.StatusOK, map[string]any{
"inventory": inv,
"defaults": defaults,
"options": spec.DefaultOptions(),
})
}
// handleSourceSizes measures volume sizes, which is slow enough that the UI
// asks for it separately.
func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
sizes, err := s.docker.VolumeSizes(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"volumes": sizes})
}
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.conns.List())
}
func (s *Server) handleSaveConnection(w http.ResponseWriter, r *http.Request) {
var cfg sshx.Config
if err := decode(r, &cfg); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
saved, err := s.conns.Save(cfg)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, saved)
}
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
if err := s.conns.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleProbe reads the target's host key so the operator can compare the
// fingerprint before trusting it.
func (s *Server) handleProbe(w http.ResponseWriter, r *http.Request) {
cfg, err := s.conns.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
info, err := sshx.Probe(ctx, cfg, s.hosts)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, info)
}
// handleTrust records the host key. The fingerprint the operator approved is
// echoed back and re-checked, so approving one key cannot trust another.
func (s *Server) handleTrust(w http.ResponseWriter, r *http.Request) {
cfg, err := s.conns.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
var body struct {
Fingerprint string `json:"fingerprint"`
}
if err := decode(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := sshx.TrustFromProbe(ctx, cfg, s.hosts, body.Fingerprint); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"trusted": true})
}
func (s *Server) handleTestConnection(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
defer closeFn()
pre, err := rd.Preflight(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, pre)
}
func (s *Server) handleTargetInventory(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
if err != nil {
s.writeDialError(w, err)
return
}
defer closeFn()
inv, err := rd.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
writeJSON(w, http.StatusOK, inv)
}
// PreviewItem is what the UI shows when the operator asks "what will this do?".
type PreviewItem struct {
ContainerID string `json:"containerId"`
Name string `json:"name"`
TargetName string `json:"targetName"`
Image string `json:"image"`
Commands []string `json:"commands"`
Transfers []string `json:"transfers"`
Notes []string `json:"notes"`
Warnings []string `json:"warnings"`
TotalBytes int64 `json:"totalBytes"`
}
// handlePreview renders the exact docker commands a plan would run, without
// touching either host.
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
var plan spec.Plan
if err := decode(r, &plan); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
inv, err := s.docker.Inventory(ctx)
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
byID := map[string]*spec.Container{}
for i := range inv.Containers {
byID[inv.Containers[i].ID] = &inv.Containers[i]
}
out := []PreviewItem{}
netSeen := map[string]bool{}
var netCmds []string
for _, sel := range plan.Items {
if !sel.Include {
continue
}
p, err := migrate.Prepare(byID[sel.ContainerID], sel, inv.Volumes, inv.Networks)
if err != nil {
writeError(w, http.StatusBadRequest, "container %s: %v", sel.ContainerID, err)
return
}
item := PreviewItem{
ContainerID: p.Source.ID,
Name: p.Source.Name,
TargetName: p.ContainerName(),
Image: p.Target.Image,
Notes: p.Notes,
Warnings: p.Source.Warnings,
}
for _, n := range p.Networks {
if !netSeen[n.Name] {
netSeen[n.Name] = true
netCmds = append(netCmds, "docker "+spec.ShellQuoteAll(n.CreateArgs()))
}
}
for _, v := range p.Volumes {
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(v.CreateArgs()))
}
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(p.Target.CreateArgs(p.Render)))
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(args))
}
for _, t := range p.Transfers {
item.Transfers = append(item.Transfers, t.Label)
item.Commands = append(item.Commands,
fmt.Sprintf("docker cp -a - %s:%s # contents of %s", p.ContainerName(), t.RestoreInto, t.SourcePath))
if t.SizeBytes > 0 {
item.TotalBytes += t.SizeBytes
}
}
if p.Selection.StartAfter {
item.Commands = append(item.Commands, "docker start "+spec.ShellQuote(p.ContainerName()))
}
out = append(out, item)
}
writeJSON(w, http.StatusOK, map[string]any{
"networkCommands": netCmds,
"items": out,
})
}
type sshMigrateRequest struct {
ConnectionID string `json:"connectionId"`
Plan spec.Plan `json:"plan"`
}
func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
var req sshMigrateRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if req.ConnectionID == "" {
writeError(w, http.StatusBadRequest, "connectionId is required")
return
}
if countIncluded(req.Plan) == 0 {
writeError(w, http.StatusBadRequest, "no containers selected")
return
}
cfg, err := s.conns.Get(req.ConnectionID)
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
// The inventory is re-read now so the plan is applied to current state
// rather than to whatever the browser last loaded.
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
cancel()
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
// Dial before the job starts so a bad credential is an immediate error in
// the UI instead of a failed job.
dialCtx, dialCancel := context.WithTimeout(r.Context(), 45*time.Second)
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
dialCancel()
if err != nil {
s.writeDialError(w, err)
return
}
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
runner := &migrate.SSHRunner{
Src: s.docker,
Dst: sshx.NewRemoteDocker(client),
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
Plan: req.Plan,
}
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
defer client.Close()
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
return runner.Run(ctx, j)
})
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
type packageRequest struct {
Plan spec.Plan `json:"plan"`
Format migrate.PackageFormat `json:"format"`
}
func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
var req packageRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if countIncluded(req.Plan) == 0 {
writeError(w, http.StatusBadRequest, "no containers selected")
return
}
if req.Format == "" {
req.Format = migrate.FormatTar
}
if req.Format != migrate.FormatTar && req.Format != migrate.FormatDir {
writeError(w, http.StatusBadRequest, "format must be %q or %q", migrate.FormatTar, migrate.FormatDir)
return
}
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
inv, err := s.docker.Inventory(invCtx)
cancel()
if err != nil {
writeError(w, http.StatusBadGateway, "%v", err)
return
}
packager := &migrate.Packager{
Src: s.docker,
Containers: inv.Containers,
Volumes: inv.Volumes,
Networks: inv.Networks,
Plan: req.Plan,
OutputDir: s.cfg.PackageDir,
Format: req.Format,
SourceHost: inv.Host,
}
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
func(ctx context.Context, j *job.Job) error {
res, err := packager.Run(ctx, j)
if err != nil {
return err
}
j.Logf(job.LevelInfo, "", "package ready: %s (%s)", res.Name, humanBytes(res.Bytes))
return nil
})
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.jobs.List())
}
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeJSON(w, http.StatusOK, j.Snapshot())
}
// handleJobEvents streams job snapshots over server-sent events, coalescing
// rapid updates so a fast transfer does not saturate the browser.
func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
changes, unsubscribe := j.Subscribe()
defer unsubscribe()
send := func() bool {
b, err := json.Marshal(j.Snapshot())
if err != nil {
return false
}
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
return false
}
flusher.Flush()
return true
}
if !send() {
return
}
// Updates are batched: at most one frame every 250ms while work is busy.
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
keepalive := time.NewTicker(20 * time.Second)
defer keepalive.Stop()
dirty := false
for {
select {
case <-r.Context().Done():
return
case _, open := <-changes:
if !open {
return
}
dirty = true
case <-ticker.C:
if !dirty {
continue
}
dirty = false
if !send() {
return
}
if j.Snapshot().State.Terminal() {
fmt.Fprint(w, "event: done\ndata: {}\n\n")
flusher.Flush()
return
}
case <-keepalive.C:
fmt.Fprint(w, ": keepalive\n\n")
flusher.Flush()
case <-j.Done():
// Drain one last snapshot so the client sees the final state.
send()
fmt.Fprint(w, "event: done\ndata: {}\n\n")
flusher.Flush()
return
}
}
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
j, err := s.jobs.Get(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
j.Cancel()
writeJSON(w, http.StatusOK, map[string]any{"canceled": true})
}
func (s *Server) handleDeleteJob(w http.ResponseWriter, r *http.Request) {
if err := s.jobs.Delete(r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// PackageInfo describes one built package on disk.
type PackageInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
IsDir bool `json:"isDir"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Server) handleListPackages(w http.ResponseWriter, r *http.Request) {
entries, err := os.ReadDir(s.cfg.PackageDir)
if err != nil {
writeError(w, http.StatusInternalServerError, "read package directory: %v", err)
return
}
out := []PackageInfo{}
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
p := PackageInfo{
Name: e.Name(),
Path: filepath.Join(s.cfg.PackageDir, e.Name()),
IsDir: e.IsDir(),
CreatedAt: info.ModTime(),
}
if e.IsDir() {
p.Bytes, _ = dirBytes(p.Path)
} else {
p.Bytes = info.Size()
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
writeJSON(w, http.StatusOK, out)
}
// handleDownloadPackage streams a package file. Directory packages are not
// downloadable as-is; the operator picks the tar format for that.
func (s *Server) handleDownloadPackage(w http.ResponseWriter, r *http.Request) {
path, err := s.packagePath(r.PathValue("name"))
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
info, err := os.Stat(path)
if err != nil {
writeError(w, http.StatusNotFound, "package not found")
return
}
if info.IsDir() {
writeError(w, http.StatusBadRequest,
"this package is a directory; copy it from %s, or rebuild with the tar format", path)
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/x-tar")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(path)))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
func (s *Server) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
path, err := s.packagePath(r.PathValue("name"))
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if err := os.RemoveAll(path); err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// packagePath resolves a package name against the package directory, refusing
// anything that would escape it.
func (s *Server) packagePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return "", errors.New("invalid package name")
}
base, err := filepath.Abs(s.cfg.PackageDir)
if err != nil {
return "", err
}
full := filepath.Join(base, name)
if !strings.HasPrefix(full, base+string(os.PathSeparator)) {
return "", errors.New("invalid package name")
}
return full, nil
}
// dialTarget opens a short-lived connection for an interactive request.
func (s *Server) dialTarget(ctx context.Context, id string) (*sshx.RemoteDocker, func(), error) {
cfg, err := s.conns.Get(id)
if err != nil {
return nil, nil, err
}
client, err := sshx.Dial(ctx, cfg, s.hosts)
if err != nil {
return nil, nil, err
}
return sshx.NewRemoteDocker(client), func() { client.Close() }, nil
}
// writeDialError turns an untrusted host key into a structured response the UI
// can turn into a trust prompt.
func (s *Server) writeDialError(w http.ResponseWriter, err error) {
var hk *sshx.HostKeyError
if errors.As(err, &hk) {
writeJSON(w, http.StatusPreconditionRequired, map[string]any{
"error": err.Error(),
"hostKey": hk.Fingerprint,
"keyType": hk.KeyType,
"changed": hk.Changed,
"needsTrust": true,
"host": hk.Host,
})
return
}
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "%v", err)
return
}
writeError(w, http.StatusBadGateway, "%v", err)
}
func countIncluded(p spec.Plan) int {
n := 0
for _, i := range p.Items {
if i.Include {
n++
}
}
return n
}
func dirBytes(dir string) (int64, error) {
var total int64
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
var _ = dkr.RestorePath
+246
View File
@@ -0,0 +1,246 @@
// Package api exposes the migration tool over HTTP and serves the web UI.
package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/sshx"
"github.com/arescom/docker-migrate/internal/store"
)
// Config configures the HTTP server.
type Config struct {
// Addr is the listen address, e.g. 127.0.0.1:8080.
Addr string
// Token, when set, must be presented on every API request.
Token string
// DataDir holds connections and the known-hosts file.
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
// DockerHost overrides the source daemon address.
DockerHost string
// UI is the embedded web app; nil disables the UI.
UI fs.FS
// Logger receives request and error logs.
Logger *slog.Logger
}
// Server ties the Docker client, connection store and job manager to HTTP.
type Server struct {
cfg Config
log *slog.Logger
docker *dkr.Client
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
}
// New builds the server and everything it owns.
func New(cfg Config) (*Server, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
if cfg.PackageDir == "" {
cfg.PackageDir = filepath.Join(cfg.DataDir, "packages")
}
if err := os.MkdirAll(cfg.PackageDir, 0o755); err != nil {
return nil, fmt.Errorf("create package directory: %w", err)
}
docker, err := dkr.New(cfg.DockerHost)
if err != nil {
return nil, err
}
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
if err != nil {
return nil, err
}
hosts, err := sshx.NewKnownHosts(filepath.Join(cfg.DataDir, "known_hosts"))
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg, log: cfg.Logger, docker: docker,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// Close releases the Docker connection.
func (s *Server) Close() error { return s.docker.Close() }
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
return s.recoverer(s.logging(s.auth(s.mux)))
}
func (s *Server) routes() {
m := s.mux
m.HandleFunc("GET /api/health", s.handleHealth)
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
m.HandleFunc("GET /api/connections", s.handleListConnections)
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
m.HandleFunc("POST /api/connections/{id}/probe", s.handleProbe)
m.HandleFunc("POST /api/connections/{id}/trust", s.handleTrust)
m.HandleFunc("POST /api/connections/{id}/test", s.handleTestConnection)
m.HandleFunc("GET /api/connections/{id}/inventory", s.handleTargetInventory)
m.HandleFunc("POST /api/plan/preview", s.handlePreview)
m.HandleFunc("POST /api/migrate/ssh", s.handleMigrateSSH)
m.HandleFunc("POST /api/migrate/package", s.handleBuildPackage)
m.HandleFunc("GET /api/jobs", s.handleListJobs)
m.HandleFunc("GET /api/jobs/{id}", s.handleGetJob)
m.HandleFunc("GET /api/jobs/{id}/events", s.handleJobEvents)
m.HandleFunc("POST /api/jobs/{id}/cancel", s.handleCancelJob)
m.HandleFunc("DELETE /api/jobs/{id}", s.handleDeleteJob)
m.HandleFunc("GET /api/packages", s.handleListPackages)
m.HandleFunc("GET /api/packages/{name}/download", s.handleDownloadPackage)
m.HandleFunc("DELETE /api/packages/{name}", s.handleDeletePackage)
if s.cfg.UI != nil {
m.Handle("/", s.spaHandler())
}
}
// auth enforces the shared token on the API. The token may also be passed as a
// query parameter, because EventSource cannot set headers and neither can a
// download link.
//
// Static assets are deliberately served without it. A browser opening
// /?token=… does not carry the query string over to /assets/app.js, so gating
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
// bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
if s.cfg.Token == "" {
return next
}
want := []byte(s.cfg.Token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-Auth-Token")
if got == "" {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got = strings.TrimPrefix(h, "Bearer ")
}
}
if got == "" {
got = r.URL.Query().Get("token")
}
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
writeError(w, http.StatusUnauthorized, "invalid or missing token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
if strings.HasPrefix(r.URL.Path, "/api/") {
s.log.Debug("request", "method", r.Method, "path", r.URL.Path,
"status", sw.status, "duration", time.Since(start).Round(time.Millisecond))
}
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic serving request", "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
// Flush forwards to the wrapped writer so server-sent events keep streaming.
func (w *statusWriter) Flush() {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// spaHandler serves the built web app, falling back to index.html so client
// side routing works on a hard refresh.
func (s *Server) spaHandler() http.Handler {
files := http.FileServer(http.FS(s.cfg.UI))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(s.cfg.UI, p); err != nil {
r = r.Clone(r.Context())
r.URL.Path = "/"
w.Header().Set("Cache-Control", "no-store")
} else if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
files.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(v); err != nil {
// The response is already partially written; nothing useful is left to do.
return
}
}
type errorBody struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, code int, format string, args ...any) {
writeJSON(w, code, errorBody{Error: fmt.Sprintf(format, args...)})
}
func decode(r *http.Request, v any) error {
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 8<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return fmt.Errorf("invalid request body: %w", err)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
// Package dkr wraps the Docker Engine API with the operations the migration
// tool needs: reading a full container inventory, streaming data out of a
// container's mounts, and streaming image layers.
package dkr
import (
"context"
"fmt"
"github.com/docker/docker/api/types/system"
"github.com/docker/docker/client"
)
// Client is a connection to one Docker daemon.
type Client struct {
api *client.Client
// Endpoint is the daemon address, shown in the UI.
Endpoint string
}
// New connects to the daemon described by the standard DOCKER_* environment
// variables, or to host when it is non-empty (e.g. unix:///var/run/docker.sock
// or tcp://10.0.0.5:2375).
func New(host string) (*Client, error) {
opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()}
if host != "" {
opts = append(opts, client.WithHost(host))
}
api, err := client.NewClientWithOpts(opts...)
if err != nil {
return nil, fmt.Errorf("create docker client: %w", err)
}
return &Client{api: api, Endpoint: api.DaemonHost()}, nil
}
// API exposes the underlying SDK client for callers that need an operation
// this package does not wrap.
func (c *Client) API() *client.Client { return c.api }
// Close releases the daemon connection.
func (c *Client) Close() error { return c.api.Close() }
// Info returns daemon information, and doubles as a connectivity check.
func (c *Client) Info(ctx context.Context) (system.Info, error) {
return c.api.Info(ctx)
}
// Ping verifies the daemon is reachable and returns its version string.
func (c *Client) Ping(ctx context.Context) (string, error) {
v, err := c.api.ServerVersion(ctx)
if err != nil {
return "", err
}
return v.Version, nil
}
+160
View File
@@ -0,0 +1,160 @@
package dkr
import (
"context"
"errors"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/spec"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
)
// CopyOut streams the contents of a path inside a container as an uncompressed
// tar archive.
//
// This is the single mechanism used for every kind of data location. It works
// for named volumes, anonymous volumes and bind mounts alike, because the
// daemon resolves the mount and produces the tar itself: no helper image is
// needed, the container's own image needs no tar binary, and the container does
// not have to be running.
//
// The archive entries are rooted at the last path segment, matching `docker cp`
// semantics. Restoring therefore targets the parent directory; use RestorePath.
func (c *Client) CopyOut(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
rc, _, err := c.api.CopyFromContainer(ctx, containerID, srcPath)
if err != nil {
return nil, fmt.Errorf("read %s from %s: %w", srcPath, short(containerID), err)
}
return rc, nil
}
// CopyIn writes an uncompressed tar archive into a path inside a container.
func (c *Client) CopyIn(ctx context.Context, containerID, dstPath string, r io.Reader) error {
err := c.api.CopyToContainer(ctx, containerID, dstPath, r, container.CopyToContainerOptions{
AllowOverwriteDirWithFile: false,
CopyUIDGID: true,
})
if err != nil {
return fmt.Errorf("write %s into %s: %w", dstPath, short(containerID), err)
}
return nil
}
// RestorePath is the directory an archive produced by CopyOut must be extracted
// into so that the contents land back at the original destination.
func RestorePath(destination string) string {
d := path.Dir(strings.TrimSuffix(destination, "/"))
if d == "" || d == "." {
return "/"
}
return d
}
// SaveImage streams `docker save` output for one or more image references.
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
rc, err := c.api.ImageSave(ctx, refs)
if err != nil {
return nil, fmt.Errorf("save image %s: %w", strings.Join(refs, ","), err)
}
return rc, nil
}
// ImageSizeBytes returns the on-disk size of an image, used to estimate how
// long a streamed transfer will take.
func (c *Client) ImageSizeBytes(ctx context.Context, ref string) int64 {
insp, err := c.api.ImageInspect(ctx, ref)
if err != nil {
return -1
}
return insp.Size
}
// State returns the current status of a container, e.g. "running".
func (c *Client) State(ctx context.Context, id string) (string, error) {
j, err := c.api.ContainerInspect(ctx, id)
if err != nil {
return "", err
}
if j.State == nil {
return "", errors.New("no state in inspect payload")
}
return j.State.Status, nil
}
// Stop stops a container and waits for it to settle. A container that is
// already stopped is left alone.
func (c *Client) Stop(ctx context.Context, id string, timeout time.Duration) error {
st, err := c.State(ctx, id)
if err != nil {
return err
}
if st != "running" && st != "restarting" && st != "paused" {
return nil
}
secs := int(timeout.Seconds())
if err := c.api.ContainerStop(ctx, id, container.StopOptions{Timeout: &secs}); err != nil {
return fmt.Errorf("stop %s: %w", short(id), err)
}
return nil
}
// Start starts a container.
func (c *Client) Start(ctx context.Context, id string) error {
if err := c.api.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
return fmt.Errorf("start %s: %w", short(id), err)
}
return nil
}
// VolumeSizes measures every local volume in one daemon round trip. It can be
// slow on hosts with a lot of data, so the UI asks for it explicitly rather
// than including it in the inventory.
func (c *Client) VolumeSizes(ctx context.Context) (map[string]int64, error) {
du, err := c.api.DiskUsage(ctx, dockertypes.DiskUsageOptions{
Types: []dockertypes.DiskUsageObject{dockertypes.VolumeObject},
})
if err != nil {
return nil, fmt.Errorf("compute disk usage: %w", err)
}
out := map[string]int64{}
for _, v := range du.Volumes {
if v == nil || v.UsageData == nil {
continue
}
out[v.Name] = v.UsageData.Size
}
return out, nil
}
// MeasureMounts fills in the SizeBytes of every mount it can determine.
// Volume sizes come from the daemon; bind mount sizes are measured by walking
// the path from inside the container, which works even when the daemon is
// remote.
func (c *Client) MeasureMounts(ctx context.Context, containers []spec.Container) error {
sizes, err := c.VolumeSizes(ctx)
if err != nil {
return err
}
for i := range containers {
for j := range containers[i].Mounts {
m := &containers[i].Mounts[j]
switch m.Kind {
case spec.MountVolume, spec.MountAnonymous:
if s, ok := sizes[m.Name]; ok {
m.SizeBytes = s
}
case spec.MountTmpfs:
m.SizeBytes = 0
}
}
}
return nil
}
var _ = image.InspectResponse{}
+570
View File
@@ -0,0 +1,570 @@
package dkr
import (
"context"
"fmt"
"regexp"
"sort"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
imagetypes "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/network"
networktypes "github.com/docker/docker/api/types/network"
volumetypes "github.com/docker/docker/api/types/volume"
)
// Inventory is everything the UI needs to render the source host.
type Inventory struct {
Host string `json:"host"`
DockerVersion string `json:"dockerVersion"`
Containers []spec.Container `json:"containers"`
Volumes []spec.Volume `json:"volumes"`
Networks []spec.Network `json:"networks"`
Warnings []string `json:"warnings,omitempty"`
}
// anonymousVolume matches the 64-hex names Docker generates for volumes that
// were never explicitly named.
var anonymousVolume = regexp.MustCompile(`^[0-9a-f]{64}$`)
// Inventory reads every container on the daemon, plus the volumes and networks
// they reference, and normalizes them into the transport spec.
func (c *Client) Inventory(ctx context.Context) (*Inventory, error) {
version, err := c.Ping(ctx)
if err != nil {
return nil, fmt.Errorf("connect to docker: %w", err)
}
info, err := c.api.Info(ctx)
if err != nil {
return nil, fmt.Errorf("read docker info: %w", err)
}
summaries, err := c.api.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
inv := &Inventory{Host: info.Name, DockerVersion: version}
imgCache := map[string]*imagetypes.InspectResponse{}
volNames := map[string]bool{}
netNames := map[string]bool{}
for _, s := range summaries {
cs, err := c.inspectContainer(ctx, s.ID, imgCache)
if err != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("skipped container %s: %v", short(s.ID), err))
continue
}
for _, m := range cs.Mounts {
if m.Kind == spec.MountVolume && m.Name != "" {
volNames[m.Name] = true
}
}
for _, e := range cs.Endpoints {
netNames[e.Network] = true
}
inv.Containers = append(inv.Containers, *cs)
}
sort.Slice(inv.Containers, func(i, j int) bool {
a, b := inv.Containers[i], inv.Containers[j]
if a.ComposeProject != b.ComposeProject {
return a.ComposeProject < b.ComposeProject
}
return a.Name < b.Name
})
for name := range volNames {
v, err := c.api.VolumeInspect(ctx, name)
if err != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("volume %s: %v", name, err))
continue
}
inv.Volumes = append(inv.Volumes, convertVolume(v))
}
sort.Slice(inv.Volumes, func(i, j int) bool { return inv.Volumes[i].Name < inv.Volumes[j].Name })
for name := range netNames {
if isBuiltinNetwork(name) {
continue
}
n, err := c.api.NetworkInspect(ctx, name, networktypes.InspectOptions{})
if err != nil {
inv.Warnings = append(inv.Warnings, fmt.Sprintf("network %s: %v", name, err))
continue
}
inv.Networks = append(inv.Networks, convertNetwork(n))
}
sort.Slice(inv.Networks, func(i, j int) bool { return inv.Networks[i].Name < inv.Networks[j].Name })
return inv, nil
}
// InspectContainer normalizes one container by id or name.
func (c *Client) InspectContainer(ctx context.Context, id string) (*spec.Container, error) {
return c.inspectContainer(ctx, id, map[string]*imagetypes.InspectResponse{})
}
func (c *Client) inspectContainer(ctx context.Context, id string, imgCache map[string]*imagetypes.InspectResponse) (*spec.Container, error) {
j, err := c.api.ContainerInspect(ctx, id)
if err != nil {
return nil, err
}
if j.Config == nil || j.HostConfig == nil {
return nil, fmt.Errorf("incomplete inspect payload")
}
out := &spec.Container{
ID: j.ID,
Name: strings.TrimPrefix(j.Name, "/"),
Image: j.Config.Image,
ImageID: j.Image,
}
if out.Image == "" {
out.Image = j.Image
}
if j.State != nil {
out.State = j.State.Status
}
// Image config is used to strip everything the image already provides, so
// the recreated container carries only genuine run-time overrides.
img := c.imageConfig(ctx, j.Image, imgCache)
if img == nil {
out.Warnings = append(out.Warnings,
"image config unavailable; env, command and labels are reproduced in full")
} else if len(img.RepoDigests) > 0 {
out.ImageDigest = img.RepoDigests[0]
}
cfg := j.Config
out.Hostname = dropGeneratedHostname(cfg.Hostname, j.ID)
out.Domainname = cfg.Domainname
out.User = cfg.User
out.WorkingDir = cfg.WorkingDir
out.Tty = cfg.Tty
out.OpenStdin = cfg.OpenStdin
out.StopSignal = cfg.StopSignal
out.StopTimeout = cfg.StopTimeout
var imgEnv, imgCmd, imgEntry []string
var imgLabels map[string]string
if img != nil && img.Config != nil {
imgEnv, imgLabels = img.Config.Env, img.Config.Labels
imgCmd, imgEntry = img.Config.Cmd, img.Config.Entrypoint
if img.Config.User == cfg.User {
out.User = ""
}
if img.Config.WorkingDir == cfg.WorkingDir {
out.WorkingDir = ""
}
}
out.Env = subtractStrings(cfg.Env, imgEnv)
out.Labels = subtractLabels(cfg.Labels, imgLabels)
out.Cmd = cfg.Cmd
out.CmdSet = !equalStrings(cfg.Cmd, imgCmd)
out.Entrypoint = cfg.Entrypoint
out.EntrypointSet = !equalStrings(cfg.Entrypoint, imgEntry)
if p := cfg.Labels["com.docker.compose.project"]; p != "" {
out.ComposeProject = p
out.ComposeService = cfg.Labels["com.docker.compose.service"]
}
if cfg.Healthcheck != nil {
var imgHC *container.HealthConfig
if img != nil && img.Config != nil {
imgHC = img.Config.Healthcheck
}
if !sameHealthcheck(cfg.Healthcheck, imgHC) {
out.Healthcheck = &spec.Healthcheck{
Test: cfg.Healthcheck.Test,
Interval: int64(cfg.Healthcheck.Interval),
Timeout: int64(cfg.Healthcheck.Timeout),
StartPeriod: int64(cfg.Healthcheck.StartPeriod),
Retries: cfg.Healthcheck.Retries,
}
}
}
hc := j.HostConfig
out.RestartPolicy = string(hc.RestartPolicy.Name)
out.RestartMaxRetries = hc.RestartPolicy.MaximumRetryCount
out.AutoRemove = hc.AutoRemove
out.Privileged = hc.Privileged
out.ReadonlyRootfs = hc.ReadonlyRootfs
out.CapAdd = hc.CapAdd
out.CapDrop = hc.CapDrop
out.SecurityOpt = dropDefaultSecurityOpt(hc.SecurityOpt)
out.GroupAdd = hc.GroupAdd
out.Sysctls = hc.Sysctls
out.Runtime = hc.Runtime
out.PidMode = string(hc.PidMode)
out.IpcMode = string(hc.IpcMode)
out.UtsMode = string(hc.UTSMode)
out.UsernsMode = string(hc.UsernsMode)
out.CgroupnsMode = string(hc.CgroupnsMode)
out.DNS = hc.DNS
out.DNSSearch = hc.DNSSearch
out.DNSOptions = hc.DNSOptions
out.ExtraHosts = hc.ExtraHosts
out.NetworkMode = string(hc.NetworkMode)
out.PublishAll = hc.PublishAllPorts
out.Init = hc.Init
out.LogDriver = hc.LogConfig.Type
out.LogOptions = hc.LogConfig.Config
for _, d := range hc.Devices {
out.Devices = append(out.Devices, spec.Device{
PathOnHost: d.PathOnHost,
PathInContainer: d.PathInContainer,
CgroupPermissions: d.CgroupPermissions,
})
}
for _, u := range hc.Ulimits {
if u == nil {
continue
}
out.Ulimits = append(out.Ulimits, spec.Ulimit{Name: u.Name, Soft: u.Soft, Hard: u.Hard})
}
out.Resources = spec.Resources{
Memory: hc.Memory,
MemoryReservation: hc.MemoryReservation,
MemorySwap: hc.MemorySwap,
MemorySwappiness: hc.MemorySwappiness,
NanoCPUs: hc.NanoCPUs,
CPUShares: hc.CPUShares,
CPUPeriod: hc.CPUPeriod,
CPUQuota: hc.CPUQuota,
CpusetCpus: hc.CpusetCpus,
CpusetMems: hc.CpusetMems,
PidsLimit: hc.PidsLimit,
OomKillDisable: hc.OomKillDisable,
OomScoreAdj: hc.OomScoreAdj,
ShmSize: hc.ShmSize,
}
for portProto, bindings := range hc.PortBindings {
for _, b := range bindings {
out.Ports = append(out.Ports, spec.PortBinding{
ContainerPort: string(portProto),
HostIP: b.HostIP,
HostPort: b.HostPort,
})
}
}
sort.Slice(out.Ports, func(i, j int) bool {
if out.Ports[i].ContainerPort != out.Ports[j].ContainerPort {
return out.Ports[i].ContainerPort < out.Ports[j].ContainerPort
}
return out.Ports[i].HostPort < out.Ports[j].HostPort
})
for p := range cfg.ExposedPorts {
out.ExposedPorts = append(out.ExposedPorts, string(p))
}
sort.Strings(out.ExposedPorts)
out.Mounts = convertMounts(j.Mounts, hc.Tmpfs)
out.Endpoints = convertEndpoints(j.NetworkSettings, out.NetworkMode, j.ID)
if hc.AutoRemove {
out.Warnings = append(out.Warnings,
"source runs with --rm; the migrated container is created without it so it survives inspection")
}
if strings.HasPrefix(out.NetworkMode, "container:") {
out.Warnings = append(out.Warnings,
"shares another container's network namespace; migrate that container too")
}
for _, m := range out.Mounts {
if m.Kind == spec.MountBind && isSensitiveBind(m.Source) {
out.Warnings = append(out.Warnings,
"binds host path "+m.Source+"; copying it is usually wrong, review before migrating")
}
}
if len(hc.VolumesFrom) > 0 {
out.Warnings = append(out.Warnings,
"uses --volumes-from ("+strings.Join(hc.VolumesFrom, ", ")+"), which is not reproduced")
}
return out, nil
}
func (c *Client) imageConfig(ctx context.Context, id string, cache map[string]*imagetypes.InspectResponse) *imagetypes.InspectResponse {
if v, ok := cache[id]; ok {
return v
}
insp, err := c.api.ImageInspect(ctx, id)
if err != nil {
cache[id] = nil
return nil
}
cache[id] = &insp
return &insp
}
func convertMounts(mounts []container.MountPoint, tmpfs map[string]string) []spec.Mount {
out := make([]spec.Mount, 0, len(mounts)+len(tmpfs))
for _, m := range mounts {
sm := spec.Mount{
Destination: m.Destination,
ReadOnly: !m.RW,
Propagation: string(m.Propagation),
SizeBytes: -1,
}
switch m.Type {
case "volume":
sm.Name = m.Name
if anonymousVolume.MatchString(m.Name) {
sm.Kind = spec.MountAnonymous
} else {
sm.Kind = spec.MountVolume
}
case "bind":
sm.Kind = spec.MountBind
sm.Source = m.Source
case "tmpfs":
sm.Kind = spec.MountTmpfs
default:
// npipe and unknown driver types carry no portable data.
sm.Kind = spec.MountKind(m.Type)
sm.Source = m.Source
}
out = append(out, sm)
}
for dest, opts := range tmpfs {
if hasDestination(out, dest) {
continue
}
out = append(out, spec.Mount{Kind: spec.MountTmpfs, Destination: dest, TmpfsOpts: opts, SizeBytes: 0})
}
sort.Slice(out, func(i, j int) bool { return out[i].Destination < out[j].Destination })
return out
}
func convertEndpoints(ns *container.NetworkSettings, networkMode, containerID string) []spec.Endpoint {
if ns == nil {
return nil
}
out := make([]spec.Endpoint, 0, len(ns.Networks))
for name, ep := range ns.Networks {
if ep == nil {
continue
}
e := spec.Endpoint{
Network: name,
Aliases: dropGeneratedAliases(ep.Aliases, containerID),
Links: ep.Links,
DriverOpts: ep.DriverOpts,
}
// The MAC address is normally derived by the daemon. Carrying it over
// only makes sense alongside the static addressing it belongs to;
// otherwise it risks colliding with an address on the target network.
if ep.IPAMConfig != nil && (ep.IPAMConfig.IPv4Address != "" || ep.IPAMConfig.IPv6Address != "") {
e.MacAddress = ep.MacAddress
}
if ep.IPAMConfig != nil {
e.IPv4Address = ep.IPAMConfig.IPv4Address
e.IPv6Address = ep.IPAMConfig.IPv6Address
}
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
// The network named by NetworkMode has to come first: it is the one
// `docker create --network` can express.
if out[i].Network == networkMode {
return true
}
if out[j].Network == networkMode {
return false
}
return out[i].Network < out[j].Network
})
return out
}
func convertVolume(v volumetypes.Volume) spec.Volume {
return spec.Volume{
Name: v.Name,
Driver: v.Driver,
DriverOpts: v.Options,
Labels: v.Labels,
}
}
func convertNetwork(n network.Inspect) spec.Network {
out := spec.Network{
Name: n.Name,
Driver: n.Driver,
Scope: n.Scope,
EnableIPv6: n.EnableIPv6,
Internal: n.Internal,
Attachable: n.Attachable,
Ingress: n.Ingress,
IPAMDriver: n.IPAM.Driver,
Options: n.Options,
Labels: n.Labels,
}
for _, p := range n.IPAM.Config {
out.IPAMPools = append(out.IPAMPools, spec.IPAMPool{
Subnet: p.Subnet,
IPRange: p.IPRange,
Gateway: p.Gateway,
AuxAddress: p.AuxAddress,
})
}
return out
}
// ImageInspectExists reports whether the daemon holds the given image.
func (c *Client) ImageExists(ctx context.Context, ref string) bool {
_, err := c.api.ImageInspect(ctx, ref)
return err == nil
}
// ImageRepoDigests returns the registry digests of an image, used to decide
// whether the target can simply pull it.
func (c *Client) ImageRepoDigests(ctx context.Context, ref string) []string {
insp, err := c.api.ImageInspect(ctx, ref)
if err != nil {
return nil
}
return insp.RepoDigests
}
var _ = image.InspectResponse{}
func isBuiltinNetwork(name string) bool {
switch name {
case "bridge", "host", "none":
return true
}
return false
}
// isSensitiveBind flags host paths that almost never should be copied wholesale
// to another machine.
func isSensitiveBind(p string) bool {
p = strings.TrimSuffix(strings.ReplaceAll(p, `\`, "/"), "/")
switch p {
case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/", "/etc", "/var/run", "/run":
return true
}
return strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/dev/")
}
// dropGeneratedHostname removes the hostname Docker derives from the container
// id, which must not be pinned on the target.
func dropGeneratedHostname(hostname, id string) string {
if hostname == "" || strings.HasPrefix(id, hostname) {
return ""
}
return hostname
}
// dropGeneratedAliases removes the short-container-id alias Docker attaches to
// every endpoint by itself. Re-applying it would pin the target container to
// the source container's id.
func dropGeneratedAliases(aliases []string, containerID string) []string {
out := make([]string, 0, len(aliases))
for _, a := range aliases {
if len(a) == 12 && strings.HasPrefix(containerID, a) {
continue
}
out = append(out, a)
}
sort.Strings(out)
if len(out) == 0 {
return nil
}
return out
}
// dropDefaultSecurityOpt removes the label=disable style entries Docker reports
// on hosts without SELinux, which would fail to apply elsewhere.
func dropDefaultSecurityOpt(opts []string) []string {
out := make([]string, 0, len(opts))
for _, o := range opts {
if strings.HasPrefix(o, "name=") {
continue
}
out = append(out, o)
}
if len(out) == 0 {
return nil
}
return out
}
func hasDestination(ms []spec.Mount, dest string) bool {
for _, m := range ms {
if m.Destination == dest {
return true
}
}
return false
}
func subtractStrings(all, base []string) []string {
if len(base) == 0 {
return all
}
seen := make(map[string]bool, len(base))
for _, b := range base {
seen[b] = true
}
out := make([]string, 0, len(all))
for _, v := range all {
if !seen[v] {
out = append(out, v)
}
}
if len(out) == 0 {
return nil
}
return out
}
func subtractLabels(all, base map[string]string) map[string]string {
out := map[string]string{}
for k, v := range all {
if bv, ok := base[k]; ok && bv == v {
continue
}
out[k] = v
}
if len(out) == 0 {
return nil
}
return out
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func sameHealthcheck(a, b *container.HealthConfig) bool {
if a == nil || b == nil {
return a == b
}
return equalStrings(a.Test, b.Test) && a.Interval == b.Interval &&
a.Timeout == b.Timeout && a.StartPeriod == b.StartPeriod && a.Retries == b.Retries
}
func short(id string) string {
if len(id) > 12 {
return id[:12]
}
return id
}
+49
View File
@@ -0,0 +1,49 @@
package dkr
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/arescom/docker-migrate/internal/spec"
)
// TestLiveInventory is a smoke test against whatever daemon the environment
// points at. It is skipped unless DOCKER_MIGRATE_LIVE_TEST is set, because it
// needs a real Docker host.
func TestLiveInventory(t *testing.T) {
if os.Getenv("DOCKER_MIGRATE_LIVE_TEST") == "" {
t.Skip("set DOCKER_MIGRATE_LIVE_TEST=1 to run against the local daemon")
}
c, err := New("")
if err != nil {
t.Fatal(err)
}
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
inv, err := c.Inventory(ctx)
if err != nil {
t.Fatal(err)
}
t.Logf("host=%s docker=%s containers=%d volumes=%d networks=%d warnings=%v",
inv.Host, inv.DockerVersion, len(inv.Containers), len(inv.Volumes), len(inv.Networks), inv.Warnings)
for i := range inv.Containers {
ct := &inv.Containers[i]
args := ct.CreateArgs(spec.RenderOptions{})
t.Logf("%s [%s] -> docker %s", ct.Name, ct.State, spec.ShellQuoteAll(args))
for _, m := range ct.DataMounts() {
t.Logf(" mount %-8s %-40s restore-into %s", m.Kind, m.Destination, RestorePath(m.Destination))
}
for _, w := range ct.Warnings {
t.Logf(" warn: %s", w)
}
}
b, _ := json.MarshalIndent(inv, "", " ")
t.Logf("inventory bytes: %d", len(b))
}
+348
View File
@@ -0,0 +1,348 @@
// Package job tracks long-running migrations and streams their progress to
// the UI. A job is a tree: job -> per-container item -> per-operation step,
// with byte counters on the steps that move data.
package job
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
)
// State is the lifecycle of a job, item or step.
type State string
const (
StatePending State = "pending"
StateRunning State = "running"
StateSucceeded State = "succeeded"
StateFailed State = "failed"
StateSkipped State = "skipped"
StateCanceled State = "canceled"
)
// Terminal reports whether no further transitions are expected.
func (s State) Terminal() bool {
switch s {
case StateSucceeded, StateFailed, StateSkipped, StateCanceled:
return true
}
return false
}
// Kind distinguishes the two migration modes.
type Kind string
const (
KindSSH Kind = "ssh"
KindPackage Kind = "package"
KindRestore Kind = "restore"
)
// Level classifies a log line.
type Level string
const (
LevelInfo Level = "info"
LevelWarn Level = "warn"
LevelError Level = "error"
LevelCmd Level = "cmd" // a command that was (or would be) run on a host
)
// LogEntry is one line in the job log.
type LogEntry struct {
Seq int64 `json:"seq"`
At time.Time `json:"at"`
Level Level `json:"level"`
Item string `json:"item,omitempty"`
Message string `json:"message"`
}
// Step is one unit of work inside an item, e.g. "transfer volume pgdata".
type Step struct {
ID string `json:"id"`
Label string `json:"label"`
State State `json:"state"`
BytesDone int64 `json:"bytesDone"`
BytesTotal int64 `json:"bytesTotal"` // -1 when unknown
Error string `json:"error,omitempty"`
StartedAt *time.Time `json:"startedAt,omitempty"`
EndedAt *time.Time `json:"endedAt,omitempty"`
}
// Item is the migration of one container.
type Item struct {
ID string `json:"id"` // container id on the source
Name string `json:"name"`
State State `json:"state"`
Error string `json:"error,omitempty"`
Steps []*Step `json:"steps"`
Warnings []string `json:"warnings,omitempty"`
}
// Snapshot is the serializable view of a job handed to the UI.
type Snapshot struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
Title string `json:"title"`
State State `json:"state"`
DryRun bool `json:"dryRun"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"`
EndedAt *time.Time `json:"endedAt,omitempty"`
Items []*Item `json:"items"`
Log []LogEntry `json:"log"`
BytesDone int64 `json:"bytesDone"`
BytesTotal int64 `json:"bytesTotal"`
// Artifact is the produced package path, for package jobs.
Artifact string `json:"artifact,omitempty"`
// ArtifactBytes is the package size on disk.
ArtifactBytes int64 `json:"artifactBytes,omitempty"`
Revision int64 `json:"revision"`
}
// Job is a running or finished migration.
type Job struct {
mu sync.RWMutex
snap Snapshot
seq int64
revision int64
maxLog int
cancel context.CancelFunc
done chan struct{}
subsMu sync.Mutex
subs map[int]chan struct{}
nextID int
}
func newJob(id string, kind Kind, title string, dryRun bool) *Job {
return &Job{
snap: Snapshot{
ID: id, Kind: kind, Title: title, State: StatePending,
DryRun: dryRun, CreatedAt: time.Now(), Items: []*Item{}, Log: []LogEntry{},
BytesTotal: 0,
},
maxLog: 5000,
done: make(chan struct{}),
subs: map[int]chan struct{}{},
}
}
// ID returns the job identifier.
func (j *Job) ID() string { return j.snap.ID }
// Done is closed once the job reaches a terminal state.
func (j *Job) Done() <-chan struct{} { return j.done }
// Snapshot returns a deep-enough copy for JSON serialization.
func (j *Job) Snapshot() Snapshot {
j.mu.RLock()
defer j.mu.RUnlock()
s := j.snap
s.Items = make([]*Item, len(j.snap.Items))
var done, total int64
for i, it := range j.snap.Items {
cp := *it
cp.Steps = make([]*Step, len(it.Steps))
for k, st := range it.Steps {
sc := *st
cp.Steps[k] = &sc
done += sc.BytesDone
if sc.BytesTotal > 0 {
total += sc.BytesTotal
}
}
s.Items[i] = &cp
}
s.Log = append([]LogEntry(nil), j.snap.Log...)
s.BytesDone, s.BytesTotal = done, total
s.Revision = atomic.LoadInt64(&j.revision)
return s
}
// Subscribe returns a channel that receives a signal whenever the job changes,
// plus a function to unsubscribe.
func (j *Job) Subscribe() (<-chan struct{}, func()) {
j.subsMu.Lock()
defer j.subsMu.Unlock()
id := j.nextID
j.nextID++
ch := make(chan struct{}, 1)
j.subs[id] = ch
return ch, func() {
j.subsMu.Lock()
defer j.subsMu.Unlock()
if c, ok := j.subs[id]; ok {
delete(j.subs, id)
close(c)
}
}
}
func (j *Job) touch() {
atomic.AddInt64(&j.revision, 1)
j.subsMu.Lock()
for _, ch := range j.subs {
select {
case ch <- struct{}{}:
default: // a signal is already pending; the reader will see the latest state
}
}
j.subsMu.Unlock()
}
// Logf appends a line to the job log.
func (j *Job) Logf(level Level, item, format string, args ...any) {
j.mu.Lock()
j.seq++
e := LogEntry{Seq: j.seq, At: time.Now(), Level: level, Item: item, Message: fmt.Sprintf(format, args...)}
j.snap.Log = append(j.snap.Log, e)
if len(j.snap.Log) > j.maxLog {
j.snap.Log = j.snap.Log[len(j.snap.Log)-j.maxLog:]
}
j.mu.Unlock()
j.touch()
}
// AddItem registers a container in the job and returns its handle.
func (j *Job) AddItem(id, name string) *Item {
j.mu.Lock()
it := &Item{ID: id, Name: name, State: StatePending, Steps: []*Step{}}
j.snap.Items = append(j.snap.Items, it)
j.mu.Unlock()
j.touch()
return it
}
// AddStep registers a unit of work under an item. bytesTotal may be -1 when
// the size is not known ahead of time.
func (j *Job) AddStep(it *Item, id, label string, bytesTotal int64) *Step {
j.mu.Lock()
st := &Step{ID: id, Label: label, State: StatePending, BytesTotal: bytesTotal}
it.Steps = append(it.Steps, st)
j.mu.Unlock()
j.touch()
return st
}
// StartStep marks a step as running.
func (j *Job) StartStep(st *Step) {
now := time.Now()
j.mu.Lock()
st.State = StateRunning
st.StartedAt = &now
j.mu.Unlock()
j.touch()
}
// FinishStep closes a step, recording an error when one occurred.
func (j *Job) FinishStep(st *Step, err error) {
now := time.Now()
j.mu.Lock()
st.EndedAt = &now
if err != nil {
st.State = StateFailed
st.Error = err.Error()
} else {
st.State = StateSucceeded
if st.BytesTotal < 0 {
st.BytesTotal = st.BytesDone
}
}
j.mu.Unlock()
j.touch()
}
// SkipStep marks a step as deliberately not performed.
func (j *Job) SkipStep(st *Step, reason string) {
now := time.Now()
j.mu.Lock()
st.State = StateSkipped
st.EndedAt = &now
st.Error = reason
j.mu.Unlock()
j.touch()
}
// AddBytes advances a step's byte counter. It is safe to call at high rates
// from the transfer goroutine.
func (j *Job) AddBytes(st *Step, n int64) {
j.mu.Lock()
st.BytesDone += n
j.mu.Unlock()
j.touch()
}
// SetItemState transitions an item.
func (j *Job) SetItemState(it *Item, s State, err error) {
j.mu.Lock()
it.State = s
if err != nil {
it.Error = err.Error()
}
j.mu.Unlock()
j.touch()
}
// AddItemWarning attaches a non-fatal note to an item.
func (j *Job) AddItemWarning(it *Item, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
j.mu.Lock()
it.Warnings = append(it.Warnings, msg)
j.mu.Unlock()
j.Logf(LevelWarn, it.ID, "%s", msg)
}
// SetArtifact records the produced package.
func (j *Job) SetArtifact(path string, bytes int64) {
j.mu.Lock()
j.snap.Artifact = path
j.snap.ArtifactBytes = bytes
j.mu.Unlock()
j.touch()
}
func (j *Job) start() {
now := time.Now()
j.mu.Lock()
j.snap.State = StateRunning
j.snap.StartedAt = &now
j.mu.Unlock()
j.touch()
}
func (j *Job) finish(err error) {
now := time.Now()
j.mu.Lock()
if j.snap.State.Terminal() {
j.mu.Unlock()
return
}
j.snap.EndedAt = &now
switch {
case err == nil:
j.snap.State = StateSucceeded
case err == context.Canceled:
j.snap.State = StateCanceled
j.snap.Error = "canceled by operator"
default:
j.snap.State = StateFailed
j.snap.Error = err.Error()
}
j.mu.Unlock()
j.touch()
close(j.done)
}
// Cancel asks the job to stop. Work already in flight unwinds through context
// cancellation.
func (j *Job) Cancel() {
if j.cancel != nil {
j.cancel()
}
}
+183
View File
@@ -0,0 +1,183 @@
package job
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"io"
"sort"
"sync"
"time"
)
// Manager owns every job in the process.
type Manager struct {
mu sync.RWMutex
jobs map[string]*Job
// keep bounds how many finished jobs are retained.
keep int
}
// NewManager creates an empty job manager.
func NewManager() *Manager {
return &Manager{jobs: map[string]*Job{}, keep: 50}
}
// ErrNotFound is returned for an unknown job id.
var ErrNotFound = errors.New("job not found")
// Run creates a job and executes fn in the background. fn receives a context
// that is canceled when the job is canceled, and the job handle for progress
// reporting.
func (m *Manager) Run(parent context.Context, kind Kind, title string, dryRun bool, fn func(context.Context, *Job) error) *Job {
j := newJob(newID(), kind, title, dryRun)
ctx, cancel := context.WithCancel(parent)
j.cancel = cancel
m.mu.Lock()
m.jobs[j.snap.ID] = j
m.mu.Unlock()
m.prune()
go func() {
defer cancel()
j.start()
err := fn(ctx, j)
if err == nil && ctx.Err() != nil {
err = context.Canceled
}
if errors.Is(err, context.Canceled) {
err = context.Canceled
}
j.finish(err)
}()
return j
}
// Get returns a job by id.
func (m *Manager) Get(id string) (*Job, error) {
m.mu.RLock()
defer m.mu.RUnlock()
j, ok := m.jobs[id]
if !ok {
return nil, ErrNotFound
}
return j, nil
}
// List returns every job, newest first.
func (m *Manager) List() []Snapshot {
m.mu.RLock()
jobs := make([]*Job, 0, len(m.jobs))
for _, j := range m.jobs {
jobs = append(jobs, j)
}
m.mu.RUnlock()
out := make([]Snapshot, 0, len(jobs))
for _, j := range jobs {
s := j.Snapshot()
// The list view does not need the full log.
if len(s.Log) > 5 {
s.Log = s.Log[len(s.Log)-5:]
}
out = append(out, s)
}
sort.Slice(out, func(i, k int) bool { return out[i].CreatedAt.After(out[k].CreatedAt) })
return out
}
// Delete removes a finished job. A running job is canceled instead.
func (m *Manager) Delete(id string) error {
m.mu.Lock()
j, ok := m.jobs[id]
if !ok {
m.mu.Unlock()
return ErrNotFound
}
if !j.Snapshot().State.Terminal() {
m.mu.Unlock()
j.Cancel()
return nil
}
delete(m.jobs, id)
m.mu.Unlock()
return nil
}
// prune drops the oldest finished jobs once the retention limit is exceeded.
func (m *Manager) prune() {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.jobs) <= m.keep {
return
}
type entry struct {
id string
at time.Time
}
var finished []entry
for id, j := range m.jobs {
s := j.Snapshot()
if s.State.Terminal() {
finished = append(finished, entry{id, s.CreatedAt})
}
}
sort.Slice(finished, func(i, k int) bool { return finished[i].at.Before(finished[k].at) })
for i := 0; i < len(finished) && len(m.jobs) > m.keep; i++ {
delete(m.jobs, finished[i].id)
}
}
func newID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return hex.EncodeToString([]byte(time.Now().Format("150405.000000")))
}
return hex.EncodeToString(b)
}
// CountingReader wraps a reader and reports every read to a job step, which is
// how transfer progress reaches the UI.
type CountingReader struct {
R io.Reader
Job *Job
St *Step
pending int64
lastFlush time.Time
}
// NewCountingReader builds a progress-reporting reader.
func NewCountingReader(r io.Reader, j *Job, st *Step) *CountingReader {
return &CountingReader{R: r, Job: j, St: st, lastFlush: time.Now()}
}
// Read implements io.Reader, batching counter updates so a fast transfer does
// not flood subscribers with notifications.
func (c *CountingReader) Read(p []byte) (int, error) {
n, err := c.R.Read(p)
if n > 0 {
c.pending += int64(n)
if c.pending >= 4<<20 || time.Since(c.lastFlush) > 200*time.Millisecond {
c.flush()
}
}
if err != nil {
c.flush()
}
return n, err
}
// Flush pushes any buffered byte count to the job.
func (c *CountingReader) Flush() { c.flush() }
func (c *CountingReader) flush() {
if c.pending == 0 {
return
}
c.Job.AddBytes(c.St, c.pending)
c.pending = 0
c.lastFlush = time.Now()
}
+580
View File
@@ -0,0 +1,580 @@
package migrate
import (
"fmt"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// renderInstaller generates the shell script shipped inside a migration
// package. The script is self-contained: it never parses the manifest and
// depends on nothing but bash, gzip and the docker CLI, so it can be read and
// audited by whoever runs it on the target host.
func renderInstaller(prepared []*Prepared, man *spec.Manifest, savedImages map[string]string, pkgName string) string {
payload := map[string]spec.Payload{}
for _, p := range man.Payloads {
if p.Kind == "mount" {
payload[p.Container+"\x00"+p.Destination] = p
}
}
imagePayload := map[string]spec.Payload{}
for _, p := range man.Payloads {
if p.Kind == "image" {
imagePayload[p.Image] = p
}
}
var b strings.Builder
// w formats a line. Literal blocks must be passed as an argument, never as
// the format itself: shell text is full of % and would be mangled.
w := func(format string, args ...any) {
if len(args) == 0 {
b.WriteString(format)
b.WriteByte('\n')
return
}
fmt.Fprintf(&b, format+"\n", args...)
}
w("#!/usr/bin/env bash")
w("#")
w("# Migration package: %s", pkgName)
w("# Created: %s", man.CreatedAt.Format("2006-01-02 15:04:05 MST"))
w("# Source host: %s (docker %s)", orDash(man.SourceHost), orDash(man.DockerVersion))
w("# Containers: %d", len(prepared))
w("#")
w("# Run this on the TARGET host. It needs bash, gzip and a working docker CLI.")
w("# Nothing is written outside docker's own storage and the bind mount paths")
w("# listed below.")
w("#")
w("# Usage: ./install.sh [options]")
w("# --dry-run print every command without changing anything")
w("# --yes do not ask for confirmation")
w("# --no-start create the containers but leave them stopped")
w("# --conflict MODE fail (default) | skip | replace | rename")
w("# --rename-suffix S suffix used by --conflict rename (default -migrated)")
w("# --skip-verify do not checksum the payloads")
w("# --only NAME[,NAME...] restore only these containers")
w("# --docker CMD docker command to use (default: docker)")
w("# --sudo prefix docker with sudo -n")
w("")
w("set -euo pipefail")
w("")
w(`PKGDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`)
w("DRY_RUN=0")
w("ASSUME_YES=0")
w("NO_START=0")
w("VERIFY=1")
w("ONLY=\"\"")
w("DOCKER_BIN=docker")
w("USE_SUDO=0")
w("CONFLICT=%s", spec.ShellQuote(string(defaultConflict(man.Options.Conflict))))
w("RENAME_SUFFIX=%s", spec.ShellQuote(defaultSuffix(man.Options.RenameSuffix)))
w("")
w(`while [ $# -gt 0 ]; do`)
w(` case "$1" in`)
w(` --dry-run) DRY_RUN=1 ;;`)
w(` --yes|-y) ASSUME_YES=1 ;;`)
w(` --no-start) NO_START=1 ;;`)
w(` --skip-verify) VERIFY=0 ;;`)
w(` --conflict) shift; CONFLICT="${1:-}" ;;`)
w(` --rename-suffix) shift; RENAME_SUFFIX="${1:-}" ;;`)
w(` --only) shift; ONLY="${1:-}" ;;`)
w(` --docker) shift; DOCKER_BIN="${1:-docker}" ;;`)
w(` --sudo) USE_SUDO=1 ;;`)
w(` -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;`)
w(` *) echo "unknown option: $1" >&2; exit 2 ;;`)
w(` esac`)
w(` shift`)
w(`done`)
w("")
w(`case "$CONFLICT" in fail|skip|replace|rename) ;; *) echo "invalid --conflict: $CONFLICT" >&2; exit 2 ;; esac`)
w("")
w(`if [ "$USE_SUDO" = 1 ]; then DOCKER="sudo -n $DOCKER_BIN"; else DOCKER="$DOCKER_BIN"; fi`)
w("")
w(installerHelpers)
w("")
// Preflight.
w(`log "migration package: %s"`, escapeDoubleQuoted(pkgName))
w(`preflight`)
w("")
// Bind mount summary, so the operator sees what will touch the host
// filesystem before answering the prompt.
binds := collectBinds(prepared)
if len(binds) > 0 {
w(`echo "This package writes into the following host paths:"`)
for _, p := range binds {
w(`echo " %s"`, escapeDoubleQuoted(p))
}
w("")
}
w(`confirm`)
w("")
// Networks, created once.
nets := map[string]bool{}
var netBlock strings.Builder
for _, p := range prepared {
for _, n := range p.Networks {
if nets[n.Name] || isBuiltin(n.Name) {
continue
}
nets[n.Name] = true
fmt.Fprintf(&netBlock, "ensure_network %s %s\n",
spec.ShellQuote(n.Name), spec.ShellQuoteAll(n.CreateArgs()))
}
}
if netBlock.Len() > 0 {
w(`step "networks"`)
w("%s", strings.TrimRight(netBlock.String(), "\n"))
w("")
}
// One function per container keeps the flow readable and lets --only skip
// whole containers cleanly.
for i, p := range prepared {
w("%s", renderContainerFunc(i, p, payload, imagePayload, savedImages))
}
w(`FAILED=0`)
for i, p := range prepared {
name := p.ContainerName()
w(`if selected %s; then`, spec.ShellQuote(name))
w(` if ! migrate_%d; then err "container %s failed"; FAILED=$((FAILED+1)); fi`, i, escapeDoubleQuoted(name))
w(`else`)
w(` log "skipping %s (not in --only)"`, escapeDoubleQuoted(name))
w(`fi`)
}
w("")
w(`if [ "$FAILED" -gt 0 ]; then`)
w(` err "$FAILED container(s) failed"`)
w(` exit 1`)
w(`fi`)
w(`ok "migration complete"`)
w(`if [ "$DRY_RUN" = 1 ]; then log "this was a dry run; nothing was changed"; fi`)
return b.String()
}
func renderContainerFunc(idx int, p *Prepared, payload, imagePayload map[string]spec.Payload, savedImages map[string]string) string {
var b strings.Builder
w := func(format string, args ...any) {
if len(args) == 0 {
b.WriteString(format)
b.WriteByte('\n')
return
}
fmt.Fprintf(&b, format+"\n", args...)
}
name := p.ContainerName()
sel := p.Selection
// Every command below is checked explicitly with "|| return 1". This
// function is invoked from an `if !` test, which switches `set -e` off for
// its whole body, so an unchecked failure would otherwise be swallowed and
// the container reported as migrated when it was not.
w("migrate_%d() {", idx)
w(` local base=%s`, spec.ShellQuote(name))
w(` CNAME="$base"`)
w(` step "container $base"`)
for _, note := range p.Notes {
w(` warn %s`, spec.ShellQuote(note))
}
for _, note := range p.Source.Warnings {
w(` warn %s`, spec.ShellQuote(note))
}
// Conflict handling.
w(` if object_exists container "$CNAME"; then`)
w(` case "$CONFLICT" in`)
w(` skip) warn "container $CNAME already exists; skipping"; return 0 ;;`)
w(` replace) warn "removing existing container $CNAME"; run $DOCKER rm -f "$CNAME" || return 1 ;;`)
w(` rename) CNAME="$(free_name "$base")"; warn "creating $CNAME instead" ;;`)
w(` *) err "container $CNAME already exists; rerun with --conflict replace|rename|skip"; return 1 ;;`)
w(` esac`)
w(` fi`)
// Image.
image := p.Target.Image
switch {
case !sel.MigrateImage || sel.ImageMode == spec.ImageSkip:
w(` if ! object_exists image %s; then`, spec.ShellQuote(image))
w(` err "image %s is not present and this package does not carry it"; return 1`, escapeDoubleQuoted(image))
w(` fi`)
case sel.ImageMode == spec.ImagePull:
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
default:
if rel, ok := savedImages[image]; ok {
ip := imagePayload[image]
w(` ensure_image_load %s %s %s %s || return 1`,
spec.ShellQuote(image), spec.ShellQuote(rel),
spec.ShellQuote(ip.SHA256), boolArg(ip.Compressed))
} else {
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
}
}
// Named volumes.
for _, v := range p.Volumes {
w(` ensure_volume %s %s || return 1`, spec.ShellQuote(v.Name), spec.ShellQuoteAll(v.CreateArgs()))
}
// Bind mount directories, created before the container so docker does not
// invent them with unexpected ownership halfway through.
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !isSpecialBind(m.Source) {
w(` ensure_dir %s || return 1`, spec.ShellQuote(m.Source))
}
}
// Create. The name is substituted at run time so --conflict rename works.
createArgs := p.Target.CreateArgs(p.Render)
rest := createArgs
if len(rest) >= 3 && rest[0] == "create" && rest[1] == "--name" {
rest = rest[3:]
}
w(` log "creating container $CNAME"`)
w(` run $DOCKER create --name "$CNAME" %s || { err "could not create $CNAME"; return 1; }`, spec.ShellQuoteAll(rest))
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
// The rendered args end with (network, containerName); the name is
// replaced so a renamed container still gets attached.
if len(args) < 2 {
continue
}
head := args[:len(args)-1]
w(` run $DOCKER %s "$CNAME" || { err "could not attach $CNAME to a network"; return 1; }`, spec.ShellQuoteAll(head))
}
// Data.
for _, t := range p.Transfers {
pl, ok := payload[name+"\x00"+t.Destination]
if !ok {
w(` warn "no data archive for %s in this package; leaving it empty"`, escapeDoubleQuoted(t.Destination))
continue
}
w(` verify_payload %s %s || return 1`, spec.ShellQuote(pl.Path), spec.ShellQuote(pl.SHA256))
if t.ReadOnly {
w(` log "restoring %s (read-only mount, via staging container)"`, escapeDoubleQuoted(t.Label))
w(` seed_readonly "$CNAME" %s %s %s %s || return 1`,
spec.ShellQuote(p.Target.Image), spec.ShellQuote(t.Destination),
spec.ShellQuote(pl.Path), boolArg(pl.Compressed))
} else {
w(` log "restoring %s"`, escapeDoubleQuoted(t.Label))
w(` feed_archive %s %s "$CNAME" %s || { err "could not restore %s"; return 1; }`,
spec.ShellQuote(pl.Path), boolArg(pl.Compressed), spec.ShellQuote(t.RestoreInto),
escapeDoubleQuoted(t.Label))
}
}
if sel.StartAfter {
w(` if [ "$NO_START" = 1 ]; then`)
w(` log "leaving $CNAME stopped (--no-start)"`)
w(` else`)
w(` log "starting $CNAME"`)
w(` run $DOCKER start "$CNAME" || { err "could not start $CNAME"; return 1; }`)
w(` check_running "$CNAME" || return 1`)
w(` fi`)
} else {
w(` log "$CNAME created but not started (it was not running on the source)"`)
}
w(` ok "$CNAME done"`)
w(` return 0`)
w("}")
w("")
return b.String()
}
// installerHelpers is the fixed shell prelude shared by every generated
// installer.
const installerHelpers = `
if [ -t 1 ]; then C_R=$'\033[31m'; C_G=$'\033[32m'; C_Y=$'\033[33m'; C_B=$'\033[1m'; C_0=$'\033[0m'
else C_R=""; C_G=""; C_Y=""; C_B=""; C_0=""; fi
log() { printf '%s\n' " $*"; }
step() { printf '\n%s\n' "${C_B}==> $*${C_0}"; }
ok() { printf '%s\n' " ${C_G}ok${C_0} $*"; }
warn() { printf '%s\n' " ${C_Y}warning${C_0} $*" >&2; }
err() { printf '%s\n' " ${C_R}error${C_0} $*" >&2; }
die() { err "$*"; exit 1; }
# run echoes a command and executes it, unless this is a dry run.
#
# It discards the command's own stdout itself. Callers must not add their own
# >/dev/null: that would also hide the "would run" line, leaving a dry run
# showing none of the commands it was about to execute.
run() {
if [ "$DRY_RUN" = 1 ]; then
printf ' would run:'; printf ' %q' "$@"; printf '\n'
return 0
fi
"$@" >/dev/null
}
preflight() {
command -v "$DOCKER_BIN" >/dev/null 2>&1 || die "$DOCKER_BIN is not on PATH"
if ! $DOCKER version >/dev/null 2>&1; then
die "cannot talk to the docker daemon (try --sudo, or add your user to the docker group)"
fi
command -v gzip >/dev/null 2>&1 || warn "gzip is missing; compressed payloads cannot be restored"
local srv
srv="$($DOCKER version --format '{{.Server.Version}}' 2>/dev/null || echo unknown)"
log "docker server $srv on $(uname -s) $(uname -m)"
}
confirm() {
[ "$ASSUME_YES" = 1 ] && return 0
[ "$DRY_RUN" = 1 ] && return 0
printf '%s' "Proceed? [y/N] "
local ans; read -r ans </dev/tty || ans=""
case "$ans" in y|Y|yes|YES) return 0 ;; *) echo "aborted"; exit 1 ;; esac
}
selected() {
[ -z "$ONLY" ] && return 0
local want
IFS=, read -ra want <<< "$ONLY"
local n
for n in "${want[@]}"; do [ "$n" = "$1" ] && return 0; done
return 1
}
object_exists() { # kind name
$DOCKER "$1" inspect "$2" >/dev/null 2>&1
}
free_name() { # base -> an unused container name
local base="$1" candidate="$1$RENAME_SUFFIX" i=2
while object_exists container "$candidate"; do
candidate="$base$RENAME_SUFFIX-$i"; i=$((i+1))
[ "$i" -gt 50 ] && die "no free name based on $base"
done
printf '%s' "$candidate"
}
ensure_network() { # name, then the full docker network create argv
local name="$1"; shift
if object_exists network "$name"; then
log "network $name already exists; reusing it"
return 0
fi
log "creating network $name"
run $DOCKER "$@" || { err "could not create network $name"; return 1; }
}
ensure_volume() { # name, then the full docker volume create argv
local name="$1"; shift
if object_exists volume "$name"; then
warn "volume $name already exists; restored data will be merged into it"
return 0
fi
log "creating volume $name"
run $DOCKER "$@" || { err "could not create volume $name"; return 1; }
}
ensure_dir() { # host path for a bind mount
if [ -e "$1" ]; then return 0; fi
log "creating host directory $1"
if [ "$DRY_RUN" = 1 ]; then printf ' would run: mkdir -p %q\n' "$1"; return 0; fi
mkdir -p "$1" 2>/dev/null || sudo mkdir -p "$1" || { err "cannot create $1"; return 1; }
}
ensure_image_pull() { # ref
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
log "pulling image $1"
run $DOCKER pull "$1" || { err "could not pull $1"; return 1; }
}
ensure_image_load() { # ref relpath sha256 compressed
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
verify_payload "$2" "$3" || return 1
log "loading image $1 from $2"
if [ "$DRY_RUN" = 1 ]; then printf ' would run: docker load < %q\n' "$2"; return 0; fi
if [ "$4" = 1 ]; then gzip -dc -- "$PKGDIR/$2" | $DOCKER load >/dev/null
else $DOCKER load >/dev/null < "$PKGDIR/$2"; fi
}
verify_payload() { # relpath sha256
[ -f "$PKGDIR/$1" ] || { err "payload missing from package: $1"; return 1; }
[ "$VERIFY" = 1 ] || return 0
if ! command -v sha256sum >/dev/null 2>&1; then
warn "sha256sum not available; skipping checksum verification"
VERIFY=0
return 0
fi
local got
got="$(sha256sum "$PKGDIR/$1" | cut -d' ' -f1)"
if [ "$got" != "$2" ]; then
err "checksum mismatch for $1 (package is corrupt or truncated)"
return 1
fi
}
feed_archive() { # relpath compressed container extract_into
if [ "$DRY_RUN" = 1 ]; then
printf ' would restore %q into %s:%s\n' "$1" "$3" "$4"
return 0
fi
if [ "$2" = 1 ]; then
gzip -dc -- "$PKGDIR/$1" | $DOCKER cp -a - "$3:$4"
else
$DOCKER cp -a - "$3:$4" < "$PKGDIR/$1"
fi
}
# resolve_mount prints the volume name, or the host path, backing a mount
# destination in a container that already exists.
resolve_mount() { # container destination
local d n s
while IFS='|' read -r d n s; do
if [ "$d" = "$2" ]; then
if [ -n "$n" ]; then printf '%s' "$n"; else printf '%s' "$s"; fi
return 0
fi
done < <($DOCKER inspect --format '{{range .Mounts}}{{.Destination}}|{{.Name}}|{{.Source}}{{"\n"}}{{end}}' "$1")
return 1
}
# seed_readonly fills a mount the container declares read-only. The same volume
# or host path is attached writable to a throwaway container, which is created
# but never started, and removed straight after.
seed_readonly() { # container image destination relpath compressed
if [ "$DRY_RUN" = 1 ]; then
printf ' would seed read-only mount %s from %q via a staging container\n' "$3" "$4"
return 0
fi
local store base stage mountat
store="$(resolve_mount "$1" "$3")" || { err "cannot resolve storage behind $3"; return 1; }
base="${3##*/}"
stage="dm-stage-$$-${RANDOM}"
mountat="/__docker_migrate/$base"
$DOCKER create --name "$stage" --volume "$store:$mountat" "$2" >/dev/null \
|| { err "could not create staging container for $3"; return 1; }
local rc=0
if [ "$5" = 1 ]; then
gzip -dc -- "$PKGDIR/$4" | $DOCKER cp -a - "$stage:/__docker_migrate" || rc=$?
else
$DOCKER cp -a - "$stage:/__docker_migrate" < "$PKGDIR/$4" || rc=$?
fi
$DOCKER rm -f "$stage" >/dev/null 2>&1 || true
if [ "$rc" != 0 ]; then
err "failed to seed read-only mount $3"
return 1
fi
}
check_running() { # container
[ "$DRY_RUN" = 1 ] && return 0
sleep 2
local st
st="$($DOCKER inspect --format '{{.State.Status}}' "$1" 2>/dev/null || echo missing)"
if [ "$st" != "running" ]; then
err "$1 is not running (status: $st); last log lines:"
$DOCKER logs --tail 20 "$1" 2>&1 | sed 's/^/ /' || true
return 1
fi
}
`
func renderReadme(name string, prepared []*Prepared, opts spec.Options) string {
var b strings.Builder
fmt.Fprintf(&b, "Docker migration package: %s\n", name)
fmt.Fprintf(&b, "%s\n\n", strings.Repeat("=", 27+len(name)))
b.WriteString("How to use this package\n")
b.WriteString("-----------------------\n")
b.WriteString("1. Copy this whole directory (or tar file) to the target host.\n")
b.WriteString("2. On the target host, unpack it if needed and run:\n\n")
b.WriteString(" ./install.sh --dry-run # review every command first\n")
b.WriteString(" ./install.sh # actually restore\n\n")
b.WriteString("The target host needs: bash, gzip, and a working docker CLI.\n")
b.WriteString("Nothing else is installed and no network access is required unless a\n")
b.WriteString("container's image is set to be pulled instead of carried.\n\n")
b.WriteString("Contents\n")
b.WriteString("--------\n")
b.WriteString(" install.sh self-contained restore script (read it, it is plain bash)\n")
b.WriteString(" manifest.json machine-readable description of everything in here\n")
b.WriteString(" images/ docker image archives\n")
b.WriteString(" data/ volume and bind mount contents, one tar per mount\n\n")
b.WriteString("Containers in this package\n")
b.WriteString("--------------------------\n")
for _, p := range prepared {
fmt.Fprintf(&b, " %s (image %s)\n", p.ContainerName(), p.Target.Image)
for _, t := range p.Transfers {
fmt.Fprintf(&b, " data: %s\n", t.Label)
}
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind {
fmt.Fprintf(&b, " writes host path: %s\n", m.Source)
}
}
}
if opts.DryRun {
b.WriteString("\nNOTE: this package was built in dry-run mode and contains no data archives.\n")
}
return b.String()
}
func collectBinds(prepared []*Prepared) []string {
seen := map[string]bool{}
var out []string
for _, p := range prepared {
for _, m := range p.Target.Mounts {
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !seen[m.Source] {
seen[m.Source] = true
out = append(out, m.Source)
}
}
}
return out
}
// isSpecialBind reports paths that must never be created by the installer,
// because they are kernel or daemon sockets rather than data directories.
func isSpecialBind(p string) bool {
switch p {
case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/":
return true
}
return strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/dev/")
}
func boolArg(b bool) string {
if b {
return "1"
}
return "0"
}
func defaultConflict(c spec.ConflictPolicy) spec.ConflictPolicy {
if c == "" {
return spec.ConflictFail
}
return c
}
func defaultSuffix(s string) string {
if s == "" {
return "-migrated"
}
return s
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}
// escapeDoubleQuoted makes a value safe to interpolate inside a double-quoted
// shell string in the generated script.
func escapeDoubleQuoted(s string) string {
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`)
return r.Replace(s)
}
+323
View File
@@ -0,0 +1,323 @@
package migrate
import (
"archive/tar"
"bytes"
"compress/gzip"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/arescom/docker-migrate/internal/spec"
)
func buildPrepared(t *testing.T, c *spec.Container, vols []spec.Volume, nets []spec.Network) *Prepared {
t.Helper()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, vols, nets)
if err != nil {
t.Fatal(err)
}
return p
}
func fixture(t *testing.T) ([]*Prepared, *spec.Manifest, map[string]string) {
t.Helper()
c := &spec.Container{
ID: "id1", Name: "shop-db", State: "running", Image: "postgres:16",
Env: []string{"POSTGRES_PASSWORD=p'w\"d $(whoami)"},
Labels: map[string]string{"note": "a;b`c`"},
Mounts: []spec.Mount{
{Kind: spec.MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"},
{Kind: spec.MountBind, Source: "/srv/shop/initdb", Destination: "/docker-entrypoint-initdb.d", ReadOnly: true},
},
Endpoints: []spec.Endpoint{{Network: "shopnet"}},
Ports: []spec.PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}},
}
p := buildPrepared(t,
c,
[]spec.Volume{{Name: "pgdata", Driver: "local"}},
[]spec.Network{{Name: "shopnet", Driver: "bridge"}},
)
man := &spec.Manifest{
FormatVersion: 1,
CreatedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
SourceHost: "old-host",
DockerVersion: "27.0.0",
Options: spec.DefaultOptions(),
Payloads: []spec.Payload{
{Path: "images/postgres_16.tar.gz", Kind: "image", Image: "postgres:16", SHA256: "aa", Compressed: true},
{Path: "data/shop-db/00-var_lib_postgresql_data.tar.gz", Kind: "mount",
Container: "shop-db", Destination: "/var/lib/postgresql/data", SHA256: "bb", Compressed: true},
{Path: "data/shop-db/01-docker-entrypoint-initdb.d.tar.gz", Kind: "mount",
Container: "shop-db", Destination: "/docker-entrypoint-initdb.d", SHA256: "cc", Compressed: true},
},
}
return []*Prepared{p}, man, map[string]string{"postgres:16": "images/postgres_16.tar.gz"}
}
func TestInstallerIsValidBash(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
path := filepath.Join(t.TempDir(), "install.sh")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
out, err := exec.Command(bash, "-n", path).CombinedOutput()
if err != nil {
t.Fatalf("generated installer is not valid bash: %v\n%s\n---\n%s", err, out, numbered(script))
}
}
// TestInstallerRunsCleanlyInDryRun executes the generated script against a
// stub docker, which is the closest thing to a real run that does not need a
// docker daemon.
func TestInstallerDryRunExecutes(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
// The installer checks that every payload is present even on a dry run, so
// that an incomplete package is reported before anything is changed.
writePayloads(t, dir, man)
binDir := writeStubDocker(t, dir, false)
env := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
run := func(args ...string) (string, error) {
cmd := exec.Command(bash, append([]string{"./install.sh"}, args...)...)
cmd.Dir = dir
cmd.Env = env
out, err := cmd.CombinedOutput()
return string(out), err
}
// The payload contents here are placeholders, so checksums are skipped;
// the real checksums are exercised by the end-to-end test.
text, err := run("--dry-run", "--yes", "--skip-verify")
if err != nil {
t.Fatalf("dry run failed: %v\n%s", err, text)
}
for _, want := range []string{"shop-db", "would run", "migration complete", "creating network shopnet"} {
if !strings.Contains(text, want) {
t.Errorf("dry run output missing %q:\n%s", want, text)
}
}
// A package whose payload does not match its checksum must be refused,
// rather than restoring truncated data.
corrupt, err := run("--dry-run", "--yes")
if err == nil {
t.Errorf("a payload with a bad checksum was accepted:\n%s", corrupt)
} else if !strings.Contains(corrupt, "checksum mismatch") {
t.Errorf("expected a checksum mismatch error, got:\n%s", corrupt)
}
}
// TestInstallerReportsFailedStart guards against the worst failure mode there
// is: reporting a successful migration when the container never started.
//
// The per-container work runs inside a function invoked from an `if !` test,
// which disables `set -e` for that whole function body, so every command has to
// be checked explicitly or its failure is silently discarded.
func TestInstallerReportsFailedStart(t *testing.T) {
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash is not available on this machine")
}
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
writePayloads(t, dir, man)
binDir := writeStubDocker(t, dir, true)
cmd := exec.Command(bash, "./install.sh", "--yes", "--skip-verify")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
text := string(out)
if err == nil {
t.Fatalf("the installer exited 0 even though the container never started:\n%s", text)
}
if strings.Contains(text, "migration complete") {
t.Errorf("the installer claimed the migration completed:\n%s", text)
}
for _, want := range []string{"could not start", "container(s) failed"} {
if !strings.Contains(text, want) {
t.Errorf("expected the output to contain %q:\n%s", want, text)
}
}
}
// TestInstallerQuotesHostileValues makes sure values taken from container
// metadata cannot break out of the generated script.
func TestInstallerQuotesHostileValues(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
// The password contains a quote, a double quote and a command
// substitution; none of it may appear unquoted.
if strings.Contains(script, "POSTGRES_PASSWORD=p'w\"d $(whoami)") {
t.Error("environment value was interpolated without quoting")
}
if !strings.Contains(script, `'POSTGRES_PASSWORD=p'\''w"d $(whoami)'`) {
t.Errorf("environment value is not quoted as expected:\n%s", grepLines(script, "POSTGRES_PASSWORD"))
}
}
func TestInstallerHandlesReadOnlyMountThroughStaging(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
if !strings.Contains(script, "seed_readonly") {
t.Error("read-only mount must be seeded through a staging container")
}
// The writable volume is fed into the real container directly. Shell-safe
// paths are emitted without quotes, which is what ShellQuote does.
want := `feed_archive data/shop-db/00-var_lib_postgresql_data.tar.gz 1 "$CNAME" /var/lib/postgresql`
if !strings.Contains(script, want) {
t.Errorf("writable volume restore command is wrong:\nwant a line containing: %s\ngot:\n%s",
want, grepLines(script, "feed_archive"))
}
// Restoring must target the parent directory, never the mount point itself,
// because the archive entries are already rooted at the last segment.
if strings.Contains(script, `"$CNAME" /var/lib/postgresql/data`) {
t.Error("archive is being extracted into the mount point instead of its parent")
}
}
func TestInstallerVerifiesChecksums(t *testing.T) {
prepared, man, images := fixture(t)
script := renderInstaller(prepared, man, images, "shop-migration")
// Every payload in the manifest must be checksummed before it is fed to
// docker, so a truncated package fails loudly instead of restoring garbage.
for _, p := range man.Payloads {
var want string
if p.Kind == "image" {
want = "ensure_image_load " + spec.ShellQuote(p.Image) + " " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
} else {
want = "verify_payload " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
}
if !strings.Contains(script, want) {
t.Errorf("payload %s is not verified\nwant a line containing: %s\ngot:\n%s",
p.Path, want, grepLines(script, "verify_payload"))
}
}
}
func numbered(s string) string {
var b strings.Builder
for i, line := range strings.Split(s, "\n") {
b.WriteString(strings.TrimRight(line, "\r"))
b.WriteByte('\n')
if i > 200 {
b.WriteString("...\n")
break
}
}
return b.String()
}
func grepLines(s, needle string) string {
var out []string
for _, l := range strings.Split(s, "\n") {
if strings.Contains(l, needle) {
out = append(out, l)
}
}
return strings.Join(out, "\n")
}
// writePayloads materialises every payload the manifest references as a real
// gzipped tar, so the generated installer's gzip and docker cp steps behave the
// way they would with a genuine package.
func writePayloads(t *testing.T, dir string, man *spec.Manifest) {
t.Helper()
for _, p := range man.Payloads {
full := filepath.Join(dir, filepath.FromSlash(p.Path))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
body := []byte("payload for " + p.Path + "\n")
if err := tw.WriteHeader(&tar.Header{
Name: "placeholder.txt", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
}
// writeStubDocker installs a fake docker CLI on PATH and returns its directory.
//
// It models just enough of the real thing for the installer to run without a
// daemon: nothing exists yet except the image, and `docker inspect --format`
// answers the mount lookup the read-only seeding path depends on. When
// failStart is set, `docker start` fails the way it does on a target whose
// published port is already taken.
func writeStubDocker(t *testing.T, dir string, failStart bool) string {
t.Helper()
startCase := ""
if failStart {
startCase = ` start) echo "Bind for 0.0.0.0:5432 failed: port is already allocated" >&2; exit 1 ;;` + "\n"
}
stub := `#!/usr/bin/env bash
# object existence probes: "docker <kind> inspect <name>"
case "$1 $2" in
"image inspect") exit 0 ;;
"container inspect"|"volume inspect"|"network inspect") exit 1 ;;
esac
case "$1" in
version) echo 27.0.0 ;;
# resolve_mount calls "docker inspect --format <tmpl> <container>"
inspect) echo "/docker-entrypoint-initdb.d|stub-volume|" ;;
` + startCase + `esac
exit 0
`
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte(stub), 0o755); err != nil {
t.Fatal(err)
}
return binDir
}
+440
View File
@@ -0,0 +1,440 @@
package migrate
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/spec"
)
// PackageFormat selects how the finished package is laid out on disk.
type PackageFormat string
const (
// FormatDir leaves an unpacked directory, easiest to inspect and to copy
// onto a USB stick that is already mounted.
FormatDir PackageFormat = "dir"
// FormatTar produces a single .tar file, easiest to move around.
FormatTar PackageFormat = "tar"
)
// Packager writes a self-contained migration package: the container specs, the
// data archives, optionally the images, and a shell installer that replays it
// all on a target that has nothing but docker.
type Packager struct {
Src *dkr.Client
Containers []spec.Container
Volumes []spec.Volume
Networks []spec.Network
Plan spec.Plan
// OutputDir is the directory packages are created under.
OutputDir string
// Format selects a directory or a single tar file.
Format PackageFormat
// SourceHost is recorded in the manifest.
SourceHost string
}
// Result describes the produced package.
type Result struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Name string `json:"name"`
}
// Run builds the package, reporting progress into j.
func (p *Packager) Run(ctx context.Context, j *job.Job) (*Result, error) {
opts := p.Plan.Options
name := p.Plan.PackageName
if name == "" {
name = "docker-migration-" + time.Now().Format("20060102-150405")
}
name = sanitize(name)
root := filepath.Join(p.OutputDir, name)
if _, err := os.Stat(root); err == nil {
return nil, fmt.Errorf("package %s already exists in %s", name, p.OutputDir)
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("create package directory: %w", err)
}
cleanup := true
defer func() {
if cleanup {
os.RemoveAll(root)
}
}()
byID := map[string]*spec.Container{}
for i := range p.Containers {
byID[p.Containers[i].ID] = &p.Containers[i]
}
var prepared []*Prepared
for _, sel := range p.Plan.Items {
if !sel.Include {
continue
}
pr, err := Prepare(byID[sel.ContainerID], sel, p.Volumes, p.Networks)
if err != nil {
return nil, fmt.Errorf("container %s: %w", sel.ContainerID, err)
}
prepared = append(prepared, pr)
}
if len(prepared) == 0 {
return nil, errors.New("nothing selected to migrate")
}
man := spec.Manifest{
FormatVersion: 1,
CreatedAt: time.Now(),
CreatedBy: "docker-migrate",
SourceHost: p.SourceHost,
Options: opts,
Items: p.Plan.Items,
}
if v, err := p.Src.Ping(ctx); err == nil {
man.DockerVersion = v
}
savedImages := map[string]string{} // image ref -> payload path
for _, pr := range prepared {
item := j.AddItem(pr.Source.ID, pr.Source.Name)
for _, n := range pr.Source.Warnings {
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
}
for _, n := range pr.Notes {
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
}
err := p.packOne(ctx, j, item, root, pr, opts, &man, savedImages)
if err != nil {
j.SetItemState(item, job.StateFailed, err)
return nil, fmt.Errorf("%s: %w", pr.Source.Name, err)
}
j.SetItemState(item, job.StateSucceeded, nil)
man.Containers = append(man.Containers, *pr.Target)
man.Volumes = append(man.Volumes, pr.Volumes...)
for _, n := range pr.Networks {
if !hasNetwork(man.Networks, n.Name) {
man.Networks = append(man.Networks, n)
}
}
}
if err := writeJSON(filepath.Join(root, "manifest.json"), man); err != nil {
return nil, err
}
installer := renderInstaller(prepared, &man, savedImages, name)
if err := os.WriteFile(filepath.Join(root, "install.sh"), []byte(installer), 0o755); err != nil {
return nil, fmt.Errorf("write installer: %w", err)
}
if err := os.WriteFile(filepath.Join(root, "README.txt"), []byte(renderReadme(name, prepared, opts)), 0o644); err != nil {
return nil, fmt.Errorf("write readme: %w", err)
}
j.Logf(job.LevelInfo, "", "wrote installer, manifest and readme")
if p.Format == FormatTar {
tarPath := root + ".tar"
j.Logf(job.LevelInfo, "", "packing %s into a single archive", name)
size, err := tarDirectory(ctx, root, tarPath, name)
if err != nil {
os.Remove(tarPath)
return nil, fmt.Errorf("create package archive: %w", err)
}
os.RemoveAll(root)
cleanup = false
j.SetArtifact(tarPath, size)
return &Result{Path: tarPath, Bytes: size, Name: name + ".tar"}, nil
}
size, _ := dirSize(root)
cleanup = false
j.SetArtifact(root, size)
return &Result{Path: root, Bytes: size, Name: name}, nil
}
func (p *Packager) packOne(
ctx context.Context, j *job.Job, item *job.Item, root string,
pr *Prepared, opts spec.Options, man *spec.Manifest, savedImages map[string]string,
) error {
compress := opts.Compress
level := opts.CompressLevel
// Image.
if pr.Selection.MigrateImage && pr.Selection.ImageMode != spec.ImageSkip && pr.Selection.ImageMode != spec.ImagePull {
ref := pr.Target.Image
if _, done := savedImages[ref]; !done {
size := p.Src.ImageSizeBytes(ctx, ref)
st := j.AddStep(item, "image", "save image "+ref, size)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run: image not written")
} else {
rel := filepath.ToSlash(filepath.Join("images", sanitize(ref)+tarExt(compress)))
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
func() (io.ReadCloser, error) { return p.Src.SaveImage(ctx, ref) })
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("save image %s: %w", ref, err)
}
payload.Kind, payload.Image = "image", ref
man.Payloads = append(man.Payloads, *payload)
savedImages[ref] = rel
}
} else {
st := j.AddStep(item, "image", "image "+ref+" already in package", 0)
j.StartStep(st)
j.SkipStep(st, "shared with another container")
}
} else if pr.Selection.ImageMode == spec.ImagePull {
j.Logf(job.LevelInfo, item.ID, "%s: image %s will be pulled by the installer", pr.Source.Name, pr.Target.Image)
}
// Data. The source container is stopped for the duration when asked.
if len(pr.Transfers) == 0 {
return nil
}
restore, err := p.quiesce(ctx, j, item, pr, opts)
if err != nil {
return err
}
defer func() {
if restore != nil {
restore()
}
}()
for i, t := range pr.Transfers {
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run: data not written")
continue
}
rel := filepath.ToSlash(filepath.Join("data", sanitize(pr.ContainerName()),
fmt.Sprintf("%02d-%s%s", i, sanitize(strings.Trim(t.Destination, "/")), tarExt(compress))))
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
func() (io.ReadCloser, error) { return p.Src.CopyOut(ctx, pr.Source.ID, t.SourcePath) })
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("archive %s: %w", t.Label, err)
}
payload.Kind = "mount"
payload.Container = pr.ContainerName()
payload.Destination = t.Destination
man.Payloads = append(man.Payloads, *payload)
}
return nil
}
// streamToFile copies a stream to a file inside the package, optionally
// gzipping it, while counting bytes and computing a checksum.
func (p *Packager) streamToFile(
ctx context.Context, j *job.Job, st *job.Step,
absPath, relPath string, compress bool, level int,
open func() (io.ReadCloser, error),
) (*spec.Payload, error) {
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return nil, err
}
src, err := open()
if err != nil {
return nil, err
}
defer src.Close()
f, err := os.Create(absPath)
if err != nil {
return nil, err
}
defer f.Close()
hash := sha256.New()
// The checksum covers the bytes as stored, so the installer can verify the
// file it is about to feed to docker.
out := io.MultiWriter(f, hash)
counted := job.NewCountingReader(src, j, st)
var copyErr error
if compress {
gz, gerr := gzip.NewWriterLevel(out, gzipLevel(nil, level))
if gerr != nil {
return nil, gerr
}
_, copyErr = io.Copy(gz, counted)
if cerr := gz.Close(); copyErr == nil {
copyErr = cerr
}
} else {
_, copyErr = io.Copy(out, counted)
}
counted.Flush()
if copyErr != nil {
return nil, copyErr
}
if err := f.Sync(); err != nil {
return nil, err
}
info, err := f.Stat()
if err != nil {
return nil, err
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return &spec.Payload{
Path: relPath,
Bytes: info.Size(),
SHA256: hex.EncodeToString(hash.Sum(nil)),
Compressed: compress,
}, nil
}
func (p *Packager) quiesce(ctx context.Context, j *job.Job, item *job.Item, pr *Prepared, opts spec.Options) (func(), error) {
if opts.DryRun {
return nil, nil
}
wasRunning := pr.Source.State == "running"
if !pr.Selection.StopSourceDuringCopy {
if wasRunning {
j.AddItemWarning(item,
"archiving %s while it is running; data written during the copy may be inconsistent", pr.Source.Name)
}
return nil, nil
}
if !wasRunning {
return nil, nil
}
st := j.AddStep(item, "quiesce", "stop source "+pr.Source.Name, 0)
j.StartStep(st)
err := p.Src.Stop(ctx, pr.Source.ID, 30*time.Second)
j.FinishStep(st, err)
if err != nil {
return nil, fmt.Errorf("stop source container: %w", err)
}
return func() {
// Building a package does not move the workload anywhere, so the source
// is always put back the way it was found.
if err := p.Src.Start(context.WithoutCancel(ctx), pr.Source.ID); err != nil {
j.AddItemWarning(item, "could not restart source container: %v", err)
} else {
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", pr.Source.Name)
}
}, nil
}
func tarExt(compress bool) string {
if compress {
return ".tar.gz"
}
return ".tar"
}
func writeJSON(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
func hasNetwork(ns []spec.Network, name string) bool {
for _, n := range ns {
if n.Name == name {
return true
}
}
return false
}
// tarDirectory packs a package directory into a single tar file, keeping the
// directory name as the archive's top-level entry.
func tarDirectory(ctx context.Context, dir, dest, prefix string) (int64, error) {
f, err := os.Create(dest)
if err != nil {
return 0, err
}
defer f.Close()
tw := tar.NewWriter(f)
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
name := prefix
if rel != "." {
name = prefix + "/" + filepath.ToSlash(rel)
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = name
if info.IsDir() {
hdr.Name += "/"
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if info.IsDir() {
return nil
}
src, err := os.Open(path)
if err != nil {
return err
}
defer src.Close()
_, err = io.Copy(tw, src)
return err
})
if err != nil {
tw.Close()
return 0, err
}
if err := tw.Close(); err != nil {
return 0, err
}
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
func dirSize(dir string) (int64, error) {
var total int64
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}
+235
View File
@@ -0,0 +1,235 @@
// Package migrate turns a plan into work: either commands executed on a target
// host over SSH, or a self-contained package that can be carried to the target
// on a disk.
package migrate
import (
"fmt"
"path"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// Prepared is one container resolved against the user's selection: the spec as
// it will exist on the target, plus the list of data locations to transfer.
type Prepared struct {
// Source is the container as read from the source host.
Source *spec.Container
// Target is the same container rewritten for the target: renamed mounts,
// relocated binds, dropped mounts and an optional new container name.
Target *spec.Container
// Selection is the user's answer for this container.
Selection spec.ItemSelection
// Transfers are the mounts whose contents must be copied, in target terms.
Transfers []Transfer
// Volumes are the named volumes to create on the target.
Volumes []spec.Volume
// Networks are the user-defined networks to create on the target.
Networks []spec.Network
// Render carries the flags that shape the generated docker create command.
Render spec.RenderOptions
// Notes are advisories to show next to this container.
Notes []string
}
// Transfer is one data location to copy from source to target.
type Transfer struct {
// SourcePath is the path inside the source container to read from.
SourcePath string
// Destination is the path inside the target container the data belongs at.
Destination string
// RestoreInto is the directory the tar archive is extracted into, which is
// the parent of Destination.
RestoreInto string
// Kind describes what is behind the destination on the target.
Kind spec.MountKind
// ReadOnly means the target container mounts this read-only, so the copy
// has to go through a staging container.
ReadOnly bool
// VolumeName is the named volume behind the destination, when known.
VolumeName string
// BindSource is the host path behind the destination, for bind mounts.
BindSource string
// SizeBytes is the best-effort size, or -1.
SizeBytes int64
// Label is a human description used in the progress UI.
Label string
}
// ContainerName returns the name the container will have on the target.
func (p *Prepared) ContainerName() string {
if p.Render.NameOverride != "" {
return p.Render.NameOverride
}
return p.Source.Name
}
// Prepare resolves a plan item against the source inventory.
func Prepare(
src *spec.Container,
sel spec.ItemSelection,
allVolumes []spec.Volume,
allNetworks []spec.Network,
) (*Prepared, error) {
if src == nil {
return nil, fmt.Errorf("container not found in source inventory")
}
p := &Prepared{Source: src, Selection: sel}
target := *src // shallow copy; mounts are rebuilt below
p.Render = spec.RenderOptions{
NameOverride: sel.NameOverride,
KeepStaticIPs: sel.MigrateNetworks && sel.KeepStaticIPs,
SkipNetworks: !sel.MigrateNetworks,
SkipPorts: !sel.MigratePorts,
DropMounts: map[string]bool{},
}
volByName := map[string]spec.Volume{}
for _, v := range allVolumes {
volByName[v.Name] = v
}
netByName := map[string]spec.Network{}
for _, n := range allNetworks {
netByName[n.Name] = n
}
var mounts []spec.Mount
seenVolume := map[string]bool{}
for _, m := range src.Mounts {
ms, ok := sel.Mounts[m.Destination]
if !ok {
// A mount the UI never asked about defaults to being copied, so
// data is never silently left behind.
ms = spec.MountSelection{Action: spec.MountActionCopy}
if m.Kind == spec.MountTmpfs {
ms.Action = spec.MountActionStructure
}
}
if ms.Action == spec.MountActionSkip {
p.Render.DropMounts[m.Destination] = true
p.Notes = append(p.Notes, "mount "+m.Destination+" is not migrated")
continue
}
tm := m
switch m.Kind {
case spec.MountVolume:
if ms.TargetName != "" {
tm.Name = ms.TargetName
}
if v, ok := volByName[m.Name]; ok && !seenVolume[tm.Name] {
v.Name = tm.Name
p.Volumes = append(p.Volumes, v)
seenVolume[tm.Name] = true
}
case spec.MountAnonymous:
// Anonymous volumes are recreated as fresh anonymous volumes on
// the target; their generated name carries no meaning and the data
// is restored through the container path, not the volume name.
p.Render.AnonymousVolumesAsAnonymous = true
case spec.MountBind:
if ms.TargetSource != "" {
tm.Source = ms.TargetSource
}
}
mounts = append(mounts, tm)
if ms.Action != spec.MountActionCopy || !m.HasData() {
continue
}
if isRootPath(m.Destination) {
p.Notes = append(p.Notes, "refusing to copy mount at "+m.Destination+": copying a container root is not supported")
continue
}
t := Transfer{
SourcePath: m.Destination,
Destination: tm.Destination,
RestoreInto: parentDir(tm.Destination),
Kind: tm.Kind,
ReadOnly: tm.ReadOnly,
VolumeName: tm.Name,
BindSource: tm.Source,
SizeBytes: m.SizeBytes,
}
switch tm.Kind {
case spec.MountVolume:
t.Label = "volume " + tm.Name + " -> " + tm.Destination
case spec.MountAnonymous:
t.Label = "anonymous volume -> " + tm.Destination
case spec.MountBind:
t.Label = "bind " + tm.Source + " -> " + tm.Destination
default:
t.Label = string(tm.Kind) + " -> " + tm.Destination
}
p.Transfers = append(p.Transfers, t)
}
target.Mounts = mounts
if sel.MigrateNetworks {
for _, ep := range src.Endpoints {
if n, ok := netByName[ep.Network]; ok {
p.Networks = append(p.Networks, n)
}
}
}
if !sel.MigrateImage {
p.Notes = append(p.Notes, "image is assumed to already exist on the target")
}
if sel.MigrateNetworks && sel.KeepStaticIPs {
p.Notes = append(p.Notes, "static IP addresses are reapplied; they must fit the target subnets")
}
p.Target = &target
return p, nil
}
// StagingMountPath is where a read-only destination is mounted inside the
// temporary staging container used to seed it.
const stagingRoot = "/__docker_migrate"
// StagingPaths returns the mount point and the extraction directory used when
// seeding a read-only mount through a staging container. The volume is mounted
// under a directory named after the destination's last segment so the archive,
// whose entries are rooted at that same segment, lands exactly on top of it.
func StagingPaths(destination string) (mountAt string, extractInto string) {
return path.Join(stagingRoot, path.Base(strings.TrimSuffix(destination, "/"))), stagingRoot
}
// StagingName is the throwaway container name used to seed one read-only mount.
func StagingName(container string, index int) string {
return fmt.Sprintf("dm-stage-%s-%d", sanitize(container), index)
}
func parentDir(p string) string {
d := path.Dir(strings.TrimSuffix(p, "/"))
if d == "" || d == "." {
return "/"
}
return d
}
func isRootPath(p string) bool {
p = strings.TrimSuffix(p, "/")
return p == "" || p == "/"
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if len(out) > 40 {
out = out[:40]
}
return out
}
+144
View File
@@ -0,0 +1,144 @@
package migrate
import (
"strings"
"testing"
"github.com/arescom/docker-migrate/internal/spec"
)
func sample() *spec.Container {
return &spec.Container{
ID: "abc123", Name: "app", State: "running", Image: "app:1.0",
Mounts: []spec.Mount{
{Kind: spec.MountVolume, Name: "appdata", Destination: "/data", SizeBytes: 4096},
{Kind: spec.MountBind, Source: "/srv/app/conf", Destination: "/etc/app", ReadOnly: true},
{Kind: spec.MountAnonymous, Name: strings.Repeat("f", 64), Destination: "/tmp/cache"},
{Kind: spec.MountTmpfs, Destination: "/run"},
},
Endpoints: []spec.Endpoint{{Network: "appnet"}},
}
}
func TestPrepareDefaultsCopyEverything(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel,
[]spec.Volume{{Name: "appdata", Driver: "local"}},
[]spec.Network{{Name: "appnet", Driver: "bridge"}})
if err != nil {
t.Fatal(err)
}
// tmpfs carries no data, so exactly the three real locations transfer.
if len(p.Transfers) != 3 {
t.Fatalf("expected 3 transfers, got %d: %+v", len(p.Transfers), p.Transfers)
}
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata" {
t.Errorf("named volume not scheduled for creation: %+v", p.Volumes)
}
if len(p.Networks) != 1 {
t.Errorf("network not scheduled for creation: %+v", p.Networks)
}
byDest := map[string]Transfer{}
for _, tr := range p.Transfers {
byDest[tr.Destination] = tr
}
if got := byDest["/data"].RestoreInto; got != "/" {
t.Errorf("/data must be restored into /, got %q", got)
}
if got := byDest["/etc/app"].RestoreInto; got != "/etc" {
t.Errorf("/etc/app must be restored into /etc, got %q", got)
}
if !byDest["/etc/app"].ReadOnly {
t.Error("read-only bind must be flagged so it is seeded through a staging container")
}
}
func TestPrepareSkipAndRelocate(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
sel.Mounts["/tmp/cache"] = spec.MountSelection{Action: spec.MountActionSkip}
sel.Mounts["/etc/app"] = spec.MountSelection{Action: spec.MountActionCopy, TargetSource: "/opt/app/conf"}
sel.Mounts["/data"] = spec.MountSelection{Action: spec.MountActionStructure, TargetName: "appdata2"}
p, err := Prepare(c, sel, []spec.Volume{{Name: "appdata", Driver: "local"}}, nil)
if err != nil {
t.Fatal(err)
}
if !p.Render.DropMounts["/tmp/cache"] {
t.Error("skipped mount must be dropped from the create command")
}
// structure-only means the volume is created but no data is copied.
for _, tr := range p.Transfers {
if tr.Destination == "/data" {
t.Error("a structure-only mount must not be transferred")
}
}
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata2" {
t.Errorf("renamed volume not applied: %+v", p.Volumes)
}
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
if !strings.Contains(args, "--volume /opt/app/conf:/etc/app:ro") {
t.Errorf("relocated bind not applied: %s", args)
}
if !strings.Contains(args, "--volume appdata2:/data") {
t.Errorf("renamed volume not applied to create args: %s", args)
}
if strings.Contains(args, "/tmp/cache") {
t.Errorf("skipped mount still present: %s", args)
}
}
func TestPrepareAnonymousVolumeIsRecreatedFresh(t *testing.T) {
c := sample()
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, nil, nil)
if err != nil {
t.Fatal(err)
}
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
if strings.Contains(args, strings.Repeat("f", 64)) {
t.Errorf("the generated volume name must not be pinned on the target: %s", args)
}
if !strings.Contains(args, "--volume /tmp/cache") {
t.Errorf("anonymous volume must still be declared: %s", args)
}
}
func TestStagingPathsLandArchiveOnTheMountPoint(t *testing.T) {
// A tar produced from /var/lib/postgresql/data has entries rooted at
// "data/", so the staging container must mount the volume at
// <root>/data and extract into <root>.
mountAt, into := StagingPaths("/var/lib/postgresql/data")
if mountAt != into+"/data" {
t.Fatalf("mount point %q is not directly under the extraction dir %q", mountAt, into)
}
}
func TestPrepareRefusesRootMount(t *testing.T) {
c := &spec.Container{
ID: "x", Name: "weird", Image: "img",
Mounts: []spec.Mount{{Kind: spec.MountBind, Source: "/", Destination: "/"}},
}
sel := spec.DefaultSelection(c)
sel.Include = true
p, err := Prepare(c, sel, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(p.Transfers) != 0 {
t.Errorf("a mount at / must not be copied: %+v", p.Transfers)
}
if len(p.Notes) == 0 {
t.Error("refusing to copy / should be reported to the operator")
}
}
+732
View File
@@ -0,0 +1,732 @@
package migrate
import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/job"
"github.com/arescom/docker-migrate/internal/spec"
"github.com/arescom/docker-migrate/internal/sshx"
)
// SSHRunner migrates containers straight from the local daemon to a target
// host over one SSH connection. Nothing is written to disk on either side:
// tar streams go from the source daemon into a remote `docker cp`.
type SSHRunner struct {
Src *dkr.Client
Dst *sshx.RemoteDocker
Containers []spec.Container
Volumes []spec.Volume
Networks []spec.Network
Plan spec.Plan
}
// Run executes the whole plan, reporting into j.
func (r *SSHRunner) Run(ctx context.Context, j *job.Job) error {
opts := r.Plan.Options
if opts.Parallelism < 1 {
opts.Parallelism = 1
}
pre, err := r.Dst.Preflight(ctx)
if err != nil {
return fmt.Errorf("target preflight: %w", err)
}
for _, p := range pre.Problems {
j.Logf(job.LevelWarn, "", "target: %s", p)
}
if pre.ServerVersion == "" {
return errors.New("target host cannot run docker; see the warnings above")
}
j.Logf(job.LevelInfo, "", "target docker %s (%s/%s), free space on %s: %s",
pre.ServerVersion, pre.OS, pre.Arch, pre.DockerRoot, humanBytes(pre.DiskFreeBytes))
compress := opts.Compress && pre.HasGzip
if opts.Compress && !pre.HasGzip {
j.Logf(job.LevelWarn, "", "gzip missing on target; sending data uncompressed")
}
if opts.DryRun {
j.Logf(job.LevelInfo, "", "dry run: no command below is executed on the target")
}
byID := map[string]*spec.Container{}
for i := range r.Containers {
byID[r.Containers[i].ID] = &r.Containers[i]
}
// Prepare everything up front so a bad selection fails before any change.
var prepared []*Prepared
for _, sel := range r.Plan.Items {
if !sel.Include {
continue
}
p, err := Prepare(byID[sel.ContainerID], sel, r.Volumes, r.Networks)
if err != nil {
return fmt.Errorf("container %s: %w", sel.ContainerID, err)
}
prepared = append(prepared, p)
}
if len(prepared) == 0 {
return errors.New("nothing selected to migrate")
}
// Networks are shared between containers, so they are created once, before
// the per-container work fans out.
if err := r.ensureNetworks(ctx, j, prepared, opts); err != nil {
return err
}
sem := make(chan struct{}, opts.Parallelism)
var wg sync.WaitGroup
var mu sync.Mutex
var failures int
for _, p := range prepared {
p := p
item := j.AddItem(p.Source.ID, p.Source.Name)
for _, n := range p.Source.Warnings {
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
}
for _, n := range p.Notes {
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
}
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
j.SetItemState(item, job.StateCanceled, nil)
return
}
defer func() { <-sem }()
err := r.migrateOne(ctx, j, item, p, opts, compress)
switch {
case err == nil:
j.SetItemState(item, job.StateSucceeded, nil)
case errors.Is(err, errSkipped):
j.SetItemState(item, job.StateSkipped, err)
case errors.Is(err, context.Canceled):
j.SetItemState(item, job.StateCanceled, err)
default:
j.SetItemState(item, job.StateFailed, err)
j.Logf(job.LevelError, item.ID, "%s: %v", p.Source.Name, err)
mu.Lock()
failures++
mu.Unlock()
}
}()
}
wg.Wait()
if ctx.Err() != nil {
return context.Canceled
}
if failures > 0 {
return fmt.Errorf("%d of %d containers failed to migrate", failures, len(prepared))
}
return nil
}
var errSkipped = errors.New("skipped")
func (r *SSHRunner) migrateOne(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
name := p.ContainerName()
// 1. Name conflict on the target.
stepConflict := j.AddStep(item, "conflict", "check target for an existing "+name, 0)
j.StartStep(stepConflict)
newName, action, err := r.resolveConflict(ctx, j, name, opts)
if err != nil {
j.FinishStep(stepConflict, err)
return err
}
if action == "skip" {
j.SkipStep(stepConflict, "container already exists on target")
return fmt.Errorf("%w: %s already exists on the target", errSkipped, name)
}
if newName != name {
j.Logf(job.LevelWarn, item.ID, "%s already exists on target; creating %s instead", name, newName)
p.Render.NameOverride = newName
name = newName
}
j.FinishStep(stepConflict, nil)
// 2. Image.
if err := r.ensureImage(ctx, j, item, p, opts, compress); err != nil {
return err
}
// 3. Named volumes.
if err := r.ensureVolumes(ctx, j, item, p, opts); err != nil {
return err
}
// 4. Create the container, stopped. Creating it before the data copy is
// what makes volumes and bind directories exist with the right identity.
stepCreate := j.AddStep(item, "create", "create container "+name, 0)
j.StartStep(stepCreate)
createArgs := p.Target.CreateArgs(p.Render)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(createArgs...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, createArgs...); err != nil {
j.FinishStep(stepCreate, err)
return fmt.Errorf("create container on target: %w", err)
}
}
j.FinishStep(stepCreate, nil)
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
st := j.AddStep(item, "netconnect", "attach "+args[len(args)-2], 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, args...); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("attach extra network: %w", err)
}
}
j.FinishStep(st, nil)
}
// 5. Data. The source container is stopped first when asked, so the files
// are not changing underneath the copy.
if len(p.Transfers) > 0 {
restore, err := r.quiesceSource(ctx, j, item, p, opts)
if err != nil {
return err
}
copyErr := r.transferAll(ctx, j, item, p, opts, compress, name)
if restore != nil {
restore()
}
if copyErr != nil {
return copyErr
}
} else if p.Selection.StopSourceAfter && !opts.DryRun {
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
j.AddItemWarning(item, "could not stop source container: %v", err)
}
}
// 6. Start.
if p.Selection.StartAfter {
st := j.AddStep(item, "start", "start "+name+" on target", 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("start", name))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, "start", name); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("start container on target: %w", err)
}
}
j.FinishStep(st, nil)
}
// 7. Verify.
if opts.VerifyAfter && !opts.DryRun {
st := j.AddStep(item, "verify", "verify "+name+" on target", 0)
j.StartStep(st)
err := r.verify(ctx, j, item, p, name)
j.FinishStep(st, err)
if err != nil {
return err
}
}
return nil
}
// quiesceSource stops the source container when the selection asks for a
// consistent copy, and returns the function that puts it back the way the
// operator wants it afterwards.
func (r *SSHRunner) quiesceSource(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) (func(), error) {
if opts.DryRun {
return nil, nil
}
wasRunning := p.Source.State == "running"
if !p.Selection.StopSourceDuringCopy {
if wasRunning {
j.AddItemWarning(item,
"copying %s while it is running; data written during the copy may be inconsistent", p.Source.Name)
}
return func() {
if p.Selection.StopSourceAfter && wasRunning {
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
j.AddItemWarning(item, "could not stop source container: %v", err)
}
}
}, nil
}
if wasRunning {
st := j.AddStep(item, "quiesce", "stop source "+p.Source.Name, 0)
j.StartStep(st)
err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second)
j.FinishStep(st, err)
if err != nil {
return nil, fmt.Errorf("stop source container: %w", err)
}
}
return func() {
if !wasRunning || p.Selection.StopSourceAfter {
return
}
if err := r.Src.Start(context.WithoutCancel(ctx), p.Source.ID); err != nil {
j.AddItemWarning(item, "could not restart source container: %v", err)
} else {
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", p.Source.Name)
}
}, nil
}
func (r *SSHRunner) transferAll(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool, targetName string) error {
// Ask the target what it actually created, so read-only mounts can be
// seeded through a staging container that mounts the same volume writable.
var resolved map[string]targetMount
if !opts.DryRun && anyReadOnlyTransfer(p.Transfers) {
var err error
resolved, err = r.inspectTargetMounts(ctx, targetName)
if err != nil {
return fmt.Errorf("inspect target mounts: %w", err)
}
}
for i, t := range p.Transfers {
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
j.StartStep(st)
if opts.DryRun {
j.Logf(job.LevelCmd, item.ID, "target: %s (fed with a tar stream of %s from %s)",
r.Dst.Cmd("cp", "-a", "-", targetName+":"+t.RestoreInto), t.SourcePath, p.Source.Name)
j.SkipStep(st, "dry run")
continue
}
err := r.transferOne(ctx, j, item, st, p, t, i, opts, compress, targetName, resolved)
j.FinishStep(st, err)
if err != nil {
return err
}
}
return nil
}
func (r *SSHRunner) transferOne(
ctx context.Context, j *job.Job, item *job.Item, st *job.Step, p *Prepared,
t Transfer, index int, opts spec.Options, compress bool, targetName string, resolved map[string]targetMount,
) error {
// Pick where the archive is extracted. A writable mount is filled through
// the real container; a read-only one goes through a staging container that
// mounts the same volume or host path writable.
cpTarget := targetName
extractInto := t.RestoreInto
var cleanup func()
if t.ReadOnly {
tm, ok := resolved[t.Destination]
if !ok {
return fmt.Errorf("target does not report a mount at %s", t.Destination)
}
stageName := StagingName(targetName, index)
mountAt, into := StagingPaths(t.Destination)
var source string
switch {
case tm.Name != "":
source = tm.Name
case tm.Source != "":
source = tm.Source
default:
return fmt.Errorf("cannot resolve the storage behind read-only mount %s", t.Destination)
}
_, _ = r.Dst.Run(ctx, "rm", "-f", stageName)
args := []string{"create", "--name", stageName, "--volume", source + ":" + mountAt, p.Target.Image}
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if _, err := r.Dst.Run(ctx, args...); err != nil {
return fmt.Errorf("create staging container for read-only mount %s: %w", t.Destination, err)
}
cleanup = func() { _, _ = r.Dst.Run(context.WithoutCancel(ctx), "rm", "-f", stageName) }
cpTarget, extractInto = stageName, into
j.Logf(job.LevelInfo, item.ID, "seeding read-only mount %s through staging container %s", t.Destination, stageName)
}
if cleanup != nil {
defer cleanup()
}
src, err := r.Src.CopyOut(ctx, p.Source.ID, t.SourcePath)
if err != nil {
return err
}
defer src.Close()
counted := job.NewCountingReader(src, j, st)
body := io.Reader(counted)
if compress {
pr, pw := io.Pipe()
go func() {
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
if gerr != nil {
pw.CloseWithError(gerr)
return
}
_, cerr := io.Copy(gz, counted)
if closeErr := gz.Close(); cerr == nil {
cerr = closeErr
}
pw.CloseWithError(cerr)
}()
body = pr
}
cpArgs := []string{"cp", "-a", "-", cpTarget + ":" + extractInto}
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(cpArgs...))
if err := r.Dst.Feed(ctx, body, compress, cpArgs...); err != nil {
return fmt.Errorf("copy %s: %w", t.Label, err)
}
counted.Flush()
return nil
}
type targetMount struct {
Kind string
Name string
Source string
}
// inspectTargetMounts reads back the mounts the target actually created,
// which is the only way to learn the generated name of an anonymous volume.
func (r *SSHRunner) inspectTargetMounts(ctx context.Context, name string) (map[string]targetMount, error) {
const format = `{{range .Mounts}}{{.Type}}` + "\t" + `{{.Name}}` + "\t" + `{{.Source}}` + "\t" + `{{.Destination}}{{"\n"}}{{end}}`
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
if err != nil {
return nil, err
}
res := map[string]targetMount{}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
f := strings.Split(strings.TrimRight(line, "\r"), "\t")
if len(f) != 4 {
continue
}
res[f[3]] = targetMount{Kind: f[0], Name: f[1], Source: f[2]}
}
return res, nil
}
func (r *SSHRunner) ensureImage(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
ref := p.Target.Image
sel := p.Selection
if !sel.MigrateImage || sel.ImageMode == spec.ImageSkip {
st := j.AddStep(item, "image", "check image "+ref+" on target", 0)
j.StartStep(st)
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
ok, err := r.Dst.Exists(ctx, "image", ref)
j.FinishStep(st, err)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("image %s is not on the target and image migration is disabled", ref)
}
return nil
}
mode := sel.ImageMode
if mode == "" {
mode = spec.ImageAuto
}
if mode == spec.ImageAuto && !opts.DryRun {
if ok, err := r.Dst.Exists(ctx, "image", ref); err == nil && ok {
st := j.AddStep(item, "image", "image "+ref+" already on target", 0)
j.StartStep(st)
j.SkipStep(st, "already present")
return nil
}
}
tryPull := mode == spec.ImagePull ||
(mode == spec.ImageAuto && looksPullable(ref) && len(r.Src.ImageRepoDigests(ctx, ref)) > 0)
if tryPull {
st := j.AddStep(item, "image", "pull "+ref+" on target", 0)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("pull", ref))
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
_, err := r.Dst.Run(ctx, "pull", ref)
if err == nil {
j.FinishStep(st, nil)
return nil
}
if mode == spec.ImagePull {
j.FinishStep(st, err)
return fmt.Errorf("pull image on target: %w", err)
}
j.SkipStep(st, "pull failed, falling back to streaming the image")
j.Logf(job.LevelWarn, item.ID, "pull of %s failed on target (%v); streaming layers instead", ref, err)
}
size := r.Src.ImageSizeBytes(ctx, ref)
st := j.AddStep(item, "image", "stream image "+ref, size)
j.StartStep(st)
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("load"))
if opts.DryRun {
j.SkipStep(st, "dry run")
return nil
}
src, err := r.Src.SaveImage(ctx, ref)
if err != nil {
j.FinishStep(st, err)
return err
}
defer src.Close()
counted := job.NewCountingReader(src, j, st)
body := io.Reader(counted)
if compress {
pr, pw := io.Pipe()
go func() {
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
if gerr != nil {
pw.CloseWithError(gerr)
return
}
_, cerr := io.Copy(gz, counted)
if closeErr := gz.Close(); cerr == nil {
cerr = closeErr
}
pw.CloseWithError(cerr)
}()
body = pr
}
err = r.Dst.Feed(ctx, body, compress, "load")
counted.Flush()
j.FinishStep(st, err)
if err != nil {
return fmt.Errorf("load image on target: %w", err)
}
return nil
}
func (r *SSHRunner) ensureVolumes(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) error {
for _, v := range p.Volumes {
st := j.AddStep(item, "volume-"+v.Name, "ensure volume "+v.Name, 0)
j.StartStep(st)
if !opts.DryRun {
exists, err := r.Dst.Exists(ctx, "volume", v.Name)
if err != nil {
j.FinishStep(st, err)
return err
}
if exists {
switch opts.Conflict {
case spec.ConflictReplace:
j.Logf(job.LevelWarn, item.ID, "removing existing volume %s on target", v.Name)
if _, err := r.Dst.Run(ctx, "volume", "rm", "-f", v.Name); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("remove existing volume %s: %w", v.Name, err)
}
default:
// Reusing an existing volume is the safe default: the copy
// below writes into it without destroying anything else.
j.SkipStep(st, "volume already exists on target and is reused")
j.AddItemWarning(item, "volume %s already exists on the target; its current contents will be merged with the copied data", v.Name)
continue
}
}
}
args := v.CreateArgs()
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
if !opts.DryRun {
if _, err := r.Dst.Run(ctx, args...); err != nil {
j.FinishStep(st, err)
return fmt.Errorf("create volume %s: %w", v.Name, err)
}
}
j.FinishStep(st, nil)
}
return nil
}
func (r *SSHRunner) ensureNetworks(ctx context.Context, j *job.Job, prepared []*Prepared, opts spec.Options) error {
seen := map[string]bool{}
for _, p := range prepared {
for _, n := range p.Networks {
if seen[n.Name] || isBuiltin(n.Name) {
continue
}
seen[n.Name] = true
if !opts.DryRun {
exists, err := r.Dst.Exists(ctx, "network", n.Name)
if err != nil {
return err
}
if exists {
j.Logf(job.LevelInfo, "", "network %s already exists on target; reusing it", n.Name)
continue
}
}
args := n.CreateArgs()
j.Logf(job.LevelCmd, "", "target: %s", r.Dst.Cmd(args...))
if opts.DryRun {
continue
}
if _, err := r.Dst.Run(ctx, args...); err != nil {
return fmt.Errorf("create network %s: %w", n.Name, err)
}
j.Logf(job.LevelInfo, "", "created network %s on target", n.Name)
}
}
return nil
}
// resolveConflict decides what to do about an existing container on the target
// and returns the name to use.
func (r *SSHRunner) resolveConflict(ctx context.Context, j *job.Job, name string, opts spec.Options) (string, string, error) {
exists, err := r.Dst.Exists(ctx, "container", name)
if err != nil {
return "", "", err
}
if !exists {
return name, "create", nil
}
switch opts.Conflict {
case spec.ConflictSkip:
return name, "skip", nil
case spec.ConflictReplace:
j.Logf(job.LevelWarn, "", "removing existing container %s on target", name)
if opts.DryRun {
return name, "create", nil
}
if _, err := r.Dst.Run(ctx, "rm", "-f", name); err != nil {
return "", "", fmt.Errorf("remove existing container %s: %w", name, err)
}
return name, "create", nil
case spec.ConflictRename:
suffix := opts.RenameSuffix
if suffix == "" {
suffix = "-migrated"
}
candidate := name + suffix
for i := 2; ; i++ {
ok, err := r.Dst.Exists(ctx, "container", candidate)
if err != nil {
return "", "", err
}
if !ok {
return candidate, "create", nil
}
candidate = fmt.Sprintf("%s%s-%d", name, suffix, i)
if i > 50 {
return "", "", fmt.Errorf("could not find a free name based on %s", name)
}
}
default:
return "", "", fmt.Errorf("container %s already exists on the target", name)
}
}
func (r *SSHRunner) verify(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, name string) error {
const format = `{{.State.Status}}` + "\t" + `{{.Config.Image}}` + "\t" + `{{len .Mounts}}`
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
if err != nil {
return fmt.Errorf("container %s is not inspectable on the target: %w", name, err)
}
f := strings.Split(strings.TrimSpace(out), "\t")
if len(f) != 3 {
return fmt.Errorf("unexpected inspect output for %s", name)
}
status, image, mountCount := f[0], f[1], f[2]
wantMounts := 0
for _, m := range p.Target.Mounts {
if !p.Render.DropMounts[m.Destination] {
wantMounts++
}
}
if fmt.Sprint(wantMounts) != mountCount {
j.AddItemWarning(item, "target container %s reports %s mounts, expected %d", name, mountCount, wantMounts)
}
if image != p.Target.Image {
j.AddItemWarning(item, "target container %s runs image %s, expected %s", name, image, p.Target.Image)
}
if p.Selection.StartAfter && status != "running" {
logs, _ := r.Dst.Run(ctx, "logs", "--tail", "20", name)
if s := strings.TrimSpace(logs); s != "" {
j.Logf(job.LevelError, item.ID, "last logs from %s:\n%s", name, s)
}
return fmt.Errorf("container %s did not stay running on the target (status %s)", name, status)
}
j.Logf(job.LevelInfo, item.ID, "verified %s on target: status=%s image=%s mounts=%s", name, status, image, mountCount)
return nil
}
func anyReadOnlyTransfer(ts []Transfer) bool {
for _, t := range ts {
if t.ReadOnly {
return true
}
}
return false
}
func gzipLevel(_ *Prepared, level int) int {
if level < gzip.BestSpeed || level > gzip.BestCompression {
return gzip.BestSpeed
}
return level
}
// looksPullable reports whether a reference is a name a registry could serve,
// as opposed to a bare image id or a locally built, never-pushed tag.
func looksPullable(ref string) bool {
if ref == "" || strings.HasPrefix(ref, "sha256:") {
return false
}
if len(ref) == 64 && !strings.ContainsAny(ref, ":/.-_") {
return false
}
return true
}
func isBuiltin(name string) bool {
switch name {
case "bridge", "host", "none":
return true
}
return false
}
func humanBytes(n int64) string {
if n <= 0 {
return "unknown"
}
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
+192
View File
@@ -0,0 +1,192 @@
package spec
import "time"
// ImageMode decides how the container image reaches the target host.
type ImageMode string
const (
// ImageAuto pulls from a registry when the reference looks pullable and
// falls back to streaming the image layers otherwise.
ImageAuto ImageMode = "auto"
// ImagePull always runs `docker pull` on the target.
ImagePull ImageMode = "pull"
// ImageStream always transfers `docker save` output.
ImageStream ImageMode = "stream"
// ImageSkip assumes the image is already present on the target.
ImageSkip ImageMode = "skip"
)
// ConflictPolicy decides what to do when the target already has a container,
// volume or network with the same name.
type ConflictPolicy string
const (
ConflictFail ConflictPolicy = "fail" // abort the item
ConflictSkip ConflictPolicy = "skip" // leave the target object untouched
ConflictReplace ConflictPolicy = "replace" // remove the target object first
ConflictRename ConflictPolicy = "rename" // create alongside with a suffix
)
// ItemSelection is the per-container answer to "what do you want to migrate?".
// Every data location is opted in or out individually.
type ItemSelection struct {
ContainerID string `json:"containerId"`
// Include is the master switch for this container.
Include bool `json:"include"`
// NameOverride renames the container on the target.
NameOverride string `json:"nameOverride,omitempty"`
// MigrateImage brings the image across; when false the container is
// created assuming the image already exists on the target.
MigrateImage bool `json:"migrateImage"`
ImageMode ImageMode `json:"imageMode"`
// MigrateNetworks recreates user-defined networks and reattaches them.
MigrateNetworks bool `json:"migrateNetworks"`
KeepStaticIPs bool `json:"keepStaticIps"`
MigratePorts bool `json:"migratePorts"`
// Mounts maps a container-side destination path to how it is handled.
Mounts map[string]MountSelection `json:"mounts"`
// StartAfter starts the container on the target once restored.
StartAfter bool `json:"startAfter"`
// StopSourceDuringCopy stops the source container for the duration of the
// data copy so the files are consistent, then restores its former state.
StopSourceDuringCopy bool `json:"stopSourceDuringCopy"`
// StopSourceAfter leaves the source container stopped once the migration
// succeeded, so the two hosts do not both serve the same workload.
StopSourceAfter bool `json:"stopSourceAfter"`
}
// MountAction is what to do with one data location.
type MountAction string
const (
// MountActionCopy recreates the mount and copies its contents.
MountActionCopy MountAction = "copy"
// MountActionStructure recreates the mount (volume or host directory) but
// leaves it empty.
MountActionStructure MountAction = "structure"
// MountActionSkip drops the mount from the target container entirely.
MountActionSkip MountAction = "skip"
)
// MountSelection is the per-mount answer, including an optional relocation of
// a bind mount to a different path on the target host.
type MountSelection struct {
Action MountAction `json:"action"`
// TargetSource relocates a bind mount on the target host. Empty keeps the
// source path. Ignored for volumes.
TargetSource string `json:"targetSource,omitempty"`
// TargetName renames a named volume on the target. Empty keeps the name.
TargetName string `json:"targetName,omitempty"`
}
// Options are the settings shared by every item in one migration run.
type Options struct {
Conflict ConflictPolicy `json:"conflict"`
RenameSuffix string `json:"renameSuffix,omitempty"` // used by ConflictRename, default "-migrated"
// Compress gzips data and image streams. Requires gzip on the target for
// SSH mode; always safe for package mode.
Compress bool `json:"compress"`
// CompressLevel is 1..9, defaulting to 1 (fast) because these transfers
// are usually bound by disk and network, not CPU.
CompressLevel int `json:"compressLevel"`
// DryRun performs every check and prints every command without changing
// anything on the target.
DryRun bool `json:"dryRun"`
// Parallelism is how many containers migrate at once.
Parallelism int `json:"parallelism"`
// VerifyAfter re-inspects each container on the target and compares the
// resulting spec against the source.
VerifyAfter bool `json:"verifyAfter"`
}
// DefaultOptions returns the options used when the UI has not overridden them.
func DefaultOptions() Options {
return Options{
Conflict: ConflictFail,
RenameSuffix: "-migrated",
Compress: true,
CompressLevel: 1,
Parallelism: 1,
VerifyAfter: true,
}
}
// DefaultSelection builds the "migrate everything" answer for a container,
// which is what the UI presents before the user changes anything.
func DefaultSelection(c *Container) ItemSelection {
sel := ItemSelection{
ContainerID: c.ID,
Include: false,
MigrateImage: true,
ImageMode: ImageAuto,
MigrateNetworks: true,
KeepStaticIPs: false,
MigratePorts: true,
Mounts: map[string]MountSelection{},
StartAfter: c.State == "running",
StopSourceDuringCopy: true,
StopSourceAfter: true,
}
for _, m := range c.Mounts {
action := MountActionCopy
if m.Kind == MountTmpfs {
action = MountActionStructure
}
sel.Mounts[m.Destination] = MountSelection{Action: action}
}
return sel
}
// Plan is a complete migration request: what to move, where, and how.
type Plan struct {
Items []ItemSelection `json:"items"`
Options Options `json:"options"`
// Target is the SSH connection id for host-to-host mode. Empty means the
// plan produces an offline package instead.
Target string `json:"target,omitempty"`
// PackageName is the base name of the produced package (package mode).
PackageName string `json:"packageName,omitempty"`
}
// Manifest is written into an offline migration package. It is descriptive:
// the generated install.sh is self-contained and does not parse it.
type Manifest struct {
FormatVersion int `json:"formatVersion"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
SourceHost string `json:"sourceHost"`
DockerVersion string `json:"dockerVersion"`
Containers []Container `json:"containers"`
Volumes []Volume `json:"volumes"`
Networks []Network `json:"networks"`
Items []ItemSelection `json:"items"`
Options Options `json:"options"`
// Payloads lists every data file in the package with its checksum, so the
// installer can verify the archive survived the trip.
Payloads []Payload `json:"payloads"`
}
// Payload is one file inside a migration package.
type Payload struct {
Path string `json:"path"` // relative to the package root
Kind string `json:"kind"` // "image" | "mount"
Container string `json:"container,omitempty"`
Destination string `json:"destination,omitempty"` // mount destination it restores
Image string `json:"image,omitempty"`
Bytes int64 `json:"bytes"`
SHA256 string `json:"sha256"`
Compressed bool `json:"compressed"`
}
+499
View File
@@ -0,0 +1,499 @@
package spec
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
// ShellQuote wraps s so that a POSIX shell passes it through as a single
// literal argument. Single quotes inside are escaped the usual way.
func ShellQuote(s string) string {
if s == "" {
return "''"
}
if safeArg.MatchString(s) {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
var safeArg = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`)
// ShellQuoteAll quotes every argument and joins them with spaces.
func ShellQuoteAll(args []string) string {
parts := make([]string, len(args))
for i, a := range args {
parts[i] = ShellQuote(a)
}
return strings.Join(parts, " ")
}
// RenderOptions tunes how a container spec is turned into a create command.
type RenderOptions struct {
// NameOverride replaces the container name on the target (empty = keep).
NameOverride string
// KeepStaticIPs re-applies the source IP addresses. Off by default because
// the target subnets are often different.
KeepStaticIPs bool
// SkipNetworks drops all network flags, leaving the container on the
// default bridge. Used when the user opts out of network migration.
SkipNetworks bool
// SkipPorts drops published port flags (useful when the target already
// runs something on those ports).
SkipPorts bool
// DropMounts omits mount flags for destinations listed here, so a
// container can be migrated without one of its data locations.
DropMounts map[string]bool
// AnonymousVolumesAsAnonymous recreates generated-name volumes as fresh
// anonymous volumes instead of pinning the source name.
AnonymousVolumesAsAnonymous bool
}
// CreateArgs renders the full `docker create ...` argument list for a
// container, excluding the leading "docker". The first attached network is
// applied here; any additional networks need NetworkConnectArgs afterwards
// because `docker create` accepts only one --network.
func (c *Container) CreateArgs(o RenderOptions) []string {
name := c.Name
if o.NameOverride != "" {
name = o.NameOverride
}
a := []string{"create", "--name", name}
add := func(v ...string) { a = append(a, v...) }
flag := func(f, v string) {
if v != "" {
add(f, v)
}
}
flag("--hostname", c.Hostname)
flag("--domainname", c.Domainname)
flag("--user", c.User)
flag("--workdir", c.WorkingDir)
for _, e := range c.Env {
add("--env", e)
}
for _, k := range sortedKeys(c.Labels) {
if isManagedLabel(k) {
continue
}
add("--label", k+"="+c.Labels[k])
}
if c.Tty {
add("--tty")
}
if c.OpenStdin {
add("--interactive")
}
flag("--stop-signal", c.StopSignal)
if c.StopTimeout != nil {
add("--stop-timeout", strconv.Itoa(*c.StopTimeout))
}
if c.Init != nil && *c.Init {
add("--init")
}
if c.RestartPolicy != "" && c.RestartPolicy != "no" {
if c.RestartPolicy == "on-failure" && c.RestartMaxRetries > 0 {
add("--restart", fmt.Sprintf("on-failure:%d", c.RestartMaxRetries))
} else {
add("--restart", c.RestartPolicy)
}
}
// --rm is intentionally never re-applied: an auto-removing container would
// vanish before the operator can verify the migration.
if c.Privileged {
add("--privileged")
}
if c.ReadonlyRootfs {
add("--read-only")
}
for _, v := range c.CapAdd {
add("--cap-add", v)
}
for _, v := range c.CapDrop {
add("--cap-drop", v)
}
for _, v := range c.SecurityOpt {
add("--security-opt", v)
}
for _, v := range c.GroupAdd {
add("--group-add", v)
}
for _, k := range sortedKeys(c.Sysctls) {
add("--sysctl", k+"="+c.Sysctls[k])
}
for _, d := range c.Devices {
v := d.PathOnHost
if d.PathInContainer != "" && d.PathInContainer != d.PathOnHost {
v += ":" + d.PathInContainer
}
if p := d.CgroupPermissions; p != "" && p != "rwm" {
if !strings.Contains(v, ":") {
v += ":" + d.PathOnHost
}
v += ":" + p
}
add("--device", v)
}
for _, u := range c.Ulimits {
add("--ulimit", fmt.Sprintf("%s=%d:%d", u.Name, u.Soft, u.Hard))
}
if c.Runtime != "" && c.Runtime != "runc" {
add("--runtime", c.Runtime)
}
flag("--pid", nonDefault(c.PidMode, ""))
flag("--ipc", nonDefault(c.IpcMode, "private", "shareable"))
flag("--uts", nonDefault(c.UtsMode, ""))
flag("--userns", nonDefault(c.UsernsMode, ""))
// "private" is what a cgroup v2 host reports by default, and passing it
// explicitly breaks on a target whose kernel only has cgroup v1. Only the
// deliberate "host" override is worth carrying across.
flag("--cgroupns", nonDefault(c.CgroupnsMode, "private"))
for _, v := range c.DNS {
add("--dns", v)
}
for _, v := range c.DNSSearch {
add("--dns-search", v)
}
for _, v := range c.DNSOptions {
add("--dns-option", v)
}
for _, v := range c.ExtraHosts {
add("--add-host", v)
}
// Networking. Only the first endpoint can be expressed here.
if !o.SkipNetworks {
switch {
case strings.HasPrefix(c.NetworkMode, "container:"):
add("--network", c.NetworkMode)
case c.NetworkMode == "host" || c.NetworkMode == "none":
add("--network", c.NetworkMode)
case len(c.Endpoints) > 0:
ep := c.Endpoints[0]
add("--network", ep.Network)
for _, al := range ep.Aliases {
add("--network-alias", al)
}
if o.KeepStaticIPs {
flag("--ip", ep.IPv4Address)
flag("--ip6", ep.IPv6Address)
}
flag("--mac-address", ep.MacAddress)
case c.NetworkMode != "" && c.NetworkMode != "default":
add("--network", c.NetworkMode)
}
}
if !o.SkipPorts && c.NetworkMode != "host" {
for _, p := range c.Ports {
add("--publish", p.String())
}
if c.PublishAll {
add("--publish-all")
}
}
for _, e := range c.ExposedPorts {
if !c.isPublished(e) {
add("--expose", e)
}
}
for _, m := range c.Mounts {
if o.DropMounts[m.Destination] {
continue
}
switch m.Kind {
case MountTmpfs:
if m.TmpfsOpts != "" {
add("--tmpfs", m.Destination+":"+m.TmpfsOpts)
} else {
add("--tmpfs", m.Destination)
}
case MountAnonymous:
if o.AnonymousVolumesAsAnonymous || m.Name == "" {
add("--volume", m.Destination+roSuffix(m))
} else {
add("--volume", m.Name+":"+m.Destination+roSuffix(m))
}
case MountVolume:
add("--volume", m.Name+":"+m.Destination+roSuffix(m))
case MountBind:
v := m.Source + ":" + m.Destination + roSuffix(m)
if m.Propagation != "" && m.Propagation != "rprivate" {
if roSuffix(m) == "" {
v += ":" + m.Propagation
} else {
v += "," + m.Propagation
}
}
add("--volume", v)
}
}
if c.LogDriver != "" && c.LogDriver != "json-file" {
add("--log-driver", c.LogDriver)
}
for _, k := range sortedKeys(c.LogOptions) {
add("--log-opt", k+"="+c.LogOptions[k])
}
if h := c.Healthcheck; h != nil && len(h.Test) > 0 {
switch h.Test[0] {
case "NONE":
add("--no-healthcheck")
case "CMD":
add("--health-cmd", ShellQuoteAll(h.Test[1:]))
case "CMD-SHELL":
if len(h.Test) > 1 {
add("--health-cmd", h.Test[1])
}
}
if h.Interval > 0 {
add("--health-interval", durStr(h.Interval))
}
if h.Timeout > 0 {
add("--health-timeout", durStr(h.Timeout))
}
if h.StartPeriod > 0 {
add("--health-start-period", durStr(h.StartPeriod))
}
if h.Retries > 0 {
add("--health-retries", strconv.Itoa(h.Retries))
}
}
r := c.Resources
if r.Memory > 0 {
add("--memory", strconv.FormatInt(r.Memory, 10))
}
if r.MemoryReservation > 0 {
add("--memory-reservation", strconv.FormatInt(r.MemoryReservation, 10))
}
if r.MemorySwap != 0 {
add("--memory-swap", strconv.FormatInt(r.MemorySwap, 10))
}
if r.MemorySwappiness != nil && *r.MemorySwappiness >= 0 {
add("--memory-swappiness", strconv.FormatInt(*r.MemorySwappiness, 10))
}
if r.NanoCPUs > 0 {
add("--cpus", strconv.FormatFloat(float64(r.NanoCPUs)/1e9, 'f', -1, 64))
}
if r.CPUShares > 0 {
add("--cpu-shares", strconv.FormatInt(r.CPUShares, 10))
}
if r.CPUPeriod > 0 {
add("--cpu-period", strconv.FormatInt(r.CPUPeriod, 10))
}
if r.CPUQuota > 0 {
add("--cpu-quota", strconv.FormatInt(r.CPUQuota, 10))
}
flag("--cpuset-cpus", r.CpusetCpus)
flag("--cpuset-mems", r.CpusetMems)
if r.PidsLimit != nil && *r.PidsLimit > 0 {
add("--pids-limit", strconv.FormatInt(*r.PidsLimit, 10))
}
if r.OomKillDisable != nil && *r.OomKillDisable {
add("--oom-kill-disable")
}
if r.OomScoreAdj != 0 {
add("--oom-score-adj", strconv.Itoa(r.OomScoreAdj))
}
if r.ShmSize > 0 && r.ShmSize != 67108864 {
add("--shm-size", strconv.FormatInt(r.ShmSize, 10))
}
if c.EntrypointSet && len(c.Entrypoint) > 0 {
// docker only accepts a single --entrypoint token; extra words are
// prepended to the command instead.
add("--entrypoint", c.Entrypoint[0])
}
add(c.Image)
if c.EntrypointSet && len(c.Entrypoint) > 1 {
add(c.Entrypoint[1:]...)
}
if c.CmdSet {
add(c.Cmd...)
}
return a
}
// NetworkConnectArgs renders `docker network connect ...` for every endpoint
// beyond the first, which `docker create` could not express.
func (c *Container) NetworkConnectArgs(o RenderOptions) [][]string {
if o.SkipNetworks || len(c.Endpoints) < 2 {
return nil
}
name := c.Name
if o.NameOverride != "" {
name = o.NameOverride
}
var out [][]string
for _, ep := range c.Endpoints[1:] {
a := []string{"network", "connect"}
for _, al := range ep.Aliases {
a = append(a, "--alias", al)
}
if o.KeepStaticIPs {
if ep.IPv4Address != "" {
a = append(a, "--ip", ep.IPv4Address)
}
if ep.IPv6Address != "" {
a = append(a, "--ip6", ep.IPv6Address)
}
}
for _, l := range ep.Links {
a = append(a, "--link", l)
}
out = append(out, append(a, ep.Network, name))
}
return out
}
// CreateArgs renders `docker volume create ...` for a named volume.
func (v Volume) CreateArgs() []string {
a := []string{"volume", "create"}
if v.Driver != "" && v.Driver != "local" {
a = append(a, "--driver", v.Driver)
}
for _, k := range sortedKeys(v.DriverOpts) {
a = append(a, "--opt", k+"="+v.DriverOpts[k])
}
for _, k := range sortedKeys(v.Labels) {
if isManagedLabel(k) {
continue
}
a = append(a, "--label", k+"="+v.Labels[k])
}
return append(a, v.Name)
}
// CreateArgs renders `docker network create ...` for a user-defined network.
func (n Network) CreateArgs() []string {
a := []string{"network", "create"}
if n.Driver != "" {
a = append(a, "--driver", n.Driver)
}
if n.EnableIPv6 {
a = append(a, "--ipv6")
}
if n.Internal {
a = append(a, "--internal")
}
if n.Attachable {
a = append(a, "--attachable")
}
if n.IPAMDriver != "" && n.IPAMDriver != "default" {
a = append(a, "--ipam-driver", n.IPAMDriver)
}
for _, p := range n.IPAMPools {
if p.Subnet != "" {
a = append(a, "--subnet", p.Subnet)
}
if p.IPRange != "" {
a = append(a, "--ip-range", p.IPRange)
}
if p.Gateway != "" {
a = append(a, "--gateway", p.Gateway)
}
for _, k := range sortedKeys(p.AuxAddress) {
a = append(a, "--aux-address", k+"="+p.AuxAddress[k])
}
}
for _, k := range sortedKeys(n.Options) {
a = append(a, "--opt", k+"="+n.Options[k])
}
for _, k := range sortedKeys(n.Labels) {
if isManagedLabel(k) {
continue
}
a = append(a, "--label", k+"="+n.Labels[k])
}
return append(a, n.Name)
}
// String renders a port binding in `docker publish` syntax.
func (p PortBinding) String() string {
port := p.ContainerPort
proto := "tcp"
if i := strings.LastIndex(port, "/"); i >= 0 {
proto, port = port[i+1:], port[:i]
}
var b strings.Builder
if p.HostIP != "" && p.HostIP != "0.0.0.0" {
b.WriteString(p.HostIP + ":")
}
b.WriteString(p.HostPort + ":" + port)
if proto != "tcp" {
b.WriteString("/" + proto)
}
return b.String()
}
func (c *Container) isPublished(exposed string) bool {
for _, p := range c.Ports {
if p.ContainerPort == exposed {
return true
}
}
return false
}
func roSuffix(m Mount) string {
if m.ReadOnly {
return ":ro"
}
return ""
}
// nonDefault returns v unless it is one of the values Docker would have chosen
// anyway, in which case emitting a flag adds noise without changing behaviour.
func nonDefault(v string, defaults ...string) string {
if v == "" || v == "default" {
return ""
}
for _, d := range defaults {
if v == d {
return ""
}
}
return v
}
// isManagedLabel filters out labels Docker or compose maintain themselves;
// re-applying them would make the target look like it belongs to a compose
// project that is not actually there.
func isManagedLabel(k string) bool {
return strings.HasPrefix(k, "com.docker.compose.") ||
strings.HasPrefix(k, "com.docker.swarm.") ||
strings.HasPrefix(k, "desktop.docker.io/")
}
func durStr(ns int64) string {
if ns%1e9 == 0 {
return strconv.FormatInt(ns/1e9, 10) + "s"
}
return strconv.FormatInt(ns/1e6, 10) + "ms"
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+252
View File
@@ -0,0 +1,252 @@
package spec
import (
"strings"
"testing"
)
func TestShellQuote(t *testing.T) {
cases := map[string]string{
"simple": "simple",
"a/b-c_1.2": "a/b-c_1.2",
"": "''",
"has space": "'has space'",
"it's": `'it'\''s'`,
"$(rm -rf /)": "'$(rm -rf /)'",
"a;b": "'a;b'",
"KEY=value": "KEY=value",
"tag:1.0@sha256:ab": "tag:1.0@sha256:ab",
"back`tick`": "'back`tick`'",
}
for in, want := range cases {
if got := ShellQuote(in); got != want {
t.Errorf("ShellQuote(%q) = %q, want %q", in, got, want)
}
}
}
// TestCreateArgsFull checks that a container using most of the surface area of
// docker run is rendered back into an equivalent create command.
func TestCreateArgsFull(t *testing.T) {
stopTimeout := 15
initTrue := true
c := &Container{
Name: "web",
Image: "nginx:1.27",
Hostname: "web-1",
User: "101:101",
WorkingDir: "/srv",
Env: []string{"TZ=Europe/Paris", "SECRET=a b"},
Labels: map[string]string{"team": "infra", "com.docker.compose.project": "shop"},
Cmd: []string{"nginx", "-g", "daemon off;"},
CmdSet: true,
Entrypoint: []string{"/entry.sh", "--flag"},
EntrypointSet: true,
RestartPolicy: "on-failure",
RestartMaxRetries: 3,
StopSignal: "SIGQUIT",
StopTimeout: &stopTimeout,
Init: &initTrue,
Privileged: true,
CapAdd: []string{"NET_ADMIN"},
CapDrop: []string{"MKNOD"},
Sysctls: map[string]string{"net.core.somaxconn": "1024"},
DNS: []string{"1.1.1.1"},
ExtraHosts: []string{"db:10.0.0.5"},
NetworkMode: "frontend",
Endpoints: []Endpoint{
{Network: "frontend", Aliases: []string{"web", "www"}, IPv4Address: "172.20.0.9"},
{Network: "backend", Aliases: []string{"web"}},
},
Ports: []PortBinding{
{ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "8080"},
{ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"},
},
ExposedPorts: []string{"80/tcp", "9000/tcp"},
Mounts: []Mount{
{Kind: MountVolume, Name: "html", Destination: "/usr/share/nginx/html", ReadOnly: true},
{Kind: MountBind, Source: "/etc/nginx/conf.d", Destination: "/etc/nginx/conf.d"},
{Kind: MountAnonymous, Name: strings.Repeat("a", 64), Destination: "/cache"},
{Kind: MountTmpfs, Destination: "/run", TmpfsOpts: "size=64m"},
},
LogDriver: "json-file",
LogOptions: map[string]string{"max-size": "10m"},
Resources: Resources{Memory: 536870912, NanoCPUs: 1500000000, ShmSize: 67108864},
}
got := strings.Join(c.CreateArgs(RenderOptions{}), " ")
mustContain := []string{
"create --name web",
"--hostname web-1",
"--user 101:101",
"--env TZ=Europe/Paris",
"--label team=infra",
"--restart on-failure:3",
"--stop-timeout 15",
"--init",
"--privileged",
"--cap-add NET_ADMIN",
"--sysctl net.core.somaxconn=1024",
"--add-host db:10.0.0.5",
"--network frontend",
"--network-alias web",
"--publish 8080:80",
"--publish 127.0.0.1:5353:53/udp",
"--expose 9000/tcp",
"--volume html:/usr/share/nginx/html:ro",
"--volume /etc/nginx/conf.d:/etc/nginx/conf.d",
"--tmpfs /run:size=64m",
"--log-opt max-size=10m",
"--memory 536870912",
"--cpus 1.5",
"--entrypoint /entry.sh",
"nginx:1.27",
}
for _, want := range mustContain {
if !strings.Contains(got, want) {
t.Errorf("create args missing %q\ngot: %s", want, got)
}
}
// Labels docker or compose manage themselves must not be re-applied.
if strings.Contains(got, "com.docker.compose.project") {
t.Errorf("compose-managed label was re-applied:\n%s", got)
}
// A port already published must not also be re-exposed.
if strings.Contains(got, "--expose 80/tcp") {
t.Errorf("published port was also exposed:\n%s", got)
}
// The default shm size carries no information and should be omitted.
if strings.Contains(got, "--shm-size") {
t.Errorf("default shm size was emitted:\n%s", got)
}
// The image must be the last flag-free token before the command.
idx := strings.Index(got, "nginx:1.27")
if idx < 0 || !strings.Contains(got[idx:], "--flag") {
t.Errorf("entrypoint remainder and command must follow the image:\n%s", got)
}
}
func TestCreateArgsSecondNetworkNeedsConnect(t *testing.T) {
c := &Container{
Name: "app", Image: "app:1", NetworkMode: "a",
Endpoints: []Endpoint{
{Network: "a"},
{Network: "b", Aliases: []string{"app-b"}, IPv4Address: "10.1.2.3"},
},
}
args := c.CreateArgs(RenderOptions{})
if n := strings.Count(strings.Join(args, " "), "--network "); n != 1 {
t.Fatalf("docker create accepts one --network, got %d in %v", n, args)
}
connects := c.NetworkConnectArgs(RenderOptions{})
if len(connects) != 1 {
t.Fatalf("expected 1 network connect, got %d", len(connects))
}
joined := strings.Join(connects[0], " ")
if !strings.Contains(joined, "network connect --alias app-b b app") {
t.Errorf("unexpected connect args: %s", joined)
}
if strings.Contains(joined, "--ip ") {
t.Errorf("static IP must not be applied unless requested: %s", joined)
}
withIP := strings.Join(c.NetworkConnectArgs(RenderOptions{KeepStaticIPs: true})[0], " ")
if !strings.Contains(withIP, "--ip 10.1.2.3") {
t.Errorf("static IP was requested but not applied: %s", withIP)
}
}
func TestRenderOptionsDropAndRename(t *testing.T) {
c := &Container{
Name: "db", Image: "postgres:16",
Ports: []PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}},
Mounts: []Mount{{Kind: MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"}},
Endpoints: []Endpoint{{Network: "backend"}},
}
got := strings.Join(c.CreateArgs(RenderOptions{
NameOverride: "db-new",
SkipPorts: true,
SkipNetworks: true,
DropMounts: map[string]bool{"/var/lib/postgresql/data": true},
}), " ")
if !strings.Contains(got, "--name db-new") {
t.Errorf("name override not applied: %s", got)
}
for _, unwanted := range []string{"--publish", "--network", "--volume"} {
if strings.Contains(got, unwanted) {
t.Errorf("expected %s to be dropped: %s", unwanted, got)
}
}
}
// TestCgroupnsPrivateIsNotCarried guards a cross-host hazard: "private" is
// simply what a cgroup v2 host reports, and passing it explicitly makes the
// create fail on a target whose kernel only has cgroup v1.
func TestCgroupnsPrivateIsNotCarried(t *testing.T) {
private := &Container{Name: "a", Image: "img", CgroupnsMode: "private"}
if got := strings.Join(private.CreateArgs(RenderOptions{}), " "); strings.Contains(got, "--cgroupns") {
t.Errorf("the default cgroup namespace must not be pinned: %s", got)
}
host := &Container{Name: "a", Image: "img", CgroupnsMode: "host"}
if got := strings.Join(host.CreateArgs(RenderOptions{}), " "); !strings.Contains(got, "--cgroupns host") {
t.Errorf("an explicit host cgroup namespace must be carried across: %s", got)
}
}
func TestAutoRemoveIsNeverReapplied(t *testing.T) {
c := &Container{Name: "job", Image: "busybox", AutoRemove: true}
if strings.Contains(strings.Join(c.CreateArgs(RenderOptions{}), " "), "--rm") {
t.Error("--rm must not be reapplied; the migrated container would delete itself")
}
}
func TestVolumeAndNetworkCreateArgs(t *testing.T) {
v := Volume{
Name: "pgdata", Driver: "local",
DriverOpts: map[string]string{"type": "nfs", "device": ":/exports/pg"},
Labels: map[string]string{"app": "shop", "com.docker.compose.project": "x"},
}
got := strings.Join(v.CreateArgs(), " ")
for _, want := range []string{"volume create", "--opt device=:/exports/pg", "--opt type=nfs", "--label app=shop", "pgdata"} {
if !strings.Contains(got, want) {
t.Errorf("volume args missing %q: %s", want, got)
}
}
if strings.Contains(got, "--driver local") {
t.Errorf("the default driver should be omitted: %s", got)
}
if strings.Contains(got, "compose.project") {
t.Errorf("compose label must not be reapplied: %s", got)
}
n := Network{
Name: "backend", Driver: "bridge", Internal: true, Attachable: true,
IPAMPools: []IPAMPool{{Subnet: "172.28.0.0/16", Gateway: "172.28.0.1"}},
Options: map[string]string{"com.docker.network.bridge.name": "br-backend"},
}
gotNet := strings.Join(n.CreateArgs(), " ")
for _, want := range []string{"network create", "--driver bridge", "--internal", "--attachable", "--subnet 172.28.0.0/16", "--gateway 172.28.0.1", "backend"} {
if !strings.Contains(gotNet, want) {
t.Errorf("network args missing %q: %s", want, gotNet)
}
}
}
func TestPortBindingString(t *testing.T) {
cases := []struct {
in PortBinding
want string
}{
{PortBinding{ContainerPort: "80/tcp", HostPort: "8080"}, "8080:80"},
{PortBinding{ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "80"}, "80:80"},
{PortBinding{ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"}, "127.0.0.1:5353:53/udp"},
}
for _, c := range cases {
if got := c.in.String(); got != c.want {
t.Errorf("PortBinding%+v = %q, want %q", c.in, got, c.want)
}
}
}
+214
View File
@@ -0,0 +1,214 @@
// Package spec defines a normalized, JSON-serializable description of a Docker
// container and everything it needs to be recreated on another host.
//
// The spec is deliberately independent of the Docker SDK types: it is produced
// on the source host, travels over SSH or inside an offline migration package,
// and is consumed either by the SSH engine or by a generated shell script that
// only has the docker CLI available.
package spec
// MountKind classifies a data location attached to a container.
type MountKind string
const (
MountVolume MountKind = "volume" // named volume
MountAnonymous MountKind = "anonymous" // volume with a generated name
MountBind MountKind = "bind" // host directory or file
MountTmpfs MountKind = "tmpfs" // in-memory, never carries data
)
// Mount is one data location attached to a container.
type Mount struct {
Kind MountKind `json:"kind"`
Name string `json:"name,omitempty"` // volume name (volume kind only)
Source string `json:"source,omitempty"` // host path (bind kind only)
Destination string `json:"destination"` // path inside the container
ReadOnly bool `json:"readOnly"`
Propagation string `json:"propagation,omitempty"`
TmpfsOpts string `json:"tmpfsOpts,omitempty"`
// SizeBytes is a best-effort measurement of the data at this location,
// used to show progress and to warn about very large transfers. -1 = unknown.
SizeBytes int64 `json:"sizeBytes"`
}
// HasData reports whether this mount is worth copying. tmpfs never is.
func (m Mount) HasData() bool { return m.Kind != MountTmpfs }
// Volume describes a named volume so it can be recreated with the same driver,
// options and labels rather than falling back to a plain local volume.
type Volume struct {
Name string `json:"name"`
Driver string `json:"driver"`
DriverOpts map[string]string `json:"driverOpts,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// IPAMPool is one subnet definition of a user-defined network.
type IPAMPool struct {
Subnet string `json:"subnet,omitempty"`
IPRange string `json:"ipRange,omitempty"`
Gateway string `json:"gateway,omitempty"`
AuxAddress map[string]string `json:"auxAddress,omitempty"`
}
// Network describes a user-defined network to recreate on the target.
type Network struct {
Name string `json:"name"`
Driver string `json:"driver"`
Scope string `json:"scope,omitempty"`
EnableIPv6 bool `json:"enableIPv6,omitempty"`
Internal bool `json:"internal,omitempty"`
Attachable bool `json:"attachable,omitempty"`
Ingress bool `json:"ingress,omitempty"`
IPAMDriver string `json:"ipamDriver,omitempty"`
IPAMPools []IPAMPool `json:"ipamPools,omitempty"`
Options map[string]string `json:"options,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
}
// Endpoint is a container's attachment to one network.
type Endpoint struct {
Network string `json:"network"`
Aliases []string `json:"aliases,omitempty"`
IPv4Address string `json:"ipv4Address,omitempty"`
IPv6Address string `json:"ipv6Address,omitempty"`
MacAddress string `json:"macAddress,omitempty"`
Links []string `json:"links,omitempty"`
DriverOpts map[string]string `json:"driverOpts,omitempty"`
}
// PortBinding maps a container port onto the host.
type PortBinding struct {
ContainerPort string `json:"containerPort"` // e.g. "80/tcp"
HostIP string `json:"hostIp,omitempty"`
HostPort string `json:"hostPort,omitempty"`
}
// Healthcheck mirrors the container health configuration when it was
// overridden at run time (an image-provided healthcheck is not re-emitted).
type Healthcheck struct {
Test []string `json:"test,omitempty"`
Interval int64 `json:"interval,omitempty"` // nanoseconds
Timeout int64 `json:"timeout,omitempty"` // nanoseconds
StartPeriod int64 `json:"startPeriod,omitempty"` // nanoseconds
Retries int `json:"retries,omitempty"`
}
// Resources holds the cgroup limits applied to the container.
type Resources struct {
Memory int64 `json:"memory,omitempty"`
MemoryReservation int64 `json:"memoryReservation,omitempty"`
MemorySwap int64 `json:"memorySwap,omitempty"`
MemorySwappiness *int64 `json:"memorySwappiness,omitempty"`
NanoCPUs int64 `json:"nanoCpus,omitempty"`
CPUShares int64 `json:"cpuShares,omitempty"`
CPUPeriod int64 `json:"cpuPeriod,omitempty"`
CPUQuota int64 `json:"cpuQuota,omitempty"`
CpusetCpus string `json:"cpusetCpus,omitempty"`
CpusetMems string `json:"cpusetMems,omitempty"`
PidsLimit *int64 `json:"pidsLimit,omitempty"`
OomKillDisable *bool `json:"oomKillDisable,omitempty"`
OomScoreAdj int `json:"oomScoreAdj,omitempty"`
ShmSize int64 `json:"shmSize,omitempty"`
}
// Ulimit is a per-container resource limit.
type Ulimit struct {
Name string `json:"name"`
Soft int64 `json:"soft"`
Hard int64 `json:"hard"`
}
// Device is a host device exposed to the container.
type Device struct {
PathOnHost string `json:"pathOnHost"`
PathInContainer string `json:"pathInContainer"`
CgroupPermissions string `json:"cgroupPermissions"`
}
// Container is the full normalized description of one container.
type Container struct {
ID string `json:"id"`
Name string `json:"name"` // without the leading slash
State string `json:"state"` // running, exited, ...
Image string `json:"image"` // reference as the user wrote it, e.g. nginx:1.27
ImageID string `json:"imageId"` // sha256:...
ImageDigest string `json:"imageDigest,omitempty"`
// Compose grouping, taken from the standard compose labels. Empty when the
// container was not created by docker compose.
ComposeProject string `json:"composeProject,omitempty"`
ComposeService string `json:"composeService,omitempty"`
Hostname string `json:"hostname,omitempty"`
Domainname string `json:"domainname,omitempty"`
User string `json:"user,omitempty"`
WorkingDir string `json:"workingDir,omitempty"`
Env []string `json:"env,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Cmd []string `json:"cmd,omitempty"`
Entrypoint []string `json:"entrypoint,omitempty"`
EntrypointSet bool `json:"entrypointSet,omitempty"` // true when overridden at run time
CmdSet bool `json:"cmdSet,omitempty"`
Tty bool `json:"tty,omitempty"`
OpenStdin bool `json:"openStdin,omitempty"`
StopSignal string `json:"stopSignal,omitempty"`
StopTimeout *int `json:"stopTimeout,omitempty"`
Init *bool `json:"init,omitempty"`
RestartPolicy string `json:"restartPolicy,omitempty"`
RestartMaxRetries int `json:"restartMaxRetries,omitempty"`
AutoRemove bool `json:"autoRemove,omitempty"`
Privileged bool `json:"privileged,omitempty"`
ReadonlyRootfs bool `json:"readonlyRootfs,omitempty"`
CapAdd []string `json:"capAdd,omitempty"`
CapDrop []string `json:"capDrop,omitempty"`
SecurityOpt []string `json:"securityOpt,omitempty"`
GroupAdd []string `json:"groupAdd,omitempty"`
Sysctls map[string]string `json:"sysctls,omitempty"`
Devices []Device `json:"devices,omitempty"`
Ulimits []Ulimit `json:"ulimits,omitempty"`
Runtime string `json:"runtime,omitempty"`
PidMode string `json:"pidMode,omitempty"`
IpcMode string `json:"ipcMode,omitempty"`
UtsMode string `json:"utsMode,omitempty"`
UsernsMode string `json:"usernsMode,omitempty"`
CgroupnsMode string `json:"cgroupnsMode,omitempty"`
DNS []string `json:"dns,omitempty"`
DNSSearch []string `json:"dnsSearch,omitempty"`
DNSOptions []string `json:"dnsOptions,omitempty"`
ExtraHosts []string `json:"extraHosts,omitempty"`
NetworkMode string `json:"networkMode,omitempty"` // bridge, host, none, <name>, container:<id>
Endpoints []Endpoint `json:"endpoints,omitempty"`
Ports []PortBinding `json:"ports,omitempty"`
ExposedPorts []string `json:"exposedPorts,omitempty"`
PublishAll bool `json:"publishAll,omitempty"`
Mounts []Mount `json:"mounts,omitempty"`
LogDriver string `json:"logDriver,omitempty"`
LogOptions map[string]string `json:"logOptions,omitempty"`
Healthcheck *Healthcheck `json:"healthcheck,omitempty"`
Resources Resources `json:"resources"`
// Warnings collected while reading the container, surfaced in the UI.
Warnings []string `json:"warnings,omitempty"`
}
// DataMounts returns the mounts that actually carry data.
func (c *Container) DataMounts() []Mount {
out := make([]Mount, 0, len(c.Mounts))
for _, m := range c.Mounts {
if m.HasData() {
out = append(out, m)
}
}
return out
}
+298
View File
@@ -0,0 +1,298 @@
// Package sshx provides the SSH transport used to drive a target host that has
// nothing installed but sshd and the docker CLI.
package sshx
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/crypto/ssh/knownhosts"
)
// AuthMethod selects how to authenticate against the target host.
type AuthMethod string
const (
AuthPassword AuthMethod = "password"
AuthKey AuthMethod = "key"
AuthAgent AuthMethod = "agent"
)
// Config describes one target host.
type Config struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Auth AuthMethod `json:"auth"`
// Password is used with AuthPassword, and as the passphrase fallback when
// a key is encrypted.
Password string `json:"password,omitempty"`
// PrivateKey holds PEM key material for AuthKey. PrivateKeyPath is read
// from disk instead when PrivateKey is empty.
PrivateKey string `json:"privateKey,omitempty"`
PrivateKeyPath string `json:"privateKeyPath,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
// Sudo prefixes every docker command with sudo -n, for hosts where the
// login user is not in the docker group.
Sudo bool `json:"sudo"`
// DockerCmd overrides the docker binary, e.g. "podman" or an absolute path.
DockerCmd string `json:"dockerCmd,omitempty"`
// SaveSecrets persists the password and key material to the connection
// store. When false the secrets live only for the current process.
SaveSecrets bool `json:"saveSecrets"`
// Timeout is the TCP/handshake timeout. Zero means 20s.
Timeout time.Duration `json:"-"`
}
func (c Config) addr() string {
port := c.Port
if port == 0 {
port = 22
}
return net.JoinHostPort(c.Host, strconv.Itoa(port))
}
// Client is a live SSH connection to a target host.
type Client struct {
cfg Config
conn *ssh.Client
}
// HostKeyError reports that the target's host key is unknown or has changed.
// The UI shows the fingerprint and asks the operator to confirm before the key
// is written to the known-hosts store.
type HostKeyError struct {
Host string
Fingerprint string
KeyType string
Changed bool // true when a different key was already trusted
}
func (e *HostKeyError) Error() string {
if e.Changed {
return fmt.Sprintf("host key for %s CHANGED (%s %s); refusing to connect", e.Host, e.KeyType, e.Fingerprint)
}
return fmt.Sprintf("host key for %s is not trusted yet (%s %s)", e.Host, e.KeyType, e.Fingerprint)
}
// Dial opens a connection, verifying the host key against the known-hosts
// store. It returns a *HostKeyError when the operator has to make a trust
// decision first.
func Dial(ctx context.Context, cfg Config, hk *KnownHosts) (*Client, error) {
auths, err := authMethods(cfg)
if err != nil {
return nil, err
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 20 * time.Second
}
var hkErr *HostKeyError
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Auth: auths,
Timeout: timeout,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := hk.Check(hostname, remote, key)
var he *HostKeyError
if errors.As(err, &he) {
hkErr = he
}
return err
},
}
d := net.Dialer{Timeout: timeout}
rawConn, err := d.DialContext(ctx, "tcp", cfg.addr())
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
}
sshConn, chans, reqs, err := ssh.NewClientConn(rawConn, cfg.addr(), clientCfg)
if err != nil {
rawConn.Close()
if hkErr != nil {
return nil, hkErr
}
return nil, fmt.Errorf("ssh handshake with %s: %w", cfg.addr(), err)
}
return &Client{cfg: cfg, conn: ssh.NewClient(sshConn, chans, reqs)}, nil
}
// Close terminates the connection.
func (c *Client) Close() error { return c.conn.Close() }
// Config returns the configuration this client was dialled with.
func (c *Client) Config() Config { return c.cfg }
// Result is the outcome of a remote command.
type Result struct {
Stdout string
Stderr string
ExitCode int
}
// Run executes a command line on the remote host and collects its output.
// The command is passed to the remote login shell, so it may contain pipes.
func (c *Client) Run(ctx context.Context, cmdline string) (*Result, error) {
var stdout, stderr bytes.Buffer
code, err := c.run(ctx, cmdline, nil, &stdout, &stderr)
res := &Result{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: code}
return res, err
}
// RunCheck executes a command and turns a non-zero exit into an error that
// carries the remote stderr, which is what the operator needs to see.
func (c *Client) RunCheck(ctx context.Context, cmdline string) (string, error) {
res, err := c.Run(ctx, cmdline)
if err != nil {
return res.Stdout, err
}
if res.ExitCode != 0 {
msg := strings.TrimSpace(res.Stderr)
if msg == "" {
msg = strings.TrimSpace(res.Stdout)
}
return res.Stdout, fmt.Errorf("remote command failed (exit %d): %s", res.ExitCode, msg)
}
return res.Stdout, nil
}
// Stream executes a command, feeding it stdin and writing its stdout to out.
// This is how bulk data crosses the wire: the tar stream produced locally is
// piped straight into a remote `docker cp` without ever touching disk.
func (c *Client) Stream(ctx context.Context, cmdline string, stdin io.Reader, stdout io.Writer) (*Result, error) {
var stderr bytes.Buffer
if stdout == nil {
stdout = io.Discard
}
code, err := c.run(ctx, cmdline, stdin, stdout, &stderr)
res := &Result{Stderr: stderr.String(), ExitCode: code}
if err != nil {
return res, err
}
if code != 0 {
return res, fmt.Errorf("remote command failed (exit %d): %s", code, strings.TrimSpace(stderr.String()))
}
return res, nil
}
func (c *Client) run(ctx context.Context, cmdline string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
sess, err := c.conn.NewSession()
if err != nil {
return -1, fmt.Errorf("open ssh session: %w", err)
}
defer sess.Close()
sess.Stdout = stdout
sess.Stderr = stderr
if stdin != nil {
sess.Stdin = stdin
}
done := make(chan error, 1)
go func() { done <- sess.Run(cmdline) }()
select {
case <-ctx.Done():
_ = sess.Signal(ssh.SIGTERM)
_ = sess.Close()
return -1, ctx.Err()
case err := <-done:
if err == nil {
return 0, nil
}
var ee *ssh.ExitError
if errors.As(err, &ee) {
return ee.ExitStatus(), nil
}
return -1, err
}
}
func authMethods(cfg Config) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
switch cfg.Auth {
case AuthPassword:
if cfg.Password == "" {
return nil, errors.New("password authentication selected but no password supplied")
}
methods = append(methods,
ssh.Password(cfg.Password),
// Many sshd setups answer with keyboard-interactive instead of the
// plain password method.
ssh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range answers {
answers[i] = cfg.Password
}
return answers, nil
}),
)
case AuthKey:
pem := []byte(cfg.PrivateKey)
if len(pem) == 0 {
if cfg.PrivateKeyPath == "" {
return nil, errors.New("key authentication selected but no key supplied")
}
b, err := os.ReadFile(cfg.PrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("read private key: %w", err)
}
pem = b
}
var signer ssh.Signer
var err error
passphrase := cfg.Passphrase
if passphrase == "" {
passphrase = cfg.Password
}
if passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(pem, []byte(passphrase))
} else {
signer, err = ssh.ParsePrivateKey(pem)
}
if err != nil {
var pm *ssh.PassphraseMissingError
if errors.As(err, &pm) {
return nil, errors.New("private key is encrypted; supply the passphrase")
}
return nil, fmt.Errorf("parse private key: %w", err)
}
methods = append(methods, ssh.PublicKeys(signer))
case AuthAgent:
sock := os.Getenv("SSH_AUTH_SOCK")
if sock == "" {
return nil, errors.New("agent authentication selected but SSH_AUTH_SOCK is not set")
}
conn, err := net.Dial("unix", sock)
if err != nil {
return nil, fmt.Errorf("connect to ssh agent: %w", err)
}
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
default:
return nil, fmt.Errorf("unknown auth method %q", cfg.Auth)
}
return methods, nil
}
// Fingerprint renders a public key the way OpenSSH shows it.
func Fingerprint(key ssh.PublicKey) string { return ssh.FingerprintSHA256(key) }
var _ = knownhosts.Normalize
+261
View File
@@ -0,0 +1,261 @@
package sshx
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/arescom/docker-migrate/internal/spec"
)
// RemoteDocker drives the docker CLI on a target host over SSH. It assumes
// nothing beyond sshd, docker and a POSIX shell; gzip is used only when
// compression is enabled and is probed for first.
type RemoteDocker struct {
c *Client
binary string
sudo bool
}
// NewRemoteDocker wraps a connection.
func NewRemoteDocker(c *Client) *RemoteDocker {
cfg := c.Config()
bin := cfg.DockerCmd
if bin == "" {
bin = "docker"
}
return &RemoteDocker{c: c, binary: bin, sudo: cfg.Sudo}
}
// Cmd renders a docker invocation as a shell command line, correctly quoted.
func (r *RemoteDocker) Cmd(args ...string) string {
var b strings.Builder
if r.sudo {
b.WriteString("sudo -n ")
}
b.WriteString(spec.ShellQuote(r.binary))
if len(args) > 0 {
b.WriteString(" ")
b.WriteString(spec.ShellQuoteAll(args))
}
return b.String()
}
// Run executes a docker command and returns its stdout, failing on non-zero.
func (r *RemoteDocker) Run(ctx context.Context, args ...string) (string, error) {
return r.c.RunCheck(ctx, r.Cmd(args...))
}
// Try executes a docker command and reports success without treating a
// non-zero exit as an error. Used for existence probes.
func (r *RemoteDocker) Try(ctx context.Context, args ...string) (string, bool, error) {
res, err := r.c.Run(ctx, r.Cmd(args...))
if err != nil {
return "", false, err
}
return res.Stdout, res.ExitCode == 0, nil
}
// Feed pipes a local reader into a docker command's stdin. When decompress is
// set the remote side runs `gzip -dc` ahead of docker, so the bytes on the
// wire are compressed.
func (r *RemoteDocker) Feed(ctx context.Context, src io.Reader, decompress bool, args ...string) error {
cmd := r.Cmd(args...)
if decompress {
cmd = "gzip -dc | " + cmd
}
_, err := r.c.Stream(ctx, cmd, src, nil)
return err
}
// Preflight is what the target host was found to support.
type Preflight struct {
DockerVersion string `json:"dockerVersion"`
ServerVersion string `json:"serverVersion"`
OS string `json:"os"`
Arch string `json:"arch"`
HasGzip bool `json:"hasGzip"`
DiskFreeBytes int64 `json:"diskFreeBytes"`
DockerRoot string `json:"dockerRoot"`
Problems []string `json:"problems,omitempty"`
}
// Preflight checks everything the migration depends on before any data moves.
func (r *RemoteDocker) Preflight(ctx context.Context) (*Preflight, error) {
p := &Preflight{}
out, ok, err := r.Try(ctx, "version", "--format", "{{.Client.Version}}|{{.Server.Version}}|{{.Server.Os}}|{{.Server.Arch}}")
if err != nil {
return nil, err
}
if !ok {
res, _ := r.c.Run(ctx, r.Cmd("version"))
msg := strings.TrimSpace(res.Stderr)
if strings.Contains(msg, "permission denied") {
p.Problems = append(p.Problems,
"the login user cannot talk to the docker daemon; add it to the docker group or enable sudo for this connection")
} else if msg != "" {
p.Problems = append(p.Problems, "docker is not usable on the target: "+firstLine(msg))
} else {
p.Problems = append(p.Problems, "docker is not installed or not on PATH on the target")
}
return p, nil
}
parts := strings.Split(strings.TrimSpace(out), "|")
if len(parts) == 4 {
p.DockerVersion, p.ServerVersion, p.OS, p.Arch = parts[0], parts[1], parts[2], parts[3]
}
if p.OS != "" && p.OS != "linux" {
p.Problems = append(p.Problems, "target daemon runs "+p.OS+" containers; only linux targets are supported")
}
res, err := r.c.Run(ctx, "command -v gzip >/dev/null 2>&1")
if err != nil {
return nil, err
}
p.HasGzip = res.ExitCode == 0
if !p.HasGzip {
p.Problems = append(p.Problems, "gzip is missing on the target; transfers will run uncompressed")
}
if root, err2 := r.Run(ctx, "info", "--format", "{{.DockerRootDir}}"); err2 == nil {
p.DockerRoot = strings.TrimSpace(root)
if p.DockerRoot != "" {
// POSIX df in 1K blocks; the fourth column is available space.
cmd := "df -Pk " + spec.ShellQuote(p.DockerRoot) + " | awk 'NR==2 {print $4}'"
if dres, derr := r.c.Run(ctx, cmd); derr == nil && dres.ExitCode == 0 {
var kb int64
if _, serr := fmt.Sscanf(strings.TrimSpace(dres.Stdout), "%d", &kb); serr == nil {
p.DiskFreeBytes = kb * 1024
}
}
}
}
return p, nil
}
// TargetContainer is a container that already exists on the target host.
type TargetContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
Ports string `json:"ports"`
}
// TargetInventory is the current state of the target host, used to show it
// side by side with the source and to detect name conflicts up front.
type TargetInventory struct {
Host string `json:"host"`
Containers []TargetContainer `json:"containers"`
Volumes []string `json:"volumes"`
Networks []string `json:"networks"`
Preflight *Preflight `json:"preflight"`
}
// Inventory reads the target host's containers, volumes and networks.
func (r *RemoteDocker) Inventory(ctx context.Context) (*TargetInventory, error) {
pre, err := r.Preflight(ctx)
if err != nil {
return nil, err
}
inv := &TargetInventory{Preflight: pre}
if pre.ServerVersion == "" {
return inv, nil
}
if host, err := r.c.RunCheck(ctx, "hostname"); err == nil {
inv.Host = strings.TrimSpace(host)
}
out, err := r.Run(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
if err != nil {
return nil, err
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var raw struct {
ID, Names, Image, State, Status, Ports string
}
if json.Unmarshal([]byte(line), &raw) != nil {
continue
}
// A container attached to several networks is reported with a
// comma-separated name list; the first is its real name.
name := raw.Names
if i := strings.Index(name, ","); i >= 0 {
name = name[:i]
}
inv.Containers = append(inv.Containers, TargetContainer{
ID: raw.ID, Name: name, Image: raw.Image,
State: raw.State, Status: raw.Status, Ports: raw.Ports,
})
}
if out, err := r.Run(ctx, "volume", "ls", "--format", "{{.Name}}"); err == nil {
inv.Volumes = nonEmptyLines(out)
}
if out, err := r.Run(ctx, "network", "ls", "--format", "{{.Name}}"); err == nil {
inv.Networks = nonEmptyLines(out)
}
return inv, nil
}
// Exists reports whether an object of the given kind is present on the target.
func (r *RemoteDocker) Exists(ctx context.Context, kind, name string) (bool, error) {
var args []string
switch kind {
case "container":
args = []string{"container", "inspect", name}
case "volume":
args = []string{"volume", "inspect", name}
case "network":
args = []string{"network", "inspect", name}
case "image":
args = []string{"image", "inspect", name}
default:
return false, fmt.Errorf("unknown object kind %q", kind)
}
res, err := r.c.Run(ctx, r.Cmd(args...)+" >/dev/null 2>&1")
if err != nil {
return false, err
}
return res.ExitCode == 0, nil
}
// MkdirAll creates a directory on the target host, for bind mount sources that
// need to exist with the right ownership before the container starts.
func (r *RemoteDocker) MkdirAll(ctx context.Context, path string) error {
cmd := "mkdir -p " + spec.ShellQuote(path)
if r.sudo {
cmd = "sudo -n " + cmd
}
_, err := r.c.RunCheck(ctx, cmd)
return err
}
// Client exposes the underlying SSH connection for raw shell work.
func (r *RemoteDocker) Client() *Client { return r.c }
func nonEmptyLines(s string) []string {
var out []string
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
+231
View File
@@ -0,0 +1,231 @@
package sshx
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
// KnownHosts is the trust store for target host keys. It behaves like OpenSSH:
// an unknown key is refused until the operator confirms the fingerprint, and a
// changed key is refused outright.
type KnownHosts struct {
path string
mu sync.Mutex
}
// NewKnownHosts opens (and creates if needed) the store at path.
func NewKnownHosts(path string) (*KnownHosts, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create key store directory: %w", err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return nil, fmt.Errorf("open known hosts file: %w", err)
}
f.Close()
return &KnownHosts{path: path}, nil
}
// Path returns the on-disk location of the store.
func (k *KnownHosts) Path() string { return k.path }
// Check implements the ssh.HostKeyCallback contract.
func (k *KnownHosts) Check(hostname string, remote net.Addr, key ssh.PublicKey) error {
k.mu.Lock()
defer k.mu.Unlock()
cb, err := knownhosts.New(k.path)
if err != nil {
return fmt.Errorf("read known hosts: %w", err)
}
err = cb(hostname, remote, key)
if err == nil {
return nil
}
var keyErr *knownhosts.KeyError
if errors.As(err, &keyErr) {
return &HostKeyError{
Host: hostname,
Fingerprint: ssh.FingerprintSHA256(key),
KeyType: key.Type(),
Changed: len(keyErr.Want) > 0,
}
}
return err
}
// Trust records a host key so later connections succeed.
func (k *KnownHosts) Trust(hostname string, key ssh.PublicKey) error {
k.mu.Lock()
defer k.mu.Unlock()
f, err := os.OpenFile(k.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open known hosts for write: %w", err)
}
defer f.Close()
line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
if _, err := f.WriteString(line + "\n"); err != nil {
return fmt.Errorf("record host key: %w", err)
}
return nil
}
// Forget removes every entry for a host, so a changed key can be re-approved.
func (k *KnownHosts) Forget(hostname string) error {
k.mu.Lock()
defer k.mu.Unlock()
b, err := os.ReadFile(k.path)
if err != nil {
return err
}
want := knownhosts.Normalize(hostname)
var kept []byte
for _, line := range splitLines(b) {
if len(line) == 0 || line[0] == '#' {
kept = append(kept, line...)
kept = append(kept, '\n')
continue
}
_, hosts, _, _, _, perr := ssh.ParseKnownHosts(append(line, '\n'))
if perr == nil && containsHost(hosts, want) {
continue
}
kept = append(kept, line...)
kept = append(kept, '\n')
}
return os.WriteFile(k.path, kept, 0o600)
}
// HostKeyInfo is the fingerprint presented by a host, shown to the operator
// before they decide to trust it.
type HostKeyInfo struct {
Host string `json:"host"`
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
Trusted bool `json:"trusted"`
Changed bool `json:"changed"`
}
// Probe opens a TCP connection just far enough to read the host key, without
// authenticating. Used by the "check fingerprint" step in the UI.
func Probe(ctx context.Context, cfg Config, hk *KnownHosts) (*HostKeyInfo, error) {
timeout := cfg.Timeout
if timeout == 0 {
timeout = 15 * time.Second
}
var captured ssh.PublicKey
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Timeout: timeout,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
captured = key
// Stop the handshake here: reading the key is all this needs.
return errProbeDone
},
}
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", cfg.addr())
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
}
defer conn.Close()
_, _, _, err = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
if captured == nil {
return nil, fmt.Errorf("read host key from %s: %w", cfg.addr(), err)
}
info := &HostKeyInfo{
Host: cfg.addr(),
KeyType: captured.Type(),
Fingerprint: ssh.FingerprintSHA256(captured),
}
switch checkErr := hk.Check(cfg.addr(), conn.RemoteAddr(), captured).(type) {
case nil:
info.Trusted = true
case *HostKeyError:
info.Changed = checkErr.Changed
}
return info, nil
}
// TrustFromProbe re-reads the host key and stores it. Taking the key from a
// fresh handshake rather than from client-supplied input means the UI can only
// approve a fingerprint it actually saw.
func TrustFromProbe(ctx context.Context, cfg Config, hk *KnownHosts, expectFingerprint string) error {
info, err := Probe(ctx, cfg, hk)
if err != nil {
return err
}
if expectFingerprint != "" && info.Fingerprint != expectFingerprint {
return fmt.Errorf("host key changed between check and approval (%s vs %s); aborting",
expectFingerprint, info.Fingerprint)
}
var captured ssh.PublicKey
clientCfg := &ssh.ClientConfig{
User: cfg.User,
Timeout: 15 * time.Second,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
captured = key
return errProbeDone
},
}
conn, err := net.DialTimeout("tcp", cfg.addr(), 15*time.Second)
if err != nil {
return err
}
defer conn.Close()
_, _, _, _ = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
if captured == nil {
return errors.New("could not read host key")
}
if ssh.FingerprintSHA256(captured) != info.Fingerprint {
return errors.New("host key is unstable; aborting")
}
if info.Changed {
if err := hk.Forget(cfg.addr()); err != nil {
return fmt.Errorf("drop previous host key: %w", err)
}
}
return hk.Trust(cfg.addr(), captured)
}
var errProbeDone = errors.New("host key captured")
func splitLines(b []byte) [][]byte {
var out [][]byte
start := 0
for i := 0; i < len(b); i++ {
if b[i] == '\n' {
line := b[start:i]
if n := len(line); n > 0 && line[n-1] == '\r' {
line = line[:n-1]
}
out = append(out, line)
start = i + 1
}
}
if start < len(b) {
out = append(out, b[start:])
}
return out
}
func containsHost(hosts []string, want string) bool {
for _, h := range hosts {
if knownhosts.Normalize(h) == want {
return true
}
}
return false
}
+209
View File
@@ -0,0 +1,209 @@
// Package store persists target host connections between runs.
package store
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"github.com/arescom/docker-migrate/internal/sshx"
)
// ErrNotFound is returned for an unknown connection id.
var ErrNotFound = errors.New("connection not found")
// Connections is a small JSON-backed collection of target hosts.
//
// Secrets are only written when the operator opts in per connection. The file
// is created with owner-only permissions either way.
type Connections struct {
path string
mu sync.RWMutex
// items holds the persisted form.
items map[string]sshx.Config
// secrets holds credentials for connections that opted out of persistence,
// so they survive for the lifetime of the process but never hit disk.
secrets map[string]secret
}
type secret struct {
Password string
PrivateKey string
Passphrase string
}
// NewConnections loads (or creates) the connection file at path.
func NewConnections(path string) (*Connections, error) {
c := &Connections{path: path, items: map[string]sshx.Config{}, secrets: map[string]secret{}}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, fmt.Errorf("read connections: %w", err)
}
var list []sshx.Config
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse connections file %s: %w", path, err)
}
for _, cfg := range list {
c.items[cfg.ID] = cfg
}
return c, nil
}
// List returns every connection with secrets stripped, newest name order.
func (c *Connections) List() []sshx.Config {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]sshx.Config, 0, len(c.items))
for _, cfg := range c.items {
out = append(out, redact(cfg))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Get returns a connection ready to dial, with secrets filled back in.
func (c *Connections) Get(id string) (sshx.Config, error) {
c.mu.RLock()
defer c.mu.RUnlock()
cfg, ok := c.items[id]
if !ok {
return sshx.Config{}, ErrNotFound
}
if s, ok := c.secrets[id]; ok {
if cfg.Password == "" {
cfg.Password = s.Password
}
if cfg.PrivateKey == "" {
cfg.PrivateKey = s.PrivateKey
}
if cfg.Passphrase == "" {
cfg.Passphrase = s.Passphrase
}
}
return cfg, nil
}
// Save inserts or updates a connection and returns the stored, redacted form.
//
// When SaveSecrets is false the credentials are kept in memory only; an update
// that omits credentials keeps whatever was already held, so the UI can edit a
// connection without re-entering a password.
func (c *Connections) Save(cfg sshx.Config) (sshx.Config, error) {
if cfg.Host == "" {
return sshx.Config{}, errors.New("host is required")
}
if cfg.User == "" {
return sshx.Config{}, errors.New("user is required")
}
if cfg.Port == 0 {
cfg.Port = 22
}
if cfg.Name == "" {
cfg.Name = cfg.Host
}
c.mu.Lock()
defer c.mu.Unlock()
if cfg.ID == "" {
cfg.ID = newID()
}
prev, existed := c.items[cfg.ID]
prevSecret := c.secrets[cfg.ID]
// Carry forward credentials the caller did not resend.
if cfg.Password == "" {
cfg.Password = firstNonEmpty(prev.Password, prevSecret.Password)
}
if cfg.PrivateKey == "" {
cfg.PrivateKey = firstNonEmpty(prev.PrivateKey, prevSecret.PrivateKey)
}
if cfg.Passphrase == "" {
cfg.Passphrase = firstNonEmpty(prev.Passphrase, prevSecret.Passphrase)
}
_ = existed
if cfg.SaveSecrets {
delete(c.secrets, cfg.ID)
c.items[cfg.ID] = cfg
} else {
c.secrets[cfg.ID] = secret{
Password: cfg.Password,
PrivateKey: cfg.PrivateKey,
Passphrase: cfg.Passphrase,
}
c.items[cfg.ID] = redact(cfg)
}
if err := c.flush(); err != nil {
return sshx.Config{}, err
}
return redact(c.items[cfg.ID]), nil
}
// Delete removes a connection.
func (c *Connections) Delete(id string) error {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.items[id]; !ok {
return ErrNotFound
}
delete(c.items, id)
delete(c.secrets, id)
return c.flush()
}
// flush writes the file. The caller must hold the write lock.
func (c *Connections) flush() error {
list := make([]sshx.Config, 0, len(c.items))
for _, cfg := range c.items {
list = append(list, cfg)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write connections: %w", err)
}
if err := os.Rename(tmp, c.path); err != nil {
return fmt.Errorf("replace connections file: %w", err)
}
return nil
}
func redact(cfg sshx.Config) sshx.Config {
cfg.Password = ""
cfg.PrivateKey = ""
cfg.Passphrase = ""
return cfg
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func newID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>docker-migrate</title>
<script type="module" crossorigin src="/assets/index-BJYzrqMo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BYoxln0e.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
// Package webui embeds the built web application into the binary so the tool
// ships as a single file with no runtime assets to install.
package webui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// FS returns the built web app rooted at its index.html, or nil when the
// frontend has not been built into this binary.
func FS() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
return nil
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return nil
}
return sub
}
// Built reports whether a real UI is embedded, as opposed to the placeholder
// that keeps the package compiling before the frontend is built.
func Built() bool {
sub := FS()
if sub == nil {
return false
}
_, err := fs.Stat(sub, "assets")
return err == nil
}
+257
View File
@@ -0,0 +1,257 @@
// Command docker-migrate moves Docker containers, together with their volumes
// and bind mounts, from one host to another.
//
// It runs either as a web application (the default) or as a one-shot inventory
// dump for scripting. It needs no agent on the target: everything is driven
// through the target's own docker CLI over SSH, or through a self-contained
// package that is carried to the target by hand.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/arescom/docker-migrate/internal/api"
"github.com/arescom/docker-migrate/internal/dkr"
"github.com/arescom/docker-migrate/internal/webui"
)
// version is overridden at build time with -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(2)
}
fmt.Fprintln(os.Stderr, "error: "+err.Error())
os.Exit(1)
}
}
func run(args []string) error {
cmd := "serve"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
cmd, args = args[0], args[1:]
}
switch cmd {
case "serve":
return serve(args)
case "inspect":
return inspect(args)
case "version":
fmt.Printf("docker-migrate %s (%s %s/%s)\n", version, runtime.Version(), runtime.GOOS, runtime.GOARCH)
return nil
case "help", "-h", "--help":
usage()
return nil
default:
usage()
return fmt.Errorf("unknown command %q", cmd)
}
}
func usage() {
fmt.Fprint(os.Stderr, `docker-migrate - move Docker containers and their data between hosts
Usage:
docker-migrate [serve] [flags] start the web interface (default)
docker-migrate inspect [flags] print the source inventory as JSON
docker-migrate version print the version
Run "docker-migrate serve -h" for the server flags.
`)
}
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on; use 0.0.0.0:8080 to expose it on the network")
token := fs.String("token", "", "require this token on every request; \"auto\" generates one")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys")
pkgDir := fs.String("package-dir", "", "directory for migration packages (default <data-dir>/packages)")
dockerHost := fs.String("docker-host", "", "source docker daemon (default: the DOCKER_HOST environment)")
verbose := fs.Bool("v", false, "verbose logging")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: docker-migrate serve [flags]\n\nFlags:")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
level := slog.LevelInfo
if *verbose {
level = slog.LevelDebug
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
authToken := *token
if authToken == "auto" {
authToken = randomToken()
}
// Anything but a loopback bind is reachable by other machines. This tool
// can stop containers and read every volume on the host, so it refuses to
// be exposed without a token.
if authToken == "" && !isLoopback(*addr) {
authToken = randomToken()
logger.Warn("listening on a non-loopback address; generated an access token")
}
cfg := api.Config{
Addr: *addr,
Token: authToken,
DataDir: *dataDir,
PackageDir: *pkgDir,
DockerHost: *dockerHost,
UI: webui.FS(),
Logger: logger,
}
srv, err := api.New(cfg)
if err != nil {
return err
}
defer srv.Close()
if !webui.Built() {
logger.Warn("web UI is not embedded in this binary; only the HTTP API is available")
}
httpSrv := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 15 * time.Second,
// Migrations stream for as long as the data takes; no write timeout.
IdleTimeout: 120 * time.Second,
}
ln, err := net.Listen("tcp", *addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", *addr, err)
}
url := "http://" + displayAddr(ln.Addr().String())
if authToken != "" {
url += "/?token=" + authToken
}
fmt.Fprintf(os.Stderr, "\n docker-migrate %s\n open %s\n data: %s\n\n", version, url, *dataDir)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() {
if err := httpSrv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return httpSrv.Shutdown(shutdownCtx)
}
}
func inspect(args []string) error {
fs := flag.NewFlagSet("inspect", flag.ContinueOnError)
dockerHost := fs.String("docker-host", "", "docker daemon to read (default: the DOCKER_HOST environment)")
sizes := fs.Bool("sizes", false, "also measure volume sizes (slower)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: docker-migrate inspect [flags]\n\nFlags:")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
return err
}
c, err := dkr.New(*dockerHost)
if err != nil {
return err
}
defer c.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
inv, err := c.Inventory(ctx)
if err != nil {
return err
}
if *sizes {
if err := c.MeasureMounts(ctx, inv.Containers); err != nil {
fmt.Fprintln(os.Stderr, "warning: could not measure volume sizes: "+err.Error())
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(inv)
}
// defaultDataDir picks a per-user location, honouring the container-friendly
// DOCKER_MIGRATE_DATA override.
func defaultDataDir() string {
if v := os.Getenv("DOCKER_MIGRATE_DATA"); v != "" {
return v
}
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "docker-migrate")
}
return ".docker-migrate"
}
func randomToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func isLoopback(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
if host == "" {
return false // an empty host binds every interface
}
if host == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// displayAddr turns a wildcard bind into something clickable.
func displayAddr(addr string) string {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
if host == "" || host == "0.0.0.0" || host == "::" {
return "localhost:" + port
}
if strings.Contains(host, ":") {
return "[" + host + "]:" + port
}
return host + ":" + port
}
+309
View File
@@ -0,0 +1,309 @@
//go:build e2e
// Package e2e exercises the full pipeline against a real Docker daemon:
// inventory -> plan -> package build -> generated installer -> restored
// container, and then checks that the data actually arrived.
//
// It needs a Linux host with a local Docker daemon and bash. Run it with:
//
// go test -tags e2e ./test/... -v
package e2e
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"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/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume"
)
const (
testImage = "alpine:3.20"
srcName = "dmtest-src"
volName = "dmtest-vol"
restoreSufix = "-restored"
)
func TestPackageRoundTrip(t *testing.T) {
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash is required to run the generated installer")
}
if _, err := exec.LookPath("docker"); err != nil {
t.Skip("the docker CLI is required by the generated installer")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
c, err := dkr.New("")
if err != nil {
t.Skipf("no docker daemon available: %v", err)
}
defer c.Close()
if _, err := c.Ping(ctx); err != nil {
t.Skipf("no docker daemon available: %v", err)
}
bindDir := t.TempDir()
if err := os.WriteFile(filepath.Join(bindDir, "app.conf"), []byte("mode=production\n"), 0o644); err != nil {
t.Fatal(err)
}
cleanup(ctx, t, c, srcName, srcName+restoreSufix)
t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer ccancel()
cleanup(cctx, t, c, srcName, srcName+restoreSufix)
})
pullImage(ctx, t, c, testImage)
createSource(ctx, t, c, bindDir)
// --- inventory -------------------------------------------------------
inv, err := c.Inventory(ctx)
if err != nil {
t.Fatal(err)
}
var src *spec.Container
for i := range inv.Containers {
if inv.Containers[i].Name == srcName {
src = &inv.Containers[i]
}
}
if src == nil {
t.Fatalf("source container %s not found in the inventory", srcName)
}
t.Logf("source: %s state=%s image=%s mounts=%d", src.Name, src.State, src.Image, len(src.Mounts))
wantMounts := map[string]spec.MountKind{
"/data": spec.MountVolume,
"/conf": spec.MountBind,
"/anon": spec.MountAnonymous,
}
for dest, kind := range wantMounts {
found := false
for _, m := range src.Mounts {
if m.Destination == dest {
found = true
if m.Kind != kind {
t.Errorf("mount %s: kind %s, want %s", dest, m.Kind, kind)
}
}
}
if !found {
t.Errorf("mount %s missing from the inventory", dest)
}
}
// --- build the package ----------------------------------------------
sel := spec.DefaultSelection(src)
sel.Include = true
// The image is already on this daemon, so there is no point carrying it
// through a round trip that restores onto the same host.
sel.MigrateImage = true
sel.ImageMode = spec.ImageSkip
opts := spec.DefaultOptions()
opts.Conflict = spec.ConflictRename
opts.RenameSuffix = restoreSufix
outDir := t.TempDir()
packager := &migrate.Packager{
Src: c, Containers: inv.Containers, Volumes: inv.Volumes, Networks: inv.Networks,
Plan: spec.Plan{Items: []spec.ItemSelection{sel}, Options: opts, PackageName: "dmtest"},
OutputDir: outDir, Format: migrate.FormatDir, SourceHost: inv.Host,
}
jobs := job.NewManager()
var res *migrate.Result
var runErr error
j := jobs.Run(ctx, job.KindPackage, "e2e", false, func(ctx context.Context, j *job.Job) error {
res, runErr = packager.Run(ctx, j)
return runErr
})
<-j.Done()
snap := j.Snapshot()
for _, l := range snap.Log {
t.Logf("[%s] %s", l.Level, l.Message)
}
if snap.State != job.StateSucceeded {
t.Fatalf("package job %s: %s", snap.State, snap.Error)
}
t.Logf("package built at %s (%d bytes)", res.Path, res.Bytes)
for _, want := range []string{"install.sh", "manifest.json", "README.txt"} {
if _, err := os.Stat(filepath.Join(res.Path, want)); err != nil {
t.Fatalf("package is missing %s: %v", want, err)
}
}
// --- dry run first ---------------------------------------------------
if out, err := runInstaller(res.Path, "--dry-run", "--yes"); err != nil {
t.Fatalf("installer dry run failed: %v\n%s", err, out)
} else {
t.Logf("dry run ok:\n%s", indent(out))
}
if _, err := containerExists(ctx, c, srcName+restoreSufix); err == nil {
t.Fatal("dry run created a container; it must change nothing")
}
// --- real restore ----------------------------------------------------
out, err := runInstaller(res.Path, "--yes", "--conflict", "rename", "--rename-suffix", restoreSufix)
if err != nil {
t.Fatalf("installer failed: %v\n%s", err, out)
}
t.Logf("restore output:\n%s", indent(out))
restored := srcName + restoreSufix
if _, err := containerExists(ctx, c, restored); err != nil {
t.Fatalf("restored container %s does not exist: %v", restored, err)
}
// --- verify the data actually travelled ------------------------------
checks := []struct {
path, want string
}{
{"/data/hello.txt", "hello from the volume"},
{"/data/nested/deep.txt", "nested payload"},
{"/conf/app.conf", "mode=production"},
{"/anon/anon.txt", "anonymous volume payload"},
}
for _, chk := range checks {
got, err := readFileFromContainer(ctx, c, restored, chk.path)
if err != nil {
t.Errorf("reading %s from %s: %v", chk.path, restored, err)
continue
}
if !strings.Contains(got, chk.want) {
t.Errorf("%s = %q, want it to contain %q", chk.path, got, chk.want)
} else {
t.Logf("verified %s", chk.path)
}
}
// The read-only mount must still be read-only on the restored container.
insp, err := c.API().ContainerInspect(ctx, restored)
if err != nil {
t.Fatal(err)
}
for _, m := range insp.Mounts {
if m.Destination == "/conf" && m.RW {
t.Error("/conf was read-only on the source but is writable on the target")
}
}
}
func createSource(ctx context.Context, t *testing.T, c *dkr.Client, bindDir string) {
t.Helper()
api := c.API()
if _, err := api.VolumeCreate(ctx, volume.CreateOptions{Name: volName}); err != nil {
t.Fatal(err)
}
script := strings.Join([]string{
"echo 'hello from the volume' > /data/hello.txt",
"mkdir -p /data/nested",
"echo 'nested payload' > /data/nested/deep.txt",
"ln -sf hello.txt /data/link.txt",
"echo 'anonymous volume payload' > /anon/anon.txt",
"sleep 3600",
}, " && ")
resp, err := api.ContainerCreate(ctx,
&container.Config{
Image: testImage,
Cmd: []string{"sh", "-c", script},
Env: []string{"DM_TEST=1", "DM_QUOTED=a'b\"c"},
Labels: map[string]string{"dm.test": "yes"},
},
&container.HostConfig{
Mounts: []mount.Mount{
{Type: mount.TypeVolume, Source: volName, Target: "/data"},
{Type: mount.TypeBind, Source: bindDir, Target: "/conf", ReadOnly: true},
{Type: mount.TypeVolume, Target: "/anon"},
},
RestartPolicy: container.RestartPolicy{Name: container.RestartPolicyUnlessStopped},
},
nil, nil, srcName)
if err != nil {
t.Fatal(err)
}
if err := api.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
t.Fatal(err)
}
// Give the entrypoint script time to write the files.
time.Sleep(2 * time.Second)
}
func pullImage(ctx context.Context, t *testing.T, c *dkr.Client, ref string) {
t.Helper()
if c.ImageExists(ctx, ref) {
return
}
rc, err := c.API().ImagePull(ctx, ref, image.PullOptions{})
if err != nil {
t.Fatalf("pull %s: %v", ref, err)
}
defer rc.Close()
if _, err := io.Copy(io.Discard, rc); err != nil {
t.Fatalf("pull %s: %v", ref, err)
}
}
func cleanup(ctx context.Context, t *testing.T, c *dkr.Client, names ...string) {
t.Helper()
api := c.API()
for _, n := range names {
_ = api.ContainerRemove(ctx, n, container.RemoveOptions{Force: true, RemoveVolumes: false})
}
_ = api.VolumeRemove(ctx, volName, true)
}
func containerExists(ctx context.Context, c *dkr.Client, name string) (string, error) {
j, err := c.API().ContainerInspect(ctx, name)
if err != nil {
return "", err
}
return j.ID, nil
}
// readFileFromContainer reads one file through the same archive API the
// migration itself uses, which avoids needing the container to be running.
func readFileFromContainer(ctx context.Context, c *dkr.Client, name, path string) (string, error) {
rc, err := c.CopyOut(ctx, name, path)
if err != nil {
return "", err
}
defer rc.Close()
return firstFileInTar(rc)
}
func runInstaller(dir string, args ...string) (string, error) {
cmd := exec.Command("bash", append([]string{"./install.sh"}, args...)...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
return string(out), err
}
func indent(s string) string {
var b strings.Builder
for _, l := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
fmt.Fprintf(&b, " %s\n", l)
}
return b.String()
}
+14
View File
@@ -0,0 +1,14 @@
//go:build e2e
package e2e
import (
"errors"
"github.com/arescom/docker-migrate/internal/sshx"
)
// asHostKeyError unwraps err into a *sshx.HostKeyError if it is one.
func asHostKeyError(err error, target **sshx.HostKeyError) bool {
return errors.As(err, target)
}
+261
View File
@@ -0,0 +1,261 @@
//go:build e2e
package e2e
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"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/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/volume"
)
// TestSSHMigration drives the host-to-host engine over a real SSH connection.
//
// Source and target are the same daemon, reached over SSH, so the whole
// transport is exercised — ssh, gzip streaming, the target's docker CLI, the
// staging container for read-only mounts and the verify step — without needing
// a second machine. The container and its volume are renamed on the way in so
// the copy is genuinely verified rather than finding the data already there.
//
// Configure it with:
//
// DM_SSH_HOST=10.0.0.5 DM_SSH_USER=root DM_SSH_KEY=/root/.ssh/id_ed25519 \
// go test -tags e2e ./test/... -run TestSSHMigration -v
const (
sshSrcName = "dmssh-src"
sshSrcVolume = "dmssh-vol"
sshDstName = "dmssh-src-viassh"
sshDstVolume = "dmssh-vol-viassh"
)
func TestSSHMigration(t *testing.T) {
host := os.Getenv("DM_SSH_HOST")
user := os.Getenv("DM_SSH_USER")
key := os.Getenv("DM_SSH_KEY")
if host == "" || user == "" || key == "" {
t.Skip("set DM_SSH_HOST, DM_SSH_USER and DM_SSH_KEY to run the SSH migration test")
}
keyPEM, err := os.ReadFile(key)
if err != nil {
t.Fatalf("read %s: %v", key, err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
c, err := dkr.New("")
if err != nil {
t.Skipf("no docker daemon available: %v", err)
}
defer c.Close()
bindDir := t.TempDir()
if err := os.WriteFile(filepath.Join(bindDir, "site.conf"), []byte("listen=8443\n"), 0o644); err != nil {
t.Fatal(err)
}
sshCleanup(ctx, c)
t.Cleanup(func() {
cctx, ccancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer ccancel()
sshCleanup(cctx, c)
})
pullImage(ctx, t, c, testImage)
createSSHSource(ctx, t, c, bindDir)
// --- connect ---------------------------------------------------------
hkPath := filepath.Join(t.TempDir(), "known_hosts")
hk, err := sshx.NewKnownHosts(hkPath)
if err != nil {
t.Fatal(err)
}
cfg := sshx.Config{
Name: "loopback", Host: host, Port: 22, User: user,
Auth: sshx.AuthKey, PrivateKey: string(keyPEM),
}
// An unknown host key must be refused before it is trusted; that is the
// whole point of the trust store.
if _, err := sshx.Dial(ctx, cfg, hk); err == nil {
t.Fatal("dialling an untrusted host key must fail")
} else {
var hke *sshx.HostKeyError
if !asHostKeyError(err, &hke) {
t.Fatalf("expected a host key error, got %v", err)
}
t.Logf("host key presented: %s %s", hke.KeyType, hke.Fingerprint)
}
if err := sshx.TrustFromProbe(ctx, cfg, hk, ""); err != nil {
t.Fatalf("trust host key: %v", err)
}
client, err := sshx.Dial(ctx, cfg, hk)
if err != nil {
t.Fatalf("dial after trusting: %v", err)
}
defer client.Close()
rd := sshx.NewRemoteDocker(client)
pre, err := rd.Preflight(ctx)
if err != nil {
t.Fatal(err)
}
t.Logf("target preflight: docker %s %s/%s gzip=%v free=%d problems=%v",
pre.ServerVersion, pre.OS, pre.Arch, pre.HasGzip, pre.DiskFreeBytes, pre.Problems)
if pre.ServerVersion == "" {
t.Fatalf("target docker unusable: %v", pre.Problems)
}
// --- plan ------------------------------------------------------------
inv, err := c.Inventory(ctx)
if err != nil {
t.Fatal(err)
}
var src *spec.Container
for i := range inv.Containers {
if inv.Containers[i].Name == sshSrcName {
src = &inv.Containers[i]
}
}
if src == nil {
t.Fatalf("source container %s not found", sshSrcName)
}
sel := spec.DefaultSelection(src)
sel.Include = true
sel.NameOverride = sshDstName
sel.ImageMode = spec.ImageSkip // same daemon; the image is already there
sel.StopSourceAfter = false // exercise the restart path
sel.Mounts["/data"] = spec.MountSelection{Action: spec.MountActionCopy, TargetName: sshDstVolume}
sel.MigratePorts = false // the source publishes nothing, but be explicit
opts := spec.DefaultOptions()
opts.Compress = true
opts.VerifyAfter = true
runner := &migrate.SSHRunner{
Src: c, Dst: rd,
Containers: inv.Containers, Volumes: inv.Volumes, Networks: inv.Networks,
Plan: spec.Plan{Items: []spec.ItemSelection{sel}, Options: opts},
}
jobs := job.NewManager()
j := jobs.Run(ctx, job.KindSSH, "ssh e2e", false, runner.Run)
<-j.Done()
snap := j.Snapshot()
for _, l := range snap.Log {
t.Logf("[%-5s] %s", l.Level, l.Message)
}
for _, it := range snap.Items {
for _, st := range it.Steps {
t.Logf(" step %-12s %-9s %8d bytes %s", st.ID, st.State, st.BytesDone, st.Label)
}
}
if snap.State != job.StateSucceeded {
t.Fatalf("migration %s: %s", snap.State, snap.Error)
}
if snap.BytesDone == 0 {
t.Error("no bytes were transferred; the copy did nothing")
}
// --- verify ----------------------------------------------------------
checks := []struct{ path, want string }{
{"/data/payload.txt", "streamed over ssh"},
{"/data/sub/inner.txt", "inner payload"},
{"/conf/site.conf", "listen=8443"},
{"/anon/scratch.txt", "anonymous over ssh"},
}
for _, chk := range checks {
got, err := readFileFromContainer(ctx, c, sshDstName, chk.path)
if err != nil {
t.Errorf("reading %s: %v", chk.path, err)
continue
}
if !strings.Contains(got, chk.want) {
t.Errorf("%s = %q, want it to contain %q", chk.path, got, chk.want)
} else {
t.Logf("verified %s", chk.path)
}
}
// The renamed volume must be a genuinely new one, holding the copied data.
if _, err := c.API().VolumeInspect(ctx, sshDstVolume); err != nil {
t.Errorf("renamed volume %s was not created: %v", sshDstVolume, err)
}
// The source was asked to keep running.
if st, err := c.State(ctx, sshSrcName); err != nil {
t.Errorf("source state: %v", err)
} else if st != "running" {
t.Errorf("source container should have been restarted, state is %q", st)
}
// The staging container used for the read-only mount must be gone.
out, err := rd.Run(ctx, "ps", "-a", "--format", "{{.Names}}")
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "dm-stage-") {
t.Errorf("a staging container was left behind:\n%s", out)
}
}
func createSSHSource(ctx context.Context, t *testing.T, c *dkr.Client, bindDir string) {
t.Helper()
api := c.API()
if _, err := api.VolumeCreate(ctx, volume.CreateOptions{Name: sshSrcVolume}); err != nil {
t.Fatal(err)
}
script := strings.Join([]string{
"echo 'streamed over ssh' > /data/payload.txt",
"mkdir -p /data/sub",
"echo 'inner payload' > /data/sub/inner.txt",
"echo 'anonymous over ssh' > /anon/scratch.txt",
"sleep 3600",
}, " && ")
resp, err := api.ContainerCreate(ctx,
&container.Config{
Image: testImage,
Cmd: []string{"sh", "-c", script},
Env: []string{"DM_MODE=ssh"},
},
&container.HostConfig{
Mounts: []mount.Mount{
{Type: mount.TypeVolume, Source: sshSrcVolume, Target: "/data"},
{Type: mount.TypeBind, Source: bindDir, Target: "/conf", ReadOnly: true},
{Type: mount.TypeVolume, Target: "/anon"},
},
},
nil, nil, sshSrcName)
if err != nil {
t.Fatal(err)
}
if err := api.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
t.Fatal(err)
}
time.Sleep(2 * time.Second)
}
func sshCleanup(ctx context.Context, c *dkr.Client) {
api := c.API()
for _, n := range []string{sshSrcName, sshDstName} {
_ = api.ContainerRemove(ctx, n, container.RemoveOptions{Force: true})
}
for _, v := range []string{sshSrcVolume, sshDstVolume} {
_ = api.VolumeRemove(ctx, v, true)
}
}
+33
View File
@@ -0,0 +1,33 @@
//go:build e2e
package e2e
import (
"archive/tar"
"errors"
"io"
"strings"
)
// firstFileInTar returns the contents of the first regular file in an archive
// produced by the Docker archive API.
func firstFileInTar(r io.Reader) (string, error) {
tr := tar.NewReader(r)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
return "", errors.New("archive contains no regular file")
}
if err != nil {
return "", err
}
if hdr.Typeflag != tar.TypeReg {
continue
}
var b strings.Builder
if _, err := io.Copy(&b, tr); err != nil {
return "", err
}
return b.String(), nil
}
}
+13
View File
@@ -0,0 +1,13 @@
<!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>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1883
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "docker-migrate-ui",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.1.0",
"typescript": "^5.9.0",
"vite": "^7.1.0"
}
}
+215
View File
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api'
import type {
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, SourceResponse, TargetInventory,
} from './types'
import { Notice } from './ui'
import { Containers } from './Containers'
import { Sidebar } from './Sidebar'
import { Jobs } from './Jobs'
import { Packages } from './Packages'
type View = 'containers' | 'jobs' | 'packages'
export default function App() {
const [health, setHealth] = useState<Health | null>(null)
const [source, setSource] = useState<SourceResponse | null>(null)
const [sel, setSel] = useState<Record<string, ItemSelection>>({})
const [options, setOptions] = useState<Options>({
conflict: 'fail', renameSuffix: '-migrated', compress: true,
compressLevel: 1, dryRun: false, parallelism: 1, verifyAfter: true,
})
// Volume sizes come from a separate, slower endpoint so the container list
// can render immediately; they are merged into the rows when they arrive.
const [sizes, setSizes] = useState<Record<string, number>>({})
const [connections, setConnections] = useState<Connection[]>([])
const [activeConn, setActiveConn] = useState<string>('')
const [targetInv, setTargetInv] = useState<TargetInventory | null>(null)
const [jobs, setJobs] = useState<JobSnapshot[]>([])
const [packages, setPackages] = useState<PackageInfo[]>([])
const [view, setView] = useState<View>('containers')
const [activeJob, setActiveJob] = useState<string>('')
const [error, setError] = useState<string>('')
const [loading, setLoading] = useState(true)
const loadSource = useCallback(async () => {
setLoading(true)
try {
const s = await api.source()
setSource(s)
// Selections are re-seeded from the server defaults, but any choice the
// operator already made for a container that still exists is preserved.
setSel((prev) => {
const next: Record<string, ItemSelection> = {}
for (const c of s.inventory.containers) {
next[c.id] = prev[c.id] ?? s.defaults[c.id]
}
return next
})
setError('')
api.volumeSizes()
.then((r) => setSizes(r.volumes ?? {}))
.catch(() => undefined) // sizes are a nicety; the list works without them
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [])
const loadConnections = useCallback(async () => {
try {
const list = await api.connections()
setConnections(list)
setActiveConn((cur) => (cur && list.some((c) => c.id === cur) ? cur : list[0]?.id ?? ''))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
const loadJobs = useCallback(async () => {
try {
setJobs(await api.jobs())
} catch { /* the jobs list is refreshed again on the next tick */ }
}, [])
const loadPackages = useCallback(async () => {
try {
setPackages(await api.packages())
} catch { /* likewise */ }
}, [])
useEffect(() => {
api.health().then(setHealth).catch(() => undefined)
loadSource()
loadConnections()
loadJobs()
loadPackages()
}, [loadSource, loadConnections, loadJobs, loadPackages])
// A slow poll keeps the job list current without holding a stream open for
// every job; the detail view subscribes to its own live stream.
useEffect(() => {
const t = setInterval(loadJobs, 4000)
return () => clearInterval(t)
}, [loadJobs])
// connectTarget deliberately lets its error escape. The sidebar needs to see
// an untrusted host key so it can put the fingerprint in front of the
// operator; swallowing it here left the UI stuck on "not connected yet".
const connectTarget = useCallback(async (id: string) => {
setTargetInv(null)
if (!id) return
const inv = await api.targetInventory(id)
setTargetInv(inv)
setError('')
}, [])
const included = useMemo(() => Object.values(sel).filter((s) => s.include), [sel])
const plan = useMemo<Plan>(() => ({ items: Object.values(sel), options }), [sel, options])
const runningJobs = jobs.filter((j) => j.state === 'running' || j.state === 'pending').length
const onJobStarted = useCallback((j: JobSnapshot) => {
setJobs((prev) => [j, ...prev])
setActiveJob(j.id)
setView('jobs')
}, [])
return (
<div className="app">
<header className="topbar">
<div className="brand"><span className="dot" />docker-migrate</div>
<nav className="tabs">
<button className={`tab${view === 'containers' ? ' active' : ''}`} onClick={() => setView('containers')}>
Containers
<span className="count">{included.length}/{source?.inventory.containers.length ?? 0}</span>
</button>
<button className={`tab${view === 'jobs' ? ' active' : ''}`} onClick={() => setView('jobs')}>
Jobs
{runningJobs > 0 && <span className="count">{runningJobs} running</span>}
</button>
<button className={`tab${view === 'packages' ? ' active' : ''}`} onClick={() => setView('packages')}>
Packages
{packages.length > 0 && <span className="count">{packages.length}</span>}
</button>
</nav>
<div className="topbar-right">
{health && (
<span className="hostinfo">
source <b>{source?.inventory.host || health.dockerHost}</b>
{health.dockerVersion && <> · docker {health.dockerVersion}</>}
</span>
)}
<button className="btn tiny" onClick={loadSource} disabled={loading}>
{loading ? 'loading…' : 'refresh'}
</button>
</div>
</header>
{error && (
<div style={{ padding: '10px 16px' }}>
<Notice kind="err">
{error}
<button className="btn tiny ghost" style={{ marginLeft: 8 }} onClick={() => setError('')}>dismiss</button>
</Notice>
</div>
)}
{health && !health.ok && (
<div style={{ padding: '10px 16px' }}>
<Notice kind="err">
Cannot reach the source Docker daemon at <span className="mono">{health.dockerHost}</span>
{health.dockerError && <> {health.dockerError}</>}
</Notice>
</div>
)}
<div className="body">
<main className="main">
{view === 'containers' && (
<Containers
source={source}
sel={sel}
setSel={setSel}
targetInv={targetInv}
loading={loading}
sizes={sizes}
/>
)}
{view === 'jobs' && (
<Jobs
jobs={jobs}
activeJob={activeJob}
setActiveJob={setActiveJob}
reload={loadJobs}
reloadPackages={loadPackages}
/>
)}
{view === 'packages' && <Packages packages={packages} reload={loadPackages} />}
</main>
{view === 'containers' && (
<aside className="sidebar">
<Sidebar
source={source}
plan={plan}
includedCount={included.length}
options={options}
setOptions={setOptions}
connections={connections}
activeConn={activeConn}
setActiveConn={setActiveConn}
reloadConnections={loadConnections}
targetInv={targetInv}
connectTarget={connectTarget}
onJobStarted={onJobStarted}
onError={setError}
/>
</aside>
)}
</div>
</div>
)
}
+447
View File
@@ -0,0 +1,447 @@
import { useMemo, useState } from 'react'
import type {
Container, ImageMode, ItemSelection, Mount, MountAction, SourceResponse, TargetInventory,
} from './types'
import { Check, Field, humanBytes, StateDot } from './ui'
type SelMap = Record<string, ItemSelection>
type SizeMap = Record<string, number>
/** mountSize prefers the size carried on the mount, falling back to the
separately measured volume sizes. Bind mount sizes are not measured. */
function mountSize(m: Mount, sizes: SizeMap): number {
if (m.kind === 'tmpfs') return 0
if (m.sizeBytes >= 0) return m.sizeBytes
if (m.name && sizes[m.name] !== undefined) return sizes[m.name]
return -1
}
export function Containers({
source, sel, setSel, targetInv, loading, sizes,
}: {
source: SourceResponse | null
sel: SelMap
setSel: React.Dispatch<React.SetStateAction<SelMap>>
targetInv: TargetInventory | null
loading: boolean
sizes: SizeMap
}) {
const [query, setQuery] = useState('')
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [hideStopped, setHideStopped] = useState(false)
const containers = source?.inventory.containers ?? []
const targetNames = useMemo(
() => new Set((targetInv?.containers ?? []).map((c) => c.name)),
[targetInv],
)
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
return containers.filter((c) => {
if (hideStopped && c.state !== 'running') return false
if (!q) return true
return (
c.name.toLowerCase().includes(q) ||
c.image.toLowerCase().includes(q) ||
(c.composeProject ?? '').toLowerCase().includes(q) ||
(c.mounts ?? []).some((m) => m.destination.toLowerCase().includes(q) || (m.name ?? '').toLowerCase().includes(q))
)
})
}, [containers, query, hideStopped])
const groups = useMemo(() => {
const map = new Map<string, Container[]>()
for (const c of visible) {
const key = c.composeProject || ''
const list = map.get(key)
if (list) list.push(c)
else map.set(key, [c])
}
return [...map.entries()].sort((a, b) => {
if (a[0] === '') return 1
if (b[0] === '') return -1
return a[0].localeCompare(b[0])
})
}, [visible])
function update(id: string, patch: Partial<ItemSelection>) {
setSel((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }))
}
function setInclude(ids: string[], include: boolean) {
setSel((prev) => {
const next = { ...prev }
for (const id of ids) if (next[id]) next[id] = { ...next[id], include }
return next
})
}
/** applyToSelected edits every included container at once, which is what
makes a 40-container migration a few clicks rather than forty. */
function applyToSelected(fn: (s: ItemSelection, c: Container) => ItemSelection) {
setSel((prev) => {
const next = { ...prev }
for (const c of containers) {
const s = next[c.id]
if (s?.include) next[c.id] = fn(s, c)
}
return next
})
}
function setAllMounts(action: MountAction, kinds: Mount['kind'][]) {
applyToSelected((s, c) => {
const mounts = { ...s.mounts }
for (const m of c.mounts ?? []) {
if (m.kind === 'tmpfs') continue
if (kinds.includes(m.kind)) mounts[m.destination] = { ...mounts[m.destination], action }
}
return { ...s, mounts }
})
}
const visibleIds = visible.map((c) => c.id)
const selectedCount = visible.filter((c) => sel[c.id]?.include).length
const anySelected = Object.values(sel).some((s) => s.include)
return (
<>
<div className="toolbar">
<input
className="search"
type="text"
placeholder="filter by name, image, mount…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button className="btn tiny" onClick={() => setInclude(visibleIds, true)}>select all</button>
<button className="btn tiny" onClick={() => setInclude(visibleIds, false)}>clear</button>
<button
className="btn tiny"
onClick={() => setInclude(visible.filter((c) => c.state === 'running').map((c) => c.id), true)}
>
select running
</button>
<Check checked={hideStopped} onChange={setHideStopped} label={<span className="small muted">running only</span>} />
<span className="spacer" />
<span className="small faint nowrap">apply to {selectedCount ? `${selectedCount} selected` : 'selection'}:</span>
<button className="btn tiny" disabled={!anySelected} onClick={() => setAllMounts('copy', ['volume', 'anonymous', 'bind'])}>
copy all data
</button>
<button className="btn tiny" disabled={!anySelected} onClick={() => setAllMounts('skip', ['bind'])}>
skip binds
</button>
<button className="btn tiny" disabled={!anySelected} onClick={() => setAllMounts('structure', ['volume', 'anonymous', 'bind'])}>
structure only
</button>
<select
className="btn tiny"
style={{ width: 'auto' }}
disabled={!anySelected}
value=""
onChange={(e) => {
const v = e.target.value
if (!v) return
if (v === 'start') applyToSelected((s) => ({ ...s, startAfter: true }))
if (v === 'nostart') applyToSelected((s) => ({ ...s, startAfter: false }))
if (v === 'live') applyToSelected((s) => ({ ...s, stopSourceDuringCopy: false }))
if (v === 'quiesce') applyToSelected((s) => ({ ...s, stopSourceDuringCopy: true }))
if (v === 'keepsource') applyToSelected((s) => ({ ...s, stopSourceAfter: false }))
if (v === 'stopsource') applyToSelected((s) => ({ ...s, stopSourceAfter: true }))
if (v.startsWith('img:')) {
const mode = v.slice(4) as ImageMode
applyToSelected((s) => ({ ...s, migrateImage: mode !== 'skip', imageMode: mode }))
}
e.target.value = ''
}}
>
<option value="">more</option>
<option value="start">start after migration</option>
<option value="nostart">leave stopped on target</option>
<option value="quiesce">stop source while copying</option>
<option value="live">copy while running (hot)</option>
<option value="stopsource">stop source after migration</option>
<option value="keepsource">leave source running</option>
<option value="img:auto">image: auto</option>
<option value="img:pull">image: pull on target</option>
<option value="img:stream">image: transfer layers</option>
<option value="img:skip">image: already on target</option>
</select>
</div>
{loading && containers.length === 0 && <div className="empty">reading the source daemon</div>}
{!loading && containers.length === 0 && <div className="empty">no containers on this host</div>}
{!loading && containers.length > 0 && visible.length === 0 && <div className="empty">nothing matches the filter</div>}
<div className="clist">
{groups.map(([project, list]) => (
<div key={project || '__none'}>
{groups.length > 1 && (
<div className="group-head">
<Check
checked={list.every((c) => sel[c.id]?.include)}
onChange={(v) => setInclude(list.map((c) => c.id), v)}
label={project ? `compose: ${project}` : 'standalone'}
/>
<span className="line" />
<span>{list.length}</span>
</div>
)}
{list.map((c) => (
<Row
key={c.id}
c={c}
s={sel[c.id]}
onChange={(patch) => update(c.id, patch)}
expanded={expanded.has(c.id)}
toggleExpanded={() =>
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(c.id)) next.delete(c.id)
else next.add(c.id)
return next
})
}
conflicts={targetNames.has(sel[c.id]?.nameOverride || c.name)}
sizes={sizes}
/>
))}
</div>
))}
</div>
</>
)
}
function Row({
c, s, onChange, expanded, toggleExpanded, conflicts, sizes,
}: {
c: Container
s: ItemSelection | undefined
onChange: (patch: Partial<ItemSelection>) => void
expanded: boolean
toggleExpanded: () => void
conflicts: boolean
sizes: SizeMap
}) {
if (!s) return null
const mounts = c.mounts ?? []
const dataMounts = mounts.filter((m) => m.kind !== 'tmpfs')
const copying = dataMounts.filter((m) => (s.mounts[m.destination]?.action ?? 'copy') === 'copy')
const knownBytes = copying.reduce((a, m) => {
const n = mountSize(m, sizes)
return a + (n > 0 ? n : 0)
}, 0)
return (
<>
<div className={`crow${s.include ? ' selected' : ''}`}>
<Check checked={s.include} onChange={(v) => onChange({ include: v })} label="" />
<button className="expander" onClick={toggleExpanded} title="per-item options">
{expanded ? '▾' : '▸'}
</button>
<div style={{ minWidth: 0 }}>
<div className="name truncate" title={c.name}>{c.name}</div>
<div className="sub row" style={{ gap: 6 }}>
<StateDot state={c.state} />
{c.composeService && <span className="faint">· {c.composeService}</span>}
{conflicts && <span className="badge" style={{ borderColor: '#5c4520', color: '#e0b556' }}>on target</span>}
</div>
</div>
<div className="image truncate" title={c.image}>{c.image}</div>
<div className="tags">
{dataMounts.map((m) => (
<span
key={m.destination}
className={`badge ${m.kind === 'bind' ? 'bind' : m.kind === 'anonymous' ? 'anon' : 'vol'}`}
title={`${m.kind}${m.destination}${m.readOnly ? ' (read-only)' : ''}`}
style={{ opacity: (s.mounts[m.destination]?.action ?? 'copy') === 'skip' ? 0.35 : 1 }}
>
{m.kind === 'bind' ? (m.source ?? '').split('/').pop() || '/' : m.kind === 'anonymous' ? 'anon' : m.name}
</span>
))}
{(c.endpoints ?? []).filter((e) => !['bridge', 'host', 'none'].includes(e.network)).map((e) => (
<span key={e.network} className="badge net" title={`network ${e.network}`}>{e.network}</span>
))}
{(c.ports ?? []).slice(0, 3).map((p, i) => (
<span key={i} className="badge port">{p.hostPort}:{p.containerPort.split('/')[0]}</span>
))}
{(c.ports ?? []).length > 3 && <span className="badge port">+{(c.ports ?? []).length - 3}</span>}
</div>
<div className="small faint nowrap" style={{ textAlign: 'right' }}>
{copying.length > 0 ? `${copying.length} to copy` : 'no data'}
{knownBytes > 0 && <> · {humanBytes(knownBytes)}</>}
</div>
</div>
{expanded && <Detail c={c} s={s} onChange={onChange} sizes={sizes} />}
</>
)
}
function Detail({
c, s, onChange, sizes,
}: {
c: Container
s: ItemSelection
onChange: (patch: Partial<ItemSelection>) => void
sizes: SizeMap
}) {
const mounts = c.mounts ?? []
function setMount(dest: string, patch: Partial<{ action: MountAction; targetName: string; targetSource: string }>) {
onChange({ mounts: { ...s.mounts, [dest]: { ...s.mounts[dest], ...patch } } })
}
return (
<div className="detail">
{(c.warnings ?? []).map((w, i) => (
<div key={i} className="notice warn">{w}</div>
))}
<div className="grid2">
<Field label="name on target">
<input
type="text"
placeholder={c.name}
value={s.nameOverride ?? ''}
onChange={(e) => onChange({ nameOverride: e.target.value })}
/>
</Field>
<Field label="image">
<select
value={s.migrateImage ? s.imageMode : 'skip'}
onChange={(e) => {
const v = e.target.value as ImageMode
onChange({ migrateImage: v !== 'skip', imageMode: v })
}}
>
<option value="auto">auto reuse, pull, or transfer</option>
<option value="pull">pull on the target</option>
<option value="stream">transfer the layers</option>
<option value="skip">already on the target</option>
</select>
</Field>
<div className="stack" style={{ gap: 6 }}>
<Check
checked={s.migrateNetworks}
onChange={(v) => onChange({ migrateNetworks: v })}
label="recreate networks and reattach"
/>
<Check
checked={s.keepStaticIps}
onChange={(v) => onChange({ keepStaticIps: v })}
disabled={!s.migrateNetworks}
label="keep static IP addresses"
title="Only works when the target networks use the same subnets"
/>
<Check
checked={s.migratePorts}
onChange={(v) => onChange({ migratePorts: v })}
label="publish the same host ports"
/>
</div>
<div className="stack" style={{ gap: 6 }}>
<Check checked={s.startAfter} onChange={(v) => onChange({ startAfter: v })} label="start on the target" />
<Check
checked={s.stopSourceDuringCopy}
onChange={(v) => onChange({ stopSourceDuringCopy: v })}
label="stop the source while copying"
title="Recommended: databases and other writers produce inconsistent copies while running"
/>
<Check
checked={s.stopSourceAfter}
onChange={(v) => onChange({ stopSourceAfter: v })}
label="leave the source stopped afterwards"
/>
</div>
</div>
{mounts.length === 0 ? (
<div className="small faint">this container has no mounts</div>
) : (
<table className="mount-table">
<thead>
<tr>
<th style={{ width: 74 }}>kind</th>
<th>in the container</th>
<th>on the source</th>
<th style={{ width: 130 }}>action</th>
<th>on the target</th>
<th style={{ width: 70, textAlign: 'right' }}>size</th>
</tr>
</thead>
<tbody>
{mounts.map((m) => {
const ms = s.mounts[m.destination] ?? { action: 'copy' as MountAction }
const isTmpfs = m.kind === 'tmpfs'
return (
<tr key={m.destination}>
<td>
<span className={`badge ${m.kind === 'bind' ? 'bind' : m.kind === 'anonymous' ? 'anon' : m.kind === 'tmpfs' ? 'tmpfs' : 'vol'}`}>
{m.kind}
</span>
</td>
<td className="mono truncate" title={m.destination}>
{m.destination}
{m.readOnly && <span className="faint"> :ro</span>}
</td>
<td className="mono truncate faint" title={m.source || m.name}>
{m.kind === 'bind' ? m.source : m.kind === 'anonymous' ? '(generated)' : m.name}
</td>
<td>
<select
value={ms.action}
disabled={isTmpfs}
onChange={(e) => setMount(m.destination, { action: e.target.value as MountAction })}
>
<option value="copy">copy data</option>
<option value="structure">create empty</option>
<option value="skip">do not mount</option>
</select>
</td>
<td>
{m.kind === 'bind' && ms.action !== 'skip' && (
<input
type="text"
placeholder={m.source}
value={ms.targetSource ?? ''}
onChange={(e) => setMount(m.destination, { targetSource: e.target.value })}
/>
)}
{m.kind === 'volume' && ms.action !== 'skip' && (
<input
type="text"
placeholder={m.name}
value={ms.targetName ?? ''}
onChange={(e) => setMount(m.destination, { targetName: e.target.value })}
/>
)}
{m.kind === 'anonymous' && <span className="small faint">a fresh volume is created</span>}
{isTmpfs && <span className="small faint">in memory, nothing to copy</span>}
</td>
<td className="small faint nowrap" style={{ textAlign: 'right' }}>
{isTmpfs ? '' : humanBytes(mountSize(m, sizes))}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from './api'
import type { JobSnapshot } from './types'
import { duration, humanBytes, Notice, Progress, StateDot } from './ui'
export function Jobs({
jobs, activeJob, setActiveJob, reload, reloadPackages,
}: {
jobs: JobSnapshot[]
activeJob: string
setActiveJob: (id: string) => void
reload: () => void
reloadPackages: () => void
}) {
const selected = activeJob || jobs[0]?.id || ''
if (jobs.length === 0) {
return <div className="empty">no migrations yet select containers and start one</div>
}
return (
<div style={{ display: 'flex', minHeight: 0, height: '100%' }}>
<div className="joblist" style={{ width: 320, flex: '0 0 320px', overflow: 'auto' }}>
{jobs.map((j) => (
<div
key={j.id}
className={`jobcard${j.id === selected ? ' active' : ''}`}
onClick={() => setActiveJob(j.id)}
>
<div className="row">
<StateDot state={j.state} />
<span className="spacer" />
<span className="small faint">{j.kind === 'ssh' ? 'ssh' : 'package'}</span>
</div>
<div className="truncate" style={{ marginTop: 2 }}>{j.title}</div>
<div className="small faint">
{new Date(j.createdAt).toLocaleTimeString()} · {duration(j.startedAt, j.endedAt)}
{j.dryRun && ' · dry run'}
</div>
<div style={{ marginTop: 6 }}>
<Progress done={j.bytesDone} total={j.bytesTotal} state={j.state} />
</div>
</div>
))}
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', borderLeft: '1px solid var(--border)' }}>
{selected && <JobDetail id={selected} reload={reload} reloadPackages={reloadPackages} />}
</div>
</div>
)
}
function JobDetail({
id, reload, reloadPackages,
}: {
id: string
reload: () => void
reloadPackages: () => void
}) {
const [job, setJob] = useState<JobSnapshot | null>(null)
const [showCmds, setShowCmds] = useState(true)
const logRef = useRef<HTMLDivElement>(null)
const stick = useRef(true)
// Each job detail holds one server-sent events stream, which pushes a full
// snapshot whenever anything changes.
useEffect(() => {
setJob(null)
let closed = false
api.job(id).then((j) => !closed && setJob(j)).catch(() => undefined)
const es = api.jobEvents(id)
es.onmessage = (ev) => {
try {
setJob(JSON.parse(ev.data) as JobSnapshot)
} catch { /* a truncated frame is replaced by the next one */ }
}
es.addEventListener('done', () => {
es.close()
reload()
reloadPackages()
})
es.onerror = () => es.close()
return () => {
closed = true
es.close()
}
}, [id, reload, reloadPackages])
const lines = useMemo(
() => (job?.log ?? []).filter((l) => showCmds || l.level !== 'cmd'),
[job, showCmds],
)
useEffect(() => {
const el = logRef.current
if (el && stick.current) el.scrollTop = el.scrollHeight
}, [lines])
if (!job) return <div className="empty">loading</div>
const running = job.state === 'running' || job.state === 'pending'
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="row">
<StateDot state={job.state} />
<b>{job.title}</b>
{job.dryRun && <span className="badge">dry run</span>}
<span className="spacer" />
<span className="small faint">
{humanBytes(job.bytesDone)}
{job.bytesTotal > 0 && <> of {humanBytes(job.bytesTotal)}</>} · {duration(job.startedAt, job.endedAt)}
</span>
{running ? (
<button className="btn tiny danger" onClick={() => api.cancelJob(job.id).then(reload)}>cancel</button>
) : (
<button className="btn tiny ghost" onClick={() => api.deleteJob(job.id).then(reload)}>remove</button>
)}
</div>
<Progress done={job.bytesDone} total={job.bytesTotal} state={job.state} />
{job.error && <Notice kind="err">{job.error}</Notice>}
{job.state === 'succeeded' && job.artifact && (
<Notice kind="ok">
Package ready at <span className="mono">{job.artifact}</span> ({humanBytes(job.artifactBytes ?? 0)}).
{' '}Open the Packages tab to download it.
</Notice>
)}
{job.items.map((it) => (
<div key={it.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px' }}>
<div className="row">
<StateDot state={it.state} />
<b>{it.name}</b>
<span className="spacer" />
<span className="small faint">{it.steps.filter((s) => s.state === 'succeeded').length}/{it.steps.length} steps</span>
</div>
{it.error && <div className="small" style={{ color: 'var(--err)' }}>{it.error}</div>}
{(it.warnings ?? []).map((w, i) => (
<div key={i} className="small" style={{ color: 'var(--warn)' }}>! {w}</div>
))}
<div className="steps">
{it.steps.map((s) => (
<div key={s.id} className={`step ${s.state}`}>
<StateDot state={s.state} label="" />
<span className="label truncate" title={s.error || s.label}>{s.label}</span>
<span>
{s.bytesTotal > 0 || s.bytesDone > 0 ? (
<Progress done={s.bytesDone} total={s.bytesTotal} state={s.state} />
) : null}
</span>
<span className="faint nowrap" style={{ textAlign: 'right' }}>
{s.bytesDone > 0 ? humanBytes(s.bytesDone) : s.state === 'skipped' ? 'skipped' : ''}
</span>
</div>
))}
</div>
</div>
))}
<div className="row">
<h3 style={{ margin: 0, fontSize: 12 }}>log</h3>
<span className="spacer" />
<label className="check small">
<input type="checkbox" checked={showCmds} onChange={(e) => setShowCmds(e.target.checked)} />
<span>show commands</span>
</label>
</div>
<div
className="log"
ref={logRef}
onScroll={(e) => {
const el = e.currentTarget
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
}}
>
{lines.map((l) => (
<div key={l.seq} className={`l-${l.level}`}>
<span className="ts">{new Date(l.at).toLocaleTimeString()} </span>
{l.message}
</div>
))}
{lines.length === 0 && <span className="faint">nothing logged yet</span>}
</div>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { api } from './api'
import type { PackageInfo } from './types'
import { humanBytes, Notice } from './ui'
export function Packages({ packages, reload }: { packages: PackageInfo[]; reload: () => void }) {
if (packages.length === 0) {
return <div className="empty">no packages built yet</div>
}
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<Notice kind="info">
Copy a package to the target host, then run <span className="mono">./install.sh --dry-run</span> to review it and{' '}
<span className="mono">./install.sh</span> to restore. The target needs only bash, gzip and docker.
</Notice>
<table className="mount-table">
<thead>
<tr>
<th>name</th>
<th style={{ width: 110 }}>kind</th>
<th style={{ width: 110, textAlign: 'right' }}>size</th>
<th style={{ width: 170 }}>built</th>
<th style={{ width: 190 }}></th>
</tr>
</thead>
<tbody>
{packages.map((p) => (
<tr key={p.name}>
<td className="mono truncate" title={p.path}>{p.name}</td>
<td><span className="badge">{p.isDir ? 'directory' : 'tar'}</span></td>
<td className="nowrap" style={{ textAlign: 'right' }}>{humanBytes(p.bytes)}</td>
<td className="small faint">{new Date(p.createdAt).toLocaleString()}</td>
<td>
<div className="row" style={{ justifyContent: 'flex-end', gap: 6 }}>
{p.isDir ? (
<span className="small faint" title={p.path}>copy it from disk</span>
) : (
<a className="btn tiny" href={api.downloadUrl(p.name)} download>download</a>
)}
<button
className="btn tiny danger"
onClick={() => {
if (!confirm(`Delete package "${p.name}"? This cannot be undone.`)) return
api.deletePackage(p.name).then(reload)
}}
>
delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
+500
View File
@@ -0,0 +1,500 @@
import { useCallback, useEffect, useState } from 'react'
import { api, ApiError } from './api'
import type {
Connection, HostKeyInfo, JobSnapshot, Options, Plan, PreviewResponse, SourceResponse, TargetInventory,
} from './types'
import { Check, Field, humanBytes, Modal, Notice } from './ui'
export function Sidebar({
source, plan, includedCount, options, setOptions,
connections, activeConn, setActiveConn, reloadConnections,
targetInv, connectTarget, onJobStarted, onError,
}: {
source: SourceResponse | null
plan: Plan
includedCount: number
options: Options
setOptions: React.Dispatch<React.SetStateAction<Options>>
connections: Connection[]
activeConn: string
setActiveConn: (id: string) => void
reloadConnections: () => void
targetInv: TargetInventory | null
connectTarget: (id: string) => Promise<void>
onJobStarted: (j: JobSnapshot) => void
onError: (msg: string) => void
}) {
const [editing, setEditing] = useState<Partial<Connection> | null>(null)
const [hostKey, setHostKey] = useState<HostKeyInfo | null>(null)
const [preview, setPreview] = useState<PreviewResponse | null>(null)
const [busy, setBusy] = useState('')
const [packageName, setPackageName] = useState('')
const [packageFormat, setPackageFormat] = useState<'tar' | 'dir'>('tar')
const conn = connections.find((c) => c.id === activeConn)
const pre = targetInv?.preflight
const targetReady = !!pre?.serverVersion
async function withBusy(what: string, fn: () => Promise<void>) {
setBusy(what)
try {
await fn()
} catch (e) {
// An untrusted host key is not an error to report but a decision to
// put in front of the operator, so it opens the fingerprint dialog.
if (e instanceof ApiError && e.needsTrust) {
await checkHostKey()
} else {
onError(e instanceof Error ? e.message : String(e))
}
} finally {
setBusy('')
}
}
const connect = useCallback(() => {
if (!activeConn) return
void withBusy('test', () => connectTarget(activeConn))
// withBusy and connectTarget are stable for the lifetime of a selection.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConn])
// Selecting a target connects to it straight away; if its key is not trusted
// yet the dialog opens by itself rather than leaving the panel blank.
useEffect(() => {
connect()
}, [connect])
async function checkHostKey() {
if (!activeConn) return
try {
setHostKey(await api.probe(activeConn))
} catch (e) {
onError(e instanceof Error ? e.message : String(e))
}
}
async function trustHostKey() {
if (!activeConn || !hostKey) return
await withBusy('trust', async () => {
await api.trust(activeConn, hostKey.fingerprint)
setHostKey(null)
await connectTarget(activeConn)
})
}
const canMigrate = includedCount > 0 && targetReady && !busy
const canPackage = includedCount > 0 && !busy
return (
<>
<div className="section">
<h3>target host</h3>
<div className="stack">
<div className="row">
<select value={activeConn} onChange={(e) => setActiveConn(e.target.value)}>
<option value=""> no target selected </option>
{connections.map((c) => (
<option key={c.id} value={c.id}>{c.name} ({c.user}@{c.host})</option>
))}
</select>
<button className="btn tiny" onClick={() => setEditing({ port: 22, auth: 'password', saveSecrets: false, sudo: false })}>
new
</button>
</div>
{conn && (
<div className="row wrap" style={{ gap: 6 }}>
<button className="btn tiny" disabled={!!busy} onClick={connect}>
{busy === 'test' ? 'connecting…' : 'connect'}
</button>
<button className="btn tiny" onClick={() => setEditing(conn)}>edit</button>
<button className="btn tiny" onClick={checkHostKey}>host key</button>
<button
className="btn tiny danger"
onClick={() => {
if (!confirm(`Delete connection "${conn.name}"?`)) return
withBusy('del', async () => {
await api.deleteConnection(conn.id)
reloadConnections()
})
}}
>
delete
</button>
</div>
)}
{conn && !targetInv && <div className="small faint">not connected yet</div>}
{pre && (
<>
{(pre.problems ?? []).map((p, i) => (
<Notice key={i} kind="warn">{p}</Notice>
))}
{targetReady && (
<dl className="kv">
<dt>host</dt><dd>{targetInv?.host || conn?.host}</dd>
<dt>docker</dt><dd>{pre.serverVersion} · {pre.os}/{pre.arch}</dd>
<dt>free space</dt><dd>{humanBytes(pre.diskFreeBytes)} on {pre.dockerRoot}</dd>
<dt>existing</dt>
<dd>
{(targetInv?.containers ?? []).length} containers ·{' '}
{(targetInv?.volumes ?? []).length} volumes
</dd>
<dt>gzip</dt><dd>{pre.hasGzip ? 'yes' : 'missing'}</dd>
</dl>
)}
</>
)}
</div>
</div>
<div className="section">
<h3>options</h3>
<div className="stack">
<Field label="if the name already exists on the target">
<select
value={options.conflict}
onChange={(e) => setOptions((o) => ({ ...o, conflict: e.target.value as Options['conflict'] }))}
>
<option value="fail">stop with an error</option>
<option value="skip">skip that container</option>
<option value="rename">create it under a new name</option>
<option value="replace">remove the target's container first</option>
</select>
</Field>
{options.conflict === 'rename' && (
<Field label="suffix">
<input
type="text"
value={options.renameSuffix ?? ''}
onChange={(e) => setOptions((o) => ({ ...o, renameSuffix: e.target.value }))}
/>
</Field>
)}
{options.conflict === 'replace' && (
<Notice kind="warn">
Existing containers and volumes with the same name are deleted on the target before the copy.
</Notice>
)}
<Check
checked={options.compress}
onChange={(v) => setOptions((o) => ({ ...o, compress: v }))}
label="compress transfers (gzip)"
/>
<Check
checked={options.verifyAfter}
onChange={(v) => setOptions((o) => ({ ...o, verifyAfter: v }))}
label="verify each container after migrating"
/>
<Check
checked={options.dryRun}
onChange={(v) => setOptions((o) => ({ ...o, dryRun: v }))}
label="dry run — show every command, change nothing"
/>
<Field label={`containers at a time: ${options.parallelism}`}>
<input
type="range"
min={1}
max={6}
value={options.parallelism}
onChange={(e) => setOptions((o) => ({ ...o, parallelism: Number(e.target.value) }))}
style={{ width: '100%' }}
/>
</Field>
</div>
</div>
<div className="section">
<h3>migrate over ssh</h3>
<div className="stack">
<button
className="btn primary"
disabled={!canMigrate}
onClick={() =>
withBusy('ssh', async () => {
const j = await api.migrateSSH(activeConn, plan)
onJobStarted(j)
})
}
>
{busy === 'ssh' ? 'starting…' : `migrate ${includedCount} container${includedCount === 1 ? '' : 's'} to target`}
</button>
<button
className="btn"
disabled={includedCount === 0 || !!busy}
onClick={() =>
withBusy('preview', async () => {
setPreview(await api.preview(plan))
})
}
>
preview the commands
</button>
{includedCount === 0 && <div className="small faint">select at least one container</div>}
{includedCount > 0 && !targetReady && <div className="small faint">connect to a target first</div>}
</div>
</div>
<div className="section">
<h3>migration package</h3>
<div className="stack">
<div className="small muted">
Builds a self-contained folder with the data, the images and an <span className="mono">install.sh</span> to
run on the target. No network between the hosts required.
</div>
<Field label="package name">
<input
type="text"
placeholder="auto (timestamped)"
value={packageName}
onChange={(e) => setPackageName(e.target.value)}
/>
</Field>
<Field label="format">
<select value={packageFormat} onChange={(e) => setPackageFormat(e.target.value as 'tar' | 'dir')}>
<option value="tar">single .tar file (downloadable)</option>
<option value="dir">directory on this host</option>
</select>
</Field>
<button
className="btn"
disabled={!canPackage}
onClick={() =>
withBusy('pkg', async () => {
const j = await api.buildPackage({ ...plan, packageName }, packageFormat)
onJobStarted(j)
})
}
>
{busy === 'pkg' ? 'starting…' : 'build package'}
</button>
</div>
</div>
{source?.inventory.warnings?.length ? (
<div className="section">
<h3>source warnings</h3>
<div className="stack">
{source.inventory.warnings.map((w, i) => (
<Notice key={i} kind="warn">{w}</Notice>
))}
</div>
</div>
) : null}
{editing && (
<ConnectionDialog
initial={editing}
onClose={() => setEditing(null)}
onSaved={(c) => {
setEditing(null)
reloadConnections()
setActiveConn(c.id)
}}
onError={onError}
/>
)}
{hostKey && (
<Modal
title="SSH host key"
onClose={() => setHostKey(null)}
footer={
<>
<button className="btn" onClick={() => setHostKey(null)}>cancel</button>
<button className="btn primary" onClick={trustHostKey}>
{hostKey.changed ? 'replace the stored key and trust' : 'trust this host'}
</button>
</>
}
>
<div className="stack">
{hostKey.changed && (
<Notice kind="err">
The key presented by this host is <b>different</b> from the one recorded earlier. This happens after a
reinstall but it is also what a machine-in-the-middle looks like. Only continue if you know why it
changed.
</Notice>
)}
{hostKey.trusted && !hostKey.changed && <Notice kind="ok">This host key is already trusted.</Notice>}
<div className="small muted">
Compare this with the output of <span className="mono">ssh-keyscan -t {hostKey.keyType} {hostKey.host}</span>{' '}
run on the target itself, or with <span className="mono">ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub</span>.
</div>
<div className="fingerprint">
{hostKey.keyType}<br />
{hostKey.fingerprint}
</div>
</div>
</Modal>
)}
{preview && <PreviewModal data={preview} onClose={() => setPreview(null)} />}
</>
)
}
function ConnectionDialog({
initial, onClose, onSaved, onError,
}: {
initial: Partial<Connection>
onClose: () => void
onSaved: (c: Connection) => void
onError: (m: string) => void
}) {
const [c, setC] = useState<Partial<Connection>>(initial)
const [saving, setSaving] = useState(false)
function set<K extends keyof Connection>(k: K, v: Connection[K]) {
setC((prev) => ({ ...prev, [k]: v }))
}
return (
<Modal
title={initial.id ? `Edit ${initial.name}` : 'New target host'}
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose}>cancel</button>
<button
className="btn primary"
disabled={saving || !c.host || !c.user}
onClick={async () => {
setSaving(true)
try {
onSaved(await api.saveConnection(c))
} catch (e) {
onError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}}
>
{saving ? 'saving…' : 'save'}
</button>
</>
}
>
<div className="stack" style={{ gap: 12 }}>
<div className="row" style={{ gap: 12 }}>
<Field label="label"><input type="text" value={c.name ?? ''} onChange={(e) => set('name', e.target.value)} /></Field>
<Field label="host"><input type="text" value={c.host ?? ''} onChange={(e) => set('host', e.target.value)} /></Field>
<div style={{ width: 90 }}>
<Field label="port">
<input type="number" value={c.port ?? 22} onChange={(e) => set('port', Number(e.target.value))} />
</Field>
</div>
</div>
<div className="row" style={{ gap: 12 }}>
<Field label="user"><input type="text" value={c.user ?? ''} onChange={(e) => set('user', e.target.value)} /></Field>
<Field label="authentication">
<select value={c.auth ?? 'password'} onChange={(e) => set('auth', e.target.value as Connection['auth'])}>
<option value="password">password</option>
<option value="key">private key</option>
<option value="agent">ssh agent</option>
</select>
</Field>
</div>
{c.auth === 'password' && (
<Field label="password">
<input type="password" value={c.password ?? ''} onChange={(e) => set('password', e.target.value)} />
</Field>
)}
{c.auth === 'key' && (
<>
<Field label="private key path on this machine (leave empty to paste the key below)">
<input type="text" placeholder="/root/.ssh/id_ed25519" value={c.privateKeyPath ?? ''} onChange={(e) => set('privateKeyPath', e.target.value)} />
</Field>
<Field label="or paste the private key">
<textarea rows={5} value={c.privateKey ?? ''} onChange={(e) => set('privateKey', e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
</Field>
<Field label="passphrase (if the key is encrypted)">
<input type="password" value={c.passphrase ?? ''} onChange={(e) => set('passphrase', e.target.value)} />
</Field>
</>
)}
{c.auth === 'agent' && (
<div className="small muted">
Uses the agent at <span className="mono">$SSH_AUTH_SOCK</span> of the process running docker-migrate.
</div>
)}
<Check
checked={c.sudo ?? false}
onChange={(v) => set('sudo', v)}
label="run docker through sudo -n on the target"
title="Needed when the login user is not in the docker group. sudo must not ask for a password."
/>
<Field label="docker command on the target (optional)">
<input type="text" placeholder="docker" value={c.dockerCmd ?? ''} onChange={(e) => set('dockerCmd', e.target.value)} />
</Field>
<Check
checked={c.saveSecrets ?? false}
onChange={(v) => set('saveSecrets', v)}
label="remember the password / key on disk"
/>
{c.saveSecrets ? (
<Notice kind="warn">
Credentials are stored in plain text in the connections file, readable only by this user. Leave this off to
keep them in memory for this session only.
</Notice>
) : (
<div className="small faint">
Credentials stay in memory and are lost when docker-migrate restarts.
</div>
)}
</div>
</Modal>
)
}
function PreviewModal({ data, onClose }: { data: PreviewResponse; onClose: () => void }) {
const total = data.items.reduce((a, i) => a + i.totalBytes, 0)
return (
<Modal
title="What this migration will run"
wide
onClose={onClose}
footer={<button className="btn" onClick={onClose}>close</button>}
>
<div className="stack" style={{ gap: 16 }}>
<div className="small muted">
{data.items.length} container(s){total > 0 && <> · about {humanBytes(total)} of known volume data</>}. These are
the commands that run on the target; data is streamed into <span className="mono">docker cp</span> rather than
written to a file.
</div>
{(data.networkCommands ?? []).length > 0 && (
<div>
<h3 style={{ margin: '0 0 6px', fontSize: 12 }}>shared networks</h3>
<pre className="cmdblock">{(data.networkCommands ?? []).join('\n')}</pre>
</div>
)}
{data.items.map((it) => (
<div key={it.containerId}>
<h3 style={{ margin: '0 0 6px', fontSize: 12 }}>
{it.name}
{it.targetName !== it.name && <span className="faint"> {it.targetName}</span>}
</h3>
{(it.warnings ?? []).map((w, i) => <Notice key={`w${i}`} kind="warn">{w}</Notice>)}
{(it.notes ?? []).map((w, i) => <div key={`n${i}`} className="small faint">· {w}</div>)}
<pre className="cmdblock">{(it.commands ?? []).join('\n')}</pre>
</div>
))}
</div>
</Modal>
)
}
+93
View File
@@ -0,0 +1,93 @@
import type {
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
Preflight, PreviewResponse, SourceResponse, TargetInventory,
} from './types'
// The token, when the server requires one, arrives as a query parameter the
// first time and is kept for the tab afterwards.
function readToken(): string {
const fromUrl = new URLSearchParams(location.search).get('token')
if (fromUrl) {
sessionStorage.setItem('dm.token', fromUrl)
const clean = location.pathname + location.hash
history.replaceState(null, '', clean)
return fromUrl
}
return sessionStorage.getItem('dm.token') ?? ''
}
const token = readToken()
/** ApiError carries the server's message plus anything it attached to it. */
export class ApiError extends Error {
status: number
body: Record<string, unknown>
constructor(status: number, message: string, body: Record<string, unknown> = {}) {
super(message)
this.status = status
this.body = body
}
/** True when the target's SSH host key still has to be approved. */
get needsTrust(): boolean {
return this.body.needsTrust === true
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
if (token) headers['X-Auth-Token'] = token
if (init?.body) headers['Content-Type'] = 'application/json'
const res = await fetch(path, { ...init, headers })
if (res.status === 204) return undefined as T
const text = await res.text()
let body: Record<string, unknown> = {}
if (text) {
try {
body = JSON.parse(text)
} catch {
if (!res.ok) throw new ApiError(res.status, text.slice(0, 400))
}
}
if (!res.ok) {
const msg = typeof body.error === 'string' ? body.error : `request failed (${res.status})`
throw new ApiError(res.status, msg, body)
}
return body as T
}
const post = <T>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
export const api = {
health: () => request<Health>('/api/health'),
source: () => request<SourceResponse>('/api/source'),
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
connections: () => request<Connection[]>('/api/connections'),
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
probe: (id: string) => post<HostKeyInfo>(`/api/connections/${id}/probe`),
trust: (id: string, fingerprint: string) => post<{ trusted: boolean }>(`/api/connections/${id}/trust`, { fingerprint }),
testConnection: (id: string) => post<Preflight>(`/api/connections/${id}/test`),
targetInventory: (id: string) => request<TargetInventory>(`/api/connections/${id}/inventory`),
preview: (plan: Plan) => post<PreviewResponse>('/api/plan/preview', plan),
migrateSSH: (connectionId: string, plan: Plan) => post<JobSnapshot>('/api/migrate/ssh', { connectionId, plan }),
buildPackage: (plan: Plan, format: 'tar' | 'dir') => post<JobSnapshot>('/api/migrate/package', { plan, format }),
jobs: () => request<JobSnapshot[]>('/api/jobs'),
job: (id: string) => request<JobSnapshot>(`/api/jobs/${id}`),
cancelJob: (id: string) => post<{ canceled: boolean }>(`/api/jobs/${id}/cancel`),
deleteJob: (id: string) => request<void>(`/api/jobs/${id}`, { method: 'DELETE' }),
packages: () => request<PackageInfo[]>('/api/packages'),
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
downloadUrl: (name: string) =>
`/api/packages/${encodeURIComponent(name)}/download` + (token ? `?token=${encodeURIComponent(token)}` : ''),
/** Opens the live progress stream for a job. */
jobEvents: (id: string) =>
new EventSource(`/api/jobs/${id}/events` + (token ? `?token=${encodeURIComponent(token)}` : '')),
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+313
View File
@@ -0,0 +1,313 @@
/* docker-migrate UI.
An operations tool: dense, monospace-leaning, dark by default. Colour is
reserved for state, never for decoration. */
:root {
--bg: #0e1116;
--bg-raised: #161b22;
--bg-sunken: #0a0d12;
--bg-hover: #1c232d;
--border: #262d38;
--border-strong: #38414f;
--text: #dfe6ef;
--text-dim: #8b97a8;
--text-faint: #5d6878;
--accent: #4c9aff;
--accent-dim: #1b3a63;
--ok: #3fb950;
--warn: #d29922;
--err: #f85149;
--run: #58a6ff;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--radius: 6px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 13px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
button, input, select, textarea { font: inherit; color: inherit; }
/* ---------- layout ---------- */
.app { display: flex; flex-direction: column; height: 100%; }
.topbar {
display: flex; align-items: center; gap: 16px;
padding: 0 16px; height: 48px; flex: 0 0 auto;
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
}
.brand {
font-family: var(--mono); font-weight: 600; letter-spacing: -0.3px;
display: flex; align-items: center; gap: 8px;
}
.brand .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.tabs { display: flex; gap: 2px; margin-left: 8px; }
.tab {
background: none; border: 0; padding: 6px 12px; border-radius: var(--radius);
color: var(--text-dim); cursor: pointer;
}
.tab:hover { background: var(--bg-hover); color: var(--text); }
.tab.active { background: var(--accent-dim); color: #cfe3ff; }
.tab .count {
font-family: var(--mono); font-size: 11px; margin-left: 6px;
color: var(--text-faint);
}
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.hostinfo { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.hostinfo b { color: var(--text); font-weight: 600; }
.body { flex: 1; display: flex; min-height: 0; }
.main { flex: 1; min-width: 0; overflow: auto; }
.sidebar {
width: 340px; flex: 0 0 340px; overflow: auto;
background: var(--bg-raised); border-left: 1px solid var(--border);
}
/* Below this width the sidebar moves under the list. The two panes must then
stop scrolling independently, or the list collapses to a sliver while the
sidebar takes the whole viewport. */
@media (max-width: 1100px) {
.body { flex-direction: column; overflow: auto; }
.main { overflow: visible; flex: 0 0 auto; }
.sidebar {
width: auto; flex: 0 0 auto; overflow: visible;
border-left: 0; border-top: 1px solid var(--border);
}
}
/* ---------- generic bits ---------- */
.section { padding: 14px 16px; border-bottom: 1px solid var(--border); }
.section h3 {
margin: 0 0 10px; font-size: 11px; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--text-faint); font-weight: 600;
}
.row { display: flex; align-items: center; gap: 8px; }
.row.wrap { flex-wrap: wrap; }
.spacer { flex: 1; }
.stack { display: flex; flex-direction: column; gap: 8px; }
.muted { color: var(--text-dim); }
.faint { color: var(--text-faint); }
.mono { font-family: var(--mono); }
.small { font-size: 11px; }
.nowrap { white-space: nowrap; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn {
background: var(--bg-hover); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 6px 12px; cursor: pointer;
color: var(--text); white-space: nowrap;
}
.btn:hover:not(:disabled) { background: #232c38; border-color: #4a5566; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn.primary { background: #1f6feb; border-color: #2f7ef5; color: #fff; font-weight: 500; }
.btn.primary:hover:not(:disabled) { background: #2b7bf3; }
.btn.danger { border-color: #6b2a28; color: #ff8a80; }
.btn.danger:hover:not(:disabled) { background: #351c1b; }
.btn.tiny { padding: 2px 7px; font-size: 11px; }
.btn.ghost { background: none; border-color: transparent; color: var(--text-dim); }
.btn.ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
input[type=text], input[type=number], input[type=password], select, textarea {
background: var(--bg-sunken); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 5px 8px; width: 100%;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-dim); border-color: var(--accent); }
textarea { font-family: var(--mono); font-size: 11px; resize: vertical; }
label.field { display: block; }
label.field > span { display: block; font-size: 11px; color: var(--text-dim); margin-bottom: 3px; }
.check { display: flex; align-items: center; gap: 7px; cursor: pointer; user-select: none; }
.check input { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; cursor: pointer; }
.check.disabled { opacity: 0.45; cursor: not-allowed; }
.badge {
font-family: var(--mono); font-size: 10px; padding: 1px 5px;
border-radius: 3px; border: 1px solid var(--border-strong);
color: var(--text-dim); white-space: nowrap;
}
.badge.vol { border-color: #2d4a6b; color: #83b8f0; }
.badge.bind { border-color: #5c4520; color: #e0b556; }
.badge.anon { border-color: #46375e; color: #b294e0; }
.badge.tmpfs { border-color: #33404d; color: #9aa7b6; }
.badge.net { border-color: #2b5040; color: #6cc79b; }
.badge.port { border-color: #3a3f52; color: #a5aec9; }
.state { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; }
.state .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-faint); flex: 0 0 auto; }
.state.running .dot { background: var(--ok); }
.state.exited .dot, .state.dead .dot { background: var(--text-faint); }
.state.paused .dot, .state.restarting .dot { background: var(--warn); }
.state.succeeded .dot { background: var(--ok); }
.state.failed .dot { background: var(--err); }
.state.canceled .dot { background: var(--warn); }
.state.pending .dot { background: var(--text-faint); }
.state.skipped .dot { background: var(--text-faint); }
.state.running .dot { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
.notice {
padding: 8px 10px; border-radius: var(--radius); font-size: 12px;
border: 1px solid var(--border-strong); background: var(--bg-sunken);
}
.notice.warn { border-color: #5c4520; background: #221a0c; color: #f0cd82; }
.notice.err { border-color: #6b2a28; background: #2a1413; color: #ffb3ad; }
.notice.ok { border-color: #23543a; background: #0f2318; color: #97e0ac; }
/* ---------- container list ---------- */
.toolbar {
position: sticky; top: 0; z-index: 5;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 10px 16px; background: var(--bg);
border-bottom: 1px solid var(--border);
}
.toolbar .search { width: 220px; }
.group-head {
display: flex; align-items: center; gap: 8px;
padding: 8px 16px 4px; color: var(--text-faint);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
}
.group-head .line { flex: 1; height: 1px; background: var(--border); }
.clist { display: flex; flex-direction: column; }
.crow {
display: grid;
grid-template-columns: 26px 22px minmax(180px, 1.4fr) minmax(140px, 1.2fr) minmax(200px, 2fr) auto;
align-items: center; gap: 10px;
padding: 7px 16px; border-bottom: 1px solid var(--border);
cursor: default;
}
.crow:hover { background: var(--bg-hover); }
.crow.selected { background: #11213a; }
.crow.selected:hover { background: #16294a; }
.crow .name { font-weight: 500; }
.crow .sub { font-size: 11px; color: var(--text-faint); }
.crow .image { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.crow .tags { display: flex; gap: 4px; flex-wrap: wrap; }
.expander {
background: none; border: 0; color: var(--text-faint); cursor: pointer;
padding: 2px; line-height: 1; border-radius: 3px;
}
.expander:hover { color: var(--text); background: var(--bg-hover); }
.detail {
padding: 12px 16px 16px 52px; background: var(--bg-sunken);
border-bottom: 1px solid var(--border);
display: grid; gap: 14px;
}
.detail .grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; }
.mount-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.mount-table th {
text-align: left; font-weight: 500; color: var(--text-faint);
font-size: 11px; padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
}
.mount-table td { padding: 5px 8px 5px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
.mount-table tr:last-child td { border-bottom: 0; }
.mount-table select { width: auto; min-width: 110px; }
.mount-table input[type=text] { min-width: 150px; }
/* ---------- jobs ---------- */
.joblist { padding: 12px 16px; display: flex; flex-direction: column; gap: 8px; }
.jobcard {
border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg-raised); padding: 10px 12px; cursor: pointer;
}
.jobcard:hover { border-color: var(--border-strong); }
.jobcard.active { border-color: var(--accent); }
.progress { height: 4px; background: var(--bg-sunken); border-radius: 2px; overflow: hidden; }
.progress > div { height: 100%; background: var(--accent); transition: width 0.25s ease; }
.progress.done > div { background: var(--ok); }
.progress.failed > div { background: var(--err); }
.steps { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
.step {
display: grid; grid-template-columns: 14px 1fr 130px 90px;
align-items: center; gap: 8px; font-size: 11px;
}
.step .label { color: var(--text-dim); }
.step.failed .label { color: #ff9b95; }
.log {
font-family: var(--mono); font-size: 11px; line-height: 1.55;
background: var(--bg-sunken); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 10px;
max-height: 340px; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
.log .l-warn { color: var(--warn); }
.log .l-error { color: var(--err); }
.log .l-cmd { color: #7ee0b8; }
.log .l-info { color: var(--text-dim); }
.log .ts { color: var(--text-faint); }
/* ---------- modal ---------- */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(3, 6, 10, 0.72);
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 50;
}
.modal {
background: var(--bg-raised); border: 1px solid var(--border-strong);
border-radius: 10px; width: min(760px, 100%); max-height: 100%;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
}
.modal header {
padding: 12px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px; font-weight: 600;
}
.modal .content { padding: 16px; overflow: auto; }
.modal footer {
padding: 12px 16px; border-top: 1px solid var(--border);
display: flex; gap: 8px; justify-content: flex-end;
}
.cmdblock {
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 8px 10px; overflow-x: auto; white-space: pre; margin: 0;
}
.empty { padding: 48px 16px; text-align: center; color: var(--text-faint); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 3px 12px; font-size: 12px; }
.kv dt { color: var(--text-faint); }
.kv dd { margin: 0; font-family: var(--mono); font-size: 11px; }
.fingerprint {
font-family: var(--mono); font-size: 12px; word-break: break-all;
background: var(--bg-sunken); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 8px 10px;
}
+260
View File
@@ -0,0 +1,260 @@
// Mirrors the Go types in internal/spec, internal/dkr and internal/job.
export type MountKind = 'volume' | 'anonymous' | 'bind' | 'tmpfs' | string
export interface Mount {
kind: MountKind
name?: string
source?: string
destination: string
readOnly: boolean
propagation?: string
tmpfsOpts?: string
sizeBytes: number
}
export interface Endpoint {
network: string
aliases?: string[]
ipv4Address?: string
ipv6Address?: string
macAddress?: string
}
export interface PortBinding {
containerPort: string
hostIp?: string
hostPort?: string
}
export interface Container {
id: string
name: string
state: string
image: string
imageId: string
imageDigest?: string
composeProject?: string
composeService?: string
env?: string[]
labels?: Record<string, string>
cmd?: string[]
entrypoint?: string[]
restartPolicy?: string
privileged?: boolean
networkMode?: string
endpoints?: Endpoint[]
ports?: PortBinding[]
mounts?: Mount[]
warnings?: string[]
}
export interface Volume {
name: string
driver: string
driverOpts?: Record<string, string>
labels?: Record<string, string>
}
export interface NetworkSpec {
name: string
driver: string
internal?: boolean
attachable?: boolean
}
export interface Inventory {
host: string
dockerVersion: string
containers: Container[]
volumes: Volume[]
networks: NetworkSpec[]
warnings?: string[]
}
export type ImageMode = 'auto' | 'pull' | 'stream' | 'skip'
export type MountAction = 'copy' | 'structure' | 'skip'
export type ConflictPolicy = 'fail' | 'skip' | 'replace' | 'rename'
export interface MountSelection {
action: MountAction
targetSource?: string
targetName?: string
}
export interface ItemSelection {
containerId: string
include: boolean
nameOverride?: string
migrateImage: boolean
imageMode: ImageMode
migrateNetworks: boolean
keepStaticIps: boolean
migratePorts: boolean
mounts: Record<string, MountSelection>
startAfter: boolean
stopSourceDuringCopy: boolean
stopSourceAfter: boolean
}
export interface Options {
conflict: ConflictPolicy
renameSuffix?: string
compress: boolean
compressLevel: number
dryRun: boolean
parallelism: number
verifyAfter: boolean
}
export interface Plan {
items: ItemSelection[]
options: Options
target?: string
packageName?: string
}
export interface SourceResponse {
inventory: Inventory
defaults: Record<string, ItemSelection>
options: Options
}
export type AuthMethod = 'password' | 'key' | 'agent'
export interface Connection {
id: string
name: string
host: string
port: number
user: string
auth: AuthMethod
password?: string
privateKey?: string
privateKeyPath?: string
passphrase?: string
sudo: boolean
dockerCmd?: string
saveSecrets: boolean
}
export interface Preflight {
dockerVersion: string
serverVersion: string
os: string
arch: string
hasGzip: boolean
diskFreeBytes: number
dockerRoot: string
problems?: string[]
}
export interface TargetContainer {
id: string
name: string
image: string
state: string
status: string
ports: string
}
export interface TargetInventory {
host: string
containers: TargetContainer[] | null
volumes: string[] | null
networks: string[] | null
preflight: Preflight
}
export interface HostKeyInfo {
host: string
keyType: string
fingerprint: string
trusted: boolean
changed: boolean
}
export type JobState = 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped' | 'canceled'
export interface Step {
id: string
label: string
state: JobState
bytesDone: number
bytesTotal: number
error?: string
startedAt?: string
endedAt?: string
}
export interface JobItem {
id: string
name: string
state: JobState
error?: string
steps: Step[]
warnings?: string[]
}
export interface LogEntry {
seq: number
at: string
level: 'info' | 'warn' | 'error' | 'cmd'
item?: string
message: string
}
export interface JobSnapshot {
id: string
kind: 'ssh' | 'package' | 'restore'
title: string
state: JobState
dryRun: boolean
error?: string
createdAt: string
startedAt?: string
endedAt?: string
items: JobItem[]
log: LogEntry[]
bytesDone: number
bytesTotal: number
artifact?: string
artifactBytes?: number
revision: number
}
export interface PreviewItem {
containerId: string
name: string
targetName: string
image: string
commands: string[] | null
transfers: string[] | null
notes: string[] | null
warnings: string[] | null
totalBytes: number
}
export interface PreviewResponse {
networkCommands: string[] | null
items: PreviewItem[]
}
export interface PackageInfo {
name: string
path: string
bytes: number
isDir: boolean
createdAt: string
}
export interface Health {
ok: boolean
dockerHost: string
dockerVersion?: string
dockerError?: string
packageDir: string
dataDir: string
knownHosts: string
authRequired: boolean
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, type ReactNode } from 'react'
/** Bytes renders a byte count, or a dash when the size is unknown. */
export function humanBytes(n: number | undefined | null): string {
if (n === undefined || n === null || n < 0) return ''
if (n === 0) return '0 B'
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${i === 0 ? v : v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
}
export function duration(from?: string, to?: string): string {
if (!from) return ''
const start = new Date(from).getTime()
const end = to ? new Date(to).getTime() : Date.now()
const s = Math.max(0, Math.round((end - start) / 1000))
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ${s % 60}s`
return `${Math.floor(m / 60)}h ${m % 60}m`
}
export function StateDot({ state, label }: { state: string; label?: string }) {
return (
<span className={`state ${state}`}>
<span className="dot" />
{label ?? state}
</span>
)
}
export function Check({
checked, onChange, label, disabled, title,
}: {
checked: boolean
onChange: (v: boolean) => void
label: ReactNode
disabled?: boolean
title?: string
}) {
return (
<label className={`check${disabled ? ' disabled' : ''}`} title={title}>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span>{label}</span>
</label>
)
}
export function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="field">
<span>{label}</span>
{children}
</label>
)
}
export function Modal({
title, onClose, children, footer, wide,
}: {
title: ReactNode
onClose: () => void
children: ReactNode
footer?: ReactNode
wide?: boolean
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" style={wide ? { width: 'min(1000px, 100%)' } : undefined}>
<header>
{title}
<span className="spacer" />
<button className="btn ghost tiny" onClick={onClose}>close</button>
</header>
<div className="content">{children}</div>
{footer && <footer>{footer}</footer>}
</div>
</div>
)
}
export function Notice({ kind, children }: { kind: 'warn' | 'err' | 'ok' | 'info'; children: ReactNode }) {
return <div className={`notice ${kind === 'info' ? '' : kind}`}>{children}</div>
}
export function Progress({ done, total, state }: { done: number; total: number; state: string }) {
const pct = total > 0 ? Math.min(100, (done / total) * 100) : state === 'succeeded' ? 100 : 0
const cls = state === 'succeeded' ? 'done' : state === 'failed' ? 'failed' : ''
return (
<div className={`progress ${cls}`}>
<div style={{ width: `${pct}%` }} />
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true
},
"include": ["src", "vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// The build output goes straight into the Go package that embeds it, so
// `go build` always picks up the latest UI.
export default defineConfig({
plugins: [react()],
build: {
outDir: '../internal/webui/dist',
emptyOutDir: true,
chunkSizeWarningLimit: 900,
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
})