Files
DockMV/README.md
T
kawaandClaude Haiku 4.5 9b354636bb Add SSH source support with dialstdio and UI components
- Implement SSH dial via stdio for remote connections
- Add sources API and storage layer for managing connection sources
- Add SourcePanel and SshFields web components for SSH configuration
- Update app structure to support source-based connections
- Update handlers and server for new sources endpoint

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-13 10:19:47 +02:00

351 lines
15 KiB
Markdown

<p align="center"><img src="assets/dockmv-logo-full.png" alt="DockMV" width="480"></p>
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 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`, `docker`, `bash` and `gzip`.
The **source** is picked in the UI: the daemon DockMV runs next to, another daemon by address, or a
remote host over SSH — which needs nothing installed either. One DockMV can therefore move containers
between any two of your hosts.
---
## Install
Run DockMV on the **source** host (the one holding the containers to move), or anywhere that can
reach it — see [sources](#sources).
### Docker Compose — recommended
```bash
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
docker compose up -d
docker compose logs dockmv # prints the URL, including the access token
```
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
```
Uses the published image `git.azuze.fr/kawa/dockmv:latest`. Pin a version with `VERSION=v1.2.0 docker compose up -d`,
and set a fixed token with `DOCKMV_TOKEN` in the compose file to keep the same URL across restarts.
### Prebuilt binary
Grab the archive for your platform from the [releases](https://git.azuze.fr/kawa/DockMV/releases)
(`dockmv-<os>-<arch>.tar.gz`, `.zip` on Windows). The binary is static and embeds the web UI —
nothing to install alongside it.
```bash
tar xzf dockmv-linux-amd64.tar.gz
./dockmv-linux-amd64 serve
```
### From source
```bash
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
make build # rebuilds the UI, then the binary
./dockmv serve
```
`go build .` alone also works — the built UI is committed, so the Node toolchain is optional.
> DockMV needs access to the Docker socket on the source host: run it as a user in the `docker`
> group, or as root. That is equivalent to root on that host, so keep the UI on loopback.
---
## Dependencies
### Source host — where the containers are
| Requirement | Notes |
| --- | --- |
| Docker daemon + access to `/var/run/docker.sock` | reads containers and streams their data |
| Docker Compose | only for the compose install |
Nothing else. The binary is static: no libc, no runtime, no Python.
A **remote** source needs the same as a target — `sshd` and a `docker` CLI of 18.09 or newer, since
the Engine API is tunnelled through `docker system dial-stdio`. Nothing is installed there either.
### Target host — where containers land
| 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.
### Building from source
| Tool | Version |
| --- | --- |
| Go | 1.25+ |
| Node | 20+ (22 in CI) — only to rebuild the UI |
| `make` | optional, wraps the two above |
| PowerShell 7+ | only for `make publish` / `make release` |
Libraries: [`docker/docker`](https://github.com/docker/docker) v28.3.3 and `golang.org/x/crypto`
on the Go side; React 19, Vite 7 and TypeScript 5.9 on the UI side. That is the whole list.
---
## Using it
1. **Source host** — top of the right panel. Defaults to the daemon DockMV runs next to; pick another
one to read a different host. See [sources](#sources).
2. **Containers tab** — everything on the source host, grouped by compose project. Tick what to move.
3. **Expand a row** (`▸`) for per-container details: target name, image pulled or transferred,
networks and ports, and — per mount — **copy the data**, **create it empty**, or **do not mount it**.
Bind mounts can be relocated; named volumes renamed.
4. **Apply to selected** does the same thing to every selected container at once.
5. **Right panel** — add the target host, *connect*, then **migrate over SSH** or **build a package**.
6. **Jobs tab** — live progress per container and per mount, with the full command log.
Start with **dry run** ticked: it runs every check and prints every command without touching the target.
**Preview the commands** shows the exact `docker` invocations that will run. Nothing is hidden.
### Sources
Three kinds, all interchangeable once selected — the container list, the preview, the migration and
the package build all read from whichever source is active:
| Kind | How it is reached | Notes |
| --- | --- | --- |
| **this host** | the socket in `DOCKER_HOST`, or `--docker-host` | always present; cannot be edited or removed |
| **docker address** | `tcp://host:2375`, or another `unix://` socket | TLS uses the certificates from `DOCKER_CERT_PATH` in DockMV's own environment. A plain `tcp://` daemon is unauthenticated — anyone who reaches that port is root on that host |
| **ssh** | the remote host's own docker CLI, through `docker system dial-stdio` | host keys are verified and credentials handled exactly like a target's |
The selected source is remembered in `<data-dir>/sources.json` and reselected on the next start; an
explicit `--docker-host` on the command line overrides it for that run. Saved sources whose
credentials you chose not to remember ask for them again after a restart.
With a remote source the data relays through DockMV — source → this host → target — so it crosses the
network twice. Running DockMV on the source host keeps it to one hop.
<details>
<summary><b>How the data is actually moved</b></summary>
One mechanism for every kind of data location:
```
source daemon ──CopyFromContainer(/mount/path)──▶ tar stream ──gzip──▶ ssh ──▶ docker cp -a - ctr:/parent
```
The mount is read through the Docker archive API — the same thing `docker cp` uses. So:
- named volumes, anonymous volumes and bind mounts are handled identically;
- no helper image is pulled, and the image does not need `tar` inside it;
- it works whether the container is running or stopped;
- 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 volumes and bind directories; data is copied into the stopped container, then it
starts. Mounts declared **read-only** get a throwaway container (never started) with the volume
attached writable, and it is removed straight after.
**Faithfully reproduced:** image (pull or layer transfer), command, entrypoint, environment, labels,
working directory, user, hostname, published and exposed ports, all mount types, user-defined
networks with subnets and aliases, DNS, extra hosts, capabilities, devices, sysctls, ulimits,
security options, restart policy, stop signal and timeout, healthcheck, log driver, 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, so the recreated container
carries only genuine run-time overrides and keeps working when the image is updated.
**What it will not do:**
- `--rm` is never reapplied — a container that deletes itself cannot be inspected.
- `--volumes-from` and `--network container:other` are not reproduced; you are warned.
- Swarm services are out of scope. Plain containers only.
- **Live databases**: copying a running database's files is crash-consistent at best. The default
stops the source container while copying — leave it on, or migrate a dump instead.
- **Cross-architecture**: an `amd64` image will not run on `arm64`. The preflight shows the target's arch.
</details>
<details>
<summary><b>Safety</b></summary>
The tool can stop containers and read every volume on the host, so it is treated as a privileged
admin tool:
- Binds to **`127.0.0.1` by default**. Binding elsewhere auto-generates an access token and prints it.
- **SSH host keys are verified** like OpenSSH, for sources as well as targets. An unknown key is
refused until you approve the fingerprint in the UI; a *changed* key is refused outright. Trusted
keys go to `<data-dir>/known_hosts`.
- **Credentials are not persisted unless you ask.** *Remember* writes them to
`<data-dir>/connections.json` for targets and `<data-dir>/sources.json` for sources, mode `0600`.
- **Nothing on the target is overwritten by default.** An existing container name fails the item;
you pick *skip*, *rename* or *replace*. 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; a mount at `/` is refused.
</details>
<details>
<summary><b>The migration package</b></summary>
`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
audited before running. It checksums each payload, 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
```
</details>
<details>
<summary><b>Command line and HTTP API</b></summary>
```
dockmv [serve] [flags] start the web interface (default)
dockmv inspect [flags] print the source inventory as JSON
dockmv 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 sources, connections and trusted host keys (default: OS config dir)
--package-dir string where migration packages are written (default <data-dir>/packages)
--docker-host string local source docker daemon (default: the DOCKER_HOST environment);
given explicitly, it overrides the remembered source
-v verbose logging
```
`inspect` is handy for scripting and for reporting bugs:
```bash
dockmv inspect --sizes | jq '.containers[] | {name, image, mounts}'
```
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/sources known sources + which one is selected
POST /api/sources
DELETE /api/sources/{id}
POST /api/sources/{id}/select switch the source everything reads from
POST /api/sources/{id}/probe read the SSH host key fingerprint
POST /api/sources/{id}/trust approve that fingerprint
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
```
</details>
<details>
<summary><b>Development</b></summary>
```
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, and
tunnelling a remote source's API through `docker system dial-stdio`
internal/store/ saved sources and target connections, and which source is selected
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
```
`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/`.
End-to-end tests, 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
```
- `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`,
exercising the whole transport. Needs `DM_SSH_HOST`, `DM_SSH_USER` and `DM_SSH_KEY`, 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
```
They restore onto the same daemon under a suffixed name and clean up, so one machine is enough.
Both were run against Debian 13 with Docker 29.7.2, alongside a browser pass over the web UI.
</details>