Initial push
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
dist/
|
||||||
|
docker-migrate
|
||||||
|
docker-migrate.exe
|
||||||
|
web/node_modules
|
||||||
|
internal/webui/dist
|
||||||
|
*.test
|
||||||
|
*.test.exe
|
||||||
|
README.md
|
||||||
+18
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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/`.
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,678 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/dkr"
|
||||||
|
"github.com/arescom/docker-migrate/internal/job"
|
||||||
|
"github.com/arescom/docker-migrate/internal/migrate"
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
"github.com/arescom/docker-migrate/internal/sshx"
|
||||||
|
"github.com/arescom/docker-migrate/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"ok": true,
|
||||||
|
"dockerHost": s.docker.Endpoint,
|
||||||
|
"packageDir": s.cfg.PackageDir,
|
||||||
|
"dataDir": s.cfg.DataDir,
|
||||||
|
"knownHosts": s.hosts.Path(),
|
||||||
|
"authRequired": s.cfg.Token != "",
|
||||||
|
}
|
||||||
|
if v, err := s.docker.Ping(ctx); err != nil {
|
||||||
|
body["ok"] = false
|
||||||
|
body["dockerError"] = err.Error()
|
||||||
|
} else {
|
||||||
|
body["dockerVersion"] = v
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSource returns the full inventory of the source daemon, plus the
|
||||||
|
// default selection for every container so the UI can render immediately.
|
||||||
|
func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inv, err := s.docker.Inventory(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaults := make(map[string]spec.ItemSelection, len(inv.Containers))
|
||||||
|
for i := range inv.Containers {
|
||||||
|
defaults[inv.Containers[i].ID] = spec.DefaultSelection(&inv.Containers[i])
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"inventory": inv,
|
||||||
|
"defaults": defaults,
|
||||||
|
"options": spec.DefaultOptions(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSourceSizes measures volume sizes, which is slow enough that the UI
|
||||||
|
// asks for it separately.
|
||||||
|
func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sizes, err := s.docker.VolumeSizes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"volumes": sizes})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, s.conns.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var cfg sshx.Config
|
||||||
|
if err := decode(r, &cfg); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := s.conns.Save(cfg)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.conns.Delete(r.PathValue("id")); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleProbe reads the target's host key so the operator can compare the
|
||||||
|
// fingerprint before trusting it.
|
||||||
|
func (s *Server) handleProbe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := s.conns.Get(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
info, err := sshx.Probe(ctx, cfg, s.hosts)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTrust records the host key. The fingerprint the operator approved is
|
||||||
|
// echoed back and re-checked, so approving one key cannot trust another.
|
||||||
|
func (s *Server) handleTrust(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := s.conns.Get(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
}
|
||||||
|
if err := decode(r, &body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := sshx.TrustFromProbe(ctx, cfg, s.hosts, body.Fingerprint); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"trusted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTestConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer closeFn()
|
||||||
|
|
||||||
|
pre, err := rd.Preflight(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, pre)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTargetInventory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rd, closeFn, err := s.dialTarget(ctx, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer closeFn()
|
||||||
|
|
||||||
|
inv, err := rd.Inventory(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviewItem is what the UI shows when the operator asks "what will this do?".
|
||||||
|
type PreviewItem struct {
|
||||||
|
ContainerID string `json:"containerId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TargetName string `json:"targetName"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Commands []string `json:"commands"`
|
||||||
|
Transfers []string `json:"transfers"`
|
||||||
|
Notes []string `json:"notes"`
|
||||||
|
Warnings []string `json:"warnings"`
|
||||||
|
TotalBytes int64 `json:"totalBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePreview renders the exact docker commands a plan would run, without
|
||||||
|
// touching either host.
|
||||||
|
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var plan spec.Plan
|
||||||
|
if err := decode(r, &plan); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inv, err := s.docker.Inventory(ctx)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byID := map[string]*spec.Container{}
|
||||||
|
for i := range inv.Containers {
|
||||||
|
byID[inv.Containers[i].ID] = &inv.Containers[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []PreviewItem{}
|
||||||
|
netSeen := map[string]bool{}
|
||||||
|
var netCmds []string
|
||||||
|
|
||||||
|
for _, sel := range plan.Items {
|
||||||
|
if !sel.Include {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, err := migrate.Prepare(byID[sel.ContainerID], sel, inv.Volumes, inv.Networks)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "container %s: %v", sel.ContainerID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item := PreviewItem{
|
||||||
|
ContainerID: p.Source.ID,
|
||||||
|
Name: p.Source.Name,
|
||||||
|
TargetName: p.ContainerName(),
|
||||||
|
Image: p.Target.Image,
|
||||||
|
Notes: p.Notes,
|
||||||
|
Warnings: p.Source.Warnings,
|
||||||
|
}
|
||||||
|
for _, n := range p.Networks {
|
||||||
|
if !netSeen[n.Name] {
|
||||||
|
netSeen[n.Name] = true
|
||||||
|
netCmds = append(netCmds, "docker "+spec.ShellQuoteAll(n.CreateArgs()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, v := range p.Volumes {
|
||||||
|
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(v.CreateArgs()))
|
||||||
|
}
|
||||||
|
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(p.Target.CreateArgs(p.Render)))
|
||||||
|
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
|
||||||
|
item.Commands = append(item.Commands, "docker "+spec.ShellQuoteAll(args))
|
||||||
|
}
|
||||||
|
for _, t := range p.Transfers {
|
||||||
|
item.Transfers = append(item.Transfers, t.Label)
|
||||||
|
item.Commands = append(item.Commands,
|
||||||
|
fmt.Sprintf("docker cp -a - %s:%s # contents of %s", p.ContainerName(), t.RestoreInto, t.SourcePath))
|
||||||
|
if t.SizeBytes > 0 {
|
||||||
|
item.TotalBytes += t.SizeBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Selection.StartAfter {
|
||||||
|
item.Commands = append(item.Commands, "docker start "+spec.ShellQuote(p.ContainerName()))
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"networkCommands": netCmds,
|
||||||
|
"items": out,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type sshMigrateRequest struct {
|
||||||
|
ConnectionID string `json:"connectionId"`
|
||||||
|
Plan spec.Plan `json:"plan"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req sshMigrateRequest
|
||||||
|
if err := decode(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ConnectionID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "connectionId is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if countIncluded(req.Plan) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "no containers selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := s.conns.Get(req.ConnectionID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The inventory is re-read now so the plan is applied to current state
|
||||||
|
// rather than to whatever the browser last loaded.
|
||||||
|
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
|
inv, err := s.docker.Inventory(invCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial before the job starts so a bad credential is an immediate error in
|
||||||
|
// the UI instead of a failed job.
|
||||||
|
dialCtx, dialCancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||||
|
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
|
||||||
|
dialCancel()
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
|
||||||
|
runner := &migrate.SSHRunner{
|
||||||
|
Src: s.docker,
|
||||||
|
Dst: sshx.NewRemoteDocker(client),
|
||||||
|
Containers: inv.Containers,
|
||||||
|
Volumes: inv.Volumes,
|
||||||
|
Networks: inv.Networks,
|
||||||
|
Plan: req.Plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
|
||||||
|
func(ctx context.Context, j *job.Job) error {
|
||||||
|
defer client.Close()
|
||||||
|
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
|
||||||
|
return runner.Run(ctx, j)
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusAccepted, j.Snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
type packageRequest struct {
|
||||||
|
Plan spec.Plan `json:"plan"`
|
||||||
|
Format migrate.PackageFormat `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req packageRequest
|
||||||
|
if err := decode(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if countIncluded(req.Plan) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "no containers selected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Format == "" {
|
||||||
|
req.Format = migrate.FormatTar
|
||||||
|
}
|
||||||
|
if req.Format != migrate.FormatTar && req.Format != migrate.FormatDir {
|
||||||
|
writeError(w, http.StatusBadRequest, "format must be %q or %q", migrate.FormatTar, migrate.FormatDir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
|
inv, err := s.docker.Inventory(invCtx)
|
||||||
|
cancel()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
packager := &migrate.Packager{
|
||||||
|
Src: s.docker,
|
||||||
|
Containers: inv.Containers,
|
||||||
|
Volumes: inv.Volumes,
|
||||||
|
Networks: inv.Networks,
|
||||||
|
Plan: req.Plan,
|
||||||
|
OutputDir: s.cfg.PackageDir,
|
||||||
|
Format: req.Format,
|
||||||
|
SourceHost: inv.Host,
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
|
||||||
|
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
|
||||||
|
func(ctx context.Context, j *job.Job) error {
|
||||||
|
res, err := packager.Run(ctx, j)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
j.Logf(job.LevelInfo, "", "package ready: %s (%s)", res.Name, humanBytes(res.Bytes))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusAccepted, j.Snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, s.jobs.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
j, err := s.jobs.Get(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, j.Snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleJobEvents streams job snapshots over server-sent events, coalescing
|
||||||
|
// rapid updates so a fast transfer does not saturate the browser.
|
||||||
|
func (s *Server) handleJobEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
j, err := s.jobs.Get(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "streaming unsupported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
changes, unsubscribe := j.Subscribe()
|
||||||
|
defer unsubscribe()
|
||||||
|
|
||||||
|
send := func() bool {
|
||||||
|
b, err := json.Marshal(j.Snapshot())
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !send() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates are batched: at most one frame every 250ms while work is busy.
|
||||||
|
ticker := time.NewTicker(250 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
keepalive := time.NewTicker(20 * time.Second)
|
||||||
|
defer keepalive.Stop()
|
||||||
|
|
||||||
|
dirty := false
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case _, open := <-changes:
|
||||||
|
if !open {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dirty = true
|
||||||
|
case <-ticker.C:
|
||||||
|
if !dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dirty = false
|
||||||
|
if !send() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if j.Snapshot().State.Terminal() {
|
||||||
|
fmt.Fprint(w, "event: done\ndata: {}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-keepalive.C:
|
||||||
|
fmt.Fprint(w, ": keepalive\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
case <-j.Done():
|
||||||
|
// Drain one last snapshot so the client sees the final state.
|
||||||
|
send()
|
||||||
|
fmt.Fprint(w, "event: done\ndata: {}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
j, err := s.jobs.Get(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.Cancel()
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"canceled": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := s.jobs.Delete(r.PathValue("id")); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageInfo describes one built package on disk.
|
||||||
|
type PackageInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListPackages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
entries, err := os.ReadDir(s.cfg.PackageDir)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "read package directory: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := []PackageInfo{}
|
||||||
|
for _, e := range entries {
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := PackageInfo{
|
||||||
|
Name: e.Name(),
|
||||||
|
Path: filepath.Join(s.cfg.PackageDir, e.Name()),
|
||||||
|
IsDir: e.IsDir(),
|
||||||
|
CreatedAt: info.ModTime(),
|
||||||
|
}
|
||||||
|
if e.IsDir() {
|
||||||
|
p.Bytes, _ = dirBytes(p.Path)
|
||||||
|
} else {
|
||||||
|
p.Bytes = info.Size()
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDownloadPackage streams a package file. Directory packages are not
|
||||||
|
// downloadable as-is; the operator picks the tar format for that.
|
||||||
|
func (s *Server) handleDownloadPackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path, err := s.packagePath(r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "package not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
writeError(w, http.StatusBadRequest,
|
||||||
|
"this package is a directory; copy it from %s, or rebuild with the tar format", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w.Header().Set("Content-Type", "application/x-tar")
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(path)))
|
||||||
|
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeletePackage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path, err := s.packagePath(r.PathValue("name"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// packagePath resolves a package name against the package directory, refusing
|
||||||
|
// anything that would escape it.
|
||||||
|
func (s *Server) packagePath(name string) (string, error) {
|
||||||
|
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||||
|
return "", errors.New("invalid package name")
|
||||||
|
}
|
||||||
|
base, err := filepath.Abs(s.cfg.PackageDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
full := filepath.Join(base, name)
|
||||||
|
if !strings.HasPrefix(full, base+string(os.PathSeparator)) {
|
||||||
|
return "", errors.New("invalid package name")
|
||||||
|
}
|
||||||
|
return full, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialTarget opens a short-lived connection for an interactive request.
|
||||||
|
func (s *Server) dialTarget(ctx context.Context, id string) (*sshx.RemoteDocker, func(), error) {
|
||||||
|
cfg, err := s.conns.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
client, err := sshx.Dial(ctx, cfg, s.hosts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return sshx.NewRemoteDocker(client), func() { client.Close() }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDialError turns an untrusted host key into a structured response the UI
|
||||||
|
// can turn into a trust prompt.
|
||||||
|
func (s *Server) writeDialError(w http.ResponseWriter, err error) {
|
||||||
|
var hk *sshx.HostKeyError
|
||||||
|
if errors.As(err, &hk) {
|
||||||
|
writeJSON(w, http.StatusPreconditionRequired, map[string]any{
|
||||||
|
"error": err.Error(),
|
||||||
|
"hostKey": hk.Fingerprint,
|
||||||
|
"keyType": hk.KeyType,
|
||||||
|
"changed": hk.Changed,
|
||||||
|
"needsTrust": true,
|
||||||
|
"host": hk.Host,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func countIncluded(p spec.Plan) int {
|
||||||
|
n := 0
|
||||||
|
for _, i := range p.Items {
|
||||||
|
if i.Include {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirBytes(dir string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanBytes(n int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for v := n / unit; v >= unit; v /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = dkr.RestorePath
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
// Package api exposes the migration tool over HTTP and serves the web UI.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/dkr"
|
||||||
|
"github.com/arescom/docker-migrate/internal/job"
|
||||||
|
"github.com/arescom/docker-migrate/internal/sshx"
|
||||||
|
"github.com/arescom/docker-migrate/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config configures the HTTP server.
|
||||||
|
type Config struct {
|
||||||
|
// Addr is the listen address, e.g. 127.0.0.1:8080.
|
||||||
|
Addr string
|
||||||
|
// Token, when set, must be presented on every API request.
|
||||||
|
Token string
|
||||||
|
// DataDir holds connections and the known-hosts file.
|
||||||
|
DataDir string
|
||||||
|
// PackageDir is where migration packages are written.
|
||||||
|
PackageDir string
|
||||||
|
// DockerHost overrides the source daemon address.
|
||||||
|
DockerHost string
|
||||||
|
// UI is the embedded web app; nil disables the UI.
|
||||||
|
UI fs.FS
|
||||||
|
// Logger receives request and error logs.
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server ties the Docker client, connection store and job manager to HTTP.
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
log *slog.Logger
|
||||||
|
docker *dkr.Client
|
||||||
|
conns *store.Connections
|
||||||
|
hosts *sshx.KnownHosts
|
||||||
|
jobs *job.Manager
|
||||||
|
mux *http.ServeMux
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the server and everything it owns.
|
||||||
|
func New(cfg Config) (*Server, error) {
|
||||||
|
if cfg.Logger == nil {
|
||||||
|
cfg.Logger = slog.Default()
|
||||||
|
}
|
||||||
|
if cfg.PackageDir == "" {
|
||||||
|
cfg.PackageDir = filepath.Join(cfg.DataDir, "packages")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(cfg.PackageDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create package directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
docker, err := dkr.New(cfg.DockerHost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hosts, err := sshx.NewKnownHosts(filepath.Join(cfg.DataDir, "known_hosts"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
cfg: cfg, log: cfg.Logger, docker: docker,
|
||||||
|
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
|
||||||
|
}
|
||||||
|
s.routes()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the Docker connection.
|
||||||
|
func (s *Server) Close() error { return s.docker.Close() }
|
||||||
|
|
||||||
|
// Handler returns the root HTTP handler.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return s.recoverer(s.logging(s.auth(s.mux)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) routes() {
|
||||||
|
m := s.mux
|
||||||
|
|
||||||
|
m.HandleFunc("GET /api/health", s.handleHealth)
|
||||||
|
m.HandleFunc("GET /api/source", s.handleSource)
|
||||||
|
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
|
||||||
|
|
||||||
|
m.HandleFunc("GET /api/connections", s.handleListConnections)
|
||||||
|
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
|
||||||
|
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
|
||||||
|
m.HandleFunc("POST /api/connections/{id}/probe", s.handleProbe)
|
||||||
|
m.HandleFunc("POST /api/connections/{id}/trust", s.handleTrust)
|
||||||
|
m.HandleFunc("POST /api/connections/{id}/test", s.handleTestConnection)
|
||||||
|
m.HandleFunc("GET /api/connections/{id}/inventory", s.handleTargetInventory)
|
||||||
|
|
||||||
|
m.HandleFunc("POST /api/plan/preview", s.handlePreview)
|
||||||
|
m.HandleFunc("POST /api/migrate/ssh", s.handleMigrateSSH)
|
||||||
|
m.HandleFunc("POST /api/migrate/package", s.handleBuildPackage)
|
||||||
|
|
||||||
|
m.HandleFunc("GET /api/jobs", s.handleListJobs)
|
||||||
|
m.HandleFunc("GET /api/jobs/{id}", s.handleGetJob)
|
||||||
|
m.HandleFunc("GET /api/jobs/{id}/events", s.handleJobEvents)
|
||||||
|
m.HandleFunc("POST /api/jobs/{id}/cancel", s.handleCancelJob)
|
||||||
|
m.HandleFunc("DELETE /api/jobs/{id}", s.handleDeleteJob)
|
||||||
|
|
||||||
|
m.HandleFunc("GET /api/packages", s.handleListPackages)
|
||||||
|
m.HandleFunc("GET /api/packages/{name}/download", s.handleDownloadPackage)
|
||||||
|
m.HandleFunc("DELETE /api/packages/{name}", s.handleDeletePackage)
|
||||||
|
|
||||||
|
if s.cfg.UI != nil {
|
||||||
|
m.Handle("/", s.spaHandler())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth enforces the shared token on the API. The token may also be passed as a
|
||||||
|
// query parameter, because EventSource cannot set headers and neither can a
|
||||||
|
// download link.
|
||||||
|
//
|
||||||
|
// Static assets are deliberately served without it. A browser opening
|
||||||
|
// /?token=… does not carry the query string over to /assets/app.js, so gating
|
||||||
|
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
|
||||||
|
// bundle; every piece of data is behind /api.
|
||||||
|
func (s *Server) auth(next http.Handler) http.Handler {
|
||||||
|
if s.cfg.Token == "" {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
want := []byte(s.cfg.Token)
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got := r.Header.Get("X-Auth-Token")
|
||||||
|
if got == "" {
|
||||||
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
|
got = strings.TrimPrefix(h, "Bearer ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == "" {
|
||||||
|
got = r.URL.Query().Get("token")
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid or missing token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) logging(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
next.ServeHTTP(sw, r)
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||||
|
s.log.Debug("request", "method", r.Method, "path", r.URL.Path,
|
||||||
|
"status", sw.status, "duration", time.Since(start).Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
s.log.Error("panic serving request", "path", r.URL.Path, "panic", rec)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) WriteHeader(code int) {
|
||||||
|
w.status = code
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush forwards to the wrapped writer so server-sent events keep streaming.
|
||||||
|
func (w *statusWriter) Flush() {
|
||||||
|
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaHandler serves the built web app, falling back to index.html so client
|
||||||
|
// side routing works on a hard refresh.
|
||||||
|
func (s *Server) spaHandler() http.Handler {
|
||||||
|
files := http.FileServer(http.FS(s.cfg.UI))
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
if p == "" {
|
||||||
|
p = "index.html"
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(s.cfg.UI, p); err != nil {
|
||||||
|
r = r.Clone(r.Context())
|
||||||
|
r.URL.Path = "/"
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
} else if strings.HasPrefix(p, "assets/") {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
// The response is already partially written; nothing useful is left to do.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorBody struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, code int, format string, args ...any) {
|
||||||
|
writeJSON(w, code, errorBody{Error: fmt.Sprintf(format, args...)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decode(r *http.Request, v any) error {
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 8<<20))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(v); err != nil {
|
||||||
|
return fmt.Errorf("invalid request body: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Package dkr wraps the Docker Engine API with the operations the migration
|
||||||
|
// tool needs: reading a full container inventory, streaming data out of a
|
||||||
|
// container's mounts, and streaming image layers.
|
||||||
|
package dkr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/system"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a connection to one Docker daemon.
|
||||||
|
type Client struct {
|
||||||
|
api *client.Client
|
||||||
|
// Endpoint is the daemon address, shown in the UI.
|
||||||
|
Endpoint string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New connects to the daemon described by the standard DOCKER_* environment
|
||||||
|
// variables, or to host when it is non-empty (e.g. unix:///var/run/docker.sock
|
||||||
|
// or tcp://10.0.0.5:2375).
|
||||||
|
func New(host string) (*Client, error) {
|
||||||
|
opts := []client.Opt{client.FromEnv, client.WithAPIVersionNegotiation()}
|
||||||
|
if host != "" {
|
||||||
|
opts = append(opts, client.WithHost(host))
|
||||||
|
}
|
||||||
|
api, err := client.NewClientWithOpts(opts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create docker client: %w", err)
|
||||||
|
}
|
||||||
|
return &Client{api: api, Endpoint: api.DaemonHost()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// API exposes the underlying SDK client for callers that need an operation
|
||||||
|
// this package does not wrap.
|
||||||
|
func (c *Client) API() *client.Client { return c.api }
|
||||||
|
|
||||||
|
// Close releases the daemon connection.
|
||||||
|
func (c *Client) Close() error { return c.api.Close() }
|
||||||
|
|
||||||
|
// Info returns daemon information, and doubles as a connectivity check.
|
||||||
|
func (c *Client) Info(ctx context.Context) (system.Info, error) {
|
||||||
|
return c.api.Info(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping verifies the daemon is reachable and returns its version string.
|
||||||
|
func (c *Client) Ping(ctx context.Context) (string, error) {
|
||||||
|
v, err := c.api.ServerVersion(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return v.Version, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package dkr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
dockertypes "github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CopyOut streams the contents of a path inside a container as an uncompressed
|
||||||
|
// tar archive.
|
||||||
|
//
|
||||||
|
// This is the single mechanism used for every kind of data location. It works
|
||||||
|
// for named volumes, anonymous volumes and bind mounts alike, because the
|
||||||
|
// daemon resolves the mount and produces the tar itself: no helper image is
|
||||||
|
// needed, the container's own image needs no tar binary, and the container does
|
||||||
|
// not have to be running.
|
||||||
|
//
|
||||||
|
// The archive entries are rooted at the last path segment, matching `docker cp`
|
||||||
|
// semantics. Restoring therefore targets the parent directory; use RestorePath.
|
||||||
|
func (c *Client) CopyOut(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
|
||||||
|
rc, _, err := c.api.CopyFromContainer(ctx, containerID, srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s from %s: %w", srcPath, short(containerID), err)
|
||||||
|
}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyIn writes an uncompressed tar archive into a path inside a container.
|
||||||
|
func (c *Client) CopyIn(ctx context.Context, containerID, dstPath string, r io.Reader) error {
|
||||||
|
err := c.api.CopyToContainer(ctx, containerID, dstPath, r, container.CopyToContainerOptions{
|
||||||
|
AllowOverwriteDirWithFile: false,
|
||||||
|
CopyUIDGID: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write %s into %s: %w", dstPath, short(containerID), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestorePath is the directory an archive produced by CopyOut must be extracted
|
||||||
|
// into so that the contents land back at the original destination.
|
||||||
|
func RestorePath(destination string) string {
|
||||||
|
d := path.Dir(strings.TrimSuffix(destination, "/"))
|
||||||
|
if d == "" || d == "." {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveImage streams `docker save` output for one or more image references.
|
||||||
|
func (c *Client) SaveImage(ctx context.Context, refs ...string) (io.ReadCloser, error) {
|
||||||
|
rc, err := c.api.ImageSave(ctx, refs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("save image %s: %w", strings.Join(refs, ","), err)
|
||||||
|
}
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageSizeBytes returns the on-disk size of an image, used to estimate how
|
||||||
|
// long a streamed transfer will take.
|
||||||
|
func (c *Client) ImageSizeBytes(ctx context.Context, ref string) int64 {
|
||||||
|
insp, err := c.api.ImageInspect(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return insp.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// State returns the current status of a container, e.g. "running".
|
||||||
|
func (c *Client) State(ctx context.Context, id string) (string, error) {
|
||||||
|
j, err := c.api.ContainerInspect(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if j.State == nil {
|
||||||
|
return "", errors.New("no state in inspect payload")
|
||||||
|
}
|
||||||
|
return j.State.Status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops a container and waits for it to settle. A container that is
|
||||||
|
// already stopped is left alone.
|
||||||
|
func (c *Client) Stop(ctx context.Context, id string, timeout time.Duration) error {
|
||||||
|
st, err := c.State(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if st != "running" && st != "restarting" && st != "paused" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
secs := int(timeout.Seconds())
|
||||||
|
if err := c.api.ContainerStop(ctx, id, container.StopOptions{Timeout: &secs}); err != nil {
|
||||||
|
return fmt.Errorf("stop %s: %w", short(id), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts a container.
|
||||||
|
func (c *Client) Start(ctx context.Context, id string) error {
|
||||||
|
if err := c.api.ContainerStart(ctx, id, container.StartOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("start %s: %w", short(id), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VolumeSizes measures every local volume in one daemon round trip. It can be
|
||||||
|
// slow on hosts with a lot of data, so the UI asks for it explicitly rather
|
||||||
|
// than including it in the inventory.
|
||||||
|
func (c *Client) VolumeSizes(ctx context.Context) (map[string]int64, error) {
|
||||||
|
du, err := c.api.DiskUsage(ctx, dockertypes.DiskUsageOptions{
|
||||||
|
Types: []dockertypes.DiskUsageObject{dockertypes.VolumeObject},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("compute disk usage: %w", err)
|
||||||
|
}
|
||||||
|
out := map[string]int64{}
|
||||||
|
for _, v := range du.Volumes {
|
||||||
|
if v == nil || v.UsageData == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[v.Name] = v.UsageData.Size
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeasureMounts fills in the SizeBytes of every mount it can determine.
|
||||||
|
// Volume sizes come from the daemon; bind mount sizes are measured by walking
|
||||||
|
// the path from inside the container, which works even when the daemon is
|
||||||
|
// remote.
|
||||||
|
func (c *Client) MeasureMounts(ctx context.Context, containers []spec.Container) error {
|
||||||
|
sizes, err := c.VolumeSizes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range containers {
|
||||||
|
for j := range containers[i].Mounts {
|
||||||
|
m := &containers[i].Mounts[j]
|
||||||
|
switch m.Kind {
|
||||||
|
case spec.MountVolume, spec.MountAnonymous:
|
||||||
|
if s, ok := sizes[m.Name]; ok {
|
||||||
|
m.SizeBytes = s
|
||||||
|
}
|
||||||
|
case spec.MountTmpfs:
|
||||||
|
m.SizeBytes = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = image.InspectResponse{}
|
||||||
@@ -0,0 +1,570 @@
|
|||||||
|
package dkr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
imagetypes "github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/api/types/network"
|
||||||
|
networktypes "github.com/docker/docker/api/types/network"
|
||||||
|
volumetypes "github.com/docker/docker/api/types/volume"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inventory is everything the UI needs to render the source host.
|
||||||
|
type Inventory struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
DockerVersion string `json:"dockerVersion"`
|
||||||
|
Containers []spec.Container `json:"containers"`
|
||||||
|
Volumes []spec.Volume `json:"volumes"`
|
||||||
|
Networks []spec.Network `json:"networks"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// anonymousVolume matches the 64-hex names Docker generates for volumes that
|
||||||
|
// were never explicitly named.
|
||||||
|
var anonymousVolume = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||||
|
|
||||||
|
// Inventory reads every container on the daemon, plus the volumes and networks
|
||||||
|
// they reference, and normalizes them into the transport spec.
|
||||||
|
func (c *Client) Inventory(ctx context.Context) (*Inventory, error) {
|
||||||
|
version, err := c.Ping(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to docker: %w", err)
|
||||||
|
}
|
||||||
|
info, err := c.api.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read docker info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summaries, err := c.api.ContainerList(ctx, container.ListOptions{All: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inv := &Inventory{Host: info.Name, DockerVersion: version}
|
||||||
|
imgCache := map[string]*imagetypes.InspectResponse{}
|
||||||
|
volNames := map[string]bool{}
|
||||||
|
netNames := map[string]bool{}
|
||||||
|
|
||||||
|
for _, s := range summaries {
|
||||||
|
cs, err := c.inspectContainer(ctx, s.ID, imgCache)
|
||||||
|
if err != nil {
|
||||||
|
inv.Warnings = append(inv.Warnings, fmt.Sprintf("skipped container %s: %v", short(s.ID), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, m := range cs.Mounts {
|
||||||
|
if m.Kind == spec.MountVolume && m.Name != "" {
|
||||||
|
volNames[m.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range cs.Endpoints {
|
||||||
|
netNames[e.Network] = true
|
||||||
|
}
|
||||||
|
inv.Containers = append(inv.Containers, *cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(inv.Containers, func(i, j int) bool {
|
||||||
|
a, b := inv.Containers[i], inv.Containers[j]
|
||||||
|
if a.ComposeProject != b.ComposeProject {
|
||||||
|
return a.ComposeProject < b.ComposeProject
|
||||||
|
}
|
||||||
|
return a.Name < b.Name
|
||||||
|
})
|
||||||
|
|
||||||
|
for name := range volNames {
|
||||||
|
v, err := c.api.VolumeInspect(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
inv.Warnings = append(inv.Warnings, fmt.Sprintf("volume %s: %v", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inv.Volumes = append(inv.Volumes, convertVolume(v))
|
||||||
|
}
|
||||||
|
sort.Slice(inv.Volumes, func(i, j int) bool { return inv.Volumes[i].Name < inv.Volumes[j].Name })
|
||||||
|
|
||||||
|
for name := range netNames {
|
||||||
|
if isBuiltinNetwork(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := c.api.NetworkInspect(ctx, name, networktypes.InspectOptions{})
|
||||||
|
if err != nil {
|
||||||
|
inv.Warnings = append(inv.Warnings, fmt.Sprintf("network %s: %v", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inv.Networks = append(inv.Networks, convertNetwork(n))
|
||||||
|
}
|
||||||
|
sort.Slice(inv.Networks, func(i, j int) bool { return inv.Networks[i].Name < inv.Networks[j].Name })
|
||||||
|
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectContainer normalizes one container by id or name.
|
||||||
|
func (c *Client) InspectContainer(ctx context.Context, id string) (*spec.Container, error) {
|
||||||
|
return c.inspectContainer(ctx, id, map[string]*imagetypes.InspectResponse{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) inspectContainer(ctx context.Context, id string, imgCache map[string]*imagetypes.InspectResponse) (*spec.Container, error) {
|
||||||
|
j, err := c.api.ContainerInspect(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if j.Config == nil || j.HostConfig == nil {
|
||||||
|
return nil, fmt.Errorf("incomplete inspect payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &spec.Container{
|
||||||
|
ID: j.ID,
|
||||||
|
Name: strings.TrimPrefix(j.Name, "/"),
|
||||||
|
Image: j.Config.Image,
|
||||||
|
ImageID: j.Image,
|
||||||
|
}
|
||||||
|
if out.Image == "" {
|
||||||
|
out.Image = j.Image
|
||||||
|
}
|
||||||
|
if j.State != nil {
|
||||||
|
out.State = j.State.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image config is used to strip everything the image already provides, so
|
||||||
|
// the recreated container carries only genuine run-time overrides.
|
||||||
|
img := c.imageConfig(ctx, j.Image, imgCache)
|
||||||
|
if img == nil {
|
||||||
|
out.Warnings = append(out.Warnings,
|
||||||
|
"image config unavailable; env, command and labels are reproduced in full")
|
||||||
|
} else if len(img.RepoDigests) > 0 {
|
||||||
|
out.ImageDigest = img.RepoDigests[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := j.Config
|
||||||
|
out.Hostname = dropGeneratedHostname(cfg.Hostname, j.ID)
|
||||||
|
out.Domainname = cfg.Domainname
|
||||||
|
out.User = cfg.User
|
||||||
|
out.WorkingDir = cfg.WorkingDir
|
||||||
|
out.Tty = cfg.Tty
|
||||||
|
out.OpenStdin = cfg.OpenStdin
|
||||||
|
out.StopSignal = cfg.StopSignal
|
||||||
|
out.StopTimeout = cfg.StopTimeout
|
||||||
|
|
||||||
|
var imgEnv, imgCmd, imgEntry []string
|
||||||
|
var imgLabels map[string]string
|
||||||
|
if img != nil && img.Config != nil {
|
||||||
|
imgEnv, imgLabels = img.Config.Env, img.Config.Labels
|
||||||
|
imgCmd, imgEntry = img.Config.Cmd, img.Config.Entrypoint
|
||||||
|
if img.Config.User == cfg.User {
|
||||||
|
out.User = ""
|
||||||
|
}
|
||||||
|
if img.Config.WorkingDir == cfg.WorkingDir {
|
||||||
|
out.WorkingDir = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Env = subtractStrings(cfg.Env, imgEnv)
|
||||||
|
out.Labels = subtractLabels(cfg.Labels, imgLabels)
|
||||||
|
out.Cmd = cfg.Cmd
|
||||||
|
out.CmdSet = !equalStrings(cfg.Cmd, imgCmd)
|
||||||
|
out.Entrypoint = cfg.Entrypoint
|
||||||
|
out.EntrypointSet = !equalStrings(cfg.Entrypoint, imgEntry)
|
||||||
|
|
||||||
|
if p := cfg.Labels["com.docker.compose.project"]; p != "" {
|
||||||
|
out.ComposeProject = p
|
||||||
|
out.ComposeService = cfg.Labels["com.docker.compose.service"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Healthcheck != nil {
|
||||||
|
var imgHC *container.HealthConfig
|
||||||
|
if img != nil && img.Config != nil {
|
||||||
|
imgHC = img.Config.Healthcheck
|
||||||
|
}
|
||||||
|
if !sameHealthcheck(cfg.Healthcheck, imgHC) {
|
||||||
|
out.Healthcheck = &spec.Healthcheck{
|
||||||
|
Test: cfg.Healthcheck.Test,
|
||||||
|
Interval: int64(cfg.Healthcheck.Interval),
|
||||||
|
Timeout: int64(cfg.Healthcheck.Timeout),
|
||||||
|
StartPeriod: int64(cfg.Healthcheck.StartPeriod),
|
||||||
|
Retries: cfg.Healthcheck.Retries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hc := j.HostConfig
|
||||||
|
out.RestartPolicy = string(hc.RestartPolicy.Name)
|
||||||
|
out.RestartMaxRetries = hc.RestartPolicy.MaximumRetryCount
|
||||||
|
out.AutoRemove = hc.AutoRemove
|
||||||
|
out.Privileged = hc.Privileged
|
||||||
|
out.ReadonlyRootfs = hc.ReadonlyRootfs
|
||||||
|
out.CapAdd = hc.CapAdd
|
||||||
|
out.CapDrop = hc.CapDrop
|
||||||
|
out.SecurityOpt = dropDefaultSecurityOpt(hc.SecurityOpt)
|
||||||
|
out.GroupAdd = hc.GroupAdd
|
||||||
|
out.Sysctls = hc.Sysctls
|
||||||
|
out.Runtime = hc.Runtime
|
||||||
|
out.PidMode = string(hc.PidMode)
|
||||||
|
out.IpcMode = string(hc.IpcMode)
|
||||||
|
out.UtsMode = string(hc.UTSMode)
|
||||||
|
out.UsernsMode = string(hc.UsernsMode)
|
||||||
|
out.CgroupnsMode = string(hc.CgroupnsMode)
|
||||||
|
out.DNS = hc.DNS
|
||||||
|
out.DNSSearch = hc.DNSSearch
|
||||||
|
out.DNSOptions = hc.DNSOptions
|
||||||
|
out.ExtraHosts = hc.ExtraHosts
|
||||||
|
out.NetworkMode = string(hc.NetworkMode)
|
||||||
|
out.PublishAll = hc.PublishAllPorts
|
||||||
|
out.Init = hc.Init
|
||||||
|
out.LogDriver = hc.LogConfig.Type
|
||||||
|
out.LogOptions = hc.LogConfig.Config
|
||||||
|
|
||||||
|
for _, d := range hc.Devices {
|
||||||
|
out.Devices = append(out.Devices, spec.Device{
|
||||||
|
PathOnHost: d.PathOnHost,
|
||||||
|
PathInContainer: d.PathInContainer,
|
||||||
|
CgroupPermissions: d.CgroupPermissions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, u := range hc.Ulimits {
|
||||||
|
if u == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Ulimits = append(out.Ulimits, spec.Ulimit{Name: u.Name, Soft: u.Soft, Hard: u.Hard})
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Resources = spec.Resources{
|
||||||
|
Memory: hc.Memory,
|
||||||
|
MemoryReservation: hc.MemoryReservation,
|
||||||
|
MemorySwap: hc.MemorySwap,
|
||||||
|
MemorySwappiness: hc.MemorySwappiness,
|
||||||
|
NanoCPUs: hc.NanoCPUs,
|
||||||
|
CPUShares: hc.CPUShares,
|
||||||
|
CPUPeriod: hc.CPUPeriod,
|
||||||
|
CPUQuota: hc.CPUQuota,
|
||||||
|
CpusetCpus: hc.CpusetCpus,
|
||||||
|
CpusetMems: hc.CpusetMems,
|
||||||
|
PidsLimit: hc.PidsLimit,
|
||||||
|
OomKillDisable: hc.OomKillDisable,
|
||||||
|
OomScoreAdj: hc.OomScoreAdj,
|
||||||
|
ShmSize: hc.ShmSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
for portProto, bindings := range hc.PortBindings {
|
||||||
|
for _, b := range bindings {
|
||||||
|
out.Ports = append(out.Ports, spec.PortBinding{
|
||||||
|
ContainerPort: string(portProto),
|
||||||
|
HostIP: b.HostIP,
|
||||||
|
HostPort: b.HostPort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out.Ports, func(i, j int) bool {
|
||||||
|
if out.Ports[i].ContainerPort != out.Ports[j].ContainerPort {
|
||||||
|
return out.Ports[i].ContainerPort < out.Ports[j].ContainerPort
|
||||||
|
}
|
||||||
|
return out.Ports[i].HostPort < out.Ports[j].HostPort
|
||||||
|
})
|
||||||
|
for p := range cfg.ExposedPorts {
|
||||||
|
out.ExposedPorts = append(out.ExposedPorts, string(p))
|
||||||
|
}
|
||||||
|
sort.Strings(out.ExposedPorts)
|
||||||
|
|
||||||
|
out.Mounts = convertMounts(j.Mounts, hc.Tmpfs)
|
||||||
|
out.Endpoints = convertEndpoints(j.NetworkSettings, out.NetworkMode, j.ID)
|
||||||
|
|
||||||
|
if hc.AutoRemove {
|
||||||
|
out.Warnings = append(out.Warnings,
|
||||||
|
"source runs with --rm; the migrated container is created without it so it survives inspection")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(out.NetworkMode, "container:") {
|
||||||
|
out.Warnings = append(out.Warnings,
|
||||||
|
"shares another container's network namespace; migrate that container too")
|
||||||
|
}
|
||||||
|
for _, m := range out.Mounts {
|
||||||
|
if m.Kind == spec.MountBind && isSensitiveBind(m.Source) {
|
||||||
|
out.Warnings = append(out.Warnings,
|
||||||
|
"binds host path "+m.Source+"; copying it is usually wrong, review before migrating")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(hc.VolumesFrom) > 0 {
|
||||||
|
out.Warnings = append(out.Warnings,
|
||||||
|
"uses --volumes-from ("+strings.Join(hc.VolumesFrom, ", ")+"), which is not reproduced")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) imageConfig(ctx context.Context, id string, cache map[string]*imagetypes.InspectResponse) *imagetypes.InspectResponse {
|
||||||
|
if v, ok := cache[id]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
insp, err := c.api.ImageInspect(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
cache[id] = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cache[id] = &insp
|
||||||
|
return &insp
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertMounts(mounts []container.MountPoint, tmpfs map[string]string) []spec.Mount {
|
||||||
|
out := make([]spec.Mount, 0, len(mounts)+len(tmpfs))
|
||||||
|
for _, m := range mounts {
|
||||||
|
sm := spec.Mount{
|
||||||
|
Destination: m.Destination,
|
||||||
|
ReadOnly: !m.RW,
|
||||||
|
Propagation: string(m.Propagation),
|
||||||
|
SizeBytes: -1,
|
||||||
|
}
|
||||||
|
switch m.Type {
|
||||||
|
case "volume":
|
||||||
|
sm.Name = m.Name
|
||||||
|
if anonymousVolume.MatchString(m.Name) {
|
||||||
|
sm.Kind = spec.MountAnonymous
|
||||||
|
} else {
|
||||||
|
sm.Kind = spec.MountVolume
|
||||||
|
}
|
||||||
|
case "bind":
|
||||||
|
sm.Kind = spec.MountBind
|
||||||
|
sm.Source = m.Source
|
||||||
|
case "tmpfs":
|
||||||
|
sm.Kind = spec.MountTmpfs
|
||||||
|
default:
|
||||||
|
// npipe and unknown driver types carry no portable data.
|
||||||
|
sm.Kind = spec.MountKind(m.Type)
|
||||||
|
sm.Source = m.Source
|
||||||
|
}
|
||||||
|
out = append(out, sm)
|
||||||
|
}
|
||||||
|
for dest, opts := range tmpfs {
|
||||||
|
if hasDestination(out, dest) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, spec.Mount{Kind: spec.MountTmpfs, Destination: dest, TmpfsOpts: opts, SizeBytes: 0})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Destination < out[j].Destination })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertEndpoints(ns *container.NetworkSettings, networkMode, containerID string) []spec.Endpoint {
|
||||||
|
if ns == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]spec.Endpoint, 0, len(ns.Networks))
|
||||||
|
for name, ep := range ns.Networks {
|
||||||
|
if ep == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e := spec.Endpoint{
|
||||||
|
Network: name,
|
||||||
|
Aliases: dropGeneratedAliases(ep.Aliases, containerID),
|
||||||
|
Links: ep.Links,
|
||||||
|
DriverOpts: ep.DriverOpts,
|
||||||
|
}
|
||||||
|
// The MAC address is normally derived by the daemon. Carrying it over
|
||||||
|
// only makes sense alongside the static addressing it belongs to;
|
||||||
|
// otherwise it risks colliding with an address on the target network.
|
||||||
|
if ep.IPAMConfig != nil && (ep.IPAMConfig.IPv4Address != "" || ep.IPAMConfig.IPv6Address != "") {
|
||||||
|
e.MacAddress = ep.MacAddress
|
||||||
|
}
|
||||||
|
if ep.IPAMConfig != nil {
|
||||||
|
e.IPv4Address = ep.IPAMConfig.IPv4Address
|
||||||
|
e.IPv6Address = ep.IPAMConfig.IPv6Address
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
// The network named by NetworkMode has to come first: it is the one
|
||||||
|
// `docker create --network` can express.
|
||||||
|
if out[i].Network == networkMode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if out[j].Network == networkMode {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return out[i].Network < out[j].Network
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertVolume(v volumetypes.Volume) spec.Volume {
|
||||||
|
return spec.Volume{
|
||||||
|
Name: v.Name,
|
||||||
|
Driver: v.Driver,
|
||||||
|
DriverOpts: v.Options,
|
||||||
|
Labels: v.Labels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertNetwork(n network.Inspect) spec.Network {
|
||||||
|
out := spec.Network{
|
||||||
|
Name: n.Name,
|
||||||
|
Driver: n.Driver,
|
||||||
|
Scope: n.Scope,
|
||||||
|
EnableIPv6: n.EnableIPv6,
|
||||||
|
Internal: n.Internal,
|
||||||
|
Attachable: n.Attachable,
|
||||||
|
Ingress: n.Ingress,
|
||||||
|
IPAMDriver: n.IPAM.Driver,
|
||||||
|
Options: n.Options,
|
||||||
|
Labels: n.Labels,
|
||||||
|
}
|
||||||
|
for _, p := range n.IPAM.Config {
|
||||||
|
out.IPAMPools = append(out.IPAMPools, spec.IPAMPool{
|
||||||
|
Subnet: p.Subnet,
|
||||||
|
IPRange: p.IPRange,
|
||||||
|
Gateway: p.Gateway,
|
||||||
|
AuxAddress: p.AuxAddress,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageInspectExists reports whether the daemon holds the given image.
|
||||||
|
func (c *Client) ImageExists(ctx context.Context, ref string) bool {
|
||||||
|
_, err := c.api.ImageInspect(ctx, ref)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageRepoDigests returns the registry digests of an image, used to decide
|
||||||
|
// whether the target can simply pull it.
|
||||||
|
func (c *Client) ImageRepoDigests(ctx context.Context, ref string) []string {
|
||||||
|
insp, err := c.api.ImageInspect(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return insp.RepoDigests
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = image.InspectResponse{}
|
||||||
|
|
||||||
|
func isBuiltinNetwork(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "bridge", "host", "none":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSensitiveBind flags host paths that almost never should be copied wholesale
|
||||||
|
// to another machine.
|
||||||
|
func isSensitiveBind(p string) bool {
|
||||||
|
p = strings.TrimSuffix(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||||
|
switch p {
|
||||||
|
case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/", "/etc", "/var/run", "/run":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/dev/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropGeneratedHostname removes the hostname Docker derives from the container
|
||||||
|
// id, which must not be pinned on the target.
|
||||||
|
func dropGeneratedHostname(hostname, id string) string {
|
||||||
|
if hostname == "" || strings.HasPrefix(id, hostname) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropGeneratedAliases removes the short-container-id alias Docker attaches to
|
||||||
|
// every endpoint by itself. Re-applying it would pin the target container to
|
||||||
|
// the source container's id.
|
||||||
|
func dropGeneratedAliases(aliases []string, containerID string) []string {
|
||||||
|
out := make([]string, 0, len(aliases))
|
||||||
|
for _, a := range aliases {
|
||||||
|
if len(a) == 12 && strings.HasPrefix(containerID, a) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropDefaultSecurityOpt removes the label=disable style entries Docker reports
|
||||||
|
// on hosts without SELinux, which would fail to apply elsewhere.
|
||||||
|
func dropDefaultSecurityOpt(opts []string) []string {
|
||||||
|
out := make([]string, 0, len(opts))
|
||||||
|
for _, o := range opts {
|
||||||
|
if strings.HasPrefix(o, "name=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDestination(ms []spec.Mount, dest string) bool {
|
||||||
|
for _, m := range ms {
|
||||||
|
if m.Destination == dest {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func subtractStrings(all, base []string) []string {
|
||||||
|
if len(base) == 0 {
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool, len(base))
|
||||||
|
for _, b := range base {
|
||||||
|
seen[b] = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(all))
|
||||||
|
for _, v := range all {
|
||||||
|
if !seen[v] {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func subtractLabels(all, base map[string]string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range all {
|
||||||
|
if bv, ok := base[k]; ok && bv == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func equalStrings(a, b []string) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameHealthcheck(a, b *container.HealthConfig) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
return equalStrings(a.Test, b.Test) && a.Interval == b.Interval &&
|
||||||
|
a.Timeout == b.Timeout && a.StartPeriod == b.StartPeriod && a.Retries == b.Retries
|
||||||
|
}
|
||||||
|
|
||||||
|
func short(id string) string {
|
||||||
|
if len(id) > 12 {
|
||||||
|
return id[:12]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package dkr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLiveInventory is a smoke test against whatever daemon the environment
|
||||||
|
// points at. It is skipped unless DOCKER_MIGRATE_LIVE_TEST is set, because it
|
||||||
|
// needs a real Docker host.
|
||||||
|
func TestLiveInventory(t *testing.T) {
|
||||||
|
if os.Getenv("DOCKER_MIGRATE_LIVE_TEST") == "" {
|
||||||
|
t.Skip("set DOCKER_MIGRATE_LIVE_TEST=1 to run against the local daemon")
|
||||||
|
}
|
||||||
|
c, err := New("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
inv, err := c.Inventory(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("host=%s docker=%s containers=%d volumes=%d networks=%d warnings=%v",
|
||||||
|
inv.Host, inv.DockerVersion, len(inv.Containers), len(inv.Volumes), len(inv.Networks), inv.Warnings)
|
||||||
|
|
||||||
|
for i := range inv.Containers {
|
||||||
|
ct := &inv.Containers[i]
|
||||||
|
args := ct.CreateArgs(spec.RenderOptions{})
|
||||||
|
t.Logf("%s [%s] -> docker %s", ct.Name, ct.State, spec.ShellQuoteAll(args))
|
||||||
|
for _, m := range ct.DataMounts() {
|
||||||
|
t.Logf(" mount %-8s %-40s restore-into %s", m.Kind, m.Destination, RestorePath(m.Destination))
|
||||||
|
}
|
||||||
|
for _, w := range ct.Warnings {
|
||||||
|
t.Logf(" warn: %s", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, _ := json.MarshalIndent(inv, "", " ")
|
||||||
|
t.Logf("inventory bytes: %d", len(b))
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// Package job tracks long-running migrations and streams their progress to
|
||||||
|
// the UI. A job is a tree: job -> per-container item -> per-operation step,
|
||||||
|
// with byte counters on the steps that move data.
|
||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// State is the lifecycle of a job, item or step.
|
||||||
|
type State string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatePending State = "pending"
|
||||||
|
StateRunning State = "running"
|
||||||
|
StateSucceeded State = "succeeded"
|
||||||
|
StateFailed State = "failed"
|
||||||
|
StateSkipped State = "skipped"
|
||||||
|
StateCanceled State = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Terminal reports whether no further transitions are expected.
|
||||||
|
func (s State) Terminal() bool {
|
||||||
|
switch s {
|
||||||
|
case StateSucceeded, StateFailed, StateSkipped, StateCanceled:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kind distinguishes the two migration modes.
|
||||||
|
type Kind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindSSH Kind = "ssh"
|
||||||
|
KindPackage Kind = "package"
|
||||||
|
KindRestore Kind = "restore"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level classifies a log line.
|
||||||
|
type Level string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelInfo Level = "info"
|
||||||
|
LevelWarn Level = "warn"
|
||||||
|
LevelError Level = "error"
|
||||||
|
LevelCmd Level = "cmd" // a command that was (or would be) run on a host
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry is one line in the job log.
|
||||||
|
type LogEntry struct {
|
||||||
|
Seq int64 `json:"seq"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
Level Level `json:"level"`
|
||||||
|
Item string `json:"item,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is one unit of work inside an item, e.g. "transfer volume pgdata".
|
||||||
|
type Step struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
State State `json:"state"`
|
||||||
|
BytesDone int64 `json:"bytesDone"`
|
||||||
|
BytesTotal int64 `json:"bytesTotal"` // -1 when unknown
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item is the migration of one container.
|
||||||
|
type Item struct {
|
||||||
|
ID string `json:"id"` // container id on the source
|
||||||
|
Name string `json:"name"`
|
||||||
|
State State `json:"state"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Steps []*Step `json:"steps"`
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is the serializable view of a job handed to the UI.
|
||||||
|
type Snapshot struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Kind Kind `json:"kind"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
State State `json:"state"`
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
||||||
|
Items []*Item `json:"items"`
|
||||||
|
Log []LogEntry `json:"log"`
|
||||||
|
BytesDone int64 `json:"bytesDone"`
|
||||||
|
BytesTotal int64 `json:"bytesTotal"`
|
||||||
|
// Artifact is the produced package path, for package jobs.
|
||||||
|
Artifact string `json:"artifact,omitempty"`
|
||||||
|
// ArtifactBytes is the package size on disk.
|
||||||
|
ArtifactBytes int64 `json:"artifactBytes,omitempty"`
|
||||||
|
Revision int64 `json:"revision"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is a running or finished migration.
|
||||||
|
type Job struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
snap Snapshot
|
||||||
|
seq int64
|
||||||
|
revision int64
|
||||||
|
maxLog int
|
||||||
|
|
||||||
|
cancel context.CancelFunc
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
subsMu sync.Mutex
|
||||||
|
subs map[int]chan struct{}
|
||||||
|
nextID int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJob(id string, kind Kind, title string, dryRun bool) *Job {
|
||||||
|
return &Job{
|
||||||
|
snap: Snapshot{
|
||||||
|
ID: id, Kind: kind, Title: title, State: StatePending,
|
||||||
|
DryRun: dryRun, CreatedAt: time.Now(), Items: []*Item{}, Log: []LogEntry{},
|
||||||
|
BytesTotal: 0,
|
||||||
|
},
|
||||||
|
maxLog: 5000,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
subs: map[int]chan struct{}{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the job identifier.
|
||||||
|
func (j *Job) ID() string { return j.snap.ID }
|
||||||
|
|
||||||
|
// Done is closed once the job reaches a terminal state.
|
||||||
|
func (j *Job) Done() <-chan struct{} { return j.done }
|
||||||
|
|
||||||
|
// Snapshot returns a deep-enough copy for JSON serialization.
|
||||||
|
func (j *Job) Snapshot() Snapshot {
|
||||||
|
j.mu.RLock()
|
||||||
|
defer j.mu.RUnlock()
|
||||||
|
s := j.snap
|
||||||
|
s.Items = make([]*Item, len(j.snap.Items))
|
||||||
|
var done, total int64
|
||||||
|
for i, it := range j.snap.Items {
|
||||||
|
cp := *it
|
||||||
|
cp.Steps = make([]*Step, len(it.Steps))
|
||||||
|
for k, st := range it.Steps {
|
||||||
|
sc := *st
|
||||||
|
cp.Steps[k] = &sc
|
||||||
|
done += sc.BytesDone
|
||||||
|
if sc.BytesTotal > 0 {
|
||||||
|
total += sc.BytesTotal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Items[i] = &cp
|
||||||
|
}
|
||||||
|
s.Log = append([]LogEntry(nil), j.snap.Log...)
|
||||||
|
s.BytesDone, s.BytesTotal = done, total
|
||||||
|
s.Revision = atomic.LoadInt64(&j.revision)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a channel that receives a signal whenever the job changes,
|
||||||
|
// plus a function to unsubscribe.
|
||||||
|
func (j *Job) Subscribe() (<-chan struct{}, func()) {
|
||||||
|
j.subsMu.Lock()
|
||||||
|
defer j.subsMu.Unlock()
|
||||||
|
id := j.nextID
|
||||||
|
j.nextID++
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
j.subs[id] = ch
|
||||||
|
return ch, func() {
|
||||||
|
j.subsMu.Lock()
|
||||||
|
defer j.subsMu.Unlock()
|
||||||
|
if c, ok := j.subs[id]; ok {
|
||||||
|
delete(j.subs, id)
|
||||||
|
close(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *Job) touch() {
|
||||||
|
atomic.AddInt64(&j.revision, 1)
|
||||||
|
j.subsMu.Lock()
|
||||||
|
for _, ch := range j.subs {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default: // a signal is already pending; the reader will see the latest state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.subsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logf appends a line to the job log.
|
||||||
|
func (j *Job) Logf(level Level, item, format string, args ...any) {
|
||||||
|
j.mu.Lock()
|
||||||
|
j.seq++
|
||||||
|
e := LogEntry{Seq: j.seq, At: time.Now(), Level: level, Item: item, Message: fmt.Sprintf(format, args...)}
|
||||||
|
j.snap.Log = append(j.snap.Log, e)
|
||||||
|
if len(j.snap.Log) > j.maxLog {
|
||||||
|
j.snap.Log = j.snap.Log[len(j.snap.Log)-j.maxLog:]
|
||||||
|
}
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItem registers a container in the job and returns its handle.
|
||||||
|
func (j *Job) AddItem(id, name string) *Item {
|
||||||
|
j.mu.Lock()
|
||||||
|
it := &Item{ID: id, Name: name, State: StatePending, Steps: []*Step{}}
|
||||||
|
j.snap.Items = append(j.snap.Items, it)
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddStep registers a unit of work under an item. bytesTotal may be -1 when
|
||||||
|
// the size is not known ahead of time.
|
||||||
|
func (j *Job) AddStep(it *Item, id, label string, bytesTotal int64) *Step {
|
||||||
|
j.mu.Lock()
|
||||||
|
st := &Step{ID: id, Label: label, State: StatePending, BytesTotal: bytesTotal}
|
||||||
|
it.Steps = append(it.Steps, st)
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartStep marks a step as running.
|
||||||
|
func (j *Job) StartStep(st *Step) {
|
||||||
|
now := time.Now()
|
||||||
|
j.mu.Lock()
|
||||||
|
st.State = StateRunning
|
||||||
|
st.StartedAt = &now
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishStep closes a step, recording an error when one occurred.
|
||||||
|
func (j *Job) FinishStep(st *Step, err error) {
|
||||||
|
now := time.Now()
|
||||||
|
j.mu.Lock()
|
||||||
|
st.EndedAt = &now
|
||||||
|
if err != nil {
|
||||||
|
st.State = StateFailed
|
||||||
|
st.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
st.State = StateSucceeded
|
||||||
|
if st.BytesTotal < 0 {
|
||||||
|
st.BytesTotal = st.BytesDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkipStep marks a step as deliberately not performed.
|
||||||
|
func (j *Job) SkipStep(st *Step, reason string) {
|
||||||
|
now := time.Now()
|
||||||
|
j.mu.Lock()
|
||||||
|
st.State = StateSkipped
|
||||||
|
st.EndedAt = &now
|
||||||
|
st.Error = reason
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddBytes advances a step's byte counter. It is safe to call at high rates
|
||||||
|
// from the transfer goroutine.
|
||||||
|
func (j *Job) AddBytes(st *Step, n int64) {
|
||||||
|
j.mu.Lock()
|
||||||
|
st.BytesDone += n
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItemState transitions an item.
|
||||||
|
func (j *Job) SetItemState(it *Item, s State, err error) {
|
||||||
|
j.mu.Lock()
|
||||||
|
it.State = s
|
||||||
|
if err != nil {
|
||||||
|
it.Error = err.Error()
|
||||||
|
}
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddItemWarning attaches a non-fatal note to an item.
|
||||||
|
func (j *Job) AddItemWarning(it *Item, format string, args ...any) {
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
j.mu.Lock()
|
||||||
|
it.Warnings = append(it.Warnings, msg)
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.Logf(LevelWarn, it.ID, "%s", msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetArtifact records the produced package.
|
||||||
|
func (j *Job) SetArtifact(path string, bytes int64) {
|
||||||
|
j.mu.Lock()
|
||||||
|
j.snap.Artifact = path
|
||||||
|
j.snap.ArtifactBytes = bytes
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *Job) start() {
|
||||||
|
now := time.Now()
|
||||||
|
j.mu.Lock()
|
||||||
|
j.snap.State = StateRunning
|
||||||
|
j.snap.StartedAt = &now
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *Job) finish(err error) {
|
||||||
|
now := time.Now()
|
||||||
|
j.mu.Lock()
|
||||||
|
if j.snap.State.Terminal() {
|
||||||
|
j.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.snap.EndedAt = &now
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
j.snap.State = StateSucceeded
|
||||||
|
case err == context.Canceled:
|
||||||
|
j.snap.State = StateCanceled
|
||||||
|
j.snap.Error = "canceled by operator"
|
||||||
|
default:
|
||||||
|
j.snap.State = StateFailed
|
||||||
|
j.snap.Error = err.Error()
|
||||||
|
}
|
||||||
|
j.mu.Unlock()
|
||||||
|
j.touch()
|
||||||
|
close(j.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel asks the job to stop. Work already in flight unwinds through context
|
||||||
|
// cancellation.
|
||||||
|
func (j *Job) Cancel() {
|
||||||
|
if j.cancel != nil {
|
||||||
|
j.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager owns every job in the process.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
jobs map[string]*Job
|
||||||
|
// keep bounds how many finished jobs are retained.
|
||||||
|
keep int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates an empty job manager.
|
||||||
|
func NewManager() *Manager {
|
||||||
|
return &Manager{jobs: map[string]*Job{}, keep: 50}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound is returned for an unknown job id.
|
||||||
|
var ErrNotFound = errors.New("job not found")
|
||||||
|
|
||||||
|
// Run creates a job and executes fn in the background. fn receives a context
|
||||||
|
// that is canceled when the job is canceled, and the job handle for progress
|
||||||
|
// reporting.
|
||||||
|
func (m *Manager) Run(parent context.Context, kind Kind, title string, dryRun bool, fn func(context.Context, *Job) error) *Job {
|
||||||
|
j := newJob(newID(), kind, title, dryRun)
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
j.cancel = cancel
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.jobs[j.snap.ID] = j
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.prune()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
j.start()
|
||||||
|
err := fn(ctx, j)
|
||||||
|
if err == nil && ctx.Err() != nil {
|
||||||
|
err = context.Canceled
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
err = context.Canceled
|
||||||
|
}
|
||||||
|
j.finish(err)
|
||||||
|
}()
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a job by id.
|
||||||
|
func (m *Manager) Get(id string) (*Job, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
j, ok := m.jobs[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every job, newest first.
|
||||||
|
func (m *Manager) List() []Snapshot {
|
||||||
|
m.mu.RLock()
|
||||||
|
jobs := make([]*Job, 0, len(m.jobs))
|
||||||
|
for _, j := range m.jobs {
|
||||||
|
jobs = append(jobs, j)
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
out := make([]Snapshot, 0, len(jobs))
|
||||||
|
for _, j := range jobs {
|
||||||
|
s := j.Snapshot()
|
||||||
|
// The list view does not need the full log.
|
||||||
|
if len(s.Log) > 5 {
|
||||||
|
s.Log = s.Log[len(s.Log)-5:]
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, k int) bool { return out[i].CreatedAt.After(out[k].CreatedAt) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a finished job. A running job is canceled instead.
|
||||||
|
func (m *Manager) Delete(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
j, ok := m.jobs[id]
|
||||||
|
if !ok {
|
||||||
|
m.mu.Unlock()
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if !j.Snapshot().State.Terminal() {
|
||||||
|
m.mu.Unlock()
|
||||||
|
j.Cancel()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delete(m.jobs, id)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prune drops the oldest finished jobs once the retention limit is exceeded.
|
||||||
|
func (m *Manager) prune() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if len(m.jobs) <= m.keep {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type entry struct {
|
||||||
|
id string
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
var finished []entry
|
||||||
|
for id, j := range m.jobs {
|
||||||
|
s := j.Snapshot()
|
||||||
|
if s.State.Terminal() {
|
||||||
|
finished = append(finished, entry{id, s.CreatedAt})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(finished, func(i, k int) bool { return finished[i].at.Before(finished[k].at) })
|
||||||
|
for i := 0; i < len(finished) && len(m.jobs) > m.keep; i++ {
|
||||||
|
delete(m.jobs, finished[i].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return hex.EncodeToString([]byte(time.Now().Format("150405.000000")))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountingReader wraps a reader and reports every read to a job step, which is
|
||||||
|
// how transfer progress reaches the UI.
|
||||||
|
type CountingReader struct {
|
||||||
|
R io.Reader
|
||||||
|
Job *Job
|
||||||
|
St *Step
|
||||||
|
|
||||||
|
pending int64
|
||||||
|
lastFlush time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCountingReader builds a progress-reporting reader.
|
||||||
|
func NewCountingReader(r io.Reader, j *Job, st *Step) *CountingReader {
|
||||||
|
return &CountingReader{R: r, Job: j, St: st, lastFlush: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read implements io.Reader, batching counter updates so a fast transfer does
|
||||||
|
// not flood subscribers with notifications.
|
||||||
|
func (c *CountingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.R.Read(p)
|
||||||
|
if n > 0 {
|
||||||
|
c.pending += int64(n)
|
||||||
|
if c.pending >= 4<<20 || time.Since(c.lastFlush) > 200*time.Millisecond {
|
||||||
|
c.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.flush()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush pushes any buffered byte count to the job.
|
||||||
|
func (c *CountingReader) Flush() { c.flush() }
|
||||||
|
|
||||||
|
func (c *CountingReader) flush() {
|
||||||
|
if c.pending == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Job.AddBytes(c.St, c.pending)
|
||||||
|
c.pending = 0
|
||||||
|
c.lastFlush = time.Now()
|
||||||
|
}
|
||||||
@@ -0,0 +1,580 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderInstaller generates the shell script shipped inside a migration
|
||||||
|
// package. The script is self-contained: it never parses the manifest and
|
||||||
|
// depends on nothing but bash, gzip and the docker CLI, so it can be read and
|
||||||
|
// audited by whoever runs it on the target host.
|
||||||
|
func renderInstaller(prepared []*Prepared, man *spec.Manifest, savedImages map[string]string, pkgName string) string {
|
||||||
|
payload := map[string]spec.Payload{}
|
||||||
|
for _, p := range man.Payloads {
|
||||||
|
if p.Kind == "mount" {
|
||||||
|
payload[p.Container+"\x00"+p.Destination] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imagePayload := map[string]spec.Payload{}
|
||||||
|
for _, p := range man.Payloads {
|
||||||
|
if p.Kind == "image" {
|
||||||
|
imagePayload[p.Image] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
// w formats a line. Literal blocks must be passed as an argument, never as
|
||||||
|
// the format itself: shell text is full of % and would be mangled.
|
||||||
|
w := func(format string, args ...any) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
b.WriteString(format)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
w("#!/usr/bin/env bash")
|
||||||
|
w("#")
|
||||||
|
w("# Migration package: %s", pkgName)
|
||||||
|
w("# Created: %s", man.CreatedAt.Format("2006-01-02 15:04:05 MST"))
|
||||||
|
w("# Source host: %s (docker %s)", orDash(man.SourceHost), orDash(man.DockerVersion))
|
||||||
|
w("# Containers: %d", len(prepared))
|
||||||
|
w("#")
|
||||||
|
w("# Run this on the TARGET host. It needs bash, gzip and a working docker CLI.")
|
||||||
|
w("# Nothing is written outside docker's own storage and the bind mount paths")
|
||||||
|
w("# listed below.")
|
||||||
|
w("#")
|
||||||
|
w("# Usage: ./install.sh [options]")
|
||||||
|
w("# --dry-run print every command without changing anything")
|
||||||
|
w("# --yes do not ask for confirmation")
|
||||||
|
w("# --no-start create the containers but leave them stopped")
|
||||||
|
w("# --conflict MODE fail (default) | skip | replace | rename")
|
||||||
|
w("# --rename-suffix S suffix used by --conflict rename (default -migrated)")
|
||||||
|
w("# --skip-verify do not checksum the payloads")
|
||||||
|
w("# --only NAME[,NAME...] restore only these containers")
|
||||||
|
w("# --docker CMD docker command to use (default: docker)")
|
||||||
|
w("# --sudo prefix docker with sudo -n")
|
||||||
|
w("")
|
||||||
|
w("set -euo pipefail")
|
||||||
|
w("")
|
||||||
|
w(`PKGDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"`)
|
||||||
|
w("DRY_RUN=0")
|
||||||
|
w("ASSUME_YES=0")
|
||||||
|
w("NO_START=0")
|
||||||
|
w("VERIFY=1")
|
||||||
|
w("ONLY=\"\"")
|
||||||
|
w("DOCKER_BIN=docker")
|
||||||
|
w("USE_SUDO=0")
|
||||||
|
w("CONFLICT=%s", spec.ShellQuote(string(defaultConflict(man.Options.Conflict))))
|
||||||
|
w("RENAME_SUFFIX=%s", spec.ShellQuote(defaultSuffix(man.Options.RenameSuffix)))
|
||||||
|
w("")
|
||||||
|
w(`while [ $# -gt 0 ]; do`)
|
||||||
|
w(` case "$1" in`)
|
||||||
|
w(` --dry-run) DRY_RUN=1 ;;`)
|
||||||
|
w(` --yes|-y) ASSUME_YES=1 ;;`)
|
||||||
|
w(` --no-start) NO_START=1 ;;`)
|
||||||
|
w(` --skip-verify) VERIFY=0 ;;`)
|
||||||
|
w(` --conflict) shift; CONFLICT="${1:-}" ;;`)
|
||||||
|
w(` --rename-suffix) shift; RENAME_SUFFIX="${1:-}" ;;`)
|
||||||
|
w(` --only) shift; ONLY="${1:-}" ;;`)
|
||||||
|
w(` --docker) shift; DOCKER_BIN="${1:-docker}" ;;`)
|
||||||
|
w(` --sudo) USE_SUDO=1 ;;`)
|
||||||
|
w(` -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;`)
|
||||||
|
w(` *) echo "unknown option: $1" >&2; exit 2 ;;`)
|
||||||
|
w(` esac`)
|
||||||
|
w(` shift`)
|
||||||
|
w(`done`)
|
||||||
|
w("")
|
||||||
|
w(`case "$CONFLICT" in fail|skip|replace|rename) ;; *) echo "invalid --conflict: $CONFLICT" >&2; exit 2 ;; esac`)
|
||||||
|
w("")
|
||||||
|
w(`if [ "$USE_SUDO" = 1 ]; then DOCKER="sudo -n $DOCKER_BIN"; else DOCKER="$DOCKER_BIN"; fi`)
|
||||||
|
w("")
|
||||||
|
w(installerHelpers)
|
||||||
|
w("")
|
||||||
|
|
||||||
|
// Preflight.
|
||||||
|
w(`log "migration package: %s"`, escapeDoubleQuoted(pkgName))
|
||||||
|
w(`preflight`)
|
||||||
|
w("")
|
||||||
|
|
||||||
|
// Bind mount summary, so the operator sees what will touch the host
|
||||||
|
// filesystem before answering the prompt.
|
||||||
|
binds := collectBinds(prepared)
|
||||||
|
if len(binds) > 0 {
|
||||||
|
w(`echo "This package writes into the following host paths:"`)
|
||||||
|
for _, p := range binds {
|
||||||
|
w(`echo " %s"`, escapeDoubleQuoted(p))
|
||||||
|
}
|
||||||
|
w("")
|
||||||
|
}
|
||||||
|
w(`confirm`)
|
||||||
|
w("")
|
||||||
|
|
||||||
|
// Networks, created once.
|
||||||
|
nets := map[string]bool{}
|
||||||
|
var netBlock strings.Builder
|
||||||
|
for _, p := range prepared {
|
||||||
|
for _, n := range p.Networks {
|
||||||
|
if nets[n.Name] || isBuiltin(n.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nets[n.Name] = true
|
||||||
|
fmt.Fprintf(&netBlock, "ensure_network %s %s\n",
|
||||||
|
spec.ShellQuote(n.Name), spec.ShellQuoteAll(n.CreateArgs()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if netBlock.Len() > 0 {
|
||||||
|
w(`step "networks"`)
|
||||||
|
w("%s", strings.TrimRight(netBlock.String(), "\n"))
|
||||||
|
w("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One function per container keeps the flow readable and lets --only skip
|
||||||
|
// whole containers cleanly.
|
||||||
|
for i, p := range prepared {
|
||||||
|
w("%s", renderContainerFunc(i, p, payload, imagePayload, savedImages))
|
||||||
|
}
|
||||||
|
|
||||||
|
w(`FAILED=0`)
|
||||||
|
for i, p := range prepared {
|
||||||
|
name := p.ContainerName()
|
||||||
|
w(`if selected %s; then`, spec.ShellQuote(name))
|
||||||
|
w(` if ! migrate_%d; then err "container %s failed"; FAILED=$((FAILED+1)); fi`, i, escapeDoubleQuoted(name))
|
||||||
|
w(`else`)
|
||||||
|
w(` log "skipping %s (not in --only)"`, escapeDoubleQuoted(name))
|
||||||
|
w(`fi`)
|
||||||
|
}
|
||||||
|
w("")
|
||||||
|
w(`if [ "$FAILED" -gt 0 ]; then`)
|
||||||
|
w(` err "$FAILED container(s) failed"`)
|
||||||
|
w(` exit 1`)
|
||||||
|
w(`fi`)
|
||||||
|
w(`ok "migration complete"`)
|
||||||
|
w(`if [ "$DRY_RUN" = 1 ]; then log "this was a dry run; nothing was changed"; fi`)
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderContainerFunc(idx int, p *Prepared, payload, imagePayload map[string]spec.Payload, savedImages map[string]string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
w := func(format string, args ...any) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
b.WriteString(format)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, format+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := p.ContainerName()
|
||||||
|
sel := p.Selection
|
||||||
|
|
||||||
|
// Every command below is checked explicitly with "|| return 1". This
|
||||||
|
// function is invoked from an `if !` test, which switches `set -e` off for
|
||||||
|
// its whole body, so an unchecked failure would otherwise be swallowed and
|
||||||
|
// the container reported as migrated when it was not.
|
||||||
|
w("migrate_%d() {", idx)
|
||||||
|
w(` local base=%s`, spec.ShellQuote(name))
|
||||||
|
w(` CNAME="$base"`)
|
||||||
|
w(` step "container $base"`)
|
||||||
|
for _, note := range p.Notes {
|
||||||
|
w(` warn %s`, spec.ShellQuote(note))
|
||||||
|
}
|
||||||
|
for _, note := range p.Source.Warnings {
|
||||||
|
w(` warn %s`, spec.ShellQuote(note))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conflict handling.
|
||||||
|
w(` if object_exists container "$CNAME"; then`)
|
||||||
|
w(` case "$CONFLICT" in`)
|
||||||
|
w(` skip) warn "container $CNAME already exists; skipping"; return 0 ;;`)
|
||||||
|
w(` replace) warn "removing existing container $CNAME"; run $DOCKER rm -f "$CNAME" || return 1 ;;`)
|
||||||
|
w(` rename) CNAME="$(free_name "$base")"; warn "creating $CNAME instead" ;;`)
|
||||||
|
w(` *) err "container $CNAME already exists; rerun with --conflict replace|rename|skip"; return 1 ;;`)
|
||||||
|
w(` esac`)
|
||||||
|
w(` fi`)
|
||||||
|
|
||||||
|
// Image.
|
||||||
|
image := p.Target.Image
|
||||||
|
switch {
|
||||||
|
case !sel.MigrateImage || sel.ImageMode == spec.ImageSkip:
|
||||||
|
w(` if ! object_exists image %s; then`, spec.ShellQuote(image))
|
||||||
|
w(` err "image %s is not present and this package does not carry it"; return 1`, escapeDoubleQuoted(image))
|
||||||
|
w(` fi`)
|
||||||
|
case sel.ImageMode == spec.ImagePull:
|
||||||
|
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
|
||||||
|
default:
|
||||||
|
if rel, ok := savedImages[image]; ok {
|
||||||
|
ip := imagePayload[image]
|
||||||
|
w(` ensure_image_load %s %s %s %s || return 1`,
|
||||||
|
spec.ShellQuote(image), spec.ShellQuote(rel),
|
||||||
|
spec.ShellQuote(ip.SHA256), boolArg(ip.Compressed))
|
||||||
|
} else {
|
||||||
|
w(` ensure_image_pull %s || return 1`, spec.ShellQuote(image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Named volumes.
|
||||||
|
for _, v := range p.Volumes {
|
||||||
|
w(` ensure_volume %s %s || return 1`, spec.ShellQuote(v.Name), spec.ShellQuoteAll(v.CreateArgs()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind mount directories, created before the container so docker does not
|
||||||
|
// invent them with unexpected ownership halfway through.
|
||||||
|
for _, m := range p.Target.Mounts {
|
||||||
|
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !isSpecialBind(m.Source) {
|
||||||
|
w(` ensure_dir %s || return 1`, spec.ShellQuote(m.Source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create. The name is substituted at run time so --conflict rename works.
|
||||||
|
createArgs := p.Target.CreateArgs(p.Render)
|
||||||
|
rest := createArgs
|
||||||
|
if len(rest) >= 3 && rest[0] == "create" && rest[1] == "--name" {
|
||||||
|
rest = rest[3:]
|
||||||
|
}
|
||||||
|
w(` log "creating container $CNAME"`)
|
||||||
|
w(` run $DOCKER create --name "$CNAME" %s || { err "could not create $CNAME"; return 1; }`, spec.ShellQuoteAll(rest))
|
||||||
|
|
||||||
|
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
|
||||||
|
// The rendered args end with (network, containerName); the name is
|
||||||
|
// replaced so a renamed container still gets attached.
|
||||||
|
if len(args) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
head := args[:len(args)-1]
|
||||||
|
w(` run $DOCKER %s "$CNAME" || { err "could not attach $CNAME to a network"; return 1; }`, spec.ShellQuoteAll(head))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data.
|
||||||
|
for _, t := range p.Transfers {
|
||||||
|
pl, ok := payload[name+"\x00"+t.Destination]
|
||||||
|
if !ok {
|
||||||
|
w(` warn "no data archive for %s in this package; leaving it empty"`, escapeDoubleQuoted(t.Destination))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w(` verify_payload %s %s || return 1`, spec.ShellQuote(pl.Path), spec.ShellQuote(pl.SHA256))
|
||||||
|
if t.ReadOnly {
|
||||||
|
w(` log "restoring %s (read-only mount, via staging container)"`, escapeDoubleQuoted(t.Label))
|
||||||
|
w(` seed_readonly "$CNAME" %s %s %s %s || return 1`,
|
||||||
|
spec.ShellQuote(p.Target.Image), spec.ShellQuote(t.Destination),
|
||||||
|
spec.ShellQuote(pl.Path), boolArg(pl.Compressed))
|
||||||
|
} else {
|
||||||
|
w(` log "restoring %s"`, escapeDoubleQuoted(t.Label))
|
||||||
|
w(` feed_archive %s %s "$CNAME" %s || { err "could not restore %s"; return 1; }`,
|
||||||
|
spec.ShellQuote(pl.Path), boolArg(pl.Compressed), spec.ShellQuote(t.RestoreInto),
|
||||||
|
escapeDoubleQuoted(t.Label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sel.StartAfter {
|
||||||
|
w(` if [ "$NO_START" = 1 ]; then`)
|
||||||
|
w(` log "leaving $CNAME stopped (--no-start)"`)
|
||||||
|
w(` else`)
|
||||||
|
w(` log "starting $CNAME"`)
|
||||||
|
w(` run $DOCKER start "$CNAME" || { err "could not start $CNAME"; return 1; }`)
|
||||||
|
w(` check_running "$CNAME" || return 1`)
|
||||||
|
w(` fi`)
|
||||||
|
} else {
|
||||||
|
w(` log "$CNAME created but not started (it was not running on the source)"`)
|
||||||
|
}
|
||||||
|
w(` ok "$CNAME done"`)
|
||||||
|
w(` return 0`)
|
||||||
|
w("}")
|
||||||
|
w("")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// installerHelpers is the fixed shell prelude shared by every generated
|
||||||
|
// installer.
|
||||||
|
const installerHelpers = `
|
||||||
|
if [ -t 1 ]; then C_R=$'\033[31m'; C_G=$'\033[32m'; C_Y=$'\033[33m'; C_B=$'\033[1m'; C_0=$'\033[0m'
|
||||||
|
else C_R=""; C_G=""; C_Y=""; C_B=""; C_0=""; fi
|
||||||
|
|
||||||
|
log() { printf '%s\n' " $*"; }
|
||||||
|
step() { printf '\n%s\n' "${C_B}==> $*${C_0}"; }
|
||||||
|
ok() { printf '%s\n' " ${C_G}ok${C_0} $*"; }
|
||||||
|
warn() { printf '%s\n' " ${C_Y}warning${C_0} $*" >&2; }
|
||||||
|
err() { printf '%s\n' " ${C_R}error${C_0} $*" >&2; }
|
||||||
|
die() { err "$*"; exit 1; }
|
||||||
|
|
||||||
|
# run echoes a command and executes it, unless this is a dry run.
|
||||||
|
#
|
||||||
|
# It discards the command's own stdout itself. Callers must not add their own
|
||||||
|
# >/dev/null: that would also hide the "would run" line, leaving a dry run
|
||||||
|
# showing none of the commands it was about to execute.
|
||||||
|
run() {
|
||||||
|
if [ "$DRY_RUN" = 1 ]; then
|
||||||
|
printf ' would run:'; printf ' %q' "$@"; printf '\n'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
"$@" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
preflight() {
|
||||||
|
command -v "$DOCKER_BIN" >/dev/null 2>&1 || die "$DOCKER_BIN is not on PATH"
|
||||||
|
if ! $DOCKER version >/dev/null 2>&1; then
|
||||||
|
die "cannot talk to the docker daemon (try --sudo, or add your user to the docker group)"
|
||||||
|
fi
|
||||||
|
command -v gzip >/dev/null 2>&1 || warn "gzip is missing; compressed payloads cannot be restored"
|
||||||
|
local srv
|
||||||
|
srv="$($DOCKER version --format '{{.Server.Version}}' 2>/dev/null || echo unknown)"
|
||||||
|
log "docker server $srv on $(uname -s) $(uname -m)"
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
[ "$ASSUME_YES" = 1 ] && return 0
|
||||||
|
[ "$DRY_RUN" = 1 ] && return 0
|
||||||
|
printf '%s' "Proceed? [y/N] "
|
||||||
|
local ans; read -r ans </dev/tty || ans=""
|
||||||
|
case "$ans" in y|Y|yes|YES) return 0 ;; *) echo "aborted"; exit 1 ;; esac
|
||||||
|
}
|
||||||
|
|
||||||
|
selected() {
|
||||||
|
[ -z "$ONLY" ] && return 0
|
||||||
|
local want
|
||||||
|
IFS=, read -ra want <<< "$ONLY"
|
||||||
|
local n
|
||||||
|
for n in "${want[@]}"; do [ "$n" = "$1" ] && return 0; done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
object_exists() { # kind name
|
||||||
|
$DOCKER "$1" inspect "$2" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
free_name() { # base -> an unused container name
|
||||||
|
local base="$1" candidate="$1$RENAME_SUFFIX" i=2
|
||||||
|
while object_exists container "$candidate"; do
|
||||||
|
candidate="$base$RENAME_SUFFIX-$i"; i=$((i+1))
|
||||||
|
[ "$i" -gt 50 ] && die "no free name based on $base"
|
||||||
|
done
|
||||||
|
printf '%s' "$candidate"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_network() { # name, then the full docker network create argv
|
||||||
|
local name="$1"; shift
|
||||||
|
if object_exists network "$name"; then
|
||||||
|
log "network $name already exists; reusing it"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "creating network $name"
|
||||||
|
run $DOCKER "$@" || { err "could not create network $name"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_volume() { # name, then the full docker volume create argv
|
||||||
|
local name="$1"; shift
|
||||||
|
if object_exists volume "$name"; then
|
||||||
|
warn "volume $name already exists; restored data will be merged into it"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "creating volume $name"
|
||||||
|
run $DOCKER "$@" || { err "could not create volume $name"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dir() { # host path for a bind mount
|
||||||
|
if [ -e "$1" ]; then return 0; fi
|
||||||
|
log "creating host directory $1"
|
||||||
|
if [ "$DRY_RUN" = 1 ]; then printf ' would run: mkdir -p %q\n' "$1"; return 0; fi
|
||||||
|
mkdir -p "$1" 2>/dev/null || sudo mkdir -p "$1" || { err "cannot create $1"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_image_pull() { # ref
|
||||||
|
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
|
||||||
|
log "pulling image $1"
|
||||||
|
run $DOCKER pull "$1" || { err "could not pull $1"; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_image_load() { # ref relpath sha256 compressed
|
||||||
|
if object_exists image "$1"; then log "image $1 already present"; return 0; fi
|
||||||
|
verify_payload "$2" "$3" || return 1
|
||||||
|
log "loading image $1 from $2"
|
||||||
|
if [ "$DRY_RUN" = 1 ]; then printf ' would run: docker load < %q\n' "$2"; return 0; fi
|
||||||
|
if [ "$4" = 1 ]; then gzip -dc -- "$PKGDIR/$2" | $DOCKER load >/dev/null
|
||||||
|
else $DOCKER load >/dev/null < "$PKGDIR/$2"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_payload() { # relpath sha256
|
||||||
|
[ -f "$PKGDIR/$1" ] || { err "payload missing from package: $1"; return 1; }
|
||||||
|
[ "$VERIFY" = 1 ] || return 0
|
||||||
|
if ! command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
warn "sha256sum not available; skipping checksum verification"
|
||||||
|
VERIFY=0
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local got
|
||||||
|
got="$(sha256sum "$PKGDIR/$1" | cut -d' ' -f1)"
|
||||||
|
if [ "$got" != "$2" ]; then
|
||||||
|
err "checksum mismatch for $1 (package is corrupt or truncated)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
feed_archive() { # relpath compressed container extract_into
|
||||||
|
if [ "$DRY_RUN" = 1 ]; then
|
||||||
|
printf ' would restore %q into %s:%s\n' "$1" "$3" "$4"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$2" = 1 ]; then
|
||||||
|
gzip -dc -- "$PKGDIR/$1" | $DOCKER cp -a - "$3:$4"
|
||||||
|
else
|
||||||
|
$DOCKER cp -a - "$3:$4" < "$PKGDIR/$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# resolve_mount prints the volume name, or the host path, backing a mount
|
||||||
|
# destination in a container that already exists.
|
||||||
|
resolve_mount() { # container destination
|
||||||
|
local d n s
|
||||||
|
while IFS='|' read -r d n s; do
|
||||||
|
if [ "$d" = "$2" ]; then
|
||||||
|
if [ -n "$n" ]; then printf '%s' "$n"; else printf '%s' "$s"; fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done < <($DOCKER inspect --format '{{range .Mounts}}{{.Destination}}|{{.Name}}|{{.Source}}{{"\n"}}{{end}}' "$1")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# seed_readonly fills a mount the container declares read-only. The same volume
|
||||||
|
# or host path is attached writable to a throwaway container, which is created
|
||||||
|
# but never started, and removed straight after.
|
||||||
|
seed_readonly() { # container image destination relpath compressed
|
||||||
|
if [ "$DRY_RUN" = 1 ]; then
|
||||||
|
printf ' would seed read-only mount %s from %q via a staging container\n' "$3" "$4"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local store base stage mountat
|
||||||
|
store="$(resolve_mount "$1" "$3")" || { err "cannot resolve storage behind $3"; return 1; }
|
||||||
|
base="${3##*/}"
|
||||||
|
stage="dm-stage-$$-${RANDOM}"
|
||||||
|
mountat="/__docker_migrate/$base"
|
||||||
|
$DOCKER create --name "$stage" --volume "$store:$mountat" "$2" >/dev/null \
|
||||||
|
|| { err "could not create staging container for $3"; return 1; }
|
||||||
|
local rc=0
|
||||||
|
if [ "$5" = 1 ]; then
|
||||||
|
gzip -dc -- "$PKGDIR/$4" | $DOCKER cp -a - "$stage:/__docker_migrate" || rc=$?
|
||||||
|
else
|
||||||
|
$DOCKER cp -a - "$stage:/__docker_migrate" < "$PKGDIR/$4" || rc=$?
|
||||||
|
fi
|
||||||
|
$DOCKER rm -f "$stage" >/dev/null 2>&1 || true
|
||||||
|
if [ "$rc" != 0 ]; then
|
||||||
|
err "failed to seed read-only mount $3"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_running() { # container
|
||||||
|
[ "$DRY_RUN" = 1 ] && return 0
|
||||||
|
sleep 2
|
||||||
|
local st
|
||||||
|
st="$($DOCKER inspect --format '{{.State.Status}}' "$1" 2>/dev/null || echo missing)"
|
||||||
|
if [ "$st" != "running" ]; then
|
||||||
|
err "$1 is not running (status: $st); last log lines:"
|
||||||
|
$DOCKER logs --tail 20 "$1" 2>&1 | sed 's/^/ /' || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
func renderReadme(name string, prepared []*Prepared, opts spec.Options) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "Docker migration package: %s\n", name)
|
||||||
|
fmt.Fprintf(&b, "%s\n\n", strings.Repeat("=", 27+len(name)))
|
||||||
|
b.WriteString("How to use this package\n")
|
||||||
|
b.WriteString("-----------------------\n")
|
||||||
|
b.WriteString("1. Copy this whole directory (or tar file) to the target host.\n")
|
||||||
|
b.WriteString("2. On the target host, unpack it if needed and run:\n\n")
|
||||||
|
b.WriteString(" ./install.sh --dry-run # review every command first\n")
|
||||||
|
b.WriteString(" ./install.sh # actually restore\n\n")
|
||||||
|
b.WriteString("The target host needs: bash, gzip, and a working docker CLI.\n")
|
||||||
|
b.WriteString("Nothing else is installed and no network access is required unless a\n")
|
||||||
|
b.WriteString("container's image is set to be pulled instead of carried.\n\n")
|
||||||
|
|
||||||
|
b.WriteString("Contents\n")
|
||||||
|
b.WriteString("--------\n")
|
||||||
|
b.WriteString(" install.sh self-contained restore script (read it, it is plain bash)\n")
|
||||||
|
b.WriteString(" manifest.json machine-readable description of everything in here\n")
|
||||||
|
b.WriteString(" images/ docker image archives\n")
|
||||||
|
b.WriteString(" data/ volume and bind mount contents, one tar per mount\n\n")
|
||||||
|
|
||||||
|
b.WriteString("Containers in this package\n")
|
||||||
|
b.WriteString("--------------------------\n")
|
||||||
|
for _, p := range prepared {
|
||||||
|
fmt.Fprintf(&b, " %s (image %s)\n", p.ContainerName(), p.Target.Image)
|
||||||
|
for _, t := range p.Transfers {
|
||||||
|
fmt.Fprintf(&b, " data: %s\n", t.Label)
|
||||||
|
}
|
||||||
|
for _, m := range p.Target.Mounts {
|
||||||
|
if m.Kind == spec.MountBind {
|
||||||
|
fmt.Fprintf(&b, " writes host path: %s\n", m.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.DryRun {
|
||||||
|
b.WriteString("\nNOTE: this package was built in dry-run mode and contains no data archives.\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectBinds(prepared []*Prepared) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, p := range prepared {
|
||||||
|
for _, m := range p.Target.Mounts {
|
||||||
|
if m.Kind == spec.MountBind && !p.Render.DropMounts[m.Destination] && !seen[m.Source] {
|
||||||
|
seen[m.Source] = true
|
||||||
|
out = append(out, m.Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSpecialBind reports paths that must never be created by the installer,
|
||||||
|
// because they are kernel or daemon sockets rather than data directories.
|
||||||
|
func isSpecialBind(p string) bool {
|
||||||
|
switch p {
|
||||||
|
case "/var/run/docker.sock", "/run/docker.sock", "/proc", "/sys", "/dev", "/":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(p, "/proc/") || strings.HasPrefix(p, "/sys/") || strings.HasPrefix(p, "/dev/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolArg(b bool) string {
|
||||||
|
if b {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultConflict(c spec.ConflictPolicy) spec.ConflictPolicy {
|
||||||
|
if c == "" {
|
||||||
|
return spec.ConflictFail
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSuffix(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-migrated"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDash(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeDoubleQuoted makes a value safe to interpolate inside a double-quoted
|
||||||
|
// shell string in the generated script.
|
||||||
|
func escapeDoubleQuoted(s string) string {
|
||||||
|
r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`)
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func buildPrepared(t *testing.T, c *spec.Container, vols []spec.Volume, nets []spec.Network) *Prepared {
|
||||||
|
t.Helper()
|
||||||
|
sel := spec.DefaultSelection(c)
|
||||||
|
sel.Include = true
|
||||||
|
p, err := Prepare(c, sel, vols, nets)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixture(t *testing.T) ([]*Prepared, *spec.Manifest, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
c := &spec.Container{
|
||||||
|
ID: "id1", Name: "shop-db", State: "running", Image: "postgres:16",
|
||||||
|
Env: []string{"POSTGRES_PASSWORD=p'w\"d $(whoami)"},
|
||||||
|
Labels: map[string]string{"note": "a;b`c`"},
|
||||||
|
Mounts: []spec.Mount{
|
||||||
|
{Kind: spec.MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"},
|
||||||
|
{Kind: spec.MountBind, Source: "/srv/shop/initdb", Destination: "/docker-entrypoint-initdb.d", ReadOnly: true},
|
||||||
|
},
|
||||||
|
Endpoints: []spec.Endpoint{{Network: "shopnet"}},
|
||||||
|
Ports: []spec.PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}},
|
||||||
|
}
|
||||||
|
p := buildPrepared(t,
|
||||||
|
c,
|
||||||
|
[]spec.Volume{{Name: "pgdata", Driver: "local"}},
|
||||||
|
[]spec.Network{{Name: "shopnet", Driver: "bridge"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
man := &spec.Manifest{
|
||||||
|
FormatVersion: 1,
|
||||||
|
CreatedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
|
||||||
|
SourceHost: "old-host",
|
||||||
|
DockerVersion: "27.0.0",
|
||||||
|
Options: spec.DefaultOptions(),
|
||||||
|
Payloads: []spec.Payload{
|
||||||
|
{Path: "images/postgres_16.tar.gz", Kind: "image", Image: "postgres:16", SHA256: "aa", Compressed: true},
|
||||||
|
{Path: "data/shop-db/00-var_lib_postgresql_data.tar.gz", Kind: "mount",
|
||||||
|
Container: "shop-db", Destination: "/var/lib/postgresql/data", SHA256: "bb", Compressed: true},
|
||||||
|
{Path: "data/shop-db/01-docker-entrypoint-initdb.d.tar.gz", Kind: "mount",
|
||||||
|
Container: "shop-db", Destination: "/docker-entrypoint-initdb.d", SHA256: "cc", Compressed: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return []*Prepared{p}, man, map[string]string{"postgres:16": "images/postgres_16.tar.gz"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerIsValidBash(t *testing.T) {
|
||||||
|
bash, err := exec.LookPath("bash")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("bash is not available on this machine")
|
||||||
|
}
|
||||||
|
prepared, man, images := fixture(t)
|
||||||
|
script := renderInstaller(prepared, man, images, "shop-migration")
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "install.sh")
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, err := exec.Command(bash, "-n", path).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generated installer is not valid bash: %v\n%s\n---\n%s", err, out, numbered(script))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstallerRunsCleanlyInDryRun executes the generated script against a
|
||||||
|
// stub docker, which is the closest thing to a real run that does not need a
|
||||||
|
// docker daemon.
|
||||||
|
func TestInstallerDryRunExecutes(t *testing.T) {
|
||||||
|
bash, err := exec.LookPath("bash")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("bash is not available on this machine")
|
||||||
|
}
|
||||||
|
prepared, man, images := fixture(t)
|
||||||
|
script := renderInstaller(prepared, man, images, "shop-migration")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The installer checks that every payload is present even on a dry run, so
|
||||||
|
// that an incomplete package is reported before anything is changed.
|
||||||
|
writePayloads(t, dir, man)
|
||||||
|
binDir := writeStubDocker(t, dir, false)
|
||||||
|
|
||||||
|
env := append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
run := func(args ...string) (string, error) {
|
||||||
|
cmd := exec.Command(bash, append([]string{"./install.sh"}, args...)...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = env
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payload contents here are placeholders, so checksums are skipped;
|
||||||
|
// the real checksums are exercised by the end-to-end test.
|
||||||
|
text, err := run("--dry-run", "--yes", "--skip-verify")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry run failed: %v\n%s", err, text)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"shop-db", "would run", "migration complete", "creating network shopnet"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("dry run output missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A package whose payload does not match its checksum must be refused,
|
||||||
|
// rather than restoring truncated data.
|
||||||
|
corrupt, err := run("--dry-run", "--yes")
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("a payload with a bad checksum was accepted:\n%s", corrupt)
|
||||||
|
} else if !strings.Contains(corrupt, "checksum mismatch") {
|
||||||
|
t.Errorf("expected a checksum mismatch error, got:\n%s", corrupt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstallerReportsFailedStart guards against the worst failure mode there
|
||||||
|
// is: reporting a successful migration when the container never started.
|
||||||
|
//
|
||||||
|
// The per-container work runs inside a function invoked from an `if !` test,
|
||||||
|
// which disables `set -e` for that whole function body, so every command has to
|
||||||
|
// be checked explicitly or its failure is silently discarded.
|
||||||
|
func TestInstallerReportsFailedStart(t *testing.T) {
|
||||||
|
bash, err := exec.LookPath("bash")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("bash is not available on this machine")
|
||||||
|
}
|
||||||
|
prepared, man, images := fixture(t)
|
||||||
|
script := renderInstaller(prepared, man, images, "shop-migration")
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "install.sh"), []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writePayloads(t, dir, man)
|
||||||
|
|
||||||
|
binDir := writeStubDocker(t, dir, true)
|
||||||
|
|
||||||
|
cmd := exec.Command(bash, "./install.sh", "--yes", "--skip-verify")
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
text := string(out)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("the installer exited 0 even though the container never started:\n%s", text)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "migration complete") {
|
||||||
|
t.Errorf("the installer claimed the migration completed:\n%s", text)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"could not start", "container(s) failed"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("expected the output to contain %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstallerQuotesHostileValues makes sure values taken from container
|
||||||
|
// metadata cannot break out of the generated script.
|
||||||
|
func TestInstallerQuotesHostileValues(t *testing.T) {
|
||||||
|
prepared, man, images := fixture(t)
|
||||||
|
script := renderInstaller(prepared, man, images, "shop-migration")
|
||||||
|
|
||||||
|
// The password contains a quote, a double quote and a command
|
||||||
|
// substitution; none of it may appear unquoted.
|
||||||
|
if strings.Contains(script, "POSTGRES_PASSWORD=p'w\"d $(whoami)") {
|
||||||
|
t.Error("environment value was interpolated without quoting")
|
||||||
|
}
|
||||||
|
if !strings.Contains(script, `'POSTGRES_PASSWORD=p'\''w"d $(whoami)'`) {
|
||||||
|
t.Errorf("environment value is not quoted as expected:\n%s", grepLines(script, "POSTGRES_PASSWORD"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerHandlesReadOnlyMountThroughStaging(t *testing.T) {
|
||||||
|
prepared, man, images := fixture(t)
|
||||||
|
script := renderInstaller(prepared, man, images, "shop-migration")
|
||||||
|
|
||||||
|
if !strings.Contains(script, "seed_readonly") {
|
||||||
|
t.Error("read-only mount must be seeded through a staging container")
|
||||||
|
}
|
||||||
|
// The writable volume is fed into the real container directly. Shell-safe
|
||||||
|
// paths are emitted without quotes, which is what ShellQuote does.
|
||||||
|
want := `feed_archive data/shop-db/00-var_lib_postgresql_data.tar.gz 1 "$CNAME" /var/lib/postgresql`
|
||||||
|
if !strings.Contains(script, want) {
|
||||||
|
t.Errorf("writable volume restore command is wrong:\nwant a line containing: %s\ngot:\n%s",
|
||||||
|
want, grepLines(script, "feed_archive"))
|
||||||
|
}
|
||||||
|
// Restoring must target the parent directory, never the mount point itself,
|
||||||
|
// because the archive entries are already rooted at the last segment.
|
||||||
|
if strings.Contains(script, `"$CNAME" /var/lib/postgresql/data`) {
|
||||||
|
t.Error("archive is being extracted into the mount point instead of its parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallerVerifiesChecksums(t *testing.T) {
|
||||||
|
prepared, man, images := fixture(t)
|
||||||
|
script := renderInstaller(prepared, man, images, "shop-migration")
|
||||||
|
|
||||||
|
// Every payload in the manifest must be checksummed before it is fed to
|
||||||
|
// docker, so a truncated package fails loudly instead of restoring garbage.
|
||||||
|
for _, p := range man.Payloads {
|
||||||
|
var want string
|
||||||
|
if p.Kind == "image" {
|
||||||
|
want = "ensure_image_load " + spec.ShellQuote(p.Image) + " " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
|
||||||
|
} else {
|
||||||
|
want = "verify_payload " + spec.ShellQuote(p.Path) + " " + spec.ShellQuote(p.SHA256)
|
||||||
|
}
|
||||||
|
if !strings.Contains(script, want) {
|
||||||
|
t.Errorf("payload %s is not verified\nwant a line containing: %s\ngot:\n%s",
|
||||||
|
p.Path, want, grepLines(script, "verify_payload"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func numbered(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, line := range strings.Split(s, "\n") {
|
||||||
|
b.WriteString(strings.TrimRight(line, "\r"))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
if i > 200 {
|
||||||
|
b.WriteString("...\n")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func grepLines(s, needle string) string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.Contains(l, needle) {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePayloads materialises every payload the manifest references as a real
|
||||||
|
// gzipped tar, so the generated installer's gzip and docker cp steps behave the
|
||||||
|
// way they would with a genuine package.
|
||||||
|
func writePayloads(t *testing.T, dir string, man *spec.Manifest) {
|
||||||
|
t.Helper()
|
||||||
|
for _, p := range man.Payloads {
|
||||||
|
full := filepath.Join(dir, filepath.FromSlash(p.Path))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
body := []byte("payload for " + p.Path + "\n")
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "placeholder.txt", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := gz.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeStubDocker installs a fake docker CLI on PATH and returns its directory.
|
||||||
|
//
|
||||||
|
// It models just enough of the real thing for the installer to run without a
|
||||||
|
// daemon: nothing exists yet except the image, and `docker inspect --format`
|
||||||
|
// answers the mount lookup the read-only seeding path depends on. When
|
||||||
|
// failStart is set, `docker start` fails the way it does on a target whose
|
||||||
|
// published port is already taken.
|
||||||
|
func writeStubDocker(t *testing.T, dir string, failStart bool) string {
|
||||||
|
t.Helper()
|
||||||
|
startCase := ""
|
||||||
|
if failStart {
|
||||||
|
startCase = ` start) echo "Bind for 0.0.0.0:5432 failed: port is already allocated" >&2; exit 1 ;;` + "\n"
|
||||||
|
}
|
||||||
|
stub := `#!/usr/bin/env bash
|
||||||
|
# object existence probes: "docker <kind> inspect <name>"
|
||||||
|
case "$1 $2" in
|
||||||
|
"image inspect") exit 0 ;;
|
||||||
|
"container inspect"|"volume inspect"|"network inspect") exit 1 ;;
|
||||||
|
esac
|
||||||
|
case "$1" in
|
||||||
|
version) echo 27.0.0 ;;
|
||||||
|
# resolve_mount calls "docker inspect --format <tmpl> <container>"
|
||||||
|
inspect) echo "/docker-entrypoint-initdb.d|stub-volume|" ;;
|
||||||
|
` + startCase + `esac
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
binDir := filepath.Join(dir, "bin")
|
||||||
|
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(binDir, "docker"), []byte(stub), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return binDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/dkr"
|
||||||
|
"github.com/arescom/docker-migrate/internal/job"
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PackageFormat selects how the finished package is laid out on disk.
|
||||||
|
type PackageFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FormatDir leaves an unpacked directory, easiest to inspect and to copy
|
||||||
|
// onto a USB stick that is already mounted.
|
||||||
|
FormatDir PackageFormat = "dir"
|
||||||
|
// FormatTar produces a single .tar file, easiest to move around.
|
||||||
|
FormatTar PackageFormat = "tar"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Packager writes a self-contained migration package: the container specs, the
|
||||||
|
// data archives, optionally the images, and a shell installer that replays it
|
||||||
|
// all on a target that has nothing but docker.
|
||||||
|
type Packager struct {
|
||||||
|
Src *dkr.Client
|
||||||
|
Containers []spec.Container
|
||||||
|
Volumes []spec.Volume
|
||||||
|
Networks []spec.Network
|
||||||
|
Plan spec.Plan
|
||||||
|
|
||||||
|
// OutputDir is the directory packages are created under.
|
||||||
|
OutputDir string
|
||||||
|
// Format selects a directory or a single tar file.
|
||||||
|
Format PackageFormat
|
||||||
|
// SourceHost is recorded in the manifest.
|
||||||
|
SourceHost string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result describes the produced package.
|
||||||
|
type Result struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run builds the package, reporting progress into j.
|
||||||
|
func (p *Packager) Run(ctx context.Context, j *job.Job) (*Result, error) {
|
||||||
|
opts := p.Plan.Options
|
||||||
|
name := p.Plan.PackageName
|
||||||
|
if name == "" {
|
||||||
|
name = "docker-migration-" + time.Now().Format("20060102-150405")
|
||||||
|
}
|
||||||
|
name = sanitize(name)
|
||||||
|
|
||||||
|
root := filepath.Join(p.OutputDir, name)
|
||||||
|
if _, err := os.Stat(root); err == nil {
|
||||||
|
return nil, fmt.Errorf("package %s already exists in %s", name, p.OutputDir)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create package directory: %w", err)
|
||||||
|
}
|
||||||
|
cleanup := true
|
||||||
|
defer func() {
|
||||||
|
if cleanup {
|
||||||
|
os.RemoveAll(root)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
byID := map[string]*spec.Container{}
|
||||||
|
for i := range p.Containers {
|
||||||
|
byID[p.Containers[i].ID] = &p.Containers[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var prepared []*Prepared
|
||||||
|
for _, sel := range p.Plan.Items {
|
||||||
|
if !sel.Include {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pr, err := Prepare(byID[sel.ContainerID], sel, p.Volumes, p.Networks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("container %s: %w", sel.ContainerID, err)
|
||||||
|
}
|
||||||
|
prepared = append(prepared, pr)
|
||||||
|
}
|
||||||
|
if len(prepared) == 0 {
|
||||||
|
return nil, errors.New("nothing selected to migrate")
|
||||||
|
}
|
||||||
|
|
||||||
|
man := spec.Manifest{
|
||||||
|
FormatVersion: 1,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
CreatedBy: "docker-migrate",
|
||||||
|
SourceHost: p.SourceHost,
|
||||||
|
Options: opts,
|
||||||
|
Items: p.Plan.Items,
|
||||||
|
}
|
||||||
|
if v, err := p.Src.Ping(ctx); err == nil {
|
||||||
|
man.DockerVersion = v
|
||||||
|
}
|
||||||
|
|
||||||
|
savedImages := map[string]string{} // image ref -> payload path
|
||||||
|
|
||||||
|
for _, pr := range prepared {
|
||||||
|
item := j.AddItem(pr.Source.ID, pr.Source.Name)
|
||||||
|
for _, n := range pr.Source.Warnings {
|
||||||
|
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
|
||||||
|
}
|
||||||
|
for _, n := range pr.Notes {
|
||||||
|
j.AddItemWarning(item, "%s: %s", pr.Source.Name, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := p.packOne(ctx, j, item, root, pr, opts, &man, savedImages)
|
||||||
|
if err != nil {
|
||||||
|
j.SetItemState(item, job.StateFailed, err)
|
||||||
|
return nil, fmt.Errorf("%s: %w", pr.Source.Name, err)
|
||||||
|
}
|
||||||
|
j.SetItemState(item, job.StateSucceeded, nil)
|
||||||
|
|
||||||
|
man.Containers = append(man.Containers, *pr.Target)
|
||||||
|
man.Volumes = append(man.Volumes, pr.Volumes...)
|
||||||
|
for _, n := range pr.Networks {
|
||||||
|
if !hasNetwork(man.Networks, n.Name) {
|
||||||
|
man.Networks = append(man.Networks, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeJSON(filepath.Join(root, "manifest.json"), man); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
installer := renderInstaller(prepared, &man, savedImages, name)
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "install.sh"), []byte(installer), 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("write installer: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "README.txt"), []byte(renderReadme(name, prepared, opts)), 0o644); err != nil {
|
||||||
|
return nil, fmt.Errorf("write readme: %w", err)
|
||||||
|
}
|
||||||
|
j.Logf(job.LevelInfo, "", "wrote installer, manifest and readme")
|
||||||
|
|
||||||
|
if p.Format == FormatTar {
|
||||||
|
tarPath := root + ".tar"
|
||||||
|
j.Logf(job.LevelInfo, "", "packing %s into a single archive", name)
|
||||||
|
size, err := tarDirectory(ctx, root, tarPath, name)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tarPath)
|
||||||
|
return nil, fmt.Errorf("create package archive: %w", err)
|
||||||
|
}
|
||||||
|
os.RemoveAll(root)
|
||||||
|
cleanup = false
|
||||||
|
j.SetArtifact(tarPath, size)
|
||||||
|
return &Result{Path: tarPath, Bytes: size, Name: name + ".tar"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
size, _ := dirSize(root)
|
||||||
|
cleanup = false
|
||||||
|
j.SetArtifact(root, size)
|
||||||
|
return &Result{Path: root, Bytes: size, Name: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packager) packOne(
|
||||||
|
ctx context.Context, j *job.Job, item *job.Item, root string,
|
||||||
|
pr *Prepared, opts spec.Options, man *spec.Manifest, savedImages map[string]string,
|
||||||
|
) error {
|
||||||
|
compress := opts.Compress
|
||||||
|
level := opts.CompressLevel
|
||||||
|
|
||||||
|
// Image.
|
||||||
|
if pr.Selection.MigrateImage && pr.Selection.ImageMode != spec.ImageSkip && pr.Selection.ImageMode != spec.ImagePull {
|
||||||
|
ref := pr.Target.Image
|
||||||
|
if _, done := savedImages[ref]; !done {
|
||||||
|
size := p.Src.ImageSizeBytes(ctx, ref)
|
||||||
|
st := j.AddStep(item, "image", "save image "+ref, size)
|
||||||
|
j.StartStep(st)
|
||||||
|
if opts.DryRun {
|
||||||
|
j.SkipStep(st, "dry run: image not written")
|
||||||
|
} else {
|
||||||
|
rel := filepath.ToSlash(filepath.Join("images", sanitize(ref)+tarExt(compress)))
|
||||||
|
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
|
||||||
|
func() (io.ReadCloser, error) { return p.Src.SaveImage(ctx, ref) })
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("save image %s: %w", ref, err)
|
||||||
|
}
|
||||||
|
payload.Kind, payload.Image = "image", ref
|
||||||
|
man.Payloads = append(man.Payloads, *payload)
|
||||||
|
savedImages[ref] = rel
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
st := j.AddStep(item, "image", "image "+ref+" already in package", 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
j.SkipStep(st, "shared with another container")
|
||||||
|
}
|
||||||
|
} else if pr.Selection.ImageMode == spec.ImagePull {
|
||||||
|
j.Logf(job.LevelInfo, item.ID, "%s: image %s will be pulled by the installer", pr.Source.Name, pr.Target.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data. The source container is stopped for the duration when asked.
|
||||||
|
if len(pr.Transfers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
restore, err := p.quiesce(ctx, j, item, pr, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if restore != nil {
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for i, t := range pr.Transfers {
|
||||||
|
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
|
||||||
|
j.StartStep(st)
|
||||||
|
if opts.DryRun {
|
||||||
|
j.SkipStep(st, "dry run: data not written")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel := filepath.ToSlash(filepath.Join("data", sanitize(pr.ContainerName()),
|
||||||
|
fmt.Sprintf("%02d-%s%s", i, sanitize(strings.Trim(t.Destination, "/")), tarExt(compress))))
|
||||||
|
payload, err := p.streamToFile(ctx, j, st, filepath.Join(root, filepath.FromSlash(rel)), rel, compress, level,
|
||||||
|
func() (io.ReadCloser, error) { return p.Src.CopyOut(ctx, pr.Source.ID, t.SourcePath) })
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("archive %s: %w", t.Label, err)
|
||||||
|
}
|
||||||
|
payload.Kind = "mount"
|
||||||
|
payload.Container = pr.ContainerName()
|
||||||
|
payload.Destination = t.Destination
|
||||||
|
man.Payloads = append(man.Payloads, *payload)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamToFile copies a stream to a file inside the package, optionally
|
||||||
|
// gzipping it, while counting bytes and computing a checksum.
|
||||||
|
func (p *Packager) streamToFile(
|
||||||
|
ctx context.Context, j *job.Job, st *job.Step,
|
||||||
|
absPath, relPath string, compress bool, level int,
|
||||||
|
open func() (io.ReadCloser, error),
|
||||||
|
) (*spec.Payload, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
src, err := open()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
f, err := os.Create(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
hash := sha256.New()
|
||||||
|
// The checksum covers the bytes as stored, so the installer can verify the
|
||||||
|
// file it is about to feed to docker.
|
||||||
|
out := io.MultiWriter(f, hash)
|
||||||
|
|
||||||
|
counted := job.NewCountingReader(src, j, st)
|
||||||
|
var copyErr error
|
||||||
|
if compress {
|
||||||
|
gz, gerr := gzip.NewWriterLevel(out, gzipLevel(nil, level))
|
||||||
|
if gerr != nil {
|
||||||
|
return nil, gerr
|
||||||
|
}
|
||||||
|
_, copyErr = io.Copy(gz, counted)
|
||||||
|
if cerr := gz.Close(); copyErr == nil {
|
||||||
|
copyErr = cerr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, copyErr = io.Copy(out, counted)
|
||||||
|
}
|
||||||
|
counted.Flush()
|
||||||
|
if copyErr != nil {
|
||||||
|
return nil, copyErr
|
||||||
|
}
|
||||||
|
if err := f.Sync(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
return &spec.Payload{
|
||||||
|
Path: relPath,
|
||||||
|
Bytes: info.Size(),
|
||||||
|
SHA256: hex.EncodeToString(hash.Sum(nil)),
|
||||||
|
Compressed: compress,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Packager) quiesce(ctx context.Context, j *job.Job, item *job.Item, pr *Prepared, opts spec.Options) (func(), error) {
|
||||||
|
if opts.DryRun {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
wasRunning := pr.Source.State == "running"
|
||||||
|
if !pr.Selection.StopSourceDuringCopy {
|
||||||
|
if wasRunning {
|
||||||
|
j.AddItemWarning(item,
|
||||||
|
"archiving %s while it is running; data written during the copy may be inconsistent", pr.Source.Name)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !wasRunning {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
st := j.AddStep(item, "quiesce", "stop source "+pr.Source.Name, 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
err := p.Src.Stop(ctx, pr.Source.ID, 30*time.Second)
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stop source container: %w", err)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
// Building a package does not move the workload anywhere, so the source
|
||||||
|
// is always put back the way it was found.
|
||||||
|
if err := p.Src.Start(context.WithoutCancel(ctx), pr.Source.ID); err != nil {
|
||||||
|
j.AddItemWarning(item, "could not restart source container: %v", err)
|
||||||
|
} else {
|
||||||
|
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", pr.Source.Name)
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tarExt(compress bool) string {
|
||||||
|
if compress {
|
||||||
|
return ".tar.gz"
|
||||||
|
}
|
||||||
|
return ".tar"
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(path string, v any) error {
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNetwork(ns []spec.Network, name string) bool {
|
||||||
|
for _, n := range ns {
|
||||||
|
if n.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarDirectory packs a package directory into a single tar file, keeping the
|
||||||
|
// directory name as the archive's top-level entry.
|
||||||
|
func tarDirectory(ctx context.Context, dir, dest, prefix string) (int64, error) {
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
tw := tar.NewWriter(f)
|
||||||
|
|
||||||
|
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(dir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
name := prefix
|
||||||
|
if rel != "." {
|
||||||
|
name = prefix + "/" + filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
hdr, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hdr.Name = name
|
||||||
|
if info.IsDir() {
|
||||||
|
hdr.Name += "/"
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(hdr); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
src, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
_, err = io.Copy(tw, src)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
tw.Close()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return info.Size(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirSize(dir string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Package migrate turns a plan into work: either commands executed on a target
|
||||||
|
// host over SSH, or a self-contained package that can be carried to the target
|
||||||
|
// on a disk.
|
||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prepared is one container resolved against the user's selection: the spec as
|
||||||
|
// it will exist on the target, plus the list of data locations to transfer.
|
||||||
|
type Prepared struct {
|
||||||
|
// Source is the container as read from the source host.
|
||||||
|
Source *spec.Container
|
||||||
|
// Target is the same container rewritten for the target: renamed mounts,
|
||||||
|
// relocated binds, dropped mounts and an optional new container name.
|
||||||
|
Target *spec.Container
|
||||||
|
// Selection is the user's answer for this container.
|
||||||
|
Selection spec.ItemSelection
|
||||||
|
// Transfers are the mounts whose contents must be copied, in target terms.
|
||||||
|
Transfers []Transfer
|
||||||
|
// Volumes are the named volumes to create on the target.
|
||||||
|
Volumes []spec.Volume
|
||||||
|
// Networks are the user-defined networks to create on the target.
|
||||||
|
Networks []spec.Network
|
||||||
|
// Render carries the flags that shape the generated docker create command.
|
||||||
|
Render spec.RenderOptions
|
||||||
|
// Notes are advisories to show next to this container.
|
||||||
|
Notes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer is one data location to copy from source to target.
|
||||||
|
type Transfer struct {
|
||||||
|
// SourcePath is the path inside the source container to read from.
|
||||||
|
SourcePath string
|
||||||
|
// Destination is the path inside the target container the data belongs at.
|
||||||
|
Destination string
|
||||||
|
// RestoreInto is the directory the tar archive is extracted into, which is
|
||||||
|
// the parent of Destination.
|
||||||
|
RestoreInto string
|
||||||
|
// Kind describes what is behind the destination on the target.
|
||||||
|
Kind spec.MountKind
|
||||||
|
// ReadOnly means the target container mounts this read-only, so the copy
|
||||||
|
// has to go through a staging container.
|
||||||
|
ReadOnly bool
|
||||||
|
// VolumeName is the named volume behind the destination, when known.
|
||||||
|
VolumeName string
|
||||||
|
// BindSource is the host path behind the destination, for bind mounts.
|
||||||
|
BindSource string
|
||||||
|
// SizeBytes is the best-effort size, or -1.
|
||||||
|
SizeBytes int64
|
||||||
|
// Label is a human description used in the progress UI.
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerName returns the name the container will have on the target.
|
||||||
|
func (p *Prepared) ContainerName() string {
|
||||||
|
if p.Render.NameOverride != "" {
|
||||||
|
return p.Render.NameOverride
|
||||||
|
}
|
||||||
|
return p.Source.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare resolves a plan item against the source inventory.
|
||||||
|
func Prepare(
|
||||||
|
src *spec.Container,
|
||||||
|
sel spec.ItemSelection,
|
||||||
|
allVolumes []spec.Volume,
|
||||||
|
allNetworks []spec.Network,
|
||||||
|
) (*Prepared, error) {
|
||||||
|
if src == nil {
|
||||||
|
return nil, fmt.Errorf("container not found in source inventory")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &Prepared{Source: src, Selection: sel}
|
||||||
|
target := *src // shallow copy; mounts are rebuilt below
|
||||||
|
|
||||||
|
p.Render = spec.RenderOptions{
|
||||||
|
NameOverride: sel.NameOverride,
|
||||||
|
KeepStaticIPs: sel.MigrateNetworks && sel.KeepStaticIPs,
|
||||||
|
SkipNetworks: !sel.MigrateNetworks,
|
||||||
|
SkipPorts: !sel.MigratePorts,
|
||||||
|
DropMounts: map[string]bool{},
|
||||||
|
}
|
||||||
|
|
||||||
|
volByName := map[string]spec.Volume{}
|
||||||
|
for _, v := range allVolumes {
|
||||||
|
volByName[v.Name] = v
|
||||||
|
}
|
||||||
|
netByName := map[string]spec.Network{}
|
||||||
|
for _, n := range allNetworks {
|
||||||
|
netByName[n.Name] = n
|
||||||
|
}
|
||||||
|
|
||||||
|
var mounts []spec.Mount
|
||||||
|
seenVolume := map[string]bool{}
|
||||||
|
|
||||||
|
for _, m := range src.Mounts {
|
||||||
|
ms, ok := sel.Mounts[m.Destination]
|
||||||
|
if !ok {
|
||||||
|
// A mount the UI never asked about defaults to being copied, so
|
||||||
|
// data is never silently left behind.
|
||||||
|
ms = spec.MountSelection{Action: spec.MountActionCopy}
|
||||||
|
if m.Kind == spec.MountTmpfs {
|
||||||
|
ms.Action = spec.MountActionStructure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ms.Action == spec.MountActionSkip {
|
||||||
|
p.Render.DropMounts[m.Destination] = true
|
||||||
|
p.Notes = append(p.Notes, "mount "+m.Destination+" is not migrated")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tm := m
|
||||||
|
switch m.Kind {
|
||||||
|
case spec.MountVolume:
|
||||||
|
if ms.TargetName != "" {
|
||||||
|
tm.Name = ms.TargetName
|
||||||
|
}
|
||||||
|
if v, ok := volByName[m.Name]; ok && !seenVolume[tm.Name] {
|
||||||
|
v.Name = tm.Name
|
||||||
|
p.Volumes = append(p.Volumes, v)
|
||||||
|
seenVolume[tm.Name] = true
|
||||||
|
}
|
||||||
|
case spec.MountAnonymous:
|
||||||
|
// Anonymous volumes are recreated as fresh anonymous volumes on
|
||||||
|
// the target; their generated name carries no meaning and the data
|
||||||
|
// is restored through the container path, not the volume name.
|
||||||
|
p.Render.AnonymousVolumesAsAnonymous = true
|
||||||
|
case spec.MountBind:
|
||||||
|
if ms.TargetSource != "" {
|
||||||
|
tm.Source = ms.TargetSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mounts = append(mounts, tm)
|
||||||
|
|
||||||
|
if ms.Action != spec.MountActionCopy || !m.HasData() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if isRootPath(m.Destination) {
|
||||||
|
p.Notes = append(p.Notes, "refusing to copy mount at "+m.Destination+": copying a container root is not supported")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := Transfer{
|
||||||
|
SourcePath: m.Destination,
|
||||||
|
Destination: tm.Destination,
|
||||||
|
RestoreInto: parentDir(tm.Destination),
|
||||||
|
Kind: tm.Kind,
|
||||||
|
ReadOnly: tm.ReadOnly,
|
||||||
|
VolumeName: tm.Name,
|
||||||
|
BindSource: tm.Source,
|
||||||
|
SizeBytes: m.SizeBytes,
|
||||||
|
}
|
||||||
|
switch tm.Kind {
|
||||||
|
case spec.MountVolume:
|
||||||
|
t.Label = "volume " + tm.Name + " -> " + tm.Destination
|
||||||
|
case spec.MountAnonymous:
|
||||||
|
t.Label = "anonymous volume -> " + tm.Destination
|
||||||
|
case spec.MountBind:
|
||||||
|
t.Label = "bind " + tm.Source + " -> " + tm.Destination
|
||||||
|
default:
|
||||||
|
t.Label = string(tm.Kind) + " -> " + tm.Destination
|
||||||
|
}
|
||||||
|
p.Transfers = append(p.Transfers, t)
|
||||||
|
}
|
||||||
|
target.Mounts = mounts
|
||||||
|
|
||||||
|
if sel.MigrateNetworks {
|
||||||
|
for _, ep := range src.Endpoints {
|
||||||
|
if n, ok := netByName[ep.Network]; ok {
|
||||||
|
p.Networks = append(p.Networks, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sel.MigrateImage {
|
||||||
|
p.Notes = append(p.Notes, "image is assumed to already exist on the target")
|
||||||
|
}
|
||||||
|
if sel.MigrateNetworks && sel.KeepStaticIPs {
|
||||||
|
p.Notes = append(p.Notes, "static IP addresses are reapplied; they must fit the target subnets")
|
||||||
|
}
|
||||||
|
p.Target = &target
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StagingMountPath is where a read-only destination is mounted inside the
|
||||||
|
// temporary staging container used to seed it.
|
||||||
|
const stagingRoot = "/__docker_migrate"
|
||||||
|
|
||||||
|
// StagingPaths returns the mount point and the extraction directory used when
|
||||||
|
// seeding a read-only mount through a staging container. The volume is mounted
|
||||||
|
// under a directory named after the destination's last segment so the archive,
|
||||||
|
// whose entries are rooted at that same segment, lands exactly on top of it.
|
||||||
|
func StagingPaths(destination string) (mountAt string, extractInto string) {
|
||||||
|
return path.Join(stagingRoot, path.Base(strings.TrimSuffix(destination, "/"))), stagingRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
// StagingName is the throwaway container name used to seed one read-only mount.
|
||||||
|
func StagingName(container string, index int) string {
|
||||||
|
return fmt.Sprintf("dm-stage-%s-%d", sanitize(container), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parentDir(p string) string {
|
||||||
|
d := path.Dir(strings.TrimSuffix(p, "/"))
|
||||||
|
if d == "" || d == "." {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRootPath(p string) bool {
|
||||||
|
p = strings.TrimSuffix(p, "/")
|
||||||
|
return p == "" || p == "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitize(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.', r == '-':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := b.String()
|
||||||
|
if len(out) > 40 {
|
||||||
|
out = out[:40]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sample() *spec.Container {
|
||||||
|
return &spec.Container{
|
||||||
|
ID: "abc123", Name: "app", State: "running", Image: "app:1.0",
|
||||||
|
Mounts: []spec.Mount{
|
||||||
|
{Kind: spec.MountVolume, Name: "appdata", Destination: "/data", SizeBytes: 4096},
|
||||||
|
{Kind: spec.MountBind, Source: "/srv/app/conf", Destination: "/etc/app", ReadOnly: true},
|
||||||
|
{Kind: spec.MountAnonymous, Name: strings.Repeat("f", 64), Destination: "/tmp/cache"},
|
||||||
|
{Kind: spec.MountTmpfs, Destination: "/run"},
|
||||||
|
},
|
||||||
|
Endpoints: []spec.Endpoint{{Network: "appnet"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareDefaultsCopyEverything(t *testing.T) {
|
||||||
|
c := sample()
|
||||||
|
sel := spec.DefaultSelection(c)
|
||||||
|
sel.Include = true
|
||||||
|
|
||||||
|
p, err := Prepare(c, sel,
|
||||||
|
[]spec.Volume{{Name: "appdata", Driver: "local"}},
|
||||||
|
[]spec.Network{{Name: "appnet", Driver: "bridge"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmpfs carries no data, so exactly the three real locations transfer.
|
||||||
|
if len(p.Transfers) != 3 {
|
||||||
|
t.Fatalf("expected 3 transfers, got %d: %+v", len(p.Transfers), p.Transfers)
|
||||||
|
}
|
||||||
|
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata" {
|
||||||
|
t.Errorf("named volume not scheduled for creation: %+v", p.Volumes)
|
||||||
|
}
|
||||||
|
if len(p.Networks) != 1 {
|
||||||
|
t.Errorf("network not scheduled for creation: %+v", p.Networks)
|
||||||
|
}
|
||||||
|
|
||||||
|
byDest := map[string]Transfer{}
|
||||||
|
for _, tr := range p.Transfers {
|
||||||
|
byDest[tr.Destination] = tr
|
||||||
|
}
|
||||||
|
if got := byDest["/data"].RestoreInto; got != "/" {
|
||||||
|
t.Errorf("/data must be restored into /, got %q", got)
|
||||||
|
}
|
||||||
|
if got := byDest["/etc/app"].RestoreInto; got != "/etc" {
|
||||||
|
t.Errorf("/etc/app must be restored into /etc, got %q", got)
|
||||||
|
}
|
||||||
|
if !byDest["/etc/app"].ReadOnly {
|
||||||
|
t.Error("read-only bind must be flagged so it is seeded through a staging container")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareSkipAndRelocate(t *testing.T) {
|
||||||
|
c := sample()
|
||||||
|
sel := spec.DefaultSelection(c)
|
||||||
|
sel.Include = true
|
||||||
|
sel.Mounts["/tmp/cache"] = spec.MountSelection{Action: spec.MountActionSkip}
|
||||||
|
sel.Mounts["/etc/app"] = spec.MountSelection{Action: spec.MountActionCopy, TargetSource: "/opt/app/conf"}
|
||||||
|
sel.Mounts["/data"] = spec.MountSelection{Action: spec.MountActionStructure, TargetName: "appdata2"}
|
||||||
|
|
||||||
|
p, err := Prepare(c, sel, []spec.Volume{{Name: "appdata", Driver: "local"}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.Render.DropMounts["/tmp/cache"] {
|
||||||
|
t.Error("skipped mount must be dropped from the create command")
|
||||||
|
}
|
||||||
|
// structure-only means the volume is created but no data is copied.
|
||||||
|
for _, tr := range p.Transfers {
|
||||||
|
if tr.Destination == "/data" {
|
||||||
|
t.Error("a structure-only mount must not be transferred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.Volumes) != 1 || p.Volumes[0].Name != "appdata2" {
|
||||||
|
t.Errorf("renamed volume not applied: %+v", p.Volumes)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
|
||||||
|
if !strings.Contains(args, "--volume /opt/app/conf:/etc/app:ro") {
|
||||||
|
t.Errorf("relocated bind not applied: %s", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(args, "--volume appdata2:/data") {
|
||||||
|
t.Errorf("renamed volume not applied to create args: %s", args)
|
||||||
|
}
|
||||||
|
if strings.Contains(args, "/tmp/cache") {
|
||||||
|
t.Errorf("skipped mount still present: %s", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareAnonymousVolumeIsRecreatedFresh(t *testing.T) {
|
||||||
|
c := sample()
|
||||||
|
sel := spec.DefaultSelection(c)
|
||||||
|
sel.Include = true
|
||||||
|
|
||||||
|
p, err := Prepare(c, sel, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := strings.Join(p.Target.CreateArgs(p.Render), " ")
|
||||||
|
if strings.Contains(args, strings.Repeat("f", 64)) {
|
||||||
|
t.Errorf("the generated volume name must not be pinned on the target: %s", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(args, "--volume /tmp/cache") {
|
||||||
|
t.Errorf("anonymous volume must still be declared: %s", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStagingPathsLandArchiveOnTheMountPoint(t *testing.T) {
|
||||||
|
// A tar produced from /var/lib/postgresql/data has entries rooted at
|
||||||
|
// "data/", so the staging container must mount the volume at
|
||||||
|
// <root>/data and extract into <root>.
|
||||||
|
mountAt, into := StagingPaths("/var/lib/postgresql/data")
|
||||||
|
if mountAt != into+"/data" {
|
||||||
|
t.Fatalf("mount point %q is not directly under the extraction dir %q", mountAt, into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareRefusesRootMount(t *testing.T) {
|
||||||
|
c := &spec.Container{
|
||||||
|
ID: "x", Name: "weird", Image: "img",
|
||||||
|
Mounts: []spec.Mount{{Kind: spec.MountBind, Source: "/", Destination: "/"}},
|
||||||
|
}
|
||||||
|
sel := spec.DefaultSelection(c)
|
||||||
|
sel.Include = true
|
||||||
|
p, err := Prepare(c, sel, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(p.Transfers) != 0 {
|
||||||
|
t.Errorf("a mount at / must not be copied: %+v", p.Transfers)
|
||||||
|
}
|
||||||
|
if len(p.Notes) == 0 {
|
||||||
|
t.Error("refusing to copy / should be reported to the operator")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/dkr"
|
||||||
|
"github.com/arescom/docker-migrate/internal/job"
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
"github.com/arescom/docker-migrate/internal/sshx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHRunner migrates containers straight from the local daemon to a target
|
||||||
|
// host over one SSH connection. Nothing is written to disk on either side:
|
||||||
|
// tar streams go from the source daemon into a remote `docker cp`.
|
||||||
|
type SSHRunner struct {
|
||||||
|
Src *dkr.Client
|
||||||
|
Dst *sshx.RemoteDocker
|
||||||
|
Containers []spec.Container
|
||||||
|
Volumes []spec.Volume
|
||||||
|
Networks []spec.Network
|
||||||
|
Plan spec.Plan
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the whole plan, reporting into j.
|
||||||
|
func (r *SSHRunner) Run(ctx context.Context, j *job.Job) error {
|
||||||
|
opts := r.Plan.Options
|
||||||
|
if opts.Parallelism < 1 {
|
||||||
|
opts.Parallelism = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pre, err := r.Dst.Preflight(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("target preflight: %w", err)
|
||||||
|
}
|
||||||
|
for _, p := range pre.Problems {
|
||||||
|
j.Logf(job.LevelWarn, "", "target: %s", p)
|
||||||
|
}
|
||||||
|
if pre.ServerVersion == "" {
|
||||||
|
return errors.New("target host cannot run docker; see the warnings above")
|
||||||
|
}
|
||||||
|
j.Logf(job.LevelInfo, "", "target docker %s (%s/%s), free space on %s: %s",
|
||||||
|
pre.ServerVersion, pre.OS, pre.Arch, pre.DockerRoot, humanBytes(pre.DiskFreeBytes))
|
||||||
|
|
||||||
|
compress := opts.Compress && pre.HasGzip
|
||||||
|
if opts.Compress && !pre.HasGzip {
|
||||||
|
j.Logf(job.LevelWarn, "", "gzip missing on target; sending data uncompressed")
|
||||||
|
}
|
||||||
|
if opts.DryRun {
|
||||||
|
j.Logf(job.LevelInfo, "", "dry run: no command below is executed on the target")
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := map[string]*spec.Container{}
|
||||||
|
for i := range r.Containers {
|
||||||
|
byID[r.Containers[i].ID] = &r.Containers[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare everything up front so a bad selection fails before any change.
|
||||||
|
var prepared []*Prepared
|
||||||
|
for _, sel := range r.Plan.Items {
|
||||||
|
if !sel.Include {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, err := Prepare(byID[sel.ContainerID], sel, r.Volumes, r.Networks)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("container %s: %w", sel.ContainerID, err)
|
||||||
|
}
|
||||||
|
prepared = append(prepared, p)
|
||||||
|
}
|
||||||
|
if len(prepared) == 0 {
|
||||||
|
return errors.New("nothing selected to migrate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Networks are shared between containers, so they are created once, before
|
||||||
|
// the per-container work fans out.
|
||||||
|
if err := r.ensureNetworks(ctx, j, prepared, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, opts.Parallelism)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
var failures int
|
||||||
|
|
||||||
|
for _, p := range prepared {
|
||||||
|
p := p
|
||||||
|
item := j.AddItem(p.Source.ID, p.Source.Name)
|
||||||
|
for _, n := range p.Source.Warnings {
|
||||||
|
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
|
||||||
|
}
|
||||||
|
for _, n := range p.Notes {
|
||||||
|
j.AddItemWarning(item, "%s: %s", p.Source.Name, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
j.SetItemState(item, job.StateCanceled, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
err := r.migrateOne(ctx, j, item, p, opts, compress)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
j.SetItemState(item, job.StateSucceeded, nil)
|
||||||
|
case errors.Is(err, errSkipped):
|
||||||
|
j.SetItemState(item, job.StateSkipped, err)
|
||||||
|
case errors.Is(err, context.Canceled):
|
||||||
|
j.SetItemState(item, job.StateCanceled, err)
|
||||||
|
default:
|
||||||
|
j.SetItemState(item, job.StateFailed, err)
|
||||||
|
j.Logf(job.LevelError, item.ID, "%s: %v", p.Source.Name, err)
|
||||||
|
mu.Lock()
|
||||||
|
failures++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return context.Canceled
|
||||||
|
}
|
||||||
|
if failures > 0 {
|
||||||
|
return fmt.Errorf("%d of %d containers failed to migrate", failures, len(prepared))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errSkipped = errors.New("skipped")
|
||||||
|
|
||||||
|
func (r *SSHRunner) migrateOne(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
|
||||||
|
name := p.ContainerName()
|
||||||
|
|
||||||
|
// 1. Name conflict on the target.
|
||||||
|
stepConflict := j.AddStep(item, "conflict", "check target for an existing "+name, 0)
|
||||||
|
j.StartStep(stepConflict)
|
||||||
|
newName, action, err := r.resolveConflict(ctx, j, name, opts)
|
||||||
|
if err != nil {
|
||||||
|
j.FinishStep(stepConflict, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if action == "skip" {
|
||||||
|
j.SkipStep(stepConflict, "container already exists on target")
|
||||||
|
return fmt.Errorf("%w: %s already exists on the target", errSkipped, name)
|
||||||
|
}
|
||||||
|
if newName != name {
|
||||||
|
j.Logf(job.LevelWarn, item.ID, "%s already exists on target; creating %s instead", name, newName)
|
||||||
|
p.Render.NameOverride = newName
|
||||||
|
name = newName
|
||||||
|
}
|
||||||
|
j.FinishStep(stepConflict, nil)
|
||||||
|
|
||||||
|
// 2. Image.
|
||||||
|
if err := r.ensureImage(ctx, j, item, p, opts, compress); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Named volumes.
|
||||||
|
if err := r.ensureVolumes(ctx, j, item, p, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create the container, stopped. Creating it before the data copy is
|
||||||
|
// what makes volumes and bind directories exist with the right identity.
|
||||||
|
stepCreate := j.AddStep(item, "create", "create container "+name, 0)
|
||||||
|
j.StartStep(stepCreate)
|
||||||
|
createArgs := p.Target.CreateArgs(p.Render)
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(createArgs...))
|
||||||
|
if !opts.DryRun {
|
||||||
|
if _, err := r.Dst.Run(ctx, createArgs...); err != nil {
|
||||||
|
j.FinishStep(stepCreate, err)
|
||||||
|
return fmt.Errorf("create container on target: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.FinishStep(stepCreate, nil)
|
||||||
|
|
||||||
|
for _, args := range p.Target.NetworkConnectArgs(p.Render) {
|
||||||
|
st := j.AddStep(item, "netconnect", "attach "+args[len(args)-2], 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
|
||||||
|
if !opts.DryRun {
|
||||||
|
if _, err := r.Dst.Run(ctx, args...); err != nil {
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
return fmt.Errorf("attach extra network: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.FinishStep(st, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Data. The source container is stopped first when asked, so the files
|
||||||
|
// are not changing underneath the copy.
|
||||||
|
if len(p.Transfers) > 0 {
|
||||||
|
restore, err := r.quiesceSource(ctx, j, item, p, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
copyErr := r.transferAll(ctx, j, item, p, opts, compress, name)
|
||||||
|
if restore != nil {
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
} else if p.Selection.StopSourceAfter && !opts.DryRun {
|
||||||
|
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
|
||||||
|
j.AddItemWarning(item, "could not stop source container: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Start.
|
||||||
|
if p.Selection.StartAfter {
|
||||||
|
st := j.AddStep(item, "start", "start "+name+" on target", 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("start", name))
|
||||||
|
if !opts.DryRun {
|
||||||
|
if _, err := r.Dst.Run(ctx, "start", name); err != nil {
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
return fmt.Errorf("start container on target: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.FinishStep(st, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Verify.
|
||||||
|
if opts.VerifyAfter && !opts.DryRun {
|
||||||
|
st := j.AddStep(item, "verify", "verify "+name+" on target", 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
err := r.verify(ctx, j, item, p, name)
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// quiesceSource stops the source container when the selection asks for a
|
||||||
|
// consistent copy, and returns the function that puts it back the way the
|
||||||
|
// operator wants it afterwards.
|
||||||
|
func (r *SSHRunner) quiesceSource(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) (func(), error) {
|
||||||
|
if opts.DryRun {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
wasRunning := p.Source.State == "running"
|
||||||
|
if !p.Selection.StopSourceDuringCopy {
|
||||||
|
if wasRunning {
|
||||||
|
j.AddItemWarning(item,
|
||||||
|
"copying %s while it is running; data written during the copy may be inconsistent", p.Source.Name)
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
if p.Selection.StopSourceAfter && wasRunning {
|
||||||
|
if err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second); err != nil {
|
||||||
|
j.AddItemWarning(item, "could not stop source container: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if wasRunning {
|
||||||
|
st := j.AddStep(item, "quiesce", "stop source "+p.Source.Name, 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
err := r.Src.Stop(ctx, p.Source.ID, 30*time.Second)
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stop source container: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
if !wasRunning || p.Selection.StopSourceAfter {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.Src.Start(context.WithoutCancel(ctx), p.Source.ID); err != nil {
|
||||||
|
j.AddItemWarning(item, "could not restart source container: %v", err)
|
||||||
|
} else {
|
||||||
|
j.Logf(job.LevelInfo, item.ID, "source container %s restarted", p.Source.Name)
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHRunner) transferAll(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool, targetName string) error {
|
||||||
|
// Ask the target what it actually created, so read-only mounts can be
|
||||||
|
// seeded through a staging container that mounts the same volume writable.
|
||||||
|
var resolved map[string]targetMount
|
||||||
|
if !opts.DryRun && anyReadOnlyTransfer(p.Transfers) {
|
||||||
|
var err error
|
||||||
|
resolved, err = r.inspectTargetMounts(ctx, targetName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect target mounts: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, t := range p.Transfers {
|
||||||
|
st := j.AddStep(item, fmt.Sprintf("data-%d", i), t.Label, t.SizeBytes)
|
||||||
|
j.StartStep(st)
|
||||||
|
if opts.DryRun {
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s (fed with a tar stream of %s from %s)",
|
||||||
|
r.Dst.Cmd("cp", "-a", "-", targetName+":"+t.RestoreInto), t.SourcePath, p.Source.Name)
|
||||||
|
j.SkipStep(st, "dry run")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := r.transferOne(ctx, j, item, st, p, t, i, opts, compress, targetName, resolved)
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHRunner) transferOne(
|
||||||
|
ctx context.Context, j *job.Job, item *job.Item, st *job.Step, p *Prepared,
|
||||||
|
t Transfer, index int, opts spec.Options, compress bool, targetName string, resolved map[string]targetMount,
|
||||||
|
) error {
|
||||||
|
// Pick where the archive is extracted. A writable mount is filled through
|
||||||
|
// the real container; a read-only one goes through a staging container that
|
||||||
|
// mounts the same volume or host path writable.
|
||||||
|
cpTarget := targetName
|
||||||
|
extractInto := t.RestoreInto
|
||||||
|
var cleanup func()
|
||||||
|
|
||||||
|
if t.ReadOnly {
|
||||||
|
tm, ok := resolved[t.Destination]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("target does not report a mount at %s", t.Destination)
|
||||||
|
}
|
||||||
|
stageName := StagingName(targetName, index)
|
||||||
|
mountAt, into := StagingPaths(t.Destination)
|
||||||
|
|
||||||
|
var source string
|
||||||
|
switch {
|
||||||
|
case tm.Name != "":
|
||||||
|
source = tm.Name
|
||||||
|
case tm.Source != "":
|
||||||
|
source = tm.Source
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot resolve the storage behind read-only mount %s", t.Destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = r.Dst.Run(ctx, "rm", "-f", stageName)
|
||||||
|
args := []string{"create", "--name", stageName, "--volume", source + ":" + mountAt, p.Target.Image}
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
|
||||||
|
if _, err := r.Dst.Run(ctx, args...); err != nil {
|
||||||
|
return fmt.Errorf("create staging container for read-only mount %s: %w", t.Destination, err)
|
||||||
|
}
|
||||||
|
cleanup = func() { _, _ = r.Dst.Run(context.WithoutCancel(ctx), "rm", "-f", stageName) }
|
||||||
|
cpTarget, extractInto = stageName, into
|
||||||
|
j.Logf(job.LevelInfo, item.ID, "seeding read-only mount %s through staging container %s", t.Destination, stageName)
|
||||||
|
}
|
||||||
|
if cleanup != nil {
|
||||||
|
defer cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := r.Src.CopyOut(ctx, p.Source.ID, t.SourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
counted := job.NewCountingReader(src, j, st)
|
||||||
|
body := io.Reader(counted)
|
||||||
|
if compress {
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
|
||||||
|
if gerr != nil {
|
||||||
|
pw.CloseWithError(gerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, cerr := io.Copy(gz, counted)
|
||||||
|
if closeErr := gz.Close(); cerr == nil {
|
||||||
|
cerr = closeErr
|
||||||
|
}
|
||||||
|
pw.CloseWithError(cerr)
|
||||||
|
}()
|
||||||
|
body = pr
|
||||||
|
}
|
||||||
|
|
||||||
|
cpArgs := []string{"cp", "-a", "-", cpTarget + ":" + extractInto}
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(cpArgs...))
|
||||||
|
if err := r.Dst.Feed(ctx, body, compress, cpArgs...); err != nil {
|
||||||
|
return fmt.Errorf("copy %s: %w", t.Label, err)
|
||||||
|
}
|
||||||
|
counted.Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type targetMount struct {
|
||||||
|
Kind string
|
||||||
|
Name string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
// inspectTargetMounts reads back the mounts the target actually created,
|
||||||
|
// which is the only way to learn the generated name of an anonymous volume.
|
||||||
|
func (r *SSHRunner) inspectTargetMounts(ctx context.Context, name string) (map[string]targetMount, error) {
|
||||||
|
const format = `{{range .Mounts}}{{.Type}}` + "\t" + `{{.Name}}` + "\t" + `{{.Source}}` + "\t" + `{{.Destination}}{{"\n"}}{{end}}`
|
||||||
|
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res := map[string]targetMount{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := strings.Split(strings.TrimRight(line, "\r"), "\t")
|
||||||
|
if len(f) != 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res[f[3]] = targetMount{Kind: f[0], Name: f[1], Source: f[2]}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHRunner) ensureImage(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options, compress bool) error {
|
||||||
|
ref := p.Target.Image
|
||||||
|
sel := p.Selection
|
||||||
|
|
||||||
|
if !sel.MigrateImage || sel.ImageMode == spec.ImageSkip {
|
||||||
|
st := j.AddStep(item, "image", "check image "+ref+" on target", 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
if opts.DryRun {
|
||||||
|
j.SkipStep(st, "dry run")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ok, err := r.Dst.Exists(ctx, "image", ref)
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("image %s is not on the target and image migration is disabled", ref)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := sel.ImageMode
|
||||||
|
if mode == "" {
|
||||||
|
mode = spec.ImageAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode == spec.ImageAuto && !opts.DryRun {
|
||||||
|
if ok, err := r.Dst.Exists(ctx, "image", ref); err == nil && ok {
|
||||||
|
st := j.AddStep(item, "image", "image "+ref+" already on target", 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
j.SkipStep(st, "already present")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tryPull := mode == spec.ImagePull ||
|
||||||
|
(mode == spec.ImageAuto && looksPullable(ref) && len(r.Src.ImageRepoDigests(ctx, ref)) > 0)
|
||||||
|
|
||||||
|
if tryPull {
|
||||||
|
st := j.AddStep(item, "image", "pull "+ref+" on target", 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("pull", ref))
|
||||||
|
if opts.DryRun {
|
||||||
|
j.SkipStep(st, "dry run")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := r.Dst.Run(ctx, "pull", ref)
|
||||||
|
if err == nil {
|
||||||
|
j.FinishStep(st, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if mode == spec.ImagePull {
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
return fmt.Errorf("pull image on target: %w", err)
|
||||||
|
}
|
||||||
|
j.SkipStep(st, "pull failed, falling back to streaming the image")
|
||||||
|
j.Logf(job.LevelWarn, item.ID, "pull of %s failed on target (%v); streaming layers instead", ref, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
size := r.Src.ImageSizeBytes(ctx, ref)
|
||||||
|
st := j.AddStep(item, "image", "stream image "+ref, size)
|
||||||
|
j.StartStep(st)
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd("load"))
|
||||||
|
if opts.DryRun {
|
||||||
|
j.SkipStep(st, "dry run")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := r.Src.SaveImage(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
counted := job.NewCountingReader(src, j, st)
|
||||||
|
body := io.Reader(counted)
|
||||||
|
if compress {
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
gz, gerr := gzip.NewWriterLevel(pw, gzipLevel(p, opts.CompressLevel))
|
||||||
|
if gerr != nil {
|
||||||
|
pw.CloseWithError(gerr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, cerr := io.Copy(gz, counted)
|
||||||
|
if closeErr := gz.Close(); cerr == nil {
|
||||||
|
cerr = closeErr
|
||||||
|
}
|
||||||
|
pw.CloseWithError(cerr)
|
||||||
|
}()
|
||||||
|
body = pr
|
||||||
|
}
|
||||||
|
err = r.Dst.Feed(ctx, body, compress, "load")
|
||||||
|
counted.Flush()
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load image on target: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHRunner) ensureVolumes(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, opts spec.Options) error {
|
||||||
|
for _, v := range p.Volumes {
|
||||||
|
st := j.AddStep(item, "volume-"+v.Name, "ensure volume "+v.Name, 0)
|
||||||
|
j.StartStep(st)
|
||||||
|
if !opts.DryRun {
|
||||||
|
exists, err := r.Dst.Exists(ctx, "volume", v.Name)
|
||||||
|
if err != nil {
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
switch opts.Conflict {
|
||||||
|
case spec.ConflictReplace:
|
||||||
|
j.Logf(job.LevelWarn, item.ID, "removing existing volume %s on target", v.Name)
|
||||||
|
if _, err := r.Dst.Run(ctx, "volume", "rm", "-f", v.Name); err != nil {
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
return fmt.Errorf("remove existing volume %s: %w", v.Name, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Reusing an existing volume is the safe default: the copy
|
||||||
|
// below writes into it without destroying anything else.
|
||||||
|
j.SkipStep(st, "volume already exists on target and is reused")
|
||||||
|
j.AddItemWarning(item, "volume %s already exists on the target; its current contents will be merged with the copied data", v.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := v.CreateArgs()
|
||||||
|
j.Logf(job.LevelCmd, item.ID, "target: %s", r.Dst.Cmd(args...))
|
||||||
|
if !opts.DryRun {
|
||||||
|
if _, err := r.Dst.Run(ctx, args...); err != nil {
|
||||||
|
j.FinishStep(st, err)
|
||||||
|
return fmt.Errorf("create volume %s: %w", v.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
j.FinishStep(st, nil)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHRunner) ensureNetworks(ctx context.Context, j *job.Job, prepared []*Prepared, opts spec.Options) error {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, p := range prepared {
|
||||||
|
for _, n := range p.Networks {
|
||||||
|
if seen[n.Name] || isBuiltin(n.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[n.Name] = true
|
||||||
|
if !opts.DryRun {
|
||||||
|
exists, err := r.Dst.Exists(ctx, "network", n.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
j.Logf(job.LevelInfo, "", "network %s already exists on target; reusing it", n.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args := n.CreateArgs()
|
||||||
|
j.Logf(job.LevelCmd, "", "target: %s", r.Dst.Cmd(args...))
|
||||||
|
if opts.DryRun {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := r.Dst.Run(ctx, args...); err != nil {
|
||||||
|
return fmt.Errorf("create network %s: %w", n.Name, err)
|
||||||
|
}
|
||||||
|
j.Logf(job.LevelInfo, "", "created network %s on target", n.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveConflict decides what to do about an existing container on the target
|
||||||
|
// and returns the name to use.
|
||||||
|
func (r *SSHRunner) resolveConflict(ctx context.Context, j *job.Job, name string, opts spec.Options) (string, string, error) {
|
||||||
|
exists, err := r.Dst.Exists(ctx, "container", name)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return name, "create", nil
|
||||||
|
}
|
||||||
|
switch opts.Conflict {
|
||||||
|
case spec.ConflictSkip:
|
||||||
|
return name, "skip", nil
|
||||||
|
case spec.ConflictReplace:
|
||||||
|
j.Logf(job.LevelWarn, "", "removing existing container %s on target", name)
|
||||||
|
if opts.DryRun {
|
||||||
|
return name, "create", nil
|
||||||
|
}
|
||||||
|
if _, err := r.Dst.Run(ctx, "rm", "-f", name); err != nil {
|
||||||
|
return "", "", fmt.Errorf("remove existing container %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return name, "create", nil
|
||||||
|
case spec.ConflictRename:
|
||||||
|
suffix := opts.RenameSuffix
|
||||||
|
if suffix == "" {
|
||||||
|
suffix = "-migrated"
|
||||||
|
}
|
||||||
|
candidate := name + suffix
|
||||||
|
for i := 2; ; i++ {
|
||||||
|
ok, err := r.Dst.Exists(ctx, "container", candidate)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return candidate, "create", nil
|
||||||
|
}
|
||||||
|
candidate = fmt.Sprintf("%s%s-%d", name, suffix, i)
|
||||||
|
if i > 50 {
|
||||||
|
return "", "", fmt.Errorf("could not find a free name based on %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("container %s already exists on the target", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SSHRunner) verify(ctx context.Context, j *job.Job, item *job.Item, p *Prepared, name string) error {
|
||||||
|
const format = `{{.State.Status}}` + "\t" + `{{.Config.Image}}` + "\t" + `{{len .Mounts}}`
|
||||||
|
out, err := r.Dst.Run(ctx, "inspect", "--format", format, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("container %s is not inspectable on the target: %w", name, err)
|
||||||
|
}
|
||||||
|
f := strings.Split(strings.TrimSpace(out), "\t")
|
||||||
|
if len(f) != 3 {
|
||||||
|
return fmt.Errorf("unexpected inspect output for %s", name)
|
||||||
|
}
|
||||||
|
status, image, mountCount := f[0], f[1], f[2]
|
||||||
|
|
||||||
|
wantMounts := 0
|
||||||
|
for _, m := range p.Target.Mounts {
|
||||||
|
if !p.Render.DropMounts[m.Destination] {
|
||||||
|
wantMounts++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fmt.Sprint(wantMounts) != mountCount {
|
||||||
|
j.AddItemWarning(item, "target container %s reports %s mounts, expected %d", name, mountCount, wantMounts)
|
||||||
|
}
|
||||||
|
if image != p.Target.Image {
|
||||||
|
j.AddItemWarning(item, "target container %s runs image %s, expected %s", name, image, p.Target.Image)
|
||||||
|
}
|
||||||
|
if p.Selection.StartAfter && status != "running" {
|
||||||
|
logs, _ := r.Dst.Run(ctx, "logs", "--tail", "20", name)
|
||||||
|
if s := strings.TrimSpace(logs); s != "" {
|
||||||
|
j.Logf(job.LevelError, item.ID, "last logs from %s:\n%s", name, s)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("container %s did not stay running on the target (status %s)", name, status)
|
||||||
|
}
|
||||||
|
j.Logf(job.LevelInfo, item.ID, "verified %s on target: status=%s image=%s mounts=%s", name, status, image, mountCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func anyReadOnlyTransfer(ts []Transfer) bool {
|
||||||
|
for _, t := range ts {
|
||||||
|
if t.ReadOnly {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func gzipLevel(_ *Prepared, level int) int {
|
||||||
|
if level < gzip.BestSpeed || level > gzip.BestCompression {
|
||||||
|
return gzip.BestSpeed
|
||||||
|
}
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksPullable reports whether a reference is a name a registry could serve,
|
||||||
|
// as opposed to a bare image id or a locally built, never-pushed tag.
|
||||||
|
func looksPullable(ref string) bool {
|
||||||
|
if ref == "" || strings.HasPrefix(ref, "sha256:") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(ref) == 64 && !strings.ContainsAny(ref, ":/.-_") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBuiltin(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "bridge", "host", "none":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanBytes(n int64) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
const unit = 1024
|
||||||
|
if n < unit {
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for v := n / unit; v >= unit; v /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package spec
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ImageMode decides how the container image reaches the target host.
|
||||||
|
type ImageMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ImageAuto pulls from a registry when the reference looks pullable and
|
||||||
|
// falls back to streaming the image layers otherwise.
|
||||||
|
ImageAuto ImageMode = "auto"
|
||||||
|
// ImagePull always runs `docker pull` on the target.
|
||||||
|
ImagePull ImageMode = "pull"
|
||||||
|
// ImageStream always transfers `docker save` output.
|
||||||
|
ImageStream ImageMode = "stream"
|
||||||
|
// ImageSkip assumes the image is already present on the target.
|
||||||
|
ImageSkip ImageMode = "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConflictPolicy decides what to do when the target already has a container,
|
||||||
|
// volume or network with the same name.
|
||||||
|
type ConflictPolicy string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConflictFail ConflictPolicy = "fail" // abort the item
|
||||||
|
ConflictSkip ConflictPolicy = "skip" // leave the target object untouched
|
||||||
|
ConflictReplace ConflictPolicy = "replace" // remove the target object first
|
||||||
|
ConflictRename ConflictPolicy = "rename" // create alongside with a suffix
|
||||||
|
)
|
||||||
|
|
||||||
|
// ItemSelection is the per-container answer to "what do you want to migrate?".
|
||||||
|
// Every data location is opted in or out individually.
|
||||||
|
type ItemSelection struct {
|
||||||
|
ContainerID string `json:"containerId"`
|
||||||
|
|
||||||
|
// Include is the master switch for this container.
|
||||||
|
Include bool `json:"include"`
|
||||||
|
|
||||||
|
// NameOverride renames the container on the target.
|
||||||
|
NameOverride string `json:"nameOverride,omitempty"`
|
||||||
|
|
||||||
|
// MigrateImage brings the image across; when false the container is
|
||||||
|
// created assuming the image already exists on the target.
|
||||||
|
MigrateImage bool `json:"migrateImage"`
|
||||||
|
ImageMode ImageMode `json:"imageMode"`
|
||||||
|
|
||||||
|
// MigrateNetworks recreates user-defined networks and reattaches them.
|
||||||
|
MigrateNetworks bool `json:"migrateNetworks"`
|
||||||
|
KeepStaticIPs bool `json:"keepStaticIps"`
|
||||||
|
MigratePorts bool `json:"migratePorts"`
|
||||||
|
|
||||||
|
// Mounts maps a container-side destination path to how it is handled.
|
||||||
|
Mounts map[string]MountSelection `json:"mounts"`
|
||||||
|
|
||||||
|
// StartAfter starts the container on the target once restored.
|
||||||
|
StartAfter bool `json:"startAfter"`
|
||||||
|
// StopSourceDuringCopy stops the source container for the duration of the
|
||||||
|
// data copy so the files are consistent, then restores its former state.
|
||||||
|
StopSourceDuringCopy bool `json:"stopSourceDuringCopy"`
|
||||||
|
// StopSourceAfter leaves the source container stopped once the migration
|
||||||
|
// succeeded, so the two hosts do not both serve the same workload.
|
||||||
|
StopSourceAfter bool `json:"stopSourceAfter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountAction is what to do with one data location.
|
||||||
|
type MountAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MountActionCopy recreates the mount and copies its contents.
|
||||||
|
MountActionCopy MountAction = "copy"
|
||||||
|
// MountActionStructure recreates the mount (volume or host directory) but
|
||||||
|
// leaves it empty.
|
||||||
|
MountActionStructure MountAction = "structure"
|
||||||
|
// MountActionSkip drops the mount from the target container entirely.
|
||||||
|
MountActionSkip MountAction = "skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MountSelection is the per-mount answer, including an optional relocation of
|
||||||
|
// a bind mount to a different path on the target host.
|
||||||
|
type MountSelection struct {
|
||||||
|
Action MountAction `json:"action"`
|
||||||
|
// TargetSource relocates a bind mount on the target host. Empty keeps the
|
||||||
|
// source path. Ignored for volumes.
|
||||||
|
TargetSource string `json:"targetSource,omitempty"`
|
||||||
|
// TargetName renames a named volume on the target. Empty keeps the name.
|
||||||
|
TargetName string `json:"targetName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options are the settings shared by every item in one migration run.
|
||||||
|
type Options struct {
|
||||||
|
Conflict ConflictPolicy `json:"conflict"`
|
||||||
|
RenameSuffix string `json:"renameSuffix,omitempty"` // used by ConflictRename, default "-migrated"
|
||||||
|
|
||||||
|
// Compress gzips data and image streams. Requires gzip on the target for
|
||||||
|
// SSH mode; always safe for package mode.
|
||||||
|
Compress bool `json:"compress"`
|
||||||
|
// CompressLevel is 1..9, defaulting to 1 (fast) because these transfers
|
||||||
|
// are usually bound by disk and network, not CPU.
|
||||||
|
CompressLevel int `json:"compressLevel"`
|
||||||
|
|
||||||
|
// DryRun performs every check and prints every command without changing
|
||||||
|
// anything on the target.
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
|
||||||
|
// Parallelism is how many containers migrate at once.
|
||||||
|
Parallelism int `json:"parallelism"`
|
||||||
|
|
||||||
|
// VerifyAfter re-inspects each container on the target and compares the
|
||||||
|
// resulting spec against the source.
|
||||||
|
VerifyAfter bool `json:"verifyAfter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultOptions returns the options used when the UI has not overridden them.
|
||||||
|
func DefaultOptions() Options {
|
||||||
|
return Options{
|
||||||
|
Conflict: ConflictFail,
|
||||||
|
RenameSuffix: "-migrated",
|
||||||
|
Compress: true,
|
||||||
|
CompressLevel: 1,
|
||||||
|
Parallelism: 1,
|
||||||
|
VerifyAfter: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSelection builds the "migrate everything" answer for a container,
|
||||||
|
// which is what the UI presents before the user changes anything.
|
||||||
|
func DefaultSelection(c *Container) ItemSelection {
|
||||||
|
sel := ItemSelection{
|
||||||
|
ContainerID: c.ID,
|
||||||
|
Include: false,
|
||||||
|
MigrateImage: true,
|
||||||
|
ImageMode: ImageAuto,
|
||||||
|
MigrateNetworks: true,
|
||||||
|
KeepStaticIPs: false,
|
||||||
|
MigratePorts: true,
|
||||||
|
Mounts: map[string]MountSelection{},
|
||||||
|
StartAfter: c.State == "running",
|
||||||
|
StopSourceDuringCopy: true,
|
||||||
|
StopSourceAfter: true,
|
||||||
|
}
|
||||||
|
for _, m := range c.Mounts {
|
||||||
|
action := MountActionCopy
|
||||||
|
if m.Kind == MountTmpfs {
|
||||||
|
action = MountActionStructure
|
||||||
|
}
|
||||||
|
sel.Mounts[m.Destination] = MountSelection{Action: action}
|
||||||
|
}
|
||||||
|
return sel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan is a complete migration request: what to move, where, and how.
|
||||||
|
type Plan struct {
|
||||||
|
Items []ItemSelection `json:"items"`
|
||||||
|
Options Options `json:"options"`
|
||||||
|
// Target is the SSH connection id for host-to-host mode. Empty means the
|
||||||
|
// plan produces an offline package instead.
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
// PackageName is the base name of the produced package (package mode).
|
||||||
|
PackageName string `json:"packageName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest is written into an offline migration package. It is descriptive:
|
||||||
|
// the generated install.sh is self-contained and does not parse it.
|
||||||
|
type Manifest struct {
|
||||||
|
FormatVersion int `json:"formatVersion"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
SourceHost string `json:"sourceHost"`
|
||||||
|
DockerVersion string `json:"dockerVersion"`
|
||||||
|
|
||||||
|
Containers []Container `json:"containers"`
|
||||||
|
Volumes []Volume `json:"volumes"`
|
||||||
|
Networks []Network `json:"networks"`
|
||||||
|
Items []ItemSelection `json:"items"`
|
||||||
|
Options Options `json:"options"`
|
||||||
|
|
||||||
|
// Payloads lists every data file in the package with its checksum, so the
|
||||||
|
// installer can verify the archive survived the trip.
|
||||||
|
Payloads []Payload `json:"payloads"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload is one file inside a migration package.
|
||||||
|
type Payload struct {
|
||||||
|
Path string `json:"path"` // relative to the package root
|
||||||
|
Kind string `json:"kind"` // "image" | "mount"
|
||||||
|
Container string `json:"container,omitempty"`
|
||||||
|
Destination string `json:"destination,omitempty"` // mount destination it restores
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
Compressed bool `json:"compressed"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
package spec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShellQuote wraps s so that a POSIX shell passes it through as a single
|
||||||
|
// literal argument. Single quotes inside are escaped the usual way.
|
||||||
|
func ShellQuote(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "''"
|
||||||
|
}
|
||||||
|
if safeArg.MatchString(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
var safeArg = regexp.MustCompile(`^[A-Za-z0-9_@%+=:,./-]+$`)
|
||||||
|
|
||||||
|
// ShellQuoteAll quotes every argument and joins them with spaces.
|
||||||
|
func ShellQuoteAll(args []string) string {
|
||||||
|
parts := make([]string, len(args))
|
||||||
|
for i, a := range args {
|
||||||
|
parts[i] = ShellQuote(a)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderOptions tunes how a container spec is turned into a create command.
|
||||||
|
type RenderOptions struct {
|
||||||
|
// NameOverride replaces the container name on the target (empty = keep).
|
||||||
|
NameOverride string
|
||||||
|
// KeepStaticIPs re-applies the source IP addresses. Off by default because
|
||||||
|
// the target subnets are often different.
|
||||||
|
KeepStaticIPs bool
|
||||||
|
// SkipNetworks drops all network flags, leaving the container on the
|
||||||
|
// default bridge. Used when the user opts out of network migration.
|
||||||
|
SkipNetworks bool
|
||||||
|
// SkipPorts drops published port flags (useful when the target already
|
||||||
|
// runs something on those ports).
|
||||||
|
SkipPorts bool
|
||||||
|
// DropMounts omits mount flags for destinations listed here, so a
|
||||||
|
// container can be migrated without one of its data locations.
|
||||||
|
DropMounts map[string]bool
|
||||||
|
// AnonymousVolumesAsAnonymous recreates generated-name volumes as fresh
|
||||||
|
// anonymous volumes instead of pinning the source name.
|
||||||
|
AnonymousVolumesAsAnonymous bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArgs renders the full `docker create ...` argument list for a
|
||||||
|
// container, excluding the leading "docker". The first attached network is
|
||||||
|
// applied here; any additional networks need NetworkConnectArgs afterwards
|
||||||
|
// because `docker create` accepts only one --network.
|
||||||
|
func (c *Container) CreateArgs(o RenderOptions) []string {
|
||||||
|
name := c.Name
|
||||||
|
if o.NameOverride != "" {
|
||||||
|
name = o.NameOverride
|
||||||
|
}
|
||||||
|
|
||||||
|
a := []string{"create", "--name", name}
|
||||||
|
|
||||||
|
add := func(v ...string) { a = append(a, v...) }
|
||||||
|
flag := func(f, v string) {
|
||||||
|
if v != "" {
|
||||||
|
add(f, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flag("--hostname", c.Hostname)
|
||||||
|
flag("--domainname", c.Domainname)
|
||||||
|
flag("--user", c.User)
|
||||||
|
flag("--workdir", c.WorkingDir)
|
||||||
|
|
||||||
|
for _, e := range c.Env {
|
||||||
|
add("--env", e)
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(c.Labels) {
|
||||||
|
if isManagedLabel(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
add("--label", k+"="+c.Labels[k])
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Tty {
|
||||||
|
add("--tty")
|
||||||
|
}
|
||||||
|
if c.OpenStdin {
|
||||||
|
add("--interactive")
|
||||||
|
}
|
||||||
|
flag("--stop-signal", c.StopSignal)
|
||||||
|
if c.StopTimeout != nil {
|
||||||
|
add("--stop-timeout", strconv.Itoa(*c.StopTimeout))
|
||||||
|
}
|
||||||
|
if c.Init != nil && *c.Init {
|
||||||
|
add("--init")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.RestartPolicy != "" && c.RestartPolicy != "no" {
|
||||||
|
if c.RestartPolicy == "on-failure" && c.RestartMaxRetries > 0 {
|
||||||
|
add("--restart", fmt.Sprintf("on-failure:%d", c.RestartMaxRetries))
|
||||||
|
} else {
|
||||||
|
add("--restart", c.RestartPolicy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --rm is intentionally never re-applied: an auto-removing container would
|
||||||
|
// vanish before the operator can verify the migration.
|
||||||
|
|
||||||
|
if c.Privileged {
|
||||||
|
add("--privileged")
|
||||||
|
}
|
||||||
|
if c.ReadonlyRootfs {
|
||||||
|
add("--read-only")
|
||||||
|
}
|
||||||
|
for _, v := range c.CapAdd {
|
||||||
|
add("--cap-add", v)
|
||||||
|
}
|
||||||
|
for _, v := range c.CapDrop {
|
||||||
|
add("--cap-drop", v)
|
||||||
|
}
|
||||||
|
for _, v := range c.SecurityOpt {
|
||||||
|
add("--security-opt", v)
|
||||||
|
}
|
||||||
|
for _, v := range c.GroupAdd {
|
||||||
|
add("--group-add", v)
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(c.Sysctls) {
|
||||||
|
add("--sysctl", k+"="+c.Sysctls[k])
|
||||||
|
}
|
||||||
|
for _, d := range c.Devices {
|
||||||
|
v := d.PathOnHost
|
||||||
|
if d.PathInContainer != "" && d.PathInContainer != d.PathOnHost {
|
||||||
|
v += ":" + d.PathInContainer
|
||||||
|
}
|
||||||
|
if p := d.CgroupPermissions; p != "" && p != "rwm" {
|
||||||
|
if !strings.Contains(v, ":") {
|
||||||
|
v += ":" + d.PathOnHost
|
||||||
|
}
|
||||||
|
v += ":" + p
|
||||||
|
}
|
||||||
|
add("--device", v)
|
||||||
|
}
|
||||||
|
for _, u := range c.Ulimits {
|
||||||
|
add("--ulimit", fmt.Sprintf("%s=%d:%d", u.Name, u.Soft, u.Hard))
|
||||||
|
}
|
||||||
|
if c.Runtime != "" && c.Runtime != "runc" {
|
||||||
|
add("--runtime", c.Runtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
flag("--pid", nonDefault(c.PidMode, ""))
|
||||||
|
flag("--ipc", nonDefault(c.IpcMode, "private", "shareable"))
|
||||||
|
flag("--uts", nonDefault(c.UtsMode, ""))
|
||||||
|
flag("--userns", nonDefault(c.UsernsMode, ""))
|
||||||
|
// "private" is what a cgroup v2 host reports by default, and passing it
|
||||||
|
// explicitly breaks on a target whose kernel only has cgroup v1. Only the
|
||||||
|
// deliberate "host" override is worth carrying across.
|
||||||
|
flag("--cgroupns", nonDefault(c.CgroupnsMode, "private"))
|
||||||
|
|
||||||
|
for _, v := range c.DNS {
|
||||||
|
add("--dns", v)
|
||||||
|
}
|
||||||
|
for _, v := range c.DNSSearch {
|
||||||
|
add("--dns-search", v)
|
||||||
|
}
|
||||||
|
for _, v := range c.DNSOptions {
|
||||||
|
add("--dns-option", v)
|
||||||
|
}
|
||||||
|
for _, v := range c.ExtraHosts {
|
||||||
|
add("--add-host", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Networking. Only the first endpoint can be expressed here.
|
||||||
|
if !o.SkipNetworks {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(c.NetworkMode, "container:"):
|
||||||
|
add("--network", c.NetworkMode)
|
||||||
|
case c.NetworkMode == "host" || c.NetworkMode == "none":
|
||||||
|
add("--network", c.NetworkMode)
|
||||||
|
case len(c.Endpoints) > 0:
|
||||||
|
ep := c.Endpoints[0]
|
||||||
|
add("--network", ep.Network)
|
||||||
|
for _, al := range ep.Aliases {
|
||||||
|
add("--network-alias", al)
|
||||||
|
}
|
||||||
|
if o.KeepStaticIPs {
|
||||||
|
flag("--ip", ep.IPv4Address)
|
||||||
|
flag("--ip6", ep.IPv6Address)
|
||||||
|
}
|
||||||
|
flag("--mac-address", ep.MacAddress)
|
||||||
|
case c.NetworkMode != "" && c.NetworkMode != "default":
|
||||||
|
add("--network", c.NetworkMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !o.SkipPorts && c.NetworkMode != "host" {
|
||||||
|
for _, p := range c.Ports {
|
||||||
|
add("--publish", p.String())
|
||||||
|
}
|
||||||
|
if c.PublishAll {
|
||||||
|
add("--publish-all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range c.ExposedPorts {
|
||||||
|
if !c.isPublished(e) {
|
||||||
|
add("--expose", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range c.Mounts {
|
||||||
|
if o.DropMounts[m.Destination] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch m.Kind {
|
||||||
|
case MountTmpfs:
|
||||||
|
if m.TmpfsOpts != "" {
|
||||||
|
add("--tmpfs", m.Destination+":"+m.TmpfsOpts)
|
||||||
|
} else {
|
||||||
|
add("--tmpfs", m.Destination)
|
||||||
|
}
|
||||||
|
case MountAnonymous:
|
||||||
|
if o.AnonymousVolumesAsAnonymous || m.Name == "" {
|
||||||
|
add("--volume", m.Destination+roSuffix(m))
|
||||||
|
} else {
|
||||||
|
add("--volume", m.Name+":"+m.Destination+roSuffix(m))
|
||||||
|
}
|
||||||
|
case MountVolume:
|
||||||
|
add("--volume", m.Name+":"+m.Destination+roSuffix(m))
|
||||||
|
case MountBind:
|
||||||
|
v := m.Source + ":" + m.Destination + roSuffix(m)
|
||||||
|
if m.Propagation != "" && m.Propagation != "rprivate" {
|
||||||
|
if roSuffix(m) == "" {
|
||||||
|
v += ":" + m.Propagation
|
||||||
|
} else {
|
||||||
|
v += "," + m.Propagation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("--volume", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.LogDriver != "" && c.LogDriver != "json-file" {
|
||||||
|
add("--log-driver", c.LogDriver)
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(c.LogOptions) {
|
||||||
|
add("--log-opt", k+"="+c.LogOptions[k])
|
||||||
|
}
|
||||||
|
|
||||||
|
if h := c.Healthcheck; h != nil && len(h.Test) > 0 {
|
||||||
|
switch h.Test[0] {
|
||||||
|
case "NONE":
|
||||||
|
add("--no-healthcheck")
|
||||||
|
case "CMD":
|
||||||
|
add("--health-cmd", ShellQuoteAll(h.Test[1:]))
|
||||||
|
case "CMD-SHELL":
|
||||||
|
if len(h.Test) > 1 {
|
||||||
|
add("--health-cmd", h.Test[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.Interval > 0 {
|
||||||
|
add("--health-interval", durStr(h.Interval))
|
||||||
|
}
|
||||||
|
if h.Timeout > 0 {
|
||||||
|
add("--health-timeout", durStr(h.Timeout))
|
||||||
|
}
|
||||||
|
if h.StartPeriod > 0 {
|
||||||
|
add("--health-start-period", durStr(h.StartPeriod))
|
||||||
|
}
|
||||||
|
if h.Retries > 0 {
|
||||||
|
add("--health-retries", strconv.Itoa(h.Retries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r := c.Resources
|
||||||
|
if r.Memory > 0 {
|
||||||
|
add("--memory", strconv.FormatInt(r.Memory, 10))
|
||||||
|
}
|
||||||
|
if r.MemoryReservation > 0 {
|
||||||
|
add("--memory-reservation", strconv.FormatInt(r.MemoryReservation, 10))
|
||||||
|
}
|
||||||
|
if r.MemorySwap != 0 {
|
||||||
|
add("--memory-swap", strconv.FormatInt(r.MemorySwap, 10))
|
||||||
|
}
|
||||||
|
if r.MemorySwappiness != nil && *r.MemorySwappiness >= 0 {
|
||||||
|
add("--memory-swappiness", strconv.FormatInt(*r.MemorySwappiness, 10))
|
||||||
|
}
|
||||||
|
if r.NanoCPUs > 0 {
|
||||||
|
add("--cpus", strconv.FormatFloat(float64(r.NanoCPUs)/1e9, 'f', -1, 64))
|
||||||
|
}
|
||||||
|
if r.CPUShares > 0 {
|
||||||
|
add("--cpu-shares", strconv.FormatInt(r.CPUShares, 10))
|
||||||
|
}
|
||||||
|
if r.CPUPeriod > 0 {
|
||||||
|
add("--cpu-period", strconv.FormatInt(r.CPUPeriod, 10))
|
||||||
|
}
|
||||||
|
if r.CPUQuota > 0 {
|
||||||
|
add("--cpu-quota", strconv.FormatInt(r.CPUQuota, 10))
|
||||||
|
}
|
||||||
|
flag("--cpuset-cpus", r.CpusetCpus)
|
||||||
|
flag("--cpuset-mems", r.CpusetMems)
|
||||||
|
if r.PidsLimit != nil && *r.PidsLimit > 0 {
|
||||||
|
add("--pids-limit", strconv.FormatInt(*r.PidsLimit, 10))
|
||||||
|
}
|
||||||
|
if r.OomKillDisable != nil && *r.OomKillDisable {
|
||||||
|
add("--oom-kill-disable")
|
||||||
|
}
|
||||||
|
if r.OomScoreAdj != 0 {
|
||||||
|
add("--oom-score-adj", strconv.Itoa(r.OomScoreAdj))
|
||||||
|
}
|
||||||
|
if r.ShmSize > 0 && r.ShmSize != 67108864 {
|
||||||
|
add("--shm-size", strconv.FormatInt(r.ShmSize, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.EntrypointSet && len(c.Entrypoint) > 0 {
|
||||||
|
// docker only accepts a single --entrypoint token; extra words are
|
||||||
|
// prepended to the command instead.
|
||||||
|
add("--entrypoint", c.Entrypoint[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
add(c.Image)
|
||||||
|
|
||||||
|
if c.EntrypointSet && len(c.Entrypoint) > 1 {
|
||||||
|
add(c.Entrypoint[1:]...)
|
||||||
|
}
|
||||||
|
if c.CmdSet {
|
||||||
|
add(c.Cmd...)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkConnectArgs renders `docker network connect ...` for every endpoint
|
||||||
|
// beyond the first, which `docker create` could not express.
|
||||||
|
func (c *Container) NetworkConnectArgs(o RenderOptions) [][]string {
|
||||||
|
if o.SkipNetworks || len(c.Endpoints) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name := c.Name
|
||||||
|
if o.NameOverride != "" {
|
||||||
|
name = o.NameOverride
|
||||||
|
}
|
||||||
|
var out [][]string
|
||||||
|
for _, ep := range c.Endpoints[1:] {
|
||||||
|
a := []string{"network", "connect"}
|
||||||
|
for _, al := range ep.Aliases {
|
||||||
|
a = append(a, "--alias", al)
|
||||||
|
}
|
||||||
|
if o.KeepStaticIPs {
|
||||||
|
if ep.IPv4Address != "" {
|
||||||
|
a = append(a, "--ip", ep.IPv4Address)
|
||||||
|
}
|
||||||
|
if ep.IPv6Address != "" {
|
||||||
|
a = append(a, "--ip6", ep.IPv6Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range ep.Links {
|
||||||
|
a = append(a, "--link", l)
|
||||||
|
}
|
||||||
|
out = append(out, append(a, ep.Network, name))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArgs renders `docker volume create ...` for a named volume.
|
||||||
|
func (v Volume) CreateArgs() []string {
|
||||||
|
a := []string{"volume", "create"}
|
||||||
|
if v.Driver != "" && v.Driver != "local" {
|
||||||
|
a = append(a, "--driver", v.Driver)
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(v.DriverOpts) {
|
||||||
|
a = append(a, "--opt", k+"="+v.DriverOpts[k])
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(v.Labels) {
|
||||||
|
if isManagedLabel(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a = append(a, "--label", k+"="+v.Labels[k])
|
||||||
|
}
|
||||||
|
return append(a, v.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateArgs renders `docker network create ...` for a user-defined network.
|
||||||
|
func (n Network) CreateArgs() []string {
|
||||||
|
a := []string{"network", "create"}
|
||||||
|
if n.Driver != "" {
|
||||||
|
a = append(a, "--driver", n.Driver)
|
||||||
|
}
|
||||||
|
if n.EnableIPv6 {
|
||||||
|
a = append(a, "--ipv6")
|
||||||
|
}
|
||||||
|
if n.Internal {
|
||||||
|
a = append(a, "--internal")
|
||||||
|
}
|
||||||
|
if n.Attachable {
|
||||||
|
a = append(a, "--attachable")
|
||||||
|
}
|
||||||
|
if n.IPAMDriver != "" && n.IPAMDriver != "default" {
|
||||||
|
a = append(a, "--ipam-driver", n.IPAMDriver)
|
||||||
|
}
|
||||||
|
for _, p := range n.IPAMPools {
|
||||||
|
if p.Subnet != "" {
|
||||||
|
a = append(a, "--subnet", p.Subnet)
|
||||||
|
}
|
||||||
|
if p.IPRange != "" {
|
||||||
|
a = append(a, "--ip-range", p.IPRange)
|
||||||
|
}
|
||||||
|
if p.Gateway != "" {
|
||||||
|
a = append(a, "--gateway", p.Gateway)
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(p.AuxAddress) {
|
||||||
|
a = append(a, "--aux-address", k+"="+p.AuxAddress[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(n.Options) {
|
||||||
|
a = append(a, "--opt", k+"="+n.Options[k])
|
||||||
|
}
|
||||||
|
for _, k := range sortedKeys(n.Labels) {
|
||||||
|
if isManagedLabel(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a = append(a, "--label", k+"="+n.Labels[k])
|
||||||
|
}
|
||||||
|
return append(a, n.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String renders a port binding in `docker publish` syntax.
|
||||||
|
func (p PortBinding) String() string {
|
||||||
|
port := p.ContainerPort
|
||||||
|
proto := "tcp"
|
||||||
|
if i := strings.LastIndex(port, "/"); i >= 0 {
|
||||||
|
proto, port = port[i+1:], port[:i]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if p.HostIP != "" && p.HostIP != "0.0.0.0" {
|
||||||
|
b.WriteString(p.HostIP + ":")
|
||||||
|
}
|
||||||
|
b.WriteString(p.HostPort + ":" + port)
|
||||||
|
if proto != "tcp" {
|
||||||
|
b.WriteString("/" + proto)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Container) isPublished(exposed string) bool {
|
||||||
|
for _, p := range c.Ports {
|
||||||
|
if p.ContainerPort == exposed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func roSuffix(m Mount) string {
|
||||||
|
if m.ReadOnly {
|
||||||
|
return ":ro"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonDefault returns v unless it is one of the values Docker would have chosen
|
||||||
|
// anyway, in which case emitting a flag adds noise without changing behaviour.
|
||||||
|
func nonDefault(v string, defaults ...string) string {
|
||||||
|
if v == "" || v == "default" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, d := range defaults {
|
||||||
|
if v == d {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// isManagedLabel filters out labels Docker or compose maintain themselves;
|
||||||
|
// re-applying them would make the target look like it belongs to a compose
|
||||||
|
// project that is not actually there.
|
||||||
|
func isManagedLabel(k string) bool {
|
||||||
|
return strings.HasPrefix(k, "com.docker.compose.") ||
|
||||||
|
strings.HasPrefix(k, "com.docker.swarm.") ||
|
||||||
|
strings.HasPrefix(k, "desktop.docker.io/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func durStr(ns int64) string {
|
||||||
|
if ns%1e9 == 0 {
|
||||||
|
return strconv.FormatInt(ns/1e9, 10) + "s"
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(ns/1e6, 10) + "ms"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedKeys(m map[string]string) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package spec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShellQuote(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"simple": "simple",
|
||||||
|
"a/b-c_1.2": "a/b-c_1.2",
|
||||||
|
"": "''",
|
||||||
|
"has space": "'has space'",
|
||||||
|
"it's": `'it'\''s'`,
|
||||||
|
"$(rm -rf /)": "'$(rm -rf /)'",
|
||||||
|
"a;b": "'a;b'",
|
||||||
|
"KEY=value": "KEY=value",
|
||||||
|
"tag:1.0@sha256:ab": "tag:1.0@sha256:ab",
|
||||||
|
"back`tick`": "'back`tick`'",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := ShellQuote(in); got != want {
|
||||||
|
t.Errorf("ShellQuote(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreateArgsFull checks that a container using most of the surface area of
|
||||||
|
// docker run is rendered back into an equivalent create command.
|
||||||
|
func TestCreateArgsFull(t *testing.T) {
|
||||||
|
stopTimeout := 15
|
||||||
|
initTrue := true
|
||||||
|
c := &Container{
|
||||||
|
Name: "web",
|
||||||
|
Image: "nginx:1.27",
|
||||||
|
Hostname: "web-1",
|
||||||
|
User: "101:101",
|
||||||
|
WorkingDir: "/srv",
|
||||||
|
Env: []string{"TZ=Europe/Paris", "SECRET=a b"},
|
||||||
|
Labels: map[string]string{"team": "infra", "com.docker.compose.project": "shop"},
|
||||||
|
Cmd: []string{"nginx", "-g", "daemon off;"},
|
||||||
|
CmdSet: true,
|
||||||
|
Entrypoint: []string{"/entry.sh", "--flag"},
|
||||||
|
EntrypointSet: true,
|
||||||
|
RestartPolicy: "on-failure",
|
||||||
|
RestartMaxRetries: 3,
|
||||||
|
StopSignal: "SIGQUIT",
|
||||||
|
StopTimeout: &stopTimeout,
|
||||||
|
Init: &initTrue,
|
||||||
|
Privileged: true,
|
||||||
|
CapAdd: []string{"NET_ADMIN"},
|
||||||
|
CapDrop: []string{"MKNOD"},
|
||||||
|
Sysctls: map[string]string{"net.core.somaxconn": "1024"},
|
||||||
|
DNS: []string{"1.1.1.1"},
|
||||||
|
ExtraHosts: []string{"db:10.0.0.5"},
|
||||||
|
NetworkMode: "frontend",
|
||||||
|
Endpoints: []Endpoint{
|
||||||
|
{Network: "frontend", Aliases: []string{"web", "www"}, IPv4Address: "172.20.0.9"},
|
||||||
|
{Network: "backend", Aliases: []string{"web"}},
|
||||||
|
},
|
||||||
|
Ports: []PortBinding{
|
||||||
|
{ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "8080"},
|
||||||
|
{ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"},
|
||||||
|
},
|
||||||
|
ExposedPorts: []string{"80/tcp", "9000/tcp"},
|
||||||
|
Mounts: []Mount{
|
||||||
|
{Kind: MountVolume, Name: "html", Destination: "/usr/share/nginx/html", ReadOnly: true},
|
||||||
|
{Kind: MountBind, Source: "/etc/nginx/conf.d", Destination: "/etc/nginx/conf.d"},
|
||||||
|
{Kind: MountAnonymous, Name: strings.Repeat("a", 64), Destination: "/cache"},
|
||||||
|
{Kind: MountTmpfs, Destination: "/run", TmpfsOpts: "size=64m"},
|
||||||
|
},
|
||||||
|
LogDriver: "json-file",
|
||||||
|
LogOptions: map[string]string{"max-size": "10m"},
|
||||||
|
Resources: Resources{Memory: 536870912, NanoCPUs: 1500000000, ShmSize: 67108864},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.Join(c.CreateArgs(RenderOptions{}), " ")
|
||||||
|
|
||||||
|
mustContain := []string{
|
||||||
|
"create --name web",
|
||||||
|
"--hostname web-1",
|
||||||
|
"--user 101:101",
|
||||||
|
"--env TZ=Europe/Paris",
|
||||||
|
"--label team=infra",
|
||||||
|
"--restart on-failure:3",
|
||||||
|
"--stop-timeout 15",
|
||||||
|
"--init",
|
||||||
|
"--privileged",
|
||||||
|
"--cap-add NET_ADMIN",
|
||||||
|
"--sysctl net.core.somaxconn=1024",
|
||||||
|
"--add-host db:10.0.0.5",
|
||||||
|
"--network frontend",
|
||||||
|
"--network-alias web",
|
||||||
|
"--publish 8080:80",
|
||||||
|
"--publish 127.0.0.1:5353:53/udp",
|
||||||
|
"--expose 9000/tcp",
|
||||||
|
"--volume html:/usr/share/nginx/html:ro",
|
||||||
|
"--volume /etc/nginx/conf.d:/etc/nginx/conf.d",
|
||||||
|
"--tmpfs /run:size=64m",
|
||||||
|
"--log-opt max-size=10m",
|
||||||
|
"--memory 536870912",
|
||||||
|
"--cpus 1.5",
|
||||||
|
"--entrypoint /entry.sh",
|
||||||
|
"nginx:1.27",
|
||||||
|
}
|
||||||
|
for _, want := range mustContain {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("create args missing %q\ngot: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels docker or compose manage themselves must not be re-applied.
|
||||||
|
if strings.Contains(got, "com.docker.compose.project") {
|
||||||
|
t.Errorf("compose-managed label was re-applied:\n%s", got)
|
||||||
|
}
|
||||||
|
// A port already published must not also be re-exposed.
|
||||||
|
if strings.Contains(got, "--expose 80/tcp") {
|
||||||
|
t.Errorf("published port was also exposed:\n%s", got)
|
||||||
|
}
|
||||||
|
// The default shm size carries no information and should be omitted.
|
||||||
|
if strings.Contains(got, "--shm-size") {
|
||||||
|
t.Errorf("default shm size was emitted:\n%s", got)
|
||||||
|
}
|
||||||
|
// The image must be the last flag-free token before the command.
|
||||||
|
idx := strings.Index(got, "nginx:1.27")
|
||||||
|
if idx < 0 || !strings.Contains(got[idx:], "--flag") {
|
||||||
|
t.Errorf("entrypoint remainder and command must follow the image:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateArgsSecondNetworkNeedsConnect(t *testing.T) {
|
||||||
|
c := &Container{
|
||||||
|
Name: "app", Image: "app:1", NetworkMode: "a",
|
||||||
|
Endpoints: []Endpoint{
|
||||||
|
{Network: "a"},
|
||||||
|
{Network: "b", Aliases: []string{"app-b"}, IPv4Address: "10.1.2.3"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
args := c.CreateArgs(RenderOptions{})
|
||||||
|
if n := strings.Count(strings.Join(args, " "), "--network "); n != 1 {
|
||||||
|
t.Fatalf("docker create accepts one --network, got %d in %v", n, args)
|
||||||
|
}
|
||||||
|
connects := c.NetworkConnectArgs(RenderOptions{})
|
||||||
|
if len(connects) != 1 {
|
||||||
|
t.Fatalf("expected 1 network connect, got %d", len(connects))
|
||||||
|
}
|
||||||
|
joined := strings.Join(connects[0], " ")
|
||||||
|
if !strings.Contains(joined, "network connect --alias app-b b app") {
|
||||||
|
t.Errorf("unexpected connect args: %s", joined)
|
||||||
|
}
|
||||||
|
if strings.Contains(joined, "--ip ") {
|
||||||
|
t.Errorf("static IP must not be applied unless requested: %s", joined)
|
||||||
|
}
|
||||||
|
|
||||||
|
withIP := strings.Join(c.NetworkConnectArgs(RenderOptions{KeepStaticIPs: true})[0], " ")
|
||||||
|
if !strings.Contains(withIP, "--ip 10.1.2.3") {
|
||||||
|
t.Errorf("static IP was requested but not applied: %s", withIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderOptionsDropAndRename(t *testing.T) {
|
||||||
|
c := &Container{
|
||||||
|
Name: "db", Image: "postgres:16",
|
||||||
|
Ports: []PortBinding{{ContainerPort: "5432/tcp", HostPort: "5432"}},
|
||||||
|
Mounts: []Mount{{Kind: MountVolume, Name: "pgdata", Destination: "/var/lib/postgresql/data"}},
|
||||||
|
Endpoints: []Endpoint{{Network: "backend"}},
|
||||||
|
}
|
||||||
|
got := strings.Join(c.CreateArgs(RenderOptions{
|
||||||
|
NameOverride: "db-new",
|
||||||
|
SkipPorts: true,
|
||||||
|
SkipNetworks: true,
|
||||||
|
DropMounts: map[string]bool{"/var/lib/postgresql/data": true},
|
||||||
|
}), " ")
|
||||||
|
|
||||||
|
if !strings.Contains(got, "--name db-new") {
|
||||||
|
t.Errorf("name override not applied: %s", got)
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"--publish", "--network", "--volume"} {
|
||||||
|
if strings.Contains(got, unwanted) {
|
||||||
|
t.Errorf("expected %s to be dropped: %s", unwanted, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCgroupnsPrivateIsNotCarried guards a cross-host hazard: "private" is
|
||||||
|
// simply what a cgroup v2 host reports, and passing it explicitly makes the
|
||||||
|
// create fail on a target whose kernel only has cgroup v1.
|
||||||
|
func TestCgroupnsPrivateIsNotCarried(t *testing.T) {
|
||||||
|
private := &Container{Name: "a", Image: "img", CgroupnsMode: "private"}
|
||||||
|
if got := strings.Join(private.CreateArgs(RenderOptions{}), " "); strings.Contains(got, "--cgroupns") {
|
||||||
|
t.Errorf("the default cgroup namespace must not be pinned: %s", got)
|
||||||
|
}
|
||||||
|
host := &Container{Name: "a", Image: "img", CgroupnsMode: "host"}
|
||||||
|
if got := strings.Join(host.CreateArgs(RenderOptions{}), " "); !strings.Contains(got, "--cgroupns host") {
|
||||||
|
t.Errorf("an explicit host cgroup namespace must be carried across: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoRemoveIsNeverReapplied(t *testing.T) {
|
||||||
|
c := &Container{Name: "job", Image: "busybox", AutoRemove: true}
|
||||||
|
if strings.Contains(strings.Join(c.CreateArgs(RenderOptions{}), " "), "--rm") {
|
||||||
|
t.Error("--rm must not be reapplied; the migrated container would delete itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVolumeAndNetworkCreateArgs(t *testing.T) {
|
||||||
|
v := Volume{
|
||||||
|
Name: "pgdata", Driver: "local",
|
||||||
|
DriverOpts: map[string]string{"type": "nfs", "device": ":/exports/pg"},
|
||||||
|
Labels: map[string]string{"app": "shop", "com.docker.compose.project": "x"},
|
||||||
|
}
|
||||||
|
got := strings.Join(v.CreateArgs(), " ")
|
||||||
|
for _, want := range []string{"volume create", "--opt device=:/exports/pg", "--opt type=nfs", "--label app=shop", "pgdata"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("volume args missing %q: %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "--driver local") {
|
||||||
|
t.Errorf("the default driver should be omitted: %s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "compose.project") {
|
||||||
|
t.Errorf("compose label must not be reapplied: %s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
n := Network{
|
||||||
|
Name: "backend", Driver: "bridge", Internal: true, Attachable: true,
|
||||||
|
IPAMPools: []IPAMPool{{Subnet: "172.28.0.0/16", Gateway: "172.28.0.1"}},
|
||||||
|
Options: map[string]string{"com.docker.network.bridge.name": "br-backend"},
|
||||||
|
}
|
||||||
|
gotNet := strings.Join(n.CreateArgs(), " ")
|
||||||
|
for _, want := range []string{"network create", "--driver bridge", "--internal", "--attachable", "--subnet 172.28.0.0/16", "--gateway 172.28.0.1", "backend"} {
|
||||||
|
if !strings.Contains(gotNet, want) {
|
||||||
|
t.Errorf("network args missing %q: %s", want, gotNet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortBindingString(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in PortBinding
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{PortBinding{ContainerPort: "80/tcp", HostPort: "8080"}, "8080:80"},
|
||||||
|
{PortBinding{ContainerPort: "80/tcp", HostIP: "0.0.0.0", HostPort: "80"}, "80:80"},
|
||||||
|
{PortBinding{ContainerPort: "53/udp", HostIP: "127.0.0.1", HostPort: "5353"}, "127.0.0.1:5353:53/udp"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := c.in.String(); got != c.want {
|
||||||
|
t.Errorf("PortBinding%+v = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
// Package spec defines a normalized, JSON-serializable description of a Docker
|
||||||
|
// container and everything it needs to be recreated on another host.
|
||||||
|
//
|
||||||
|
// The spec is deliberately independent of the Docker SDK types: it is produced
|
||||||
|
// on the source host, travels over SSH or inside an offline migration package,
|
||||||
|
// and is consumed either by the SSH engine or by a generated shell script that
|
||||||
|
// only has the docker CLI available.
|
||||||
|
package spec
|
||||||
|
|
||||||
|
// MountKind classifies a data location attached to a container.
|
||||||
|
type MountKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MountVolume MountKind = "volume" // named volume
|
||||||
|
MountAnonymous MountKind = "anonymous" // volume with a generated name
|
||||||
|
MountBind MountKind = "bind" // host directory or file
|
||||||
|
MountTmpfs MountKind = "tmpfs" // in-memory, never carries data
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mount is one data location attached to a container.
|
||||||
|
type Mount struct {
|
||||||
|
Kind MountKind `json:"kind"`
|
||||||
|
Name string `json:"name,omitempty"` // volume name (volume kind only)
|
||||||
|
Source string `json:"source,omitempty"` // host path (bind kind only)
|
||||||
|
Destination string `json:"destination"` // path inside the container
|
||||||
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
Propagation string `json:"propagation,omitempty"`
|
||||||
|
TmpfsOpts string `json:"tmpfsOpts,omitempty"`
|
||||||
|
|
||||||
|
// SizeBytes is a best-effort measurement of the data at this location,
|
||||||
|
// used to show progress and to warn about very large transfers. -1 = unknown.
|
||||||
|
SizeBytes int64 `json:"sizeBytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasData reports whether this mount is worth copying. tmpfs never is.
|
||||||
|
func (m Mount) HasData() bool { return m.Kind != MountTmpfs }
|
||||||
|
|
||||||
|
// Volume describes a named volume so it can be recreated with the same driver,
|
||||||
|
// options and labels rather than falling back to a plain local volume.
|
||||||
|
type Volume struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
DriverOpts map[string]string `json:"driverOpts,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPAMPool is one subnet definition of a user-defined network.
|
||||||
|
type IPAMPool struct {
|
||||||
|
Subnet string `json:"subnet,omitempty"`
|
||||||
|
IPRange string `json:"ipRange,omitempty"`
|
||||||
|
Gateway string `json:"gateway,omitempty"`
|
||||||
|
AuxAddress map[string]string `json:"auxAddress,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network describes a user-defined network to recreate on the target.
|
||||||
|
type Network struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
EnableIPv6 bool `json:"enableIPv6,omitempty"`
|
||||||
|
Internal bool `json:"internal,omitempty"`
|
||||||
|
Attachable bool `json:"attachable,omitempty"`
|
||||||
|
Ingress bool `json:"ingress,omitempty"`
|
||||||
|
IPAMDriver string `json:"ipamDriver,omitempty"`
|
||||||
|
IPAMPools []IPAMPool `json:"ipamPools,omitempty"`
|
||||||
|
Options map[string]string `json:"options,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Endpoint is a container's attachment to one network.
|
||||||
|
type Endpoint struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
IPv4Address string `json:"ipv4Address,omitempty"`
|
||||||
|
IPv6Address string `json:"ipv6Address,omitempty"`
|
||||||
|
MacAddress string `json:"macAddress,omitempty"`
|
||||||
|
Links []string `json:"links,omitempty"`
|
||||||
|
DriverOpts map[string]string `json:"driverOpts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PortBinding maps a container port onto the host.
|
||||||
|
type PortBinding struct {
|
||||||
|
ContainerPort string `json:"containerPort"` // e.g. "80/tcp"
|
||||||
|
HostIP string `json:"hostIp,omitempty"`
|
||||||
|
HostPort string `json:"hostPort,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthcheck mirrors the container health configuration when it was
|
||||||
|
// overridden at run time (an image-provided healthcheck is not re-emitted).
|
||||||
|
type Healthcheck struct {
|
||||||
|
Test []string `json:"test,omitempty"`
|
||||||
|
Interval int64 `json:"interval,omitempty"` // nanoseconds
|
||||||
|
Timeout int64 `json:"timeout,omitempty"` // nanoseconds
|
||||||
|
StartPeriod int64 `json:"startPeriod,omitempty"` // nanoseconds
|
||||||
|
Retries int `json:"retries,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resources holds the cgroup limits applied to the container.
|
||||||
|
type Resources struct {
|
||||||
|
Memory int64 `json:"memory,omitempty"`
|
||||||
|
MemoryReservation int64 `json:"memoryReservation,omitempty"`
|
||||||
|
MemorySwap int64 `json:"memorySwap,omitempty"`
|
||||||
|
MemorySwappiness *int64 `json:"memorySwappiness,omitempty"`
|
||||||
|
NanoCPUs int64 `json:"nanoCpus,omitempty"`
|
||||||
|
CPUShares int64 `json:"cpuShares,omitempty"`
|
||||||
|
CPUPeriod int64 `json:"cpuPeriod,omitempty"`
|
||||||
|
CPUQuota int64 `json:"cpuQuota,omitempty"`
|
||||||
|
CpusetCpus string `json:"cpusetCpus,omitempty"`
|
||||||
|
CpusetMems string `json:"cpusetMems,omitempty"`
|
||||||
|
PidsLimit *int64 `json:"pidsLimit,omitempty"`
|
||||||
|
OomKillDisable *bool `json:"oomKillDisable,omitempty"`
|
||||||
|
OomScoreAdj int `json:"oomScoreAdj,omitempty"`
|
||||||
|
ShmSize int64 `json:"shmSize,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ulimit is a per-container resource limit.
|
||||||
|
type Ulimit struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Soft int64 `json:"soft"`
|
||||||
|
Hard int64 `json:"hard"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is a host device exposed to the container.
|
||||||
|
type Device struct {
|
||||||
|
PathOnHost string `json:"pathOnHost"`
|
||||||
|
PathInContainer string `json:"pathInContainer"`
|
||||||
|
CgroupPermissions string `json:"cgroupPermissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container is the full normalized description of one container.
|
||||||
|
type Container struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"` // without the leading slash
|
||||||
|
State string `json:"state"` // running, exited, ...
|
||||||
|
|
||||||
|
Image string `json:"image"` // reference as the user wrote it, e.g. nginx:1.27
|
||||||
|
ImageID string `json:"imageId"` // sha256:...
|
||||||
|
ImageDigest string `json:"imageDigest,omitempty"`
|
||||||
|
|
||||||
|
// Compose grouping, taken from the standard compose labels. Empty when the
|
||||||
|
// container was not created by docker compose.
|
||||||
|
ComposeProject string `json:"composeProject,omitempty"`
|
||||||
|
ComposeService string `json:"composeService,omitempty"`
|
||||||
|
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
Domainname string `json:"domainname,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
WorkingDir string `json:"workingDir,omitempty"`
|
||||||
|
Env []string `json:"env,omitempty"`
|
||||||
|
Labels map[string]string `json:"labels,omitempty"`
|
||||||
|
Cmd []string `json:"cmd,omitempty"`
|
||||||
|
Entrypoint []string `json:"entrypoint,omitempty"`
|
||||||
|
EntrypointSet bool `json:"entrypointSet,omitempty"` // true when overridden at run time
|
||||||
|
CmdSet bool `json:"cmdSet,omitempty"`
|
||||||
|
Tty bool `json:"tty,omitempty"`
|
||||||
|
OpenStdin bool `json:"openStdin,omitempty"`
|
||||||
|
StopSignal string `json:"stopSignal,omitempty"`
|
||||||
|
StopTimeout *int `json:"stopTimeout,omitempty"`
|
||||||
|
Init *bool `json:"init,omitempty"`
|
||||||
|
|
||||||
|
RestartPolicy string `json:"restartPolicy,omitempty"`
|
||||||
|
RestartMaxRetries int `json:"restartMaxRetries,omitempty"`
|
||||||
|
AutoRemove bool `json:"autoRemove,omitempty"`
|
||||||
|
|
||||||
|
Privileged bool `json:"privileged,omitempty"`
|
||||||
|
ReadonlyRootfs bool `json:"readonlyRootfs,omitempty"`
|
||||||
|
CapAdd []string `json:"capAdd,omitempty"`
|
||||||
|
CapDrop []string `json:"capDrop,omitempty"`
|
||||||
|
SecurityOpt []string `json:"securityOpt,omitempty"`
|
||||||
|
GroupAdd []string `json:"groupAdd,omitempty"`
|
||||||
|
Sysctls map[string]string `json:"sysctls,omitempty"`
|
||||||
|
Devices []Device `json:"devices,omitempty"`
|
||||||
|
Ulimits []Ulimit `json:"ulimits,omitempty"`
|
||||||
|
Runtime string `json:"runtime,omitempty"`
|
||||||
|
|
||||||
|
PidMode string `json:"pidMode,omitempty"`
|
||||||
|
IpcMode string `json:"ipcMode,omitempty"`
|
||||||
|
UtsMode string `json:"utsMode,omitempty"`
|
||||||
|
UsernsMode string `json:"usernsMode,omitempty"`
|
||||||
|
CgroupnsMode string `json:"cgroupnsMode,omitempty"`
|
||||||
|
|
||||||
|
DNS []string `json:"dns,omitempty"`
|
||||||
|
DNSSearch []string `json:"dnsSearch,omitempty"`
|
||||||
|
DNSOptions []string `json:"dnsOptions,omitempty"`
|
||||||
|
ExtraHosts []string `json:"extraHosts,omitempty"`
|
||||||
|
|
||||||
|
NetworkMode string `json:"networkMode,omitempty"` // bridge, host, none, <name>, container:<id>
|
||||||
|
Endpoints []Endpoint `json:"endpoints,omitempty"`
|
||||||
|
Ports []PortBinding `json:"ports,omitempty"`
|
||||||
|
ExposedPorts []string `json:"exposedPorts,omitempty"`
|
||||||
|
PublishAll bool `json:"publishAll,omitempty"`
|
||||||
|
|
||||||
|
Mounts []Mount `json:"mounts,omitempty"`
|
||||||
|
|
||||||
|
LogDriver string `json:"logDriver,omitempty"`
|
||||||
|
LogOptions map[string]string `json:"logOptions,omitempty"`
|
||||||
|
|
||||||
|
Healthcheck *Healthcheck `json:"healthcheck,omitempty"`
|
||||||
|
Resources Resources `json:"resources"`
|
||||||
|
|
||||||
|
// Warnings collected while reading the container, surfaced in the UI.
|
||||||
|
Warnings []string `json:"warnings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataMounts returns the mounts that actually carry data.
|
||||||
|
func (c *Container) DataMounts() []Mount {
|
||||||
|
out := make([]Mount, 0, len(c.Mounts))
|
||||||
|
for _, m := range c.Mounts {
|
||||||
|
if m.HasData() {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
// Package sshx provides the SSH transport used to drive a target host that has
|
||||||
|
// nothing installed but sshd and the docker CLI.
|
||||||
|
package sshx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/agent"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMethod selects how to authenticate against the target host.
|
||||||
|
type AuthMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthPassword AuthMethod = "password"
|
||||||
|
AuthKey AuthMethod = "key"
|
||||||
|
AuthAgent AuthMethod = "agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config describes one target host.
|
||||||
|
type Config struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
|
||||||
|
Auth AuthMethod `json:"auth"`
|
||||||
|
// Password is used with AuthPassword, and as the passphrase fallback when
|
||||||
|
// a key is encrypted.
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
// PrivateKey holds PEM key material for AuthKey. PrivateKeyPath is read
|
||||||
|
// from disk instead when PrivateKey is empty.
|
||||||
|
PrivateKey string `json:"privateKey,omitempty"`
|
||||||
|
PrivateKeyPath string `json:"privateKeyPath,omitempty"`
|
||||||
|
Passphrase string `json:"passphrase,omitempty"`
|
||||||
|
|
||||||
|
// Sudo prefixes every docker command with sudo -n, for hosts where the
|
||||||
|
// login user is not in the docker group.
|
||||||
|
Sudo bool `json:"sudo"`
|
||||||
|
// DockerCmd overrides the docker binary, e.g. "podman" or an absolute path.
|
||||||
|
DockerCmd string `json:"dockerCmd,omitempty"`
|
||||||
|
|
||||||
|
// SaveSecrets persists the password and key material to the connection
|
||||||
|
// store. When false the secrets live only for the current process.
|
||||||
|
SaveSecrets bool `json:"saveSecrets"`
|
||||||
|
|
||||||
|
// Timeout is the TCP/handshake timeout. Zero means 20s.
|
||||||
|
Timeout time.Duration `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) addr() string {
|
||||||
|
port := c.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(c.Host, strconv.Itoa(port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a live SSH connection to a target host.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
conn *ssh.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostKeyError reports that the target's host key is unknown or has changed.
|
||||||
|
// The UI shows the fingerprint and asks the operator to confirm before the key
|
||||||
|
// is written to the known-hosts store.
|
||||||
|
type HostKeyError struct {
|
||||||
|
Host string
|
||||||
|
Fingerprint string
|
||||||
|
KeyType string
|
||||||
|
Changed bool // true when a different key was already trusted
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HostKeyError) Error() string {
|
||||||
|
if e.Changed {
|
||||||
|
return fmt.Sprintf("host key for %s CHANGED (%s %s); refusing to connect", e.Host, e.KeyType, e.Fingerprint)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("host key for %s is not trusted yet (%s %s)", e.Host, e.KeyType, e.Fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dial opens a connection, verifying the host key against the known-hosts
|
||||||
|
// store. It returns a *HostKeyError when the operator has to make a trust
|
||||||
|
// decision first.
|
||||||
|
func Dial(ctx context.Context, cfg Config, hk *KnownHosts) (*Client, error) {
|
||||||
|
auths, err := authMethods(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 20 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
var hkErr *HostKeyError
|
||||||
|
clientCfg := &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Auth: auths,
|
||||||
|
Timeout: timeout,
|
||||||
|
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
err := hk.Check(hostname, remote, key)
|
||||||
|
var he *HostKeyError
|
||||||
|
if errors.As(err, &he) {
|
||||||
|
hkErr = he
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
d := net.Dialer{Timeout: timeout}
|
||||||
|
rawConn, err := d.DialContext(ctx, "tcp", cfg.addr())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
|
||||||
|
}
|
||||||
|
sshConn, chans, reqs, err := ssh.NewClientConn(rawConn, cfg.addr(), clientCfg)
|
||||||
|
if err != nil {
|
||||||
|
rawConn.Close()
|
||||||
|
if hkErr != nil {
|
||||||
|
return nil, hkErr
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("ssh handshake with %s: %w", cfg.addr(), err)
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, conn: ssh.NewClient(sshConn, chans, reqs)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the connection.
|
||||||
|
func (c *Client) Close() error { return c.conn.Close() }
|
||||||
|
|
||||||
|
// Config returns the configuration this client was dialled with.
|
||||||
|
func (c *Client) Config() Config { return c.cfg }
|
||||||
|
|
||||||
|
// Result is the outcome of a remote command.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
ExitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a command line on the remote host and collects its output.
|
||||||
|
// The command is passed to the remote login shell, so it may contain pipes.
|
||||||
|
func (c *Client) Run(ctx context.Context, cmdline string) (*Result, error) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
code, err := c.run(ctx, cmdline, nil, &stdout, &stderr)
|
||||||
|
res := &Result{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: code}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCheck executes a command and turns a non-zero exit into an error that
|
||||||
|
// carries the remote stderr, which is what the operator needs to see.
|
||||||
|
func (c *Client) RunCheck(ctx context.Context, cmdline string) (string, error) {
|
||||||
|
res, err := c.Run(ctx, cmdline)
|
||||||
|
if err != nil {
|
||||||
|
return res.Stdout, err
|
||||||
|
}
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
msg := strings.TrimSpace(res.Stderr)
|
||||||
|
if msg == "" {
|
||||||
|
msg = strings.TrimSpace(res.Stdout)
|
||||||
|
}
|
||||||
|
return res.Stdout, fmt.Errorf("remote command failed (exit %d): %s", res.ExitCode, msg)
|
||||||
|
}
|
||||||
|
return res.Stdout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream executes a command, feeding it stdin and writing its stdout to out.
|
||||||
|
// This is how bulk data crosses the wire: the tar stream produced locally is
|
||||||
|
// piped straight into a remote `docker cp` without ever touching disk.
|
||||||
|
func (c *Client) Stream(ctx context.Context, cmdline string, stdin io.Reader, stdout io.Writer) (*Result, error) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if stdout == nil {
|
||||||
|
stdout = io.Discard
|
||||||
|
}
|
||||||
|
code, err := c.run(ctx, cmdline, stdin, stdout, &stderr)
|
||||||
|
res := &Result{Stderr: stderr.String(), ExitCode: code}
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if code != 0 {
|
||||||
|
return res, fmt.Errorf("remote command failed (exit %d): %s", code, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) run(ctx context.Context, cmdline string, stdin io.Reader, stdout, stderr io.Writer) (int, error) {
|
||||||
|
sess, err := c.conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("open ssh session: %w", err)
|
||||||
|
}
|
||||||
|
defer sess.Close()
|
||||||
|
|
||||||
|
sess.Stdout = stdout
|
||||||
|
sess.Stderr = stderr
|
||||||
|
if stdin != nil {
|
||||||
|
sess.Stdin = stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- sess.Run(cmdline) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = sess.Signal(ssh.SIGTERM)
|
||||||
|
_ = sess.Close()
|
||||||
|
return -1, ctx.Err()
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
var ee *ssh.ExitError
|
||||||
|
if errors.As(err, &ee) {
|
||||||
|
return ee.ExitStatus(), nil
|
||||||
|
}
|
||||||
|
return -1, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func authMethods(cfg Config) ([]ssh.AuthMethod, error) {
|
||||||
|
var methods []ssh.AuthMethod
|
||||||
|
switch cfg.Auth {
|
||||||
|
case AuthPassword:
|
||||||
|
if cfg.Password == "" {
|
||||||
|
return nil, errors.New("password authentication selected but no password supplied")
|
||||||
|
}
|
||||||
|
methods = append(methods,
|
||||||
|
ssh.Password(cfg.Password),
|
||||||
|
// Many sshd setups answer with keyboard-interactive instead of the
|
||||||
|
// plain password method.
|
||||||
|
ssh.KeyboardInteractive(func(_, _ string, questions []string, _ []bool) ([]string, error) {
|
||||||
|
answers := make([]string, len(questions))
|
||||||
|
for i := range answers {
|
||||||
|
answers[i] = cfg.Password
|
||||||
|
}
|
||||||
|
return answers, nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
case AuthKey:
|
||||||
|
pem := []byte(cfg.PrivateKey)
|
||||||
|
if len(pem) == 0 {
|
||||||
|
if cfg.PrivateKeyPath == "" {
|
||||||
|
return nil, errors.New("key authentication selected but no key supplied")
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(cfg.PrivateKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read private key: %w", err)
|
||||||
|
}
|
||||||
|
pem = b
|
||||||
|
}
|
||||||
|
var signer ssh.Signer
|
||||||
|
var err error
|
||||||
|
passphrase := cfg.Passphrase
|
||||||
|
if passphrase == "" {
|
||||||
|
passphrase = cfg.Password
|
||||||
|
}
|
||||||
|
if passphrase != "" {
|
||||||
|
signer, err = ssh.ParsePrivateKeyWithPassphrase(pem, []byte(passphrase))
|
||||||
|
} else {
|
||||||
|
signer, err = ssh.ParsePrivateKey(pem)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
var pm *ssh.PassphraseMissingError
|
||||||
|
if errors.As(err, &pm) {
|
||||||
|
return nil, errors.New("private key is encrypted; supply the passphrase")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("parse private key: %w", err)
|
||||||
|
}
|
||||||
|
methods = append(methods, ssh.PublicKeys(signer))
|
||||||
|
case AuthAgent:
|
||||||
|
sock := os.Getenv("SSH_AUTH_SOCK")
|
||||||
|
if sock == "" {
|
||||||
|
return nil, errors.New("agent authentication selected but SSH_AUTH_SOCK is not set")
|
||||||
|
}
|
||||||
|
conn, err := net.Dial("unix", sock)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to ssh agent: %w", err)
|
||||||
|
}
|
||||||
|
methods = append(methods, ssh.PublicKeysCallback(agent.NewClient(conn).Signers))
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown auth method %q", cfg.Auth)
|
||||||
|
}
|
||||||
|
return methods, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fingerprint renders a public key the way OpenSSH shows it.
|
||||||
|
func Fingerprint(key ssh.PublicKey) string { return ssh.FingerprintSHA256(key) }
|
||||||
|
|
||||||
|
var _ = knownhosts.Normalize
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package sshx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/spec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemoteDocker drives the docker CLI on a target host over SSH. It assumes
|
||||||
|
// nothing beyond sshd, docker and a POSIX shell; gzip is used only when
|
||||||
|
// compression is enabled and is probed for first.
|
||||||
|
type RemoteDocker struct {
|
||||||
|
c *Client
|
||||||
|
binary string
|
||||||
|
sudo bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRemoteDocker wraps a connection.
|
||||||
|
func NewRemoteDocker(c *Client) *RemoteDocker {
|
||||||
|
cfg := c.Config()
|
||||||
|
bin := cfg.DockerCmd
|
||||||
|
if bin == "" {
|
||||||
|
bin = "docker"
|
||||||
|
}
|
||||||
|
return &RemoteDocker{c: c, binary: bin, sudo: cfg.Sudo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd renders a docker invocation as a shell command line, correctly quoted.
|
||||||
|
func (r *RemoteDocker) Cmd(args ...string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
if r.sudo {
|
||||||
|
b.WriteString("sudo -n ")
|
||||||
|
}
|
||||||
|
b.WriteString(spec.ShellQuote(r.binary))
|
||||||
|
if len(args) > 0 {
|
||||||
|
b.WriteString(" ")
|
||||||
|
b.WriteString(spec.ShellQuoteAll(args))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a docker command and returns its stdout, failing on non-zero.
|
||||||
|
func (r *RemoteDocker) Run(ctx context.Context, args ...string) (string, error) {
|
||||||
|
return r.c.RunCheck(ctx, r.Cmd(args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try executes a docker command and reports success without treating a
|
||||||
|
// non-zero exit as an error. Used for existence probes.
|
||||||
|
func (r *RemoteDocker) Try(ctx context.Context, args ...string) (string, bool, error) {
|
||||||
|
res, err := r.c.Run(ctx, r.Cmd(args...))
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
return res.Stdout, res.ExitCode == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed pipes a local reader into a docker command's stdin. When decompress is
|
||||||
|
// set the remote side runs `gzip -dc` ahead of docker, so the bytes on the
|
||||||
|
// wire are compressed.
|
||||||
|
func (r *RemoteDocker) Feed(ctx context.Context, src io.Reader, decompress bool, args ...string) error {
|
||||||
|
cmd := r.Cmd(args...)
|
||||||
|
if decompress {
|
||||||
|
cmd = "gzip -dc | " + cmd
|
||||||
|
}
|
||||||
|
_, err := r.c.Stream(ctx, cmd, src, nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight is what the target host was found to support.
|
||||||
|
type Preflight struct {
|
||||||
|
DockerVersion string `json:"dockerVersion"`
|
||||||
|
ServerVersion string `json:"serverVersion"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
HasGzip bool `json:"hasGzip"`
|
||||||
|
DiskFreeBytes int64 `json:"diskFreeBytes"`
|
||||||
|
DockerRoot string `json:"dockerRoot"`
|
||||||
|
Problems []string `json:"problems,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight checks everything the migration depends on before any data moves.
|
||||||
|
func (r *RemoteDocker) Preflight(ctx context.Context) (*Preflight, error) {
|
||||||
|
p := &Preflight{}
|
||||||
|
|
||||||
|
out, ok, err := r.Try(ctx, "version", "--format", "{{.Client.Version}}|{{.Server.Version}}|{{.Server.Os}}|{{.Server.Arch}}")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
res, _ := r.c.Run(ctx, r.Cmd("version"))
|
||||||
|
msg := strings.TrimSpace(res.Stderr)
|
||||||
|
if strings.Contains(msg, "permission denied") {
|
||||||
|
p.Problems = append(p.Problems,
|
||||||
|
"the login user cannot talk to the docker daemon; add it to the docker group or enable sudo for this connection")
|
||||||
|
} else if msg != "" {
|
||||||
|
p.Problems = append(p.Problems, "docker is not usable on the target: "+firstLine(msg))
|
||||||
|
} else {
|
||||||
|
p.Problems = append(p.Problems, "docker is not installed or not on PATH on the target")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.TrimSpace(out), "|")
|
||||||
|
if len(parts) == 4 {
|
||||||
|
p.DockerVersion, p.ServerVersion, p.OS, p.Arch = parts[0], parts[1], parts[2], parts[3]
|
||||||
|
}
|
||||||
|
if p.OS != "" && p.OS != "linux" {
|
||||||
|
p.Problems = append(p.Problems, "target daemon runs "+p.OS+" containers; only linux targets are supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := r.c.Run(ctx, "command -v gzip >/dev/null 2>&1")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.HasGzip = res.ExitCode == 0
|
||||||
|
if !p.HasGzip {
|
||||||
|
p.Problems = append(p.Problems, "gzip is missing on the target; transfers will run uncompressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if root, err2 := r.Run(ctx, "info", "--format", "{{.DockerRootDir}}"); err2 == nil {
|
||||||
|
p.DockerRoot = strings.TrimSpace(root)
|
||||||
|
if p.DockerRoot != "" {
|
||||||
|
// POSIX df in 1K blocks; the fourth column is available space.
|
||||||
|
cmd := "df -Pk " + spec.ShellQuote(p.DockerRoot) + " | awk 'NR==2 {print $4}'"
|
||||||
|
if dres, derr := r.c.Run(ctx, cmd); derr == nil && dres.ExitCode == 0 {
|
||||||
|
var kb int64
|
||||||
|
if _, serr := fmt.Sscanf(strings.TrimSpace(dres.Stdout), "%d", &kb); serr == nil {
|
||||||
|
p.DiskFreeBytes = kb * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetContainer is a container that already exists on the target host.
|
||||||
|
type TargetContainer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Ports string `json:"ports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TargetInventory is the current state of the target host, used to show it
|
||||||
|
// side by side with the source and to detect name conflicts up front.
|
||||||
|
type TargetInventory struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Containers []TargetContainer `json:"containers"`
|
||||||
|
Volumes []string `json:"volumes"`
|
||||||
|
Networks []string `json:"networks"`
|
||||||
|
Preflight *Preflight `json:"preflight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory reads the target host's containers, volumes and networks.
|
||||||
|
func (r *RemoteDocker) Inventory(ctx context.Context) (*TargetInventory, error) {
|
||||||
|
pre, err := r.Preflight(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inv := &TargetInventory{Preflight: pre}
|
||||||
|
if pre.ServerVersion == "" {
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if host, err := r.c.RunCheck(ctx, "hostname"); err == nil {
|
||||||
|
inv.Host = strings.TrimSpace(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := r.Run(ctx, "ps", "-a", "--no-trunc", "--format", "{{json .}}")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw struct {
|
||||||
|
ID, Names, Image, State, Status, Ports string
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(line), &raw) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// A container attached to several networks is reported with a
|
||||||
|
// comma-separated name list; the first is its real name.
|
||||||
|
name := raw.Names
|
||||||
|
if i := strings.Index(name, ","); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
inv.Containers = append(inv.Containers, TargetContainer{
|
||||||
|
ID: raw.ID, Name: name, Image: raw.Image,
|
||||||
|
State: raw.State, Status: raw.Status, Ports: raw.Ports,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := r.Run(ctx, "volume", "ls", "--format", "{{.Name}}"); err == nil {
|
||||||
|
inv.Volumes = nonEmptyLines(out)
|
||||||
|
}
|
||||||
|
if out, err := r.Run(ctx, "network", "ls", "--format", "{{.Name}}"); err == nil {
|
||||||
|
inv.Networks = nonEmptyLines(out)
|
||||||
|
}
|
||||||
|
return inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists reports whether an object of the given kind is present on the target.
|
||||||
|
func (r *RemoteDocker) Exists(ctx context.Context, kind, name string) (bool, error) {
|
||||||
|
var args []string
|
||||||
|
switch kind {
|
||||||
|
case "container":
|
||||||
|
args = []string{"container", "inspect", name}
|
||||||
|
case "volume":
|
||||||
|
args = []string{"volume", "inspect", name}
|
||||||
|
case "network":
|
||||||
|
args = []string{"network", "inspect", name}
|
||||||
|
case "image":
|
||||||
|
args = []string{"image", "inspect", name}
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("unknown object kind %q", kind)
|
||||||
|
}
|
||||||
|
res, err := r.c.Run(ctx, r.Cmd(args...)+" >/dev/null 2>&1")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return res.ExitCode == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MkdirAll creates a directory on the target host, for bind mount sources that
|
||||||
|
// need to exist with the right ownership before the container starts.
|
||||||
|
func (r *RemoteDocker) MkdirAll(ctx context.Context, path string) error {
|
||||||
|
cmd := "mkdir -p " + spec.ShellQuote(path)
|
||||||
|
if r.sudo {
|
||||||
|
cmd = "sudo -n " + cmd
|
||||||
|
}
|
||||||
|
_, err := r.c.RunCheck(ctx, cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client exposes the underlying SSH connection for raw shell work.
|
||||||
|
func (r *RemoteDocker) Client() *Client { return r.c }
|
||||||
|
|
||||||
|
func nonEmptyLines(s string) []string {
|
||||||
|
var out []string
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if l = strings.TrimSpace(l); l != "" {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package sshx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KnownHosts is the trust store for target host keys. It behaves like OpenSSH:
|
||||||
|
// an unknown key is refused until the operator confirms the fingerprint, and a
|
||||||
|
// changed key is refused outright.
|
||||||
|
type KnownHosts struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKnownHosts opens (and creates if needed) the store at path.
|
||||||
|
func NewKnownHosts(path string) (*KnownHosts, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create key store directory: %w", err)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open known hosts file: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
return &KnownHosts{path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the on-disk location of the store.
|
||||||
|
func (k *KnownHosts) Path() string { return k.path }
|
||||||
|
|
||||||
|
// Check implements the ssh.HostKeyCallback contract.
|
||||||
|
func (k *KnownHosts) Check(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
|
||||||
|
cb, err := knownhosts.New(k.path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read known hosts: %w", err)
|
||||||
|
}
|
||||||
|
err = cb(hostname, remote, key)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var keyErr *knownhosts.KeyError
|
||||||
|
if errors.As(err, &keyErr) {
|
||||||
|
return &HostKeyError{
|
||||||
|
Host: hostname,
|
||||||
|
Fingerprint: ssh.FingerprintSHA256(key),
|
||||||
|
KeyType: key.Type(),
|
||||||
|
Changed: len(keyErr.Want) > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trust records a host key so later connections succeed.
|
||||||
|
func (k *KnownHosts) Trust(hostname string, key ssh.PublicKey) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
|
||||||
|
f, err := os.OpenFile(k.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open known hosts for write: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
line := knownhosts.Line([]string{knownhosts.Normalize(hostname)}, key)
|
||||||
|
if _, err := f.WriteString(line + "\n"); err != nil {
|
||||||
|
return fmt.Errorf("record host key: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget removes every entry for a host, so a changed key can be re-approved.
|
||||||
|
func (k *KnownHosts) Forget(hostname string) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
|
||||||
|
b, err := os.ReadFile(k.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
want := knownhosts.Normalize(hostname)
|
||||||
|
var kept []byte
|
||||||
|
for _, line := range splitLines(b) {
|
||||||
|
if len(line) == 0 || line[0] == '#' {
|
||||||
|
kept = append(kept, line...)
|
||||||
|
kept = append(kept, '\n')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, hosts, _, _, _, perr := ssh.ParseKnownHosts(append(line, '\n'))
|
||||||
|
if perr == nil && containsHost(hosts, want) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, line...)
|
||||||
|
kept = append(kept, '\n')
|
||||||
|
}
|
||||||
|
return os.WriteFile(k.path, kept, 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HostKeyInfo is the fingerprint presented by a host, shown to the operator
|
||||||
|
// before they decide to trust it.
|
||||||
|
type HostKeyInfo struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
KeyType string `json:"keyType"`
|
||||||
|
Fingerprint string `json:"fingerprint"`
|
||||||
|
Trusted bool `json:"trusted"`
|
||||||
|
Changed bool `json:"changed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe opens a TCP connection just far enough to read the host key, without
|
||||||
|
// authenticating. Used by the "check fingerprint" step in the UI.
|
||||||
|
func Probe(ctx context.Context, cfg Config, hk *KnownHosts) (*HostKeyInfo, error) {
|
||||||
|
timeout := cfg.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
var captured ssh.PublicKey
|
||||||
|
clientCfg := &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Timeout: timeout,
|
||||||
|
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||||
|
captured = key
|
||||||
|
// Stop the handshake here: reading the key is all this needs.
|
||||||
|
return errProbeDone
|
||||||
|
},
|
||||||
|
}
|
||||||
|
d := net.Dialer{Timeout: timeout}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", cfg.addr())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to %s: %w", cfg.addr(), err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_, _, _, err = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
|
||||||
|
if captured == nil {
|
||||||
|
return nil, fmt.Errorf("read host key from %s: %w", cfg.addr(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info := &HostKeyInfo{
|
||||||
|
Host: cfg.addr(),
|
||||||
|
KeyType: captured.Type(),
|
||||||
|
Fingerprint: ssh.FingerprintSHA256(captured),
|
||||||
|
}
|
||||||
|
switch checkErr := hk.Check(cfg.addr(), conn.RemoteAddr(), captured).(type) {
|
||||||
|
case nil:
|
||||||
|
info.Trusted = true
|
||||||
|
case *HostKeyError:
|
||||||
|
info.Changed = checkErr.Changed
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustFromProbe re-reads the host key and stores it. Taking the key from a
|
||||||
|
// fresh handshake rather than from client-supplied input means the UI can only
|
||||||
|
// approve a fingerprint it actually saw.
|
||||||
|
func TrustFromProbe(ctx context.Context, cfg Config, hk *KnownHosts, expectFingerprint string) error {
|
||||||
|
info, err := Probe(ctx, cfg, hk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if expectFingerprint != "" && info.Fingerprint != expectFingerprint {
|
||||||
|
return fmt.Errorf("host key changed between check and approval (%s vs %s); aborting",
|
||||||
|
expectFingerprint, info.Fingerprint)
|
||||||
|
}
|
||||||
|
var captured ssh.PublicKey
|
||||||
|
clientCfg := &ssh.ClientConfig{
|
||||||
|
User: cfg.User,
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||||
|
captured = key
|
||||||
|
return errProbeDone
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", cfg.addr(), 15*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_, _, _, _ = ssh.NewClientConn(conn, cfg.addr(), clientCfg)
|
||||||
|
if captured == nil {
|
||||||
|
return errors.New("could not read host key")
|
||||||
|
}
|
||||||
|
if ssh.FingerprintSHA256(captured) != info.Fingerprint {
|
||||||
|
return errors.New("host key is unstable; aborting")
|
||||||
|
}
|
||||||
|
if info.Changed {
|
||||||
|
if err := hk.Forget(cfg.addr()); err != nil {
|
||||||
|
return fmt.Errorf("drop previous host key: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hk.Trust(cfg.addr(), captured)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errProbeDone = errors.New("host key captured")
|
||||||
|
|
||||||
|
func splitLines(b []byte) [][]byte {
|
||||||
|
var out [][]byte
|
||||||
|
start := 0
|
||||||
|
for i := 0; i < len(b); i++ {
|
||||||
|
if b[i] == '\n' {
|
||||||
|
line := b[start:i]
|
||||||
|
if n := len(line); n > 0 && line[n-1] == '\r' {
|
||||||
|
line = line[:n-1]
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < len(b) {
|
||||||
|
out = append(out, b[start:])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsHost(hosts []string, want string) bool {
|
||||||
|
for _, h := range hosts {
|
||||||
|
if knownhosts.Normalize(h) == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// Package store persists target host connections between runs.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/arescom/docker-migrate/internal/sshx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound is returned for an unknown connection id.
|
||||||
|
var ErrNotFound = errors.New("connection not found")
|
||||||
|
|
||||||
|
// Connections is a small JSON-backed collection of target hosts.
|
||||||
|
//
|
||||||
|
// Secrets are only written when the operator opts in per connection. The file
|
||||||
|
// is created with owner-only permissions either way.
|
||||||
|
type Connections struct {
|
||||||
|
path string
|
||||||
|
mu sync.RWMutex
|
||||||
|
// items holds the persisted form.
|
||||||
|
items map[string]sshx.Config
|
||||||
|
// secrets holds credentials for connections that opted out of persistence,
|
||||||
|
// so they survive for the lifetime of the process but never hit disk.
|
||||||
|
secrets map[string]secret
|
||||||
|
}
|
||||||
|
|
||||||
|
type secret struct {
|
||||||
|
Password string
|
||||||
|
PrivateKey string
|
||||||
|
Passphrase string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConnections loads (or creates) the connection file at path.
|
||||||
|
func NewConnections(path string) (*Connections, error) {
|
||||||
|
c := &Connections{path: path, items: map[string]sshx.Config{}, secrets: map[string]secret{}}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("create data directory: %w", err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read connections: %w", err)
|
||||||
|
}
|
||||||
|
var list []sshx.Config
|
||||||
|
if err := json.Unmarshal(b, &list); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse connections file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, cfg := range list {
|
||||||
|
c.items[cfg.ID] = cfg
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every connection with secrets stripped, newest name order.
|
||||||
|
func (c *Connections) List() []sshx.Config {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
out := make([]sshx.Config, 0, len(c.items))
|
||||||
|
for _, cfg := range c.items {
|
||||||
|
out = append(out, redact(cfg))
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a connection ready to dial, with secrets filled back in.
|
||||||
|
func (c *Connections) Get(id string) (sshx.Config, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
cfg, ok := c.items[id]
|
||||||
|
if !ok {
|
||||||
|
return sshx.Config{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if s, ok := c.secrets[id]; ok {
|
||||||
|
if cfg.Password == "" {
|
||||||
|
cfg.Password = s.Password
|
||||||
|
}
|
||||||
|
if cfg.PrivateKey == "" {
|
||||||
|
cfg.PrivateKey = s.PrivateKey
|
||||||
|
}
|
||||||
|
if cfg.Passphrase == "" {
|
||||||
|
cfg.Passphrase = s.Passphrase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save inserts or updates a connection and returns the stored, redacted form.
|
||||||
|
//
|
||||||
|
// When SaveSecrets is false the credentials are kept in memory only; an update
|
||||||
|
// that omits credentials keeps whatever was already held, so the UI can edit a
|
||||||
|
// connection without re-entering a password.
|
||||||
|
func (c *Connections) Save(cfg sshx.Config) (sshx.Config, error) {
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return sshx.Config{}, errors.New("host is required")
|
||||||
|
}
|
||||||
|
if cfg.User == "" {
|
||||||
|
return sshx.Config{}, errors.New("user is required")
|
||||||
|
}
|
||||||
|
if cfg.Port == 0 {
|
||||||
|
cfg.Port = 22
|
||||||
|
}
|
||||||
|
if cfg.Name == "" {
|
||||||
|
cfg.Name = cfg.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if cfg.ID == "" {
|
||||||
|
cfg.ID = newID()
|
||||||
|
}
|
||||||
|
prev, existed := c.items[cfg.ID]
|
||||||
|
prevSecret := c.secrets[cfg.ID]
|
||||||
|
|
||||||
|
// Carry forward credentials the caller did not resend.
|
||||||
|
if cfg.Password == "" {
|
||||||
|
cfg.Password = firstNonEmpty(prev.Password, prevSecret.Password)
|
||||||
|
}
|
||||||
|
if cfg.PrivateKey == "" {
|
||||||
|
cfg.PrivateKey = firstNonEmpty(prev.PrivateKey, prevSecret.PrivateKey)
|
||||||
|
}
|
||||||
|
if cfg.Passphrase == "" {
|
||||||
|
cfg.Passphrase = firstNonEmpty(prev.Passphrase, prevSecret.Passphrase)
|
||||||
|
}
|
||||||
|
_ = existed
|
||||||
|
|
||||||
|
if cfg.SaveSecrets {
|
||||||
|
delete(c.secrets, cfg.ID)
|
||||||
|
c.items[cfg.ID] = cfg
|
||||||
|
} else {
|
||||||
|
c.secrets[cfg.ID] = secret{
|
||||||
|
Password: cfg.Password,
|
||||||
|
PrivateKey: cfg.PrivateKey,
|
||||||
|
Passphrase: cfg.Passphrase,
|
||||||
|
}
|
||||||
|
c.items[cfg.ID] = redact(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.flush(); err != nil {
|
||||||
|
return sshx.Config{}, err
|
||||||
|
}
|
||||||
|
return redact(c.items[cfg.ID]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a connection.
|
||||||
|
func (c *Connections) Delete(id string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if _, ok := c.items[id]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
delete(c.items, id)
|
||||||
|
delete(c.secrets, id)
|
||||||
|
return c.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush writes the file. The caller must hold the write lock.
|
||||||
|
func (c *Connections) flush() error {
|
||||||
|
list := make([]sshx.Config, 0, len(c.items))
|
||||||
|
for _, cfg := range c.items {
|
||||||
|
list = append(list, cfg)
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
|
||||||
|
b, err := json.MarshalIndent(list, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := c.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write connections: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, c.path); err != nil {
|
||||||
|
return fmt.Errorf("replace connections file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redact(cfg sshx.Config) sshx.Config {
|
||||||
|
cfg.Password = ""
|
||||||
|
cfg.PrivateKey = ""
|
||||||
|
cfg.Passphrase = ""
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func newID() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
+11
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<title>docker-migrate</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BJYzrqMo.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BYoxln0e.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Package webui embeds the built web application into the binary so the tool
|
||||||
|
// ships as a single file with no runtime assets to install.
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var dist embed.FS
|
||||||
|
|
||||||
|
// FS returns the built web app rooted at its index.html, or nil when the
|
||||||
|
// frontend has not been built into this binary.
|
||||||
|
func FS() fs.FS {
|
||||||
|
sub, err := fs.Sub(dist, "dist")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(sub, "index.html"); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built reports whether a real UI is embedded, as opposed to the placeholder
|
||||||
|
// that keeps the package compiling before the frontend is built.
|
||||||
|
func Built() bool {
|
||||||
|
sub := FS()
|
||||||
|
if sub == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := fs.Stat(sub, "assets")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+1883
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)}` : '')),
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user