commit fe8b354adc680a1bd99355206226b4e070a8004c Author: Kawa Date: Tue Aug 11 09:00:01 2026 +0200 Initial push diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e42c85c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +dist/ +docker-migrate +docker-migrate.exe +web/node_modules +internal/webui/dist +*.test +*.test.exe +README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c313cb --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5677c4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..afa0f02 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8e199f --- /dev/null +++ b/README.md @@ -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 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 `/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 `/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 /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/`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e1ed4a1 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b68e76 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..949b2da --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..ffb546f --- /dev/null +++ b/internal/api/handlers.go @@ -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 diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..b016690 --- /dev/null +++ b/internal/api/server.go @@ -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 +} diff --git a/internal/dkr/client.go b/internal/dkr/client.go new file mode 100644 index 0000000..32978d1 --- /dev/null +++ b/internal/dkr/client.go @@ -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 +} diff --git a/internal/dkr/data.go b/internal/dkr/data.go new file mode 100644 index 0000000..5861abe --- /dev/null +++ b/internal/dkr/data.go @@ -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{} diff --git a/internal/dkr/inventory.go b/internal/dkr/inventory.go new file mode 100644 index 0000000..f528e48 --- /dev/null +++ b/internal/dkr/inventory.go @@ -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 +} diff --git a/internal/dkr/inventory_live_test.go b/internal/dkr/inventory_live_test.go new file mode 100644 index 0000000..c5515cf --- /dev/null +++ b/internal/dkr/inventory_live_test.go @@ -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)) +} diff --git a/internal/job/job.go b/internal/job/job.go new file mode 100644 index 0000000..136aee9 --- /dev/null +++ b/internal/job/job.go @@ -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() + } +} diff --git a/internal/job/manager.go b/internal/job/manager.go new file mode 100644 index 0000000..7e2b05c --- /dev/null +++ b/internal/job/manager.go @@ -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() +} diff --git a/internal/migrate/installer.go b/internal/migrate/installer.go new file mode 100644 index 0000000..b81e114 --- /dev/null +++ b/internal/migrate/installer.go @@ -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/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) +} diff --git a/internal/migrate/installer_test.go b/internal/migrate/installer_test.go new file mode 100644 index 0000000..d9d88d1 --- /dev/null +++ b/internal/migrate/installer_test.go @@ -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 inspect " +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 " + 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 +} diff --git a/internal/migrate/packager.go b/internal/migrate/packager.go new file mode 100644 index 0000000..d034d60 --- /dev/null +++ b/internal/migrate/packager.go @@ -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 +} diff --git a/internal/migrate/prepare.go b/internal/migrate/prepare.go new file mode 100644 index 0000000..4665b3b --- /dev/null +++ b/internal/migrate/prepare.go @@ -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 +} diff --git a/internal/migrate/prepare_test.go b/internal/migrate/prepare_test.go new file mode 100644 index 0000000..de8f108 --- /dev/null +++ b/internal/migrate/prepare_test.go @@ -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 + // /data and extract into . + 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") + } +} diff --git a/internal/migrate/ssh.go b/internal/migrate/ssh.go new file mode 100644 index 0000000..9c07547 --- /dev/null +++ b/internal/migrate/ssh.go @@ -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]) +} diff --git a/internal/spec/plan.go b/internal/spec/plan.go new file mode 100644 index 0000000..8740cd8 --- /dev/null +++ b/internal/spec/plan.go @@ -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"` +} diff --git a/internal/spec/render.go b/internal/spec/render.go new file mode 100644 index 0000000..762fd36 --- /dev/null +++ b/internal/spec/render.go @@ -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 +} diff --git a/internal/spec/render_test.go b/internal/spec/render_test.go new file mode 100644 index 0000000..1c131a5 --- /dev/null +++ b/internal/spec/render_test.go @@ -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) + } + } +} diff --git a/internal/spec/spec.go b/internal/spec/spec.go new file mode 100644 index 0000000..bbf1bff --- /dev/null +++ b/internal/spec/spec.go @@ -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, , container: + 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 +} diff --git a/internal/sshx/client.go b/internal/sshx/client.go new file mode 100644 index 0000000..a3e0892 --- /dev/null +++ b/internal/sshx/client.go @@ -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 diff --git a/internal/sshx/docker.go b/internal/sshx/docker.go new file mode 100644 index 0000000..7f05881 --- /dev/null +++ b/internal/sshx/docker.go @@ -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 +} diff --git a/internal/sshx/knownhosts.go b/internal/sshx/knownhosts.go new file mode 100644 index 0000000..6e211e0 --- /dev/null +++ b/internal/sshx/knownhosts.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..51b01e5 --- /dev/null +++ b/internal/store/store.go @@ -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) +} diff --git a/internal/webui/dist/assets/index-BJYzrqMo.js b/internal/webui/dist/assets/index-BJYzrqMo.js new file mode 100644 index 0000000..00cb632 --- /dev/null +++ b/internal/webui/dist/assets/index-BJYzrqMo.js @@ -0,0 +1,11 @@ +(function(){const N=document.createElement("link").relList;if(N&&N.supports&&N.supports("modulepreload"))return;for(const _ of document.querySelectorAll('link[rel="modulepreload"]'))s(_);new MutationObserver(_=>{for(const H of _)if(H.type==="childList")for(const E of H.addedNodes)E.tagName==="LINK"&&E.rel==="modulepreload"&&s(E)}).observe(document,{childList:!0,subtree:!0});function O(_){const H={};return _.integrity&&(H.integrity=_.integrity),_.referrerPolicy&&(H.referrerPolicy=_.referrerPolicy),_.crossOrigin==="use-credentials"?H.credentials="include":_.crossOrigin==="anonymous"?H.credentials="omit":H.credentials="same-origin",H}function s(_){if(_.ep)return;_.ep=!0;const H=O(_);fetch(_.href,H)}})();var mf={exports:{}},_n={};var Ar;function cy(){if(Ar)return _n;Ar=1;var h=Symbol.for("react.transitional.element"),N=Symbol.for("react.fragment");function O(s,_,H){var E=null;if(H!==void 0&&(E=""+H),_.key!==void 0&&(E=""+_.key),"key"in _){H={};for(var k in _)k!=="key"&&(H[k]=_[k])}else H=_;return _=H.ref,{$$typeof:h,type:s,key:E,ref:_!==void 0?_:null,props:H}}return _n.Fragment=N,_n.jsx=O,_n.jsxs=O,_n}var Nr;function fy(){return Nr||(Nr=1,mf.exports=cy()),mf.exports}var c=fy(),yf={exports:{}},w={};var _r;function sy(){if(_r)return w;_r=1;var h=Symbol.for("react.transitional.element"),N=Symbol.for("react.portal"),O=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),_=Symbol.for("react.profiler"),H=Symbol.for("react.consumer"),E=Symbol.for("react.context"),k=Symbol.for("react.forward_ref"),D=Symbol.for("react.suspense"),g=Symbol.for("react.memo"),J=Symbol.for("react.lazy"),A=Symbol.for("react.activity"),B=Symbol.iterator;function dl(d){return d===null||typeof d!="object"?null:(d=B&&d[B]||d["@@iterator"],typeof d=="function"?d:null)}var yl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},il=Object.assign,Xl={};function El(d,x,R){this.props=d,this.context=x,this.refs=Xl,this.updater=R||yl}El.prototype.isReactComponent={},El.prototype.setState=function(d,x){if(typeof d!="object"&&typeof d!="function"&&d!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,d,x,"setState")},El.prototype.forceUpdate=function(d){this.updater.enqueueForceUpdate(this,d,"forceUpdate")};function Al(){}Al.prototype=El.prototype;function rl(d,x,R){this.props=d,this.context=x,this.refs=Xl,this.updater=R||yl}var xl=rl.prototype=new Al;xl.constructor=rl,il(xl,El.prototype),xl.isPureReactComponent=!0;var Dl=Array.isArray;function _l(){}var U={H:null,A:null,T:null,S:null},Q=Object.prototype.hasOwnProperty;function q(d,x,R){var Y=R.ref;return{$$typeof:h,type:d,key:x,ref:Y!==void 0?Y:null,props:R}}function $(d,x){return q(d.type,x,d.props)}function cl(d){return typeof d=="object"&&d!==null&&d.$$typeof===h}function hl(d){var x={"=":"=0",":":"=2"};return"$"+d.replace(/[=:]/g,function(R){return x[R]})}var Pl=/\/+/g;function Zl(d,x){return typeof d=="object"&&d!==null&&d.key!=null?hl(""+d.key):x.toString(36)}function Ll(d){switch(d.status){case"fulfilled":return d.value;case"rejected":throw d.reason;default:switch(typeof d.status=="string"?d.then(_l,_l):(d.status="pending",d.then(function(x){d.status==="pending"&&(d.status="fulfilled",d.value=x)},function(x){d.status==="pending"&&(d.status="rejected",d.reason=x)})),d.status){case"fulfilled":return d.value;case"rejected":throw d.reason}}throw d}function p(d,x,R,Y,W){var P=typeof d;(P==="undefined"||P==="boolean")&&(d=null);var ml=!1;if(d===null)ml=!0;else switch(P){case"bigint":case"string":case"number":ml=!0;break;case"object":switch(d.$$typeof){case h:case N:ml=!0;break;case J:return ml=d._init,p(ml(d._payload),x,R,Y,W)}}if(ml)return W=W(d),ml=Y===""?"."+Zl(d,0):Y,Dl(W)?(R="",ml!=null&&(R=ml.replace(Pl,"$&/")+"/"),p(W,x,R,"",function(Ha){return Ha})):W!=null&&(cl(W)&&(W=$(W,R+(W.key==null||d&&d.key===W.key?"":(""+W.key).replace(Pl,"$&/")+"/")+ml)),x.push(W)),1;ml=0;var lt=Y===""?".":Y+":";if(Dl(d))for(var Ul=0;Ul>>1,nl=p[V];if(0<_(nl,C))p[V]=C,p[M]=nl,M=V;else break l}}function O(p){return p.length===0?null:p[0]}function s(p){if(p.length===0)return null;var C=p[0],M=p.pop();if(M!==C){p[0]=M;l:for(var V=0,nl=p.length,d=nl>>>1;V_(R,M))Y_(W,R)?(p[V]=W,p[Y]=M,V=Y):(p[V]=R,p[x]=M,V=x);else if(Y_(W,M))p[V]=W,p[Y]=M,V=Y;else break l}}return C}function _(p,C){var M=p.sortIndex-C.sortIndex;return M!==0?M:p.id-C.id}if(h.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var H=performance;h.unstable_now=function(){return H.now()}}else{var E=Date,k=E.now();h.unstable_now=function(){return E.now()-k}}var D=[],g=[],J=1,A=null,B=3,dl=!1,yl=!1,il=!1,Xl=!1,El=typeof setTimeout=="function"?setTimeout:null,Al=typeof clearTimeout=="function"?clearTimeout:null,rl=typeof setImmediate<"u"?setImmediate:null;function xl(p){for(var C=O(g);C!==null;){if(C.callback===null)s(g);else if(C.startTime<=p)s(g),C.sortIndex=C.expirationTime,N(D,C);else break;C=O(g)}}function Dl(p){if(il=!1,xl(p),!yl)if(O(D)!==null)yl=!0,_l||(_l=!0,hl());else{var C=O(g);C!==null&&Ll(Dl,C.startTime-p)}}var _l=!1,U=-1,Q=5,q=-1;function $(){return Xl?!0:!(h.unstable_now()-qp&&$());){var V=A.callback;if(typeof V=="function"){A.callback=null,B=A.priorityLevel;var nl=V(A.expirationTime<=p);if(p=h.unstable_now(),typeof nl=="function"){A.callback=nl,xl(p),C=!0;break t}A===O(D)&&s(D),xl(p)}else s(D);A=O(D)}if(A!==null)C=!0;else{var d=O(g);d!==null&&Ll(Dl,d.startTime-p),C=!1}}break l}finally{A=null,B=M,dl=!1}C=void 0}}finally{C?hl():_l=!1}}}var hl;if(typeof rl=="function")hl=function(){rl(cl)};else if(typeof MessageChannel<"u"){var Pl=new MessageChannel,Zl=Pl.port2;Pl.port1.onmessage=cl,hl=function(){Zl.postMessage(null)}}else hl=function(){El(cl,0)};function Ll(p,C){U=El(function(){p(h.unstable_now())},C)}h.unstable_IdlePriority=5,h.unstable_ImmediatePriority=1,h.unstable_LowPriority=4,h.unstable_NormalPriority=3,h.unstable_Profiling=null,h.unstable_UserBlockingPriority=2,h.unstable_cancelCallback=function(p){p.callback=null},h.unstable_forceFrameRate=function(p){0>p||125V?(p.sortIndex=M,N(g,p),O(D)===null&&p===O(g)&&(il?(Al(U),U=-1):il=!0,Ll(Dl,M-V))):(p.sortIndex=nl,N(D,p),yl||dl||(yl=!0,_l||(_l=!0,hl()))),p},h.unstable_shouldYield=$,h.unstable_wrapCallback=function(p){var C=B;return function(){var M=B;B=C;try{return p.apply(this,arguments)}finally{B=M}}}})(bf)),bf}var Dr;function dy(){return Dr||(Dr=1,gf.exports=oy()),gf.exports}var Sf={exports:{}},Il={};var Ur;function ry(){if(Ur)return Il;Ur=1;var h=xf();function N(D){var g="https://react.dev/errors/"+D;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(h)}catch(N){console.error(N)}}return h(),Sf.exports=ry(),Sf.exports}var Hr;function my(){if(Hr)return On;Hr=1;var h=dy(),N=xf(),O=hy();function s(l){var t="https://react.dev/errors/"+l;if(1nl||(l.current=V[nl],V[nl]=null,nl--)}function R(l,t){nl++,V[nl]=l.current,l.current=t}var Y=d(null),W=d(null),P=d(null),ml=d(null);function lt(l,t){switch(R(P,t),R(W,l),R(Y,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?$d(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=$d(t),l=Wd(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}x(Y),R(Y,l)}function Ul(){x(Y),x(W),x(P)}function Ha(l){l.memoizedState!==null&&R(ml,l);var t=Y.current,e=Wd(t,l.type);t!==e&&(R(W,l),R(Y,e))}function Dn(l){W.current===l&&(x(Y),x(W)),ml.current===l&&(x(ml),Tn._currentValue=M)}var $u,Tf;function Me(l){if($u===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);$u=t&&t[1]||"",Tf=-1)":-1n||o[a]!==v[n]){var j=` +`+o[a].replace(" at new "," at ");return l.displayName&&j.includes("")&&(j=j.replace("",l.displayName)),j}while(1<=a&&0<=n);break}}}finally{Wu=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?Me(e):""}function Yr(l,t){switch(l.tag){case 26:case 27:case 5:return Me(l.type);case 16:return Me("Lazy");case 13:return l.child!==t&&t!==null?Me("Suspense Fallback"):Me("Suspense");case 19:return Me("SuspenseList");case 0:case 15:return Fu(l.type,!1);case 11:return Fu(l.type.render,!1);case 1:return Fu(l.type,!0);case 31:return Me("Activity");default:return""}}function Ef(l){try{var t="",e=null;do t+=Yr(l,e),e=l,l=l.return;while(l);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}var Iu=Object.prototype.hasOwnProperty,Pu=h.unstable_scheduleCallback,li=h.unstable_cancelCallback,Gr=h.unstable_shouldYield,Xr=h.unstable_requestPaint,ot=h.unstable_now,Qr=h.unstable_getCurrentPriorityLevel,Af=h.unstable_ImmediatePriority,Nf=h.unstable_UserBlockingPriority,Un=h.unstable_NormalPriority,Zr=h.unstable_LowPriority,_f=h.unstable_IdlePriority,Lr=h.log,Vr=h.unstable_setDisableYieldValue,Ra=null,dt=null;function ne(l){if(typeof Lr=="function"&&Vr(l),dt&&typeof dt.setStrictMode=="function")try{dt.setStrictMode(Ra,l)}catch{}}var rt=Math.clz32?Math.clz32:wr,Kr=Math.log,Jr=Math.LN2;function wr(l){return l>>>=0,l===0?32:31-(Kr(l)/Jr|0)|0}var Cn=256,Hn=262144,Rn=4194304;function De(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function qn(l,t,e){var a=l.pendingLanes;if(a===0)return 0;var n=0,u=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var f=a&134217727;return f!==0?(a=f&~u,a!==0?n=De(a):(i&=f,i!==0?n=De(i):e||(e=f&~l,e!==0&&(n=De(e))))):(f=a&~u,f!==0?n=De(f):i!==0?n=De(i):e||(e=a&~l,e!==0&&(n=De(e)))),n===0?0:t!==0&&t!==n&&(t&u)===0&&(u=n&-n,e=t&-t,u>=e||u===32&&(e&4194048)!==0)?t:n}function qa(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function kr(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Of(){var l=Rn;return Rn<<=1,(Rn&62914560)===0&&(Rn=4194304),l}function ti(l){for(var t=[],e=0;31>e;e++)t.push(l);return t}function Ba(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function $r(l,t,e,a,n,u){var i=l.pendingLanes;l.pendingLanes=e,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=e,l.entangledLanes&=e,l.errorRecoveryDisabledLanes&=e,l.shellSuspendCounter=0;var f=l.entanglements,o=l.expirationTimes,v=l.hiddenUpdates;for(e=i&~e;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var th=/[\n"\\]/g;function jt(l){return l.replace(th,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function ci(l,t,e,a,n,u,i,f){l.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?l.type=i:l.removeAttribute("type"),t!=null?i==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+pt(t)):l.value!==""+pt(t)&&(l.value=""+pt(t)):i!=="submit"&&i!=="reset"||l.removeAttribute("value"),t!=null?fi(l,i,pt(t)):e!=null?fi(l,i,pt(e)):a!=null&&l.removeAttribute("value"),n==null&&u!=null&&(l.defaultChecked=!!u),n!=null&&(l.checked=n&&typeof n!="function"&&typeof n!="symbol"),f!=null&&typeof f!="function"&&typeof f!="symbol"&&typeof f!="boolean"?l.name=""+pt(f):l.removeAttribute("name")}function Zf(l,t,e,a,n,u,i,f){if(u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"&&(l.type=u),t!=null||e!=null){if(!(u!=="submit"&&u!=="reset"||t!=null)){ii(l);return}e=e!=null?""+pt(e):"",t=t!=null?""+pt(t):e,f||t===l.value||(l.value=t),l.defaultValue=t}a=a??n,a=typeof a!="function"&&typeof a!="symbol"&&!!a,l.checked=f?l.checked:!!a,l.defaultChecked=!!a,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(l.name=i),ii(l)}function fi(l,t,e){t==="number"&&Gn(l.ownerDocument)===l||l.defaultValue===""+e||(l.defaultValue=""+e)}function la(l,t,e,a){if(l=l.options,t){t={};for(var n=0;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),hi=!1;if(Qt)try{var Qa={};Object.defineProperty(Qa,"passive",{get:function(){hi=!0}}),window.addEventListener("test",Qa,Qa),window.removeEventListener("test",Qa,Qa)}catch{hi=!1}var ie=null,mi=null,Qn=null;function $f(){if(Qn)return Qn;var l,t=mi,e=t.length,a,n="value"in ie?ie.value:ie.textContent,u=n.length;for(l=0;l=Va),ts=" ",es=!1;function as(l,t){switch(l){case"keyup":return Oh.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function ns(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var na=!1;function Dh(l,t){switch(l){case"compositionend":return ns(t);case"keypress":return t.which!==32?null:(es=!0,ts);case"textInput":return l=t.data,l===ts&&es?null:l;default:return null}}function Uh(l,t){if(na)return l==="compositionend"||!Si&&as(l,t)?(l=$f(),Qn=mi=ie=null,na=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:e,offset:t-l};l=a}l:{for(;e;){if(e.nextSibling){e=e.nextSibling;break l}e=e.parentNode}e=void 0}e=rs(e)}}function ms(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?ms(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function ys(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=Gn(l.document);t instanceof l.HTMLIFrameElement;){try{var e=typeof t.contentWindow.location.href=="string"}catch{e=!1}if(e)l=t.contentWindow;else break;t=Gn(l.document)}return t}function xi(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var Xh=Qt&&"documentMode"in document&&11>=document.documentMode,ua=null,zi=null,ka=null,Ti=!1;function vs(l,t,e){var a=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Ti||ua==null||ua!==Gn(a)||(a=ua,"selectionStart"in a&&xi(a)?a={start:a.selectionStart,end:a.selectionEnd}:(a=(a.ownerDocument&&a.ownerDocument.defaultView||window).getSelection(),a={anchorNode:a.anchorNode,anchorOffset:a.anchorOffset,focusNode:a.focusNode,focusOffset:a.focusOffset}),ka&&wa(ka,a)||(ka=a,a=Hu(zi,"onSelect"),0>=i,n-=i,Rt=1<<32-rt(t)+n|e<I?(al=X,X=null):al=X.sibling;var sl=b(m,X,y[I],z);if(sl===null){X===null&&(X=al);break}l&&X&&sl.alternate===null&&t(m,X),r=u(sl,r,I),fl===null?Z=sl:fl.sibling=sl,fl=sl,X=al}if(I===y.length)return e(m,X),ul&&Lt(m,I),Z;if(X===null){for(;II?(al=X,X=null):al=X.sibling;var _e=b(m,X,sl.value,z);if(_e===null){X===null&&(X=al);break}l&&X&&_e.alternate===null&&t(m,X),r=u(_e,r,I),fl===null?Z=_e:fl.sibling=_e,fl=_e,X=al}if(sl.done)return e(m,X),ul&&Lt(m,I),Z;if(X===null){for(;!sl.done;I++,sl=y.next())sl=T(m,sl.value,z),sl!==null&&(r=u(sl,r,I),fl===null?Z=sl:fl.sibling=sl,fl=sl);return ul&&Lt(m,I),Z}for(X=a(X);!sl.done;I++,sl=y.next())sl=S(X,m,I,sl.value,z),sl!==null&&(l&&sl.alternate!==null&&X.delete(sl.key===null?I:sl.key),r=u(sl,r,I),fl===null?Z=sl:fl.sibling=sl,fl=sl);return l&&X.forEach(function(iy){return t(m,iy)}),ul&&Lt(m,I),Z}function pl(m,r,y,z){if(typeof y=="object"&&y!==null&&y.type===il&&y.key===null&&(y=y.props.children),typeof y=="object"&&y!==null){switch(y.$$typeof){case dl:l:{for(var Z=y.key;r!==null;){if(r.key===Z){if(Z=y.type,Z===il){if(r.tag===7){e(m,r.sibling),z=n(r,y.props.children),z.return=m,m=z;break l}}else if(r.elementType===Z||typeof Z=="object"&&Z!==null&&Z.$$typeof===Q&&Ze(Z)===r.type){e(m,r.sibling),z=n(r,y.props),ln(z,y),z.return=m,m=z;break l}e(m,r);break}else t(m,r);r=r.sibling}y.type===il?(z=Be(y.props.children,m.mode,z,y.key),z.return=m,m=z):(z=Fn(y.type,y.key,y.props,null,m.mode,z),ln(z,y),z.return=m,m=z)}return i(m);case yl:l:{for(Z=y.key;r!==null;){if(r.key===Z)if(r.tag===4&&r.stateNode.containerInfo===y.containerInfo&&r.stateNode.implementation===y.implementation){e(m,r.sibling),z=n(r,y.children||[]),z.return=m,m=z;break l}else{e(m,r);break}else t(m,r);r=r.sibling}z=Di(y,m.mode,z),z.return=m,m=z}return i(m);case Q:return y=Ze(y),pl(m,r,y,z)}if(Ll(y))return G(m,r,y,z);if(hl(y)){if(Z=hl(y),typeof Z!="function")throw Error(s(150));return y=Z.call(y),L(m,r,y,z)}if(typeof y.then=="function")return pl(m,r,nu(y),z);if(y.$$typeof===rl)return pl(m,r,lu(m,y),z);uu(m,y)}return typeof y=="string"&&y!==""||typeof y=="number"||typeof y=="bigint"?(y=""+y,r!==null&&r.tag===6?(e(m,r.sibling),z=n(r,y),z.return=m,m=z):(e(m,r),z=Mi(y,m.mode,z),z.return=m,m=z),i(m)):e(m,r)}return function(m,r,y,z){try{Pa=0;var Z=pl(m,r,y,z);return va=null,Z}catch(X){if(X===ya||X===eu)throw X;var fl=mt(29,X,null,m.mode);return fl.lanes=z,fl.return=m,fl}}}var Ve=Gs(!0),Xs=Gs(!1),de=!1;function Li(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Vi(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function re(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function he(l,t,e){var a=l.updateQueue;if(a===null)return null;if(a=a.shared,(ol&2)!==0){var n=a.pending;return n===null?t.next=t:(t.next=n.next,n.next=t),a.pending=t,t=Wn(l),zs(l,null,e),t}return $n(l,a,t,e),Wn(l)}function tn(l,t,e){if(t=t.updateQueue,t!==null&&(t=t.shared,(e&4194048)!==0)){var a=t.lanes;a&=l.pendingLanes,e|=a,t.lanes=e,Df(l,e)}}function Ki(l,t){var e=l.updateQueue,a=l.alternate;if(a!==null&&(a=a.updateQueue,e===a)){var n=null,u=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};u===null?n=u=i:u=u.next=i,e=e.next}while(e!==null);u===null?n=u=t:u=u.next=t}else n=u=t;e={baseState:a.baseState,firstBaseUpdate:n,lastBaseUpdate:u,shared:a.shared,callbacks:a.callbacks},l.updateQueue=e;return}l=e.lastBaseUpdate,l===null?e.firstBaseUpdate=t:l.next=t,e.lastBaseUpdate=t}var Ji=!1;function en(){if(Ji){var l=ma;if(l!==null)throw l}}function an(l,t,e,a){Ji=!1;var n=l.updateQueue;de=!1;var u=n.firstBaseUpdate,i=n.lastBaseUpdate,f=n.shared.pending;if(f!==null){n.shared.pending=null;var o=f,v=o.next;o.next=null,i===null?u=v:i.next=v,i=o;var j=l.alternate;j!==null&&(j=j.updateQueue,f=j.lastBaseUpdate,f!==i&&(f===null?j.firstBaseUpdate=v:f.next=v,j.lastBaseUpdate=o))}if(u!==null){var T=n.baseState;i=0,j=v=o=null,f=u;do{var b=f.lane&-536870913,S=b!==f.lane;if(S?(el&b)===b:(a&b)===b){b!==0&&b===ha&&(Ji=!0),j!==null&&(j=j.next={lane:0,tag:f.tag,payload:f.payload,callback:null,next:null});l:{var G=l,L=f;b=t;var pl=e;switch(L.tag){case 1:if(G=L.payload,typeof G=="function"){T=G.call(pl,T,b);break l}T=G;break l;case 3:G.flags=G.flags&-65537|128;case 0:if(G=L.payload,b=typeof G=="function"?G.call(pl,T,b):G,b==null)break l;T=A({},T,b);break l;case 2:de=!0}}b=f.callback,b!==null&&(l.flags|=64,S&&(l.flags|=8192),S=n.callbacks,S===null?n.callbacks=[b]:S.push(b))}else S={lane:b,tag:f.tag,payload:f.payload,callback:f.callback,next:null},j===null?(v=j=S,o=T):j=j.next=S,i|=b;if(f=f.next,f===null){if(f=n.shared.pending,f===null)break;S=f,f=S.next,S.next=null,n.lastBaseUpdate=S,n.shared.pending=null}}while(!0);j===null&&(o=T),n.baseState=o,n.firstBaseUpdate=v,n.lastBaseUpdate=j,u===null&&(n.shared.lanes=0),be|=i,l.lanes=i,l.memoizedState=T}}function Qs(l,t){if(typeof l!="function")throw Error(s(191,l));l.call(t)}function Zs(l,t){var e=l.callbacks;if(e!==null)for(l.callbacks=null,l=0;lu?u:8;var i=p.T,f={};p.T=f,dc(l,!1,t,e);try{var o=n(),v=p.S;if(v!==null&&v(f,o),o!==null&&typeof o=="object"&&typeof o.then=="function"){var j=$h(o,a);cn(l,t,j,St(l))}else cn(l,t,a,St(l))}catch(T){cn(l,t,{then:function(){},status:"rejected",reason:T},St())}finally{C.p=u,i!==null&&f.types!==null&&(i.types=f.types),p.T=i}}function tm(){}function sc(l,t,e,a){if(l.tag!==5)throw Error(s(476));var n=jo(l).queue;po(l,n,t,M,e===null?tm:function(){return xo(l),e(a)})}function jo(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:M,baseState:M,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:wt,lastRenderedState:M},next:null};var e={};return t.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:wt,lastRenderedState:e},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function xo(l){var t=jo(l);t.next===null&&(t=l.alternate.memoizedState),cn(l,t.next.queue,{},St())}function oc(){return kl(Tn)}function zo(){return Hl().memoizedState}function To(){return Hl().memoizedState}function em(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var e=St();l=re(e);var a=he(t,l,e);a!==null&&(ft(a,t,e),tn(a,t,e)),t={cache:Gi()},l.payload=t;return}t=t.return}}function am(l,t,e){var a=St();e={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},yu(l)?Ao(t,e):(e=_i(l,t,e,a),e!==null&&(ft(e,l,a),No(e,t,a)))}function Eo(l,t,e){var a=St();cn(l,t,e,a)}function cn(l,t,e,a){var n={lane:a,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(yu(l))Ao(t,n);else{var u=l.alternate;if(l.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var i=t.lastRenderedState,f=u(i,e);if(n.hasEagerState=!0,n.eagerState=f,ht(f,i))return $n(l,t,n,0),jl===null&&kn(),!1}catch{}if(e=_i(l,t,n,a),e!==null)return ft(e,l,a),No(e,t,a),!0}return!1}function dc(l,t,e,a){if(a={lane:2,revertLane:Lc(),gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},yu(l)){if(t)throw Error(s(479))}else t=_i(l,e,a,2),t!==null&&ft(t,l,2)}function yu(l){var t=l.alternate;return l===F||t!==null&&t===F}function Ao(l,t){ba=fu=!0;var e=l.pending;e===null?t.next=t:(t.next=e.next,e.next=t),l.pending=t}function No(l,t,e){if((e&4194048)!==0){var a=t.lanes;a&=l.pendingLanes,e|=a,t.lanes=e,Df(l,e)}}var fn={readContext:kl,use:du,useCallback:Ol,useContext:Ol,useEffect:Ol,useImperativeHandle:Ol,useLayoutEffect:Ol,useInsertionEffect:Ol,useMemo:Ol,useReducer:Ol,useRef:Ol,useState:Ol,useDebugValue:Ol,useDeferredValue:Ol,useTransition:Ol,useSyncExternalStore:Ol,useId:Ol,useHostTransitionStatus:Ol,useFormState:Ol,useActionState:Ol,useOptimistic:Ol,useMemoCache:Ol,useCacheRefresh:Ol};fn.useEffectEvent=Ol;var _o={readContext:kl,use:du,useCallback:function(l,t){return tt().memoizedState=[l,t===void 0?null:t],l},useContext:kl,useEffect:oo,useImperativeHandle:function(l,t,e){e=e!=null?e.concat([l]):null,hu(4194308,4,yo.bind(null,t,l),e)},useLayoutEffect:function(l,t){return hu(4194308,4,l,t)},useInsertionEffect:function(l,t){hu(4,2,l,t)},useMemo:function(l,t){var e=tt();t=t===void 0?null:t;var a=l();if(Ke){ne(!0);try{l()}finally{ne(!1)}}return e.memoizedState=[a,t],a},useReducer:function(l,t,e){var a=tt();if(e!==void 0){var n=e(t);if(Ke){ne(!0);try{e(t)}finally{ne(!1)}}}else n=t;return a.memoizedState=a.baseState=n,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:n},a.queue=l,l=l.dispatch=am.bind(null,F,l),[a.memoizedState,l]},useRef:function(l){var t=tt();return l={current:l},t.memoizedState=l},useState:function(l){l=nc(l);var t=l.queue,e=Eo.bind(null,F,t);return t.dispatch=e,[l.memoizedState,e]},useDebugValue:cc,useDeferredValue:function(l,t){var e=tt();return fc(e,l,t)},useTransition:function(){var l=nc(!1);return l=po.bind(null,F,l.queue,!0,!1),tt().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,e){var a=F,n=tt();if(ul){if(e===void 0)throw Error(s(407));e=e()}else{if(e=t(),jl===null)throw Error(s(349));(el&127)!==0||ks(a,t,e)}n.memoizedState=e;var u={value:e,getSnapshot:t};return n.queue=u,oo(Ws.bind(null,a,u,l),[l]),a.flags|=2048,pa(9,{destroy:void 0},$s.bind(null,a,u,e,t),null),e},useId:function(){var l=tt(),t=jl.identifierPrefix;if(ul){var e=qt,a=Rt;e=(a&~(1<<32-rt(a)-1)).toString(32)+e,t="_"+t+"R_"+e,e=su++,0<\/script>",u=u.removeChild(u.firstChild);break;case"select":u=typeof a.is=="string"?i.createElement("select",{is:a.is}):i.createElement("select"),a.multiple?u.multiple=!0:a.size&&(u.size=a.size);break;default:u=typeof a.is=="string"?i.createElement(n,{is:a.is}):i.createElement(n)}}u[Jl]=t,u[et]=a;l:for(i=t.child;i!==null;){if(i.tag===5||i.tag===6)u.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===t)break l;for(;i.sibling===null;){if(i.return===null||i.return===t)break l;i=i.return}i.sibling.return=i.return,i=i.sibling}t.stateNode=u;l:switch(Wl(u,n,a),n){case"button":case"input":case"select":case"textarea":a=!!a.autoFocus;break l;case"img":a=!0;break l;default:a=!1}a&&$t(t)}}return Tl(t),Ec(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,e),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==a&&$t(t);else{if(typeof a!="string"&&t.stateNode===null)throw Error(s(166));if(l=P.current,da(t)){if(l=t.stateNode,e=t.memoizedProps,a=null,n=wl,n!==null)switch(n.tag){case 27:case 5:a=n.memoizedProps}l[Jl]=t,l=!!(l.nodeValue===e||a!==null&&a.suppressHydrationWarning===!0||wd(l.nodeValue,e)),l||se(t,!0)}else l=Ru(l).createTextNode(a),l[Jl]=t,t.stateNode=l}return Tl(t),null;case 31:if(e=t.memoizedState,l===null||l.memoizedState!==null){if(a=da(t),e!==null){if(l===null){if(!a)throw Error(s(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(s(557));l[Jl]=t}else Ye(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Tl(t),l=!1}else e=Ri(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=e),l=!0;if(!l)return t.flags&256?(vt(t),t):(vt(t),null);if((t.flags&128)!==0)throw Error(s(558))}return Tl(t),null;case 13:if(a=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(n=da(t),a!==null&&a.dehydrated!==null){if(l===null){if(!n)throw Error(s(318));if(n=t.memoizedState,n=n!==null?n.dehydrated:null,!n)throw Error(s(317));n[Jl]=t}else Ye(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Tl(t),n=!1}else n=Ri(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=n),n=!0;if(!n)return t.flags&256?(vt(t),t):(vt(t),null)}return vt(t),(t.flags&128)!==0?(t.lanes=e,t):(e=a!==null,l=l!==null&&l.memoizedState!==null,e&&(a=t.child,n=null,a.alternate!==null&&a.alternate.memoizedState!==null&&a.alternate.memoizedState.cachePool!==null&&(n=a.alternate.memoizedState.cachePool.pool),u=null,a.memoizedState!==null&&a.memoizedState.cachePool!==null&&(u=a.memoizedState.cachePool.pool),u!==n&&(a.flags|=2048)),e!==l&&e&&(t.child.flags|=8192),pu(t,t.updateQueue),Tl(t),null);case 4:return Ul(),l===null&&wc(t.stateNode.containerInfo),Tl(t),null;case 10:return Kt(t.type),Tl(t),null;case 19:if(x(Cl),a=t.memoizedState,a===null)return Tl(t),null;if(n=(t.flags&128)!==0,u=a.rendering,u===null)if(n)on(a,!1);else{if(Ml!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(u=cu(l),u!==null){for(t.flags|=128,on(a,!1),l=u.updateQueue,t.updateQueue=l,pu(t,l),t.subtreeFlags=0,l=e,e=t.child;e!==null;)Ts(e,l),e=e.sibling;return R(Cl,Cl.current&1|2),ul&&Lt(t,a.treeForkCount),t.child}l=l.sibling}a.tail!==null&&ot()>Eu&&(t.flags|=128,n=!0,on(a,!1),t.lanes=4194304)}else{if(!n)if(l=cu(u),l!==null){if(t.flags|=128,n=!0,l=l.updateQueue,t.updateQueue=l,pu(t,l),on(a,!0),a.tail===null&&a.tailMode==="hidden"&&!u.alternate&&!ul)return Tl(t),null}else 2*ot()-a.renderingStartTime>Eu&&e!==536870912&&(t.flags|=128,n=!0,on(a,!1),t.lanes=4194304);a.isBackwards?(u.sibling=t.child,t.child=u):(l=a.last,l!==null?l.sibling=u:t.child=u,a.last=u)}return a.tail!==null?(l=a.tail,a.rendering=l,a.tail=l.sibling,a.renderingStartTime=ot(),l.sibling=null,e=Cl.current,R(Cl,n?e&1|2:e&1),ul&&Lt(t,a.treeForkCount),l):(Tl(t),null);case 22:case 23:return vt(t),ki(),a=t.memoizedState!==null,l!==null?l.memoizedState!==null!==a&&(t.flags|=8192):a&&(t.flags|=8192),a?(e&536870912)!==0&&(t.flags&128)===0&&(Tl(t),t.subtreeFlags&6&&(t.flags|=8192)):Tl(t),e=t.updateQueue,e!==null&&pu(t,e.retryQueue),e=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(e=l.memoizedState.cachePool.pool),a=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(a=t.memoizedState.cachePool.pool),a!==e&&(t.flags|=2048),l!==null&&x(Qe),null;case 24:return e=null,l!==null&&(e=l.memoizedState.cache),t.memoizedState.cache!==e&&(t.flags|=2048),Kt(Rl),Tl(t),null;case 25:return null;case 30:return null}throw Error(s(156,t.tag))}function fm(l,t){switch(Ci(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return Kt(Rl),Ul(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return Dn(t),null;case 31:if(t.memoizedState!==null){if(vt(t),t.alternate===null)throw Error(s(340));Ye()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(vt(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(s(340));Ye()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return x(Cl),null;case 4:return Ul(),null;case 10:return Kt(t.type),null;case 22:case 23:return vt(t),ki(),l!==null&&x(Qe),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return Kt(Rl),null;case 25:return null;default:return null}}function Io(l,t){switch(Ci(t),t.tag){case 3:Kt(Rl),Ul();break;case 26:case 27:case 5:Dn(t);break;case 4:Ul();break;case 31:t.memoizedState!==null&&vt(t);break;case 13:vt(t);break;case 19:x(Cl);break;case 10:Kt(t.type);break;case 22:case 23:vt(t),ki(),l!==null&&x(Qe);break;case 24:Kt(Rl)}}function dn(l,t){try{var e=t.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var n=a.next;e=n;do{if((e.tag&l)===l){a=void 0;var u=e.create,i=e.inst;a=u(),i.destroy=a}e=e.next}while(e!==n)}}catch(f){gl(t,t.return,f)}}function ve(l,t,e){try{var a=t.updateQueue,n=a!==null?a.lastEffect:null;if(n!==null){var u=n.next;a=u;do{if((a.tag&l)===l){var i=a.inst,f=i.destroy;if(f!==void 0){i.destroy=void 0,n=t;var o=e,v=f;try{v()}catch(j){gl(n,o,j)}}}a=a.next}while(a!==u)}}catch(j){gl(t,t.return,j)}}function Po(l){var t=l.updateQueue;if(t!==null){var e=l.stateNode;try{Zs(t,e)}catch(a){gl(l,l.return,a)}}}function ld(l,t,e){e.props=Je(l.type,l.memoizedProps),e.state=l.memoizedState;try{e.componentWillUnmount()}catch(a){gl(l,t,a)}}function rn(l,t){try{var e=l.ref;if(e!==null){switch(l.tag){case 26:case 27:case 5:var a=l.stateNode;break;case 30:a=l.stateNode;break;default:a=l.stateNode}typeof e=="function"?l.refCleanup=e(a):e.current=a}}catch(n){gl(l,t,n)}}function Bt(l,t){var e=l.ref,a=l.refCleanup;if(e!==null)if(typeof a=="function")try{a()}catch(n){gl(l,t,n)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(n){gl(l,t,n)}else e.current=null}function td(l){var t=l.type,e=l.memoizedProps,a=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":e.autoFocus&&a.focus();break l;case"img":e.src?a.src=e.src:e.srcSet&&(a.srcset=e.srcSet)}}catch(n){gl(l,l.return,n)}}function Ac(l,t,e){try{var a=l.stateNode;Mm(a,l.type,e,t),a[et]=t}catch(n){gl(l,l.return,n)}}function ed(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&ze(l.type)||l.tag===4}function Nc(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||ed(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&ze(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function _c(l,t,e){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(l,t):(t=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,t.appendChild(l),e=e._reactRootContainer,e!=null||t.onclick!==null||(t.onclick=Xt));else if(a!==4&&(a===27&&ze(l.type)&&(e=l.stateNode,t=null),l=l.child,l!==null))for(_c(l,t,e),l=l.sibling;l!==null;)_c(l,t,e),l=l.sibling}function ju(l,t,e){var a=l.tag;if(a===5||a===6)l=l.stateNode,t?e.insertBefore(l,t):e.appendChild(l);else if(a!==4&&(a===27&&ze(l.type)&&(e=l.stateNode),l=l.child,l!==null))for(ju(l,t,e),l=l.sibling;l!==null;)ju(l,t,e),l=l.sibling}function ad(l){var t=l.stateNode,e=l.memoizedProps;try{for(var a=l.type,n=t.attributes;n.length;)t.removeAttributeNode(n[0]);Wl(t,a,e),t[Jl]=l,t[et]=e}catch(u){gl(l,l.return,u)}}var Wt=!1,Yl=!1,Oc=!1,nd=typeof WeakSet=="function"?WeakSet:Set,Kl=null;function sm(l,t){if(l=l.containerInfo,Wc=Zu,l=ys(l),xi(l)){if("selectionStart"in l)var e={start:l.selectionStart,end:l.selectionEnd};else l:{e=(e=l.ownerDocument)&&e.defaultView||window;var a=e.getSelection&&e.getSelection();if(a&&a.rangeCount!==0){e=a.anchorNode;var n=a.anchorOffset,u=a.focusNode;a=a.focusOffset;try{e.nodeType,u.nodeType}catch{e=null;break l}var i=0,f=-1,o=-1,v=0,j=0,T=l,b=null;t:for(;;){for(var S;T!==e||n!==0&&T.nodeType!==3||(f=i+n),T!==u||a!==0&&T.nodeType!==3||(o=i+a),T.nodeType===3&&(i+=T.nodeValue.length),(S=T.firstChild)!==null;)b=T,T=S;for(;;){if(T===l)break t;if(b===e&&++v===n&&(f=i),b===u&&++j===a&&(o=i),(S=T.nextSibling)!==null)break;T=b,b=T.parentNode}T=S}e=f===-1||o===-1?null:{start:f,end:o}}else e=null}e=e||{start:0,end:0}}else e=null;for(Fc={focusedElem:l,selectionRange:e},Zu=!1,Kl=t;Kl!==null;)if(t=Kl,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,Kl=l;else for(;Kl!==null;){switch(t=Kl,u=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(e=0;e title"))),Wl(u,a,e),u[Jl]=l,Vl(u),a=u;break l;case"link":var i=or("link","href",n).get(a+(e.href||""));if(i){for(var f=0;fpl&&(i=pl,pl=L,L=i);var m=hs(f,L),r=hs(f,pl);if(m&&r&&(S.rangeCount!==1||S.anchorNode!==m.node||S.anchorOffset!==m.offset||S.focusNode!==r.node||S.focusOffset!==r.offset)){var y=T.createRange();y.setStart(m.node,m.offset),S.removeAllRanges(),L>pl?(S.addRange(y),S.extend(r.node,r.offset)):(y.setEnd(r.node,r.offset),S.addRange(y))}}}}for(T=[],S=f;S=S.parentNode;)S.nodeType===1&&T.push({element:S,left:S.scrollLeft,top:S.scrollTop});for(typeof f.focus=="function"&&f.focus(),f=0;fe?32:e,p.T=null,e=qc,qc=null;var u=pe,i=te;if(Ql=0,Ea=pe=null,te=0,(ol&6)!==0)throw Error(s(331));var f=ol;if(ol|=4,yd(u.current),rd(u,u.current,i,e),ol=f,bn(0,!1),dt&&typeof dt.onPostCommitFiberRoot=="function")try{dt.onPostCommitFiberRoot(Ra,u)}catch{}return!0}finally{C.p=n,p.T=a,Cd(l,t)}}function Rd(l,t,e){t=zt(e,t),t=yc(l.stateNode,t,2),l=he(l,t,2),l!==null&&(Ba(l,2),Yt(l))}function gl(l,t,e){if(l.tag===3)Rd(l,l,e);else for(;t!==null;){if(t.tag===3){Rd(t,l,e);break}else if(t.tag===1){var a=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof a.componentDidCatch=="function"&&(Se===null||!Se.has(a))){l=zt(e,l),e=qo(2),a=he(t,e,2),a!==null&&(Bo(e,a,t,l),Ba(a,2),Yt(a));break}}t=t.return}}function Xc(l,t,e){var a=l.pingCache;if(a===null){a=l.pingCache=new rm;var n=new Set;a.set(t,n)}else n=a.get(t),n===void 0&&(n=new Set,a.set(t,n));n.has(e)||(Uc=!0,n.add(e),l=gm.bind(null,l,t,e),t.then(l,l))}function gm(l,t,e){var a=l.pingCache;a!==null&&a.delete(t),l.pingedLanes|=l.suspendedLanes&e,l.warmLanes&=~e,jl===l&&(el&e)===e&&(Ml===4||Ml===3&&(el&62914560)===el&&300>ot()-Tu?(ol&2)===0&&Aa(l,0):Cc|=e,Ta===el&&(Ta=0)),Yt(l)}function qd(l,t){t===0&&(t=Of()),l=qe(l,t),l!==null&&(Ba(l,t),Yt(l))}function bm(l){var t=l.memoizedState,e=0;t!==null&&(e=t.retryLane),qd(l,e)}function Sm(l,t){var e=0;switch(l.tag){case 31:case 13:var a=l.stateNode,n=l.memoizedState;n!==null&&(e=n.retryLane);break;case 19:a=l.stateNode;break;case 22:a=l.stateNode._retryCache;break;default:throw Error(s(314))}a!==null&&a.delete(t),qd(l,e)}function pm(l,t){return Pu(l,t)}var Du=null,_a=null,Qc=!1,Uu=!1,Zc=!1,xe=0;function Yt(l){l!==_a&&l.next===null&&(_a===null?Du=_a=l:_a=_a.next=l),Uu=!0,Qc||(Qc=!0,xm())}function bn(l,t){if(!Zc&&Uu){Zc=!0;do for(var e=!1,a=Du;a!==null;){if(l!==0){var n=a.pendingLanes;if(n===0)var u=0;else{var i=a.suspendedLanes,f=a.pingedLanes;u=(1<<31-rt(42|l)+1)-1,u&=n&~(i&~f),u=u&201326741?u&201326741|1:u?u|2:0}u!==0&&(e=!0,Xd(a,u))}else u=el,u=qn(a,a===jl?u:0,a.cancelPendingCommit!==null||a.timeoutHandle!==-1),(u&3)===0||qa(a,u)||(e=!0,Xd(a,u));a=a.next}while(e);Zc=!1}}function jm(){Bd()}function Bd(){Uu=Qc=!1;var l=0;xe!==0&&Um()&&(l=xe);for(var t=ot(),e=null,a=Du;a!==null;){var n=a.next,u=Yd(a,t);u===0?(a.next=null,e===null?Du=n:e.next=n,n===null&&(_a=e)):(e=a,(l!==0||(u&3)!==0)&&(Uu=!0)),a=n}Ql!==0&&Ql!==5||bn(l),xe!==0&&(xe=0)}function Yd(l,t){for(var e=l.suspendedLanes,a=l.pingedLanes,n=l.expirationTimes,u=l.pendingLanes&-62914561;0f)break;var j=o.transferSize,T=o.initiatorType;j&&kd(T)&&(o=o.responseEnd,i+=j*(o"u"?null:document;function ir(l,t,e){var a=Oa;if(a&&typeof t=="string"&&t){var n=jt(t);n='link[rel="'+l+'"][href="'+n+'"]',typeof e=="string"&&(n+='[crossorigin="'+e+'"]'),ur.has(n)||(ur.add(n),l={rel:l,crossOrigin:e,href:t},a.querySelector(n)===null&&(t=a.createElement("link"),Wl(t,"link",l),Vl(t),a.head.appendChild(t)))}}function Qm(l){ee.D(l),ir("dns-prefetch",l,null)}function Zm(l,t){ee.C(l,t),ir("preconnect",l,t)}function Lm(l,t,e){ee.L(l,t,e);var a=Oa;if(a&&l&&t){var n='link[rel="preload"][as="'+jt(t)+'"]';t==="image"&&e&&e.imageSrcSet?(n+='[imagesrcset="'+jt(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(n+='[imagesizes="'+jt(e.imageSizes)+'"]')):n+='[href="'+jt(l)+'"]';var u=n;switch(t){case"style":u=Ma(l);break;case"script":u=Da(l)}Ot.has(u)||(l=A({rel:"preload",href:t==="image"&&e&&e.imageSrcSet?void 0:l,as:t},e),Ot.set(u,l),a.querySelector(n)!==null||t==="style"&&a.querySelector(xn(u))||t==="script"&&a.querySelector(zn(u))||(t=a.createElement("link"),Wl(t,"link",l),Vl(t),a.head.appendChild(t)))}}function Vm(l,t){ee.m(l,t);var e=Oa;if(e&&l){var a=t&&typeof t.as=="string"?t.as:"script",n='link[rel="modulepreload"][as="'+jt(a)+'"][href="'+jt(l)+'"]',u=n;switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":u=Da(l)}if(!Ot.has(u)&&(l=A({rel:"modulepreload",href:l},t),Ot.set(u,l),e.querySelector(n)===null)){switch(a){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(zn(u)))return}a=e.createElement("link"),Wl(a,"link",l),Vl(a),e.head.appendChild(a)}}}function Km(l,t,e){ee.S(l,t,e);var a=Oa;if(a&&l){var n=Ie(a).hoistableStyles,u=Ma(l);t=t||"default";var i=n.get(u);if(!i){var f={loading:0,preload:null};if(i=a.querySelector(xn(u)))f.loading=5;else{l=A({rel:"stylesheet",href:l,"data-precedence":t},e),(e=Ot.get(u))&&nf(l,e);var o=i=a.createElement("link");Vl(o),Wl(o,"link",l),o._p=new Promise(function(v,j){o.onload=v,o.onerror=j}),o.addEventListener("load",function(){f.loading|=1}),o.addEventListener("error",function(){f.loading|=2}),f.loading|=4,Bu(i,t,a)}i={type:"stylesheet",instance:i,count:1,state:f},n.set(u,i)}}}function Jm(l,t){ee.X(l,t);var e=Oa;if(e&&l){var a=Ie(e).hoistableScripts,n=Da(l),u=a.get(n);u||(u=e.querySelector(zn(n)),u||(l=A({src:l,async:!0},t),(t=Ot.get(n))&&uf(l,t),u=e.createElement("script"),Vl(u),Wl(u,"link",l),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function wm(l,t){ee.M(l,t);var e=Oa;if(e&&l){var a=Ie(e).hoistableScripts,n=Da(l),u=a.get(n);u||(u=e.querySelector(zn(n)),u||(l=A({src:l,async:!0,type:"module"},t),(t=Ot.get(n))&&uf(l,t),u=e.createElement("script"),Vl(u),Wl(u,"link",l),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},a.set(n,u))}}function cr(l,t,e,a){var n=(n=P.current)?qu(n):null;if(!n)throw Error(s(446));switch(l){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(t=Ma(e.href),e=Ie(n).hoistableStyles,a=e.get(t),a||(a={type:"style",instance:null,count:0,state:null},e.set(t,a)),a):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){l=Ma(e.href);var u=Ie(n).hoistableStyles,i=u.get(l);if(i||(n=n.ownerDocument||n,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},u.set(l,i),(u=n.querySelector(xn(l)))&&!u._p&&(i.instance=u,i.state.loading=5),Ot.has(l)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},Ot.set(l,e),u||km(n,l,e,i.state))),t&&a===null)throw Error(s(528,""));return i}if(t&&a!==null)throw Error(s(529,""));return null;case"script":return t=e.async,e=e.src,typeof e=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=Da(e),e=Ie(n).hoistableScripts,a=e.get(t),a||(a={type:"script",instance:null,count:0,state:null},e.set(t,a)),a):{type:"void",instance:null,count:0,state:null};default:throw Error(s(444,l))}}function Ma(l){return'href="'+jt(l)+'"'}function xn(l){return'link[rel="stylesheet"]['+l+"]"}function fr(l){return A({},l,{"data-precedence":l.precedence,precedence:null})}function km(l,t,e,a){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?a.loading=1:(t=l.createElement("link"),a.preload=t,t.addEventListener("load",function(){return a.loading|=1}),t.addEventListener("error",function(){return a.loading|=2}),Wl(t,"link",e),Vl(t),l.head.appendChild(t))}function Da(l){return'[src="'+jt(l)+'"]'}function zn(l){return"script[async]"+l}function sr(l,t,e){if(t.count++,t.instance===null)switch(t.type){case"style":var a=l.querySelector('style[data-href~="'+jt(e.href)+'"]');if(a)return t.instance=a,Vl(a),a;var n=A({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return a=(l.ownerDocument||l).createElement("style"),Vl(a),Wl(a,"style",n),Bu(a,e.precedence,l),t.instance=a;case"stylesheet":n=Ma(e.href);var u=l.querySelector(xn(n));if(u)return t.state.loading|=4,t.instance=u,Vl(u),u;a=fr(e),(n=Ot.get(n))&&nf(a,n),u=(l.ownerDocument||l).createElement("link"),Vl(u);var i=u;return i._p=new Promise(function(f,o){i.onload=f,i.onerror=o}),Wl(u,"link",a),t.state.loading|=4,Bu(u,e.precedence,l),t.instance=u;case"script":return u=Da(e.src),(n=l.querySelector(zn(u)))?(t.instance=n,Vl(n),n):(a=e,(n=Ot.get(u))&&(a=A({},e),uf(a,n)),l=l.ownerDocument||l,n=l.createElement("script"),Vl(n),Wl(n,"link",a),l.head.appendChild(n),t.instance=n);case"void":return null;default:throw Error(s(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(a=t.instance,t.state.loading|=4,Bu(a,e.precedence,l));return t.instance}function Bu(l,t,e){for(var a=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),n=a.length?a[a.length-1]:null,u=n,i=0;i title"):null)}function $m(l,t,e){if(e===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;return t.rel==="stylesheet"?(l=t.disabled,typeof t.precedence=="string"&&l==null):!0;case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function rr(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function Wm(l,t,e,a){if(e.type==="stylesheet"&&(typeof a.media!="string"||matchMedia(a.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var n=Ma(a.href),u=t.querySelector(xn(n));if(u){t=u._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Gu.bind(l),t.then(l,l)),e.state.loading|=4,e.instance=u,Vl(u);return}u=t.ownerDocument||t,a=fr(a),(n=Ot.get(n))&&nf(a,n),u=u.createElement("link"),Vl(u);var i=u;i._p=new Promise(function(f,o){i.onload=f,i.onerror=o}),Wl(u,"link",a),e.instance=u}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(e,t),(t=e.state.preload)&&(e.state.loading&3)===0&&(l.count++,e=Gu.bind(l),t.addEventListener("load",e),t.addEventListener("error",e))}}var cf=0;function Fm(l,t){return l.stylesheets&&l.count===0&&Qu(l,l.stylesheets),0cf?50:800)+t);return l.unsuspend=e,function(){l.unsuspend=null,clearTimeout(a),clearTimeout(n)}}:null}function Gu(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Qu(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var Xu=null;function Qu(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,Xu=new Map,t.forEach(Im,l),Xu=null,Gu.call(l))}function Im(l,t){if(!(t.state.loading&4)){var e=Xu.get(l);if(e)var a=e.get(null);else{e=new Map,Xu.set(l,e);for(var n=l.querySelectorAll("link[data-precedence],style[data-precedence]"),u=0;u"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(h)}catch(N){console.error(N)}}return h(),vf.exports=my(),vf.exports}var vy=yy();function gy(){const h=new URLSearchParams(location.search).get("token");if(h){sessionStorage.setItem("dm.token",h);const N=location.pathname+location.hash;return history.replaceState(null,"",N),h}return sessionStorage.getItem("dm.token")??""}const Ca=gy();class pf extends Error{status;body;constructor(N,O,s={}){super(O),this.status=N,this.body=s}get needsTrust(){return this.body.needsTrust===!0}}async function Mt(h,N){const O={...N?.headers};Ca&&(O["X-Auth-Token"]=Ca),N?.body&&(O["Content-Type"]="application/json");const s=await fetch(h,{...N,headers:O});if(s.status===204)return;const _=await s.text();let H={};if(_)try{H=JSON.parse(_)}catch{if(!s.ok)throw new pf(s.status,_.slice(0,400))}if(!s.ok){const E=typeof H.error=="string"?H.error:`request failed (${s.status})`;throw new pf(s.status,E,H)}return H}const Oe=(h,N)=>Mt(h,{method:"POST",body:N===void 0?void 0:JSON.stringify(N)}),Gl={health:()=>Mt("/api/health"),source:()=>Mt("/api/source"),volumeSizes:()=>Mt("/api/source/sizes"),connections:()=>Mt("/api/connections"),saveConnection:h=>Oe("/api/connections",h),deleteConnection:h=>Mt(`/api/connections/${h}`,{method:"DELETE"}),probe:h=>Oe(`/api/connections/${h}/probe`),trust:(h,N)=>Oe(`/api/connections/${h}/trust`,{fingerprint:N}),testConnection:h=>Oe(`/api/connections/${h}/test`),targetInventory:h=>Mt(`/api/connections/${h}/inventory`),preview:h=>Oe("/api/plan/preview",h),migrateSSH:(h,N)=>Oe("/api/migrate/ssh",{connectionId:h,plan:N}),buildPackage:(h,N)=>Oe("/api/migrate/package",{plan:h,format:N}),jobs:()=>Mt("/api/jobs"),job:h=>Mt(`/api/jobs/${h}`),cancelJob:h=>Oe(`/api/jobs/${h}/cancel`),deleteJob:h=>Mt(`/api/jobs/${h}`,{method:"DELETE"}),packages:()=>Mt("/api/packages"),deletePackage:h=>Mt(`/api/packages/${encodeURIComponent(h)}`,{method:"DELETE"}),downloadUrl:h=>`/api/packages/${encodeURIComponent(h)}/download`+(Ca?`?token=${encodeURIComponent(Ca)}`:""),jobEvents:h=>new EventSource(`/api/jobs/${h}/events`+(Ca?`?token=${encodeURIComponent(Ca)}`:""))};function ae(h){if(h==null||h<0)return"–";if(h===0)return"0 B";const N=["B","KiB","MiB","GiB","TiB","PiB"];let O=h,s=0;for(;O>=1024&&sN(H.target.checked)}),c.jsx("span",{children:O})]})}function Fl({label:h,children:N}){return c.jsxs("label",{className:"field",children:[c.jsx("span",{children:h}),N]})}function zf({title:h,onClose:N,children:O,footer:s,wide:_}){return K.useEffect(()=>{const H=E=>{E.key==="Escape"&&N()};return window.addEventListener("keydown",H),()=>window.removeEventListener("keydown",H)},[N]),c.jsx("div",{className:"modal-backdrop",onMouseDown:H=>H.target===H.currentTarget&&N(),children:c.jsxs("div",{className:"modal",style:_?{width:"min(1000px, 100%)"}:void 0,children:[c.jsxs("header",{children:[h,c.jsx("span",{className:"spacer"}),c.jsx("button",{className:"btn ghost tiny",onClick:N,children:"close"})]}),c.jsx("div",{className:"content",children:O}),s&&c.jsx("footer",{children:s})]})})}function Dt({kind:h,children:N}){return c.jsx("div",{className:`notice ${h==="info"?"":h}`,children:N})}function jf({done:h,total:N,state:O}){const s=N>0?Math.min(100,h/N*100):O==="succeeded"?100:0,_=O==="succeeded"?"done":O==="failed"?"failed":"";return c.jsx("div",{className:`progress ${_}`,children:c.jsx("div",{style:{width:`${s}%`}})})}function Br(h,N){return h.kind==="tmpfs"?0:h.sizeBytes>=0?h.sizeBytes:h.name&&N[h.name]!==void 0?N[h.name]:-1}function by({source:h,sel:N,setSel:O,targetInv:s,loading:_,sizes:H}){const[E,k]=K.useState(""),[D,g]=K.useState(new Set),[J,A]=K.useState(!1),B=h?.inventory.containers??[],dl=K.useMemo(()=>new Set((s?.containers??[]).map(U=>U.name)),[s]),yl=K.useMemo(()=>{const U=E.trim().toLowerCase();return B.filter(Q=>J&&Q.state!=="running"?!1:U?Q.name.toLowerCase().includes(U)||Q.image.toLowerCase().includes(U)||(Q.composeProject??"").toLowerCase().includes(U)||(Q.mounts??[]).some(q=>q.destination.toLowerCase().includes(U)||(q.name??"").toLowerCase().includes(U)):!0)},[B,E,J]),il=K.useMemo(()=>{const U=new Map;for(const Q of yl){const q=Q.composeProject||"",$=U.get(q);$?$.push(Q):U.set(q,[Q])}return[...U.entries()].sort((Q,q)=>Q[0]===""?1:q[0]===""?-1:Q[0].localeCompare(q[0]))},[yl]);function Xl(U,Q){O(q=>({...q,[U]:{...q[U],...Q}}))}function El(U,Q){O(q=>{const $={...q};for(const cl of U)$[cl]&&($[cl]={...$[cl],include:Q});return $})}function Al(U){O(Q=>{const q={...Q};for(const $ of B){const cl=q[$.id];cl?.include&&(q[$.id]=U(cl,$))}return q})}function rl(U,Q){Al((q,$)=>{const cl={...q.mounts};for(const hl of $.mounts??[])hl.kind!=="tmpfs"&&Q.includes(hl.kind)&&(cl[hl.destination]={...cl[hl.destination],action:U});return{...q,mounts:cl}})}const xl=yl.map(U=>U.id),Dl=yl.filter(U=>N[U.id]?.include).length,_l=Object.values(N).some(U=>U.include);return c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"toolbar",children:[c.jsx("input",{className:"search",type:"text",placeholder:"filter by name, image, mount…",value:E,onChange:U=>k(U.target.value)}),c.jsx("button",{className:"btn tiny",onClick:()=>El(xl,!0),children:"select all"}),c.jsx("button",{className:"btn tiny",onClick:()=>El(xl,!1),children:"clear"}),c.jsx("button",{className:"btn tiny",onClick:()=>El(yl.filter(U=>U.state==="running").map(U=>U.id),!0),children:"select running"}),c.jsx(st,{checked:J,onChange:A,label:c.jsx("span",{className:"small muted",children:"running only"})}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint nowrap",children:["apply to ",Dl?`${Dl} selected`:"selection",":"]}),c.jsx("button",{className:"btn tiny",disabled:!_l,onClick:()=>rl("copy",["volume","anonymous","bind"]),children:"copy all data"}),c.jsx("button",{className:"btn tiny",disabled:!_l,onClick:()=>rl("skip",["bind"]),children:"skip binds"}),c.jsx("button",{className:"btn tiny",disabled:!_l,onClick:()=>rl("structure",["volume","anonymous","bind"]),children:"structure only"}),c.jsxs("select",{className:"btn tiny",style:{width:"auto"},disabled:!_l,value:"",onChange:U=>{const Q=U.target.value;if(Q){if(Q==="start"&&Al(q=>({...q,startAfter:!0})),Q==="nostart"&&Al(q=>({...q,startAfter:!1})),Q==="live"&&Al(q=>({...q,stopSourceDuringCopy:!1})),Q==="quiesce"&&Al(q=>({...q,stopSourceDuringCopy:!0})),Q==="keepsource"&&Al(q=>({...q,stopSourceAfter:!1})),Q==="stopsource"&&Al(q=>({...q,stopSourceAfter:!0})),Q.startsWith("img:")){const q=Q.slice(4);Al($=>({...$,migrateImage:q!=="skip",imageMode:q}))}U.target.value=""}},children:[c.jsx("option",{value:"",children:"more…"}),c.jsx("option",{value:"start",children:"start after migration"}),c.jsx("option",{value:"nostart",children:"leave stopped on target"}),c.jsx("option",{value:"quiesce",children:"stop source while copying"}),c.jsx("option",{value:"live",children:"copy while running (hot)"}),c.jsx("option",{value:"stopsource",children:"stop source after migration"}),c.jsx("option",{value:"keepsource",children:"leave source running"}),c.jsx("option",{value:"img:auto",children:"image: auto"}),c.jsx("option",{value:"img:pull",children:"image: pull on target"}),c.jsx("option",{value:"img:stream",children:"image: transfer layers"}),c.jsx("option",{value:"img:skip",children:"image: already on target"})]})]}),_&&B.length===0&&c.jsx("div",{className:"empty",children:"reading the source daemon…"}),!_&&B.length===0&&c.jsx("div",{className:"empty",children:"no containers on this host"}),!_&&B.length>0&&yl.length===0&&c.jsx("div",{className:"empty",children:"nothing matches the filter"}),c.jsx("div",{className:"clist",children:il.map(([U,Q])=>c.jsxs("div",{children:[il.length>1&&c.jsxs("div",{className:"group-head",children:[c.jsx(st,{checked:Q.every(q=>N[q.id]?.include),onChange:q=>El(Q.map($=>$.id),q),label:U?`compose: ${U}`:"standalone"}),c.jsx("span",{className:"line"}),c.jsx("span",{children:Q.length})]}),Q.map(q=>c.jsx(Sy,{c:q,s:N[q.id],onChange:$=>Xl(q.id,$),expanded:D.has(q.id),toggleExpanded:()=>g($=>{const cl=new Set($);return cl.has(q.id)?cl.delete(q.id):cl.add(q.id),cl}),conflicts:dl.has(N[q.id]?.nameOverride||q.name),sizes:H},q.id))]},U||"__none"))})]})}function Sy({c:h,s:N,onChange:O,expanded:s,toggleExpanded:_,conflicts:H,sizes:E}){if(!N)return null;const D=(h.mounts??[]).filter(A=>A.kind!=="tmpfs"),g=D.filter(A=>(N.mounts[A.destination]?.action??"copy")==="copy"),J=g.reduce((A,B)=>{const dl=Br(B,E);return A+(dl>0?dl:0)},0);return c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:`crow${N.include?" selected":""}`,children:[c.jsx(st,{checked:N.include,onChange:A=>O({include:A}),label:""}),c.jsx("button",{className:"expander",onClick:_,title:"per-item options",children:s?"▾":"▸"}),c.jsxs("div",{style:{minWidth:0},children:[c.jsx("div",{className:"name truncate",title:h.name,children:h.name}),c.jsxs("div",{className:"sub row",style:{gap:6},children:[c.jsx(Mn,{state:h.state}),h.composeService&&c.jsxs("span",{className:"faint",children:["· ",h.composeService]}),H&&c.jsx("span",{className:"badge",style:{borderColor:"#5c4520",color:"#e0b556"},children:"on target"})]})]}),c.jsx("div",{className:"image truncate",title:h.image,children:h.image}),c.jsxs("div",{className:"tags",children:[D.map(A=>c.jsx("span",{className:`badge ${A.kind==="bind"?"bind":A.kind==="anonymous"?"anon":"vol"}`,title:`${A.kind} → ${A.destination}${A.readOnly?" (read-only)":""}`,style:{opacity:(N.mounts[A.destination]?.action??"copy")==="skip"?.35:1},children:A.kind==="bind"?(A.source??"").split("/").pop()||"/":A.kind==="anonymous"?"anon":A.name},A.destination)),(h.endpoints??[]).filter(A=>!["bridge","host","none"].includes(A.network)).map(A=>c.jsx("span",{className:"badge net",title:`network ${A.network}`,children:A.network},A.network)),(h.ports??[]).slice(0,3).map((A,B)=>c.jsxs("span",{className:"badge port",children:[A.hostPort,":",A.containerPort.split("/")[0]]},B)),(h.ports??[]).length>3&&c.jsxs("span",{className:"badge port",children:["+",(h.ports??[]).length-3]})]}),c.jsxs("div",{className:"small faint nowrap",style:{textAlign:"right"},children:[g.length>0?`${g.length} to copy`:"no data",J>0&&c.jsxs(c.Fragment,{children:[" · ",ae(J)]})]})]}),s&&c.jsx(py,{c:h,s:N,onChange:O,sizes:E})]})}function py({c:h,s:N,onChange:O,sizes:s}){const _=h.mounts??[];function H(E,k){O({mounts:{...N.mounts,[E]:{...N.mounts[E],...k}}})}return c.jsxs("div",{className:"detail",children:[(h.warnings??[]).map((E,k)=>c.jsx("div",{className:"notice warn",children:E},k)),c.jsxs("div",{className:"grid2",children:[c.jsx(Fl,{label:"name on target",children:c.jsx("input",{type:"text",placeholder:h.name,value:N.nameOverride??"",onChange:E=>O({nameOverride:E.target.value})})}),c.jsx(Fl,{label:"image",children:c.jsxs("select",{value:N.migrateImage?N.imageMode:"skip",onChange:E=>{const k=E.target.value;O({migrateImage:k!=="skip",imageMode:k})},children:[c.jsx("option",{value:"auto",children:"auto — reuse, pull, or transfer"}),c.jsx("option",{value:"pull",children:"pull on the target"}),c.jsx("option",{value:"stream",children:"transfer the layers"}),c.jsx("option",{value:"skip",children:"already on the target"})]})}),c.jsxs("div",{className:"stack",style:{gap:6},children:[c.jsx(st,{checked:N.migrateNetworks,onChange:E=>O({migrateNetworks:E}),label:"recreate networks and reattach"}),c.jsx(st,{checked:N.keepStaticIps,onChange:E=>O({keepStaticIps:E}),disabled:!N.migrateNetworks,label:"keep static IP addresses",title:"Only works when the target networks use the same subnets"}),c.jsx(st,{checked:N.migratePorts,onChange:E=>O({migratePorts:E}),label:"publish the same host ports"})]}),c.jsxs("div",{className:"stack",style:{gap:6},children:[c.jsx(st,{checked:N.startAfter,onChange:E=>O({startAfter:E}),label:"start on the target"}),c.jsx(st,{checked:N.stopSourceDuringCopy,onChange:E=>O({stopSourceDuringCopy:E}),label:"stop the source while copying",title:"Recommended: databases and other writers produce inconsistent copies while running"}),c.jsx(st,{checked:N.stopSourceAfter,onChange:E=>O({stopSourceAfter:E}),label:"leave the source stopped afterwards"})]})]}),_.length===0?c.jsx("div",{className:"small faint",children:"this container has no mounts"}):c.jsxs("table",{className:"mount-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{style:{width:74},children:"kind"}),c.jsx("th",{children:"in the container"}),c.jsx("th",{children:"on the source"}),c.jsx("th",{style:{width:130},children:"action"}),c.jsx("th",{children:"on the target"}),c.jsx("th",{style:{width:70,textAlign:"right"},children:"size"})]})}),c.jsx("tbody",{children:_.map(E=>{const k=N.mounts[E.destination]??{action:"copy"},D=E.kind==="tmpfs";return c.jsxs("tr",{children:[c.jsx("td",{children:c.jsx("span",{className:`badge ${E.kind==="bind"?"bind":E.kind==="anonymous"?"anon":E.kind==="tmpfs"?"tmpfs":"vol"}`,children:E.kind})}),c.jsxs("td",{className:"mono truncate",title:E.destination,children:[E.destination,E.readOnly&&c.jsx("span",{className:"faint",children:" :ro"})]}),c.jsx("td",{className:"mono truncate faint",title:E.source||E.name,children:E.kind==="bind"?E.source:E.kind==="anonymous"?"(generated)":E.name}),c.jsx("td",{children:c.jsxs("select",{value:k.action,disabled:D,onChange:g=>H(E.destination,{action:g.target.value}),children:[c.jsx("option",{value:"copy",children:"copy data"}),c.jsx("option",{value:"structure",children:"create empty"}),c.jsx("option",{value:"skip",children:"do not mount"})]})}),c.jsxs("td",{children:[E.kind==="bind"&&k.action!=="skip"&&c.jsx("input",{type:"text",placeholder:E.source,value:k.targetSource??"",onChange:g=>H(E.destination,{targetSource:g.target.value})}),E.kind==="volume"&&k.action!=="skip"&&c.jsx("input",{type:"text",placeholder:E.name,value:k.targetName??"",onChange:g=>H(E.destination,{targetName:g.target.value})}),E.kind==="anonymous"&&c.jsx("span",{className:"small faint",children:"a fresh volume is created"}),D&&c.jsx("span",{className:"small faint",children:"in memory, nothing to copy"})]}),c.jsx("td",{className:"small faint nowrap",style:{textAlign:"right"},children:D?"–":ae(Br(E,s))})]},E.destination)})})]})]})}function jy({source:h,plan:N,includedCount:O,options:s,setOptions:_,connections:H,activeConn:E,setActiveConn:k,reloadConnections:D,targetInv:g,connectTarget:J,onJobStarted:A,onError:B}){const[dl,yl]=K.useState(null),[il,Xl]=K.useState(null),[El,Al]=K.useState(null),[rl,xl]=K.useState(""),[Dl,_l]=K.useState(""),[U,Q]=K.useState("tar"),q=H.find(M=>M.id===E),$=g?.preflight,cl=!!$?.serverVersion;async function hl(M,V){xl(M);try{await V()}catch(nl){nl instanceof pf&&nl.needsTrust?await Zl():B(nl instanceof Error?nl.message:String(nl))}finally{xl("")}}const Pl=K.useCallback(()=>{E&&hl("test",()=>J(E))},[E]);K.useEffect(()=>{Pl()},[Pl]);async function Zl(){if(E)try{Xl(await Gl.probe(E))}catch(M){B(M instanceof Error?M.message:String(M))}}async function Ll(){!E||!il||await hl("trust",async()=>{await Gl.trust(E,il.fingerprint),Xl(null),await J(E)})}const p=O>0&&cl&&!rl,C=O>0&&!rl;return c.jsxs(c.Fragment,{children:[c.jsxs("div",{className:"section",children:[c.jsx("h3",{children:"target host"}),c.jsxs("div",{className:"stack",children:[c.jsxs("div",{className:"row",children:[c.jsxs("select",{value:E,onChange:M=>k(M.target.value),children:[c.jsx("option",{value:"",children:"— no target selected —"}),H.map(M=>c.jsxs("option",{value:M.id,children:[M.name," (",M.user,"@",M.host,")"]},M.id))]}),c.jsx("button",{className:"btn tiny",onClick:()=>yl({port:22,auth:"password",saveSecrets:!1,sudo:!1}),children:"new"})]}),q&&c.jsxs("div",{className:"row wrap",style:{gap:6},children:[c.jsx("button",{className:"btn tiny",disabled:!!rl,onClick:Pl,children:rl==="test"?"connecting…":"connect"}),c.jsx("button",{className:"btn tiny",onClick:()=>yl(q),children:"edit"}),c.jsx("button",{className:"btn tiny",onClick:Zl,children:"host key"}),c.jsx("button",{className:"btn tiny danger",onClick:()=>{confirm(`Delete connection "${q.name}"?`)&&hl("del",async()=>{await Gl.deleteConnection(q.id),D()})},children:"delete"})]}),q&&!g&&c.jsx("div",{className:"small faint",children:"not connected yet"}),$&&c.jsxs(c.Fragment,{children:[($.problems??[]).map((M,V)=>c.jsx(Dt,{kind:"warn",children:M},V)),cl&&c.jsxs("dl",{className:"kv",children:[c.jsx("dt",{children:"host"}),c.jsx("dd",{children:g?.host||q?.host}),c.jsx("dt",{children:"docker"}),c.jsxs("dd",{children:[$.serverVersion," · ",$.os,"/",$.arch]}),c.jsx("dt",{children:"free space"}),c.jsxs("dd",{children:[ae($.diskFreeBytes)," on ",$.dockerRoot]}),c.jsx("dt",{children:"existing"}),c.jsxs("dd",{children:[(g?.containers??[]).length," containers ·"," ",(g?.volumes??[]).length," volumes"]}),c.jsx("dt",{children:"gzip"}),c.jsx("dd",{children:$.hasGzip?"yes":"missing"})]})]})]})]}),c.jsxs("div",{className:"section",children:[c.jsx("h3",{children:"options"}),c.jsxs("div",{className:"stack",children:[c.jsx(Fl,{label:"if the name already exists on the target",children:c.jsxs("select",{value:s.conflict,onChange:M=>_(V=>({...V,conflict:M.target.value})),children:[c.jsx("option",{value:"fail",children:"stop with an error"}),c.jsx("option",{value:"skip",children:"skip that container"}),c.jsx("option",{value:"rename",children:"create it under a new name"}),c.jsx("option",{value:"replace",children:"remove the target's container first"})]})}),s.conflict==="rename"&&c.jsx(Fl,{label:"suffix",children:c.jsx("input",{type:"text",value:s.renameSuffix??"",onChange:M=>_(V=>({...V,renameSuffix:M.target.value}))})}),s.conflict==="replace"&&c.jsx(Dt,{kind:"warn",children:"Existing containers and volumes with the same name are deleted on the target before the copy."}),c.jsx(st,{checked:s.compress,onChange:M=>_(V=>({...V,compress:M})),label:"compress transfers (gzip)"}),c.jsx(st,{checked:s.verifyAfter,onChange:M=>_(V=>({...V,verifyAfter:M})),label:"verify each container after migrating"}),c.jsx(st,{checked:s.dryRun,onChange:M=>_(V=>({...V,dryRun:M})),label:"dry run — show every command, change nothing"}),c.jsx(Fl,{label:`containers at a time: ${s.parallelism}`,children:c.jsx("input",{type:"range",min:1,max:6,value:s.parallelism,onChange:M=>_(V=>({...V,parallelism:Number(M.target.value)})),style:{width:"100%"}})})]})]}),c.jsxs("div",{className:"section",children:[c.jsx("h3",{children:"migrate over ssh"}),c.jsxs("div",{className:"stack",children:[c.jsx("button",{className:"btn primary",disabled:!p,onClick:()=>hl("ssh",async()=>{const M=await Gl.migrateSSH(E,N);A(M)}),children:rl==="ssh"?"starting…":`migrate ${O} container${O===1?"":"s"} to target`}),c.jsx("button",{className:"btn",disabled:O===0||!!rl,onClick:()=>hl("preview",async()=>{Al(await Gl.preview(N))}),children:"preview the commands"}),O===0&&c.jsx("div",{className:"small faint",children:"select at least one container"}),O>0&&!cl&&c.jsx("div",{className:"small faint",children:"connect to a target first"})]})]}),c.jsxs("div",{className:"section",children:[c.jsx("h3",{children:"migration package"}),c.jsxs("div",{className:"stack",children:[c.jsxs("div",{className:"small muted",children:["Builds a self-contained folder with the data, the images and an ",c.jsx("span",{className:"mono",children:"install.sh"})," to run on the target. No network between the hosts required."]}),c.jsx(Fl,{label:"package name",children:c.jsx("input",{type:"text",placeholder:"auto (timestamped)",value:Dl,onChange:M=>_l(M.target.value)})}),c.jsx(Fl,{label:"format",children:c.jsxs("select",{value:U,onChange:M=>Q(M.target.value),children:[c.jsx("option",{value:"tar",children:"single .tar file (downloadable)"}),c.jsx("option",{value:"dir",children:"directory on this host"})]})}),c.jsx("button",{className:"btn",disabled:!C,onClick:()=>hl("pkg",async()=>{const M=await Gl.buildPackage({...N,packageName:Dl},U);A(M)}),children:rl==="pkg"?"starting…":"build package"})]})]}),h?.inventory.warnings?.length?c.jsxs("div",{className:"section",children:[c.jsx("h3",{children:"source warnings"}),c.jsx("div",{className:"stack",children:h.inventory.warnings.map((M,V)=>c.jsx(Dt,{kind:"warn",children:M},V))})]}):null,dl&&c.jsx(xy,{initial:dl,onClose:()=>yl(null),onSaved:M=>{yl(null),D(),k(M.id)},onError:B}),il&&c.jsx(zf,{title:"SSH host key",onClose:()=>Xl(null),footer:c.jsxs(c.Fragment,{children:[c.jsx("button",{className:"btn",onClick:()=>Xl(null),children:"cancel"}),c.jsx("button",{className:"btn primary",onClick:Ll,children:il.changed?"replace the stored key and trust":"trust this host"})]}),children:c.jsxs("div",{className:"stack",children:[il.changed&&c.jsxs(Dt,{kind:"err",children:["The key presented by this host is ",c.jsx("b",{children:"different"})," 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."]}),il.trusted&&!il.changed&&c.jsx(Dt,{kind:"ok",children:"This host key is already trusted."}),c.jsxs("div",{className:"small muted",children:["Compare this with the output of ",c.jsxs("span",{className:"mono",children:["ssh-keyscan -t ",il.keyType," ",il.host]})," ","run on the target itself, or with ",c.jsx("span",{className:"mono",children:"ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub"}),"."]}),c.jsxs("div",{className:"fingerprint",children:[il.keyType,c.jsx("br",{}),il.fingerprint]})]})}),El&&c.jsx(zy,{data:El,onClose:()=>Al(null)})]})}function xy({initial:h,onClose:N,onSaved:O,onError:s}){const[_,H]=K.useState(h),[E,k]=K.useState(!1);function D(g,J){H(A=>({...A,[g]:J}))}return c.jsx(zf,{title:h.id?`Edit ${h.name}`:"New target host",onClose:N,footer:c.jsxs(c.Fragment,{children:[c.jsx("button",{className:"btn",onClick:N,children:"cancel"}),c.jsx("button",{className:"btn primary",disabled:E||!_.host||!_.user,onClick:async()=>{k(!0);try{O(await Gl.saveConnection(_))}catch(g){s(g instanceof Error?g.message:String(g))}finally{k(!1)}},children:E?"saving…":"save"})]}),children:c.jsxs("div",{className:"stack",style:{gap:12},children:[c.jsxs("div",{className:"row",style:{gap:12},children:[c.jsx(Fl,{label:"label",children:c.jsx("input",{type:"text",value:_.name??"",onChange:g=>D("name",g.target.value)})}),c.jsx(Fl,{label:"host",children:c.jsx("input",{type:"text",value:_.host??"",onChange:g=>D("host",g.target.value)})}),c.jsx("div",{style:{width:90},children:c.jsx(Fl,{label:"port",children:c.jsx("input",{type:"number",value:_.port??22,onChange:g=>D("port",Number(g.target.value))})})})]}),c.jsxs("div",{className:"row",style:{gap:12},children:[c.jsx(Fl,{label:"user",children:c.jsx("input",{type:"text",value:_.user??"",onChange:g=>D("user",g.target.value)})}),c.jsx(Fl,{label:"authentication",children:c.jsxs("select",{value:_.auth??"password",onChange:g=>D("auth",g.target.value),children:[c.jsx("option",{value:"password",children:"password"}),c.jsx("option",{value:"key",children:"private key"}),c.jsx("option",{value:"agent",children:"ssh agent"})]})})]}),_.auth==="password"&&c.jsx(Fl,{label:"password",children:c.jsx("input",{type:"password",value:_.password??"",onChange:g=>D("password",g.target.value)})}),_.auth==="key"&&c.jsxs(c.Fragment,{children:[c.jsx(Fl,{label:"private key path on this machine (leave empty to paste the key below)",children:c.jsx("input",{type:"text",placeholder:"/root/.ssh/id_ed25519",value:_.privateKeyPath??"",onChange:g=>D("privateKeyPath",g.target.value)})}),c.jsx(Fl,{label:"or paste the private key",children:c.jsx("textarea",{rows:5,value:_.privateKey??"",onChange:g=>D("privateKey",g.target.value),placeholder:"-----BEGIN OPENSSH PRIVATE KEY-----"})}),c.jsx(Fl,{label:"passphrase (if the key is encrypted)",children:c.jsx("input",{type:"password",value:_.passphrase??"",onChange:g=>D("passphrase",g.target.value)})})]}),_.auth==="agent"&&c.jsxs("div",{className:"small muted",children:["Uses the agent at ",c.jsx("span",{className:"mono",children:"$SSH_AUTH_SOCK"})," of the process running docker-migrate."]}),c.jsx(st,{checked:_.sudo??!1,onChange:g=>D("sudo",g),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."}),c.jsx(Fl,{label:"docker command on the target (optional)",children:c.jsx("input",{type:"text",placeholder:"docker",value:_.dockerCmd??"",onChange:g=>D("dockerCmd",g.target.value)})}),c.jsx(st,{checked:_.saveSecrets??!1,onChange:g=>D("saveSecrets",g),label:"remember the password / key on disk"}),_.saveSecrets?c.jsx(Dt,{kind:"warn",children:"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."}):c.jsx("div",{className:"small faint",children:"Credentials stay in memory and are lost when docker-migrate restarts."})]})})}function zy({data:h,onClose:N}){const O=h.items.reduce((s,_)=>s+_.totalBytes,0);return c.jsx(zf,{title:"What this migration will run",wide:!0,onClose:N,footer:c.jsx("button",{className:"btn",onClick:N,children:"close"}),children:c.jsxs("div",{className:"stack",style:{gap:16},children:[c.jsxs("div",{className:"small muted",children:[h.items.length," container(s)",O>0&&c.jsxs(c.Fragment,{children:[" · about ",ae(O)," of known volume data"]}),". These are the commands that run on the target; data is streamed into ",c.jsx("span",{className:"mono",children:"docker cp"})," rather than written to a file."]}),(h.networkCommands??[]).length>0&&c.jsxs("div",{children:[c.jsx("h3",{style:{margin:"0 0 6px",fontSize:12},children:"shared networks"}),c.jsx("pre",{className:"cmdblock",children:(h.networkCommands??[]).join(` +`)})]}),h.items.map(s=>c.jsxs("div",{children:[c.jsxs("h3",{style:{margin:"0 0 6px",fontSize:12},children:[s.name,s.targetName!==s.name&&c.jsxs("span",{className:"faint",children:[" → ",s.targetName]})]}),(s.warnings??[]).map((_,H)=>c.jsx(Dt,{kind:"warn",children:_},`w${H}`)),(s.notes??[]).map((_,H)=>c.jsxs("div",{className:"small faint",children:["· ",_]},`n${H}`)),c.jsx("pre",{className:"cmdblock",children:(s.commands??[]).join(` +`)})]},s.containerId))]})})}function Ty({jobs:h,activeJob:N,setActiveJob:O,reload:s,reloadPackages:_}){const H=N||h[0]?.id||"";return h.length===0?c.jsx("div",{className:"empty",children:"no migrations yet — select containers and start one"}):c.jsxs("div",{style:{display:"flex",minHeight:0,height:"100%"},children:[c.jsx("div",{className:"joblist",style:{width:320,flex:"0 0 320px",overflow:"auto"},children:h.map(E=>c.jsxs("div",{className:`jobcard${E.id===H?" active":""}`,onClick:()=>O(E.id),children:[c.jsxs("div",{className:"row",children:[c.jsx(Mn,{state:E.state}),c.jsx("span",{className:"spacer"}),c.jsx("span",{className:"small faint",children:E.kind==="ssh"?"ssh":"package"})]}),c.jsx("div",{className:"truncate",style:{marginTop:2},children:E.title}),c.jsxs("div",{className:"small faint",children:[new Date(E.createdAt).toLocaleTimeString()," · ",qr(E.startedAt,E.endedAt),E.dryRun&&" · dry run"]}),c.jsx("div",{style:{marginTop:6},children:c.jsx(jf,{done:E.bytesDone,total:E.bytesTotal,state:E.state})})]},E.id))}),c.jsx("div",{style:{flex:1,minWidth:0,overflow:"auto",borderLeft:"1px solid var(--border)"},children:H&&c.jsx(Ey,{id:H,reload:s,reloadPackages:_})})]})}function Ey({id:h,reload:N,reloadPackages:O}){const[s,_]=K.useState(null),[H,E]=K.useState(!0),k=K.useRef(null),D=K.useRef(!0);K.useEffect(()=>{_(null);let A=!1;Gl.job(h).then(dl=>!A&&_(dl)).catch(()=>{});const B=Gl.jobEvents(h);return B.onmessage=dl=>{try{_(JSON.parse(dl.data))}catch{}},B.addEventListener("done",()=>{B.close(),N(),O()}),B.onerror=()=>B.close(),()=>{A=!0,B.close()}},[h,N,O]);const g=K.useMemo(()=>(s?.log??[]).filter(A=>H||A.level!=="cmd"),[s,H]);if(K.useEffect(()=>{const A=k.current;A&&D.current&&(A.scrollTop=A.scrollHeight)},[g]),!s)return c.jsx("div",{className:"empty",children:"loading…"});const J=s.state==="running"||s.state==="pending";return c.jsxs("div",{style:{padding:16,display:"flex",flexDirection:"column",gap:14},children:[c.jsxs("div",{className:"row",children:[c.jsx(Mn,{state:s.state}),c.jsx("b",{children:s.title}),s.dryRun&&c.jsx("span",{className:"badge",children:"dry run"}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint",children:[ae(s.bytesDone),s.bytesTotal>0&&c.jsxs(c.Fragment,{children:[" of ",ae(s.bytesTotal)]})," · ",qr(s.startedAt,s.endedAt)]}),J?c.jsx("button",{className:"btn tiny danger",onClick:()=>Gl.cancelJob(s.id).then(N),children:"cancel"}):c.jsx("button",{className:"btn tiny ghost",onClick:()=>Gl.deleteJob(s.id).then(N),children:"remove"})]}),c.jsx(jf,{done:s.bytesDone,total:s.bytesTotal,state:s.state}),s.error&&c.jsx(Dt,{kind:"err",children:s.error}),s.state==="succeeded"&&s.artifact&&c.jsxs(Dt,{kind:"ok",children:["Package ready at ",c.jsx("span",{className:"mono",children:s.artifact})," (",ae(s.artifactBytes??0),")."," ","Open the Packages tab to download it."]}),s.items.map(A=>c.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:"8px 10px"},children:[c.jsxs("div",{className:"row",children:[c.jsx(Mn,{state:A.state}),c.jsx("b",{children:A.name}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint",children:[A.steps.filter(B=>B.state==="succeeded").length,"/",A.steps.length," steps"]})]}),A.error&&c.jsx("div",{className:"small",style:{color:"var(--err)"},children:A.error}),(A.warnings??[]).map((B,dl)=>c.jsxs("div",{className:"small",style:{color:"var(--warn)"},children:["! ",B]},dl)),c.jsx("div",{className:"steps",children:A.steps.map(B=>c.jsxs("div",{className:`step ${B.state}`,children:[c.jsx(Mn,{state:B.state,label:""}),c.jsx("span",{className:"label truncate",title:B.error||B.label,children:B.label}),c.jsx("span",{children:B.bytesTotal>0||B.bytesDone>0?c.jsx(jf,{done:B.bytesDone,total:B.bytesTotal,state:B.state}):null}),c.jsx("span",{className:"faint nowrap",style:{textAlign:"right"},children:B.bytesDone>0?ae(B.bytesDone):B.state==="skipped"?"skipped":""})]},B.id))})]},A.id)),c.jsxs("div",{className:"row",children:[c.jsx("h3",{style:{margin:0,fontSize:12},children:"log"}),c.jsx("span",{className:"spacer"}),c.jsxs("label",{className:"check small",children:[c.jsx("input",{type:"checkbox",checked:H,onChange:A=>E(A.target.checked)}),c.jsx("span",{children:"show commands"})]})]}),c.jsxs("div",{className:"log",ref:k,onScroll:A=>{const B=A.currentTarget;D.current=B.scrollHeight-B.scrollTop-B.clientHeight<24},children:[g.map(A=>c.jsxs("div",{className:`l-${A.level}`,children:[c.jsxs("span",{className:"ts",children:[new Date(A.at).toLocaleTimeString()," "]}),A.message]},A.seq)),g.length===0&&c.jsx("span",{className:"faint",children:"nothing logged yet"})]})]})}function Ay({packages:h,reload:N}){return h.length===0?c.jsx("div",{className:"empty",children:"no packages built yet"}):c.jsxs("div",{style:{padding:16,display:"flex",flexDirection:"column",gap:12},children:[c.jsxs(Dt,{kind:"info",children:["Copy a package to the target host, then run ",c.jsx("span",{className:"mono",children:"./install.sh --dry-run"})," to review it and"," ",c.jsx("span",{className:"mono",children:"./install.sh"})," to restore. The target needs only bash, gzip and docker."]}),c.jsxs("table",{className:"mount-table",children:[c.jsx("thead",{children:c.jsxs("tr",{children:[c.jsx("th",{children:"name"}),c.jsx("th",{style:{width:110},children:"kind"}),c.jsx("th",{style:{width:110,textAlign:"right"},children:"size"}),c.jsx("th",{style:{width:170},children:"built"}),c.jsx("th",{style:{width:190}})]})}),c.jsx("tbody",{children:h.map(O=>c.jsxs("tr",{children:[c.jsx("td",{className:"mono truncate",title:O.path,children:O.name}),c.jsx("td",{children:c.jsx("span",{className:"badge",children:O.isDir?"directory":"tar"})}),c.jsx("td",{className:"nowrap",style:{textAlign:"right"},children:ae(O.bytes)}),c.jsx("td",{className:"small faint",children:new Date(O.createdAt).toLocaleString()}),c.jsx("td",{children:c.jsxs("div",{className:"row",style:{justifyContent:"flex-end",gap:6},children:[O.isDir?c.jsx("span",{className:"small faint",title:O.path,children:"copy it from disk"}):c.jsx("a",{className:"btn tiny",href:Gl.downloadUrl(O.name),download:!0,children:"download"}),c.jsx("button",{className:"btn tiny danger",onClick:()=>{confirm(`Delete package "${O.name}"? This cannot be undone.`)&&Gl.deletePackage(O.name).then(N)},children:"delete"})]})})]},O.name))})]})]})}function Ny(){const[h,N]=K.useState(null),[O,s]=K.useState(null),[_,H]=K.useState({}),[E,k]=K.useState({conflict:"fail",renameSuffix:"-migrated",compress:!0,compressLevel:1,dryRun:!1,parallelism:1,verifyAfter:!0}),[D,g]=K.useState({}),[J,A]=K.useState([]),[B,dl]=K.useState(""),[yl,il]=K.useState(null),[Xl,El]=K.useState([]),[Al,rl]=K.useState([]),[xl,Dl]=K.useState("containers"),[_l,U]=K.useState(""),[Q,q]=K.useState(""),[$,cl]=K.useState(!0),hl=K.useCallback(async()=>{cl(!0);try{const d=await Gl.source();s(d),H(x=>{const R={};for(const Y of d.inventory.containers)R[Y.id]=x[Y.id]??d.defaults[Y.id];return R}),q(""),Gl.volumeSizes().then(x=>g(x.volumes??{})).catch(()=>{})}catch(d){q(d instanceof Error?d.message:String(d))}finally{cl(!1)}},[]),Pl=K.useCallback(async()=>{try{const d=await Gl.connections();A(d),dl(x=>x&&d.some(R=>R.id===x)?x:d[0]?.id??"")}catch(d){q(d instanceof Error?d.message:String(d))}},[]),Zl=K.useCallback(async()=>{try{El(await Gl.jobs())}catch{}},[]),Ll=K.useCallback(async()=>{try{rl(await Gl.packages())}catch{}},[]);K.useEffect(()=>{Gl.health().then(N).catch(()=>{}),hl(),Pl(),Zl(),Ll()},[hl,Pl,Zl,Ll]),K.useEffect(()=>{const d=setInterval(Zl,4e3);return()=>clearInterval(d)},[Zl]);const p=K.useCallback(async d=>{if(il(null),!d)return;const x=await Gl.targetInventory(d);il(x),q("")},[]),C=K.useMemo(()=>Object.values(_).filter(d=>d.include),[_]),M=K.useMemo(()=>({items:Object.values(_),options:E}),[_,E]),V=Xl.filter(d=>d.state==="running"||d.state==="pending").length,nl=K.useCallback(d=>{El(x=>[d,...x]),U(d.id),Dl("jobs")},[]);return c.jsxs("div",{className:"app",children:[c.jsxs("header",{className:"topbar",children:[c.jsxs("div",{className:"brand",children:[c.jsx("span",{className:"dot"}),"docker-migrate"]}),c.jsxs("nav",{className:"tabs",children:[c.jsxs("button",{className:`tab${xl==="containers"?" active":""}`,onClick:()=>Dl("containers"),children:["Containers",c.jsxs("span",{className:"count",children:[C.length,"/",O?.inventory.containers.length??0]})]}),c.jsxs("button",{className:`tab${xl==="jobs"?" active":""}`,onClick:()=>Dl("jobs"),children:["Jobs",V>0&&c.jsxs("span",{className:"count",children:[V," running"]})]}),c.jsxs("button",{className:`tab${xl==="packages"?" active":""}`,onClick:()=>Dl("packages"),children:["Packages",Al.length>0&&c.jsx("span",{className:"count",children:Al.length})]})]}),c.jsxs("div",{className:"topbar-right",children:[h&&c.jsxs("span",{className:"hostinfo",children:["source ",c.jsx("b",{children:O?.inventory.host||h.dockerHost}),h.dockerVersion&&c.jsxs(c.Fragment,{children:[" · docker ",h.dockerVersion]})]}),c.jsx("button",{className:"btn tiny",onClick:hl,disabled:$,children:$?"loading…":"refresh"})]})]}),Q&&c.jsx("div",{style:{padding:"10px 16px"},children:c.jsxs(Dt,{kind:"err",children:[Q,c.jsx("button",{className:"btn tiny ghost",style:{marginLeft:8},onClick:()=>q(""),children:"dismiss"})]})}),h&&!h.ok&&c.jsx("div",{style:{padding:"10px 16px"},children:c.jsxs(Dt,{kind:"err",children:["Cannot reach the source Docker daemon at ",c.jsx("span",{className:"mono",children:h.dockerHost}),h.dockerError&&c.jsxs(c.Fragment,{children:[" — ",h.dockerError]})]})}),c.jsxs("div",{className:"body",children:[c.jsxs("main",{className:"main",children:[xl==="containers"&&c.jsx(by,{source:O,sel:_,setSel:H,targetInv:yl,loading:$,sizes:D}),xl==="jobs"&&c.jsx(Ty,{jobs:Xl,activeJob:_l,setActiveJob:U,reload:Zl,reloadPackages:Ll}),xl==="packages"&&c.jsx(Ay,{packages:Al,reload:Ll})]}),xl==="containers"&&c.jsx("aside",{className:"sidebar",children:c.jsx(jy,{source:O,plan:M,includedCount:C.length,options:E,setOptions:k,connections:J,activeConn:B,setActiveConn:dl,reloadConnections:Pl,targetInv:yl,connectTarget:p,onJobStarted:nl,onError:q})})]})]})}vy.createRoot(document.getElementById("root")).render(c.jsx(K.StrictMode,{children:c.jsx(Ny,{})})); diff --git a/internal/webui/dist/assets/index-BYoxln0e.css b/internal/webui/dist/assets/index-BYoxln0e.css new file mode 100644 index 0000000..f70245f --- /dev/null +++ b/internal/webui/dist/assets/index-BYoxln0e.css @@ -0,0 +1 @@ +: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}.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:-.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)}@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)}}.section{padding:14px 16px;border-bottom:1px solid var(--border)}.section h3{margin:0 0 10px;font-size:11px;text-transform:uppercase;letter-spacing:.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:.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;-webkit-user-select:none;user-select:none}.check input{accent-color:var(--accent);width:14px;height:14px;margin:0;cursor:pointer}.check.disabled{opacity:.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,.state.skipped .dot{background:var(--text-faint)}.state.running .dot{animation:pulse 1.4s ease-in-out infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.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}.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:.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}.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 .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-backdrop{position:fixed;inset:0;background:#03060ab8;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 #0000008c}.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} diff --git a/internal/webui/dist/index.html b/internal/webui/dist/index.html new file mode 100644 index 0000000..c7bd1ef --- /dev/null +++ b/internal/webui/dist/index.html @@ -0,0 +1,14 @@ + + + + + + + docker-migrate + + + + +
+ + diff --git a/internal/webui/embed.go b/internal/webui/embed.go new file mode 100644 index 0000000..1d5f42d --- /dev/null +++ b/internal/webui/embed.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..fedc3b0 --- /dev/null +++ b/main.go @@ -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 /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 +} diff --git a/test/e2e_test.go b/test/e2e_test.go new file mode 100644 index 0000000..7787eae --- /dev/null +++ b/test/e2e_test.go @@ -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() +} diff --git a/test/errors_test.go b/test/errors_test.go new file mode 100644 index 0000000..912c96c --- /dev/null +++ b/test/errors_test.go @@ -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) +} diff --git a/test/ssh_e2e_test.go b/test/ssh_e2e_test.go new file mode 100644 index 0000000..0935d95 --- /dev/null +++ b/test/ssh_e2e_test.go @@ -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) + } +} diff --git a/test/tar_test.go b/test/tar_test.go new file mode 100644 index 0000000..1040054 --- /dev/null +++ b/test/tar_test.go @@ -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 + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..485cf2c --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + docker-migrate + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..ef8df78 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1883 @@ +{ + "name": "docker-migrate-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docker-migrate-ui", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.403", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", + "integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", + "integrity": "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c7ab71e --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..61cac49 --- /dev/null +++ b/web/src/App.tsx @@ -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(null) + const [source, setSource] = useState(null) + const [sel, setSel] = useState>({}) + const [options, setOptions] = useState({ + 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>({}) + const [connections, setConnections] = useState([]) + const [activeConn, setActiveConn] = useState('') + const [targetInv, setTargetInv] = useState(null) + const [jobs, setJobs] = useState([]) + const [packages, setPackages] = useState([]) + const [view, setView] = useState('containers') + const [activeJob, setActiveJob] = useState('') + const [error, setError] = useState('') + 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 = {} + 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(() => ({ 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 ( +
+
+
docker-migrate
+ +
+ {health && ( + + source {source?.inventory.host || health.dockerHost} + {health.dockerVersion && <> · docker {health.dockerVersion}} + + )} + +
+
+ + {error && ( +
+ + {error} + + +
+ )} + + {health && !health.ok && ( +
+ + Cannot reach the source Docker daemon at {health.dockerHost} + {health.dockerError && <> — {health.dockerError}} + +
+ )} + +
+
+ {view === 'containers' && ( + + )} + {view === 'jobs' && ( + + )} + {view === 'packages' && } +
+ + {view === 'containers' && ( + + )} +
+
+ ) +} diff --git a/web/src/Containers.tsx b/web/src/Containers.tsx new file mode 100644 index 0000000..f0651ef --- /dev/null +++ b/web/src/Containers.tsx @@ -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 +type SizeMap = Record + +/** 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> + targetInv: TargetInventory | null + loading: boolean + sizes: SizeMap +}) { + const [query, setQuery] = useState('') + const [expanded, setExpanded] = useState>(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() + 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) { + 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 ( + <> +
+ setQuery(e.target.value)} + /> + + + + running only} /> + + + + apply to {selectedCount ? `${selectedCount} selected` : 'selection'}: + + + + +
+ + {loading && containers.length === 0 &&
reading the source daemon…
} + {!loading && containers.length === 0 &&
no containers on this host
} + {!loading && containers.length > 0 && visible.length === 0 &&
nothing matches the filter
} + +
+ {groups.map(([project, list]) => ( +
+ {groups.length > 1 && ( +
+ sel[c.id]?.include)} + onChange={(v) => setInclude(list.map((c) => c.id), v)} + label={project ? `compose: ${project}` : 'standalone'} + /> + + {list.length} +
+ )} + {list.map((c) => ( + 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} + /> + ))} +
+ ))} +
+ + ) +} + +function Row({ + c, s, onChange, expanded, toggleExpanded, conflicts, sizes, +}: { + c: Container + s: ItemSelection | undefined + onChange: (patch: Partial) => 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 ( + <> +
+ onChange({ include: v })} label="" /> + + + +
+
{c.name}
+
+ + {c.composeService && · {c.composeService}} + {conflicts && on target} +
+
+ +
{c.image}
+ +
+ {dataMounts.map((m) => ( + + {m.kind === 'bind' ? (m.source ?? '').split('/').pop() || '/' : m.kind === 'anonymous' ? 'anon' : m.name} + + ))} + {(c.endpoints ?? []).filter((e) => !['bridge', 'host', 'none'].includes(e.network)).map((e) => ( + {e.network} + ))} + {(c.ports ?? []).slice(0, 3).map((p, i) => ( + {p.hostPort}:{p.containerPort.split('/')[0]} + ))} + {(c.ports ?? []).length > 3 && +{(c.ports ?? []).length - 3}} +
+ +
+ {copying.length > 0 ? `${copying.length} to copy` : 'no data'} + {knownBytes > 0 && <> · {humanBytes(knownBytes)}} +
+
+ + {expanded && } + + ) +} + +function Detail({ + c, s, onChange, sizes, +}: { + c: Container + s: ItemSelection + onChange: (patch: Partial) => 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 ( +
+ {(c.warnings ?? []).map((w, i) => ( +
{w}
+ ))} + +
+ + onChange({ nameOverride: e.target.value })} + /> + + + + + + +
+ onChange({ migrateNetworks: v })} + label="recreate networks and reattach" + /> + onChange({ keepStaticIps: v })} + disabled={!s.migrateNetworks} + label="keep static IP addresses" + title="Only works when the target networks use the same subnets" + /> + onChange({ migratePorts: v })} + label="publish the same host ports" + /> +
+ +
+ onChange({ startAfter: v })} label="start on the target" /> + onChange({ stopSourceDuringCopy: v })} + label="stop the source while copying" + title="Recommended: databases and other writers produce inconsistent copies while running" + /> + onChange({ stopSourceAfter: v })} + label="leave the source stopped afterwards" + /> +
+
+ + {mounts.length === 0 ? ( +
this container has no mounts
+ ) : ( + + + + + + + + + + + + + {mounts.map((m) => { + const ms = s.mounts[m.destination] ?? { action: 'copy' as MountAction } + const isTmpfs = m.kind === 'tmpfs' + return ( + + + + + + + + + ) + })} + +
kindin the containeron the sourceactionon the targetsize
+ + {m.kind} + + + {m.destination} + {m.readOnly && :ro} + + {m.kind === 'bind' ? m.source : m.kind === 'anonymous' ? '(generated)' : m.name} + + + + {m.kind === 'bind' && ms.action !== 'skip' && ( + setMount(m.destination, { targetSource: e.target.value })} + /> + )} + {m.kind === 'volume' && ms.action !== 'skip' && ( + setMount(m.destination, { targetName: e.target.value })} + /> + )} + {m.kind === 'anonymous' && a fresh volume is created} + {isTmpfs && in memory, nothing to copy} + + {isTmpfs ? '–' : humanBytes(mountSize(m, sizes))} +
+ )} +
+ ) +} diff --git a/web/src/Jobs.tsx b/web/src/Jobs.tsx new file mode 100644 index 0000000..a2f9f88 --- /dev/null +++ b/web/src/Jobs.tsx @@ -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
no migrations yet — select containers and start one
+ } + + return ( +
+
+ {jobs.map((j) => ( +
setActiveJob(j.id)} + > +
+ + + {j.kind === 'ssh' ? 'ssh' : 'package'} +
+
{j.title}
+
+ {new Date(j.createdAt).toLocaleTimeString()} · {duration(j.startedAt, j.endedAt)} + {j.dryRun && ' · dry run'} +
+
+ +
+
+ ))} +
+
+ {selected && } +
+
+ ) +} + +function JobDetail({ + id, reload, reloadPackages, +}: { + id: string + reload: () => void + reloadPackages: () => void +}) { + const [job, setJob] = useState(null) + const [showCmds, setShowCmds] = useState(true) + const logRef = useRef(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
loading…
+ + const running = job.state === 'running' || job.state === 'pending' + + return ( +
+
+ + {job.title} + {job.dryRun && dry run} + + + {humanBytes(job.bytesDone)} + {job.bytesTotal > 0 && <> of {humanBytes(job.bytesTotal)}} · {duration(job.startedAt, job.endedAt)} + + {running ? ( + + ) : ( + + )} +
+ + + + {job.error && {job.error}} + {job.state === 'succeeded' && job.artifact && ( + + Package ready at {job.artifact} ({humanBytes(job.artifactBytes ?? 0)}). + {' '}Open the Packages tab to download it. + + )} + + {job.items.map((it) => ( +
+
+ + {it.name} + + {it.steps.filter((s) => s.state === 'succeeded').length}/{it.steps.length} steps +
+ {it.error &&
{it.error}
} + {(it.warnings ?? []).map((w, i) => ( +
! {w}
+ ))} +
+ {it.steps.map((s) => ( +
+ + {s.label} + + {s.bytesTotal > 0 || s.bytesDone > 0 ? ( + + ) : null} + + + {s.bytesDone > 0 ? humanBytes(s.bytesDone) : s.state === 'skipped' ? 'skipped' : ''} + +
+ ))} +
+
+ ))} + +
+

log

+ + +
+
{ + const el = e.currentTarget + stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24 + }} + > + {lines.map((l) => ( +
+ {new Date(l.at).toLocaleTimeString()} + {l.message} +
+ ))} + {lines.length === 0 && nothing logged yet} +
+
+ ) +} diff --git a/web/src/Packages.tsx b/web/src/Packages.tsx new file mode 100644 index 0000000..df6a10a --- /dev/null +++ b/web/src/Packages.tsx @@ -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
no packages built yet
+ } + return ( +
+ + Copy a package to the target host, then run ./install.sh --dry-run to review it and{' '} + ./install.sh to restore. The target needs only bash, gzip and docker. + + + + + + + + + + + + + + {packages.map((p) => ( + + + + + + + + ))} + +
namekindsizebuilt
{p.name}{p.isDir ? 'directory' : 'tar'}{humanBytes(p.bytes)}{new Date(p.createdAt).toLocaleString()} +
+ {p.isDir ? ( + copy it from disk + ) : ( + download + )} + +
+
+
+ ) +} diff --git a/web/src/Sidebar.tsx b/web/src/Sidebar.tsx new file mode 100644 index 0000000..aa0f3c7 --- /dev/null +++ b/web/src/Sidebar.tsx @@ -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> + connections: Connection[] + activeConn: string + setActiveConn: (id: string) => void + reloadConnections: () => void + targetInv: TargetInventory | null + connectTarget: (id: string) => Promise + onJobStarted: (j: JobSnapshot) => void + onError: (msg: string) => void +}) { + const [editing, setEditing] = useState | null>(null) + const [hostKey, setHostKey] = useState(null) + const [preview, setPreview] = useState(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) { + 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 ( + <> +
+

target host

+
+
+ + +
+ + {conn && ( +
+ + + + +
+ )} + + {conn && !targetInv &&
not connected yet
} + + {pre && ( + <> + {(pre.problems ?? []).map((p, i) => ( + {p} + ))} + {targetReady && ( +
+
host
{targetInv?.host || conn?.host}
+
docker
{pre.serverVersion} · {pre.os}/{pre.arch}
+
free space
{humanBytes(pre.diskFreeBytes)} on {pre.dockerRoot}
+
existing
+
+ {(targetInv?.containers ?? []).length} containers ·{' '} + {(targetInv?.volumes ?? []).length} volumes +
+
gzip
{pre.hasGzip ? 'yes' : 'missing'}
+
+ )} + + )} +
+
+ +
+

options

+
+ + + + + {options.conflict === 'rename' && ( + + setOptions((o) => ({ ...o, renameSuffix: e.target.value }))} + /> + + )} + + {options.conflict === 'replace' && ( + + Existing containers and volumes with the same name are deleted on the target before the copy. + + )} + + setOptions((o) => ({ ...o, compress: v }))} + label="compress transfers (gzip)" + /> + setOptions((o) => ({ ...o, verifyAfter: v }))} + label="verify each container after migrating" + /> + setOptions((o) => ({ ...o, dryRun: v }))} + label="dry run — show every command, change nothing" + /> + + + setOptions((o) => ({ ...o, parallelism: Number(e.target.value) }))} + style={{ width: '100%' }} + /> + +
+
+ +
+

migrate over ssh

+
+ + + {includedCount === 0 &&
select at least one container
} + {includedCount > 0 && !targetReady &&
connect to a target first
} +
+
+ +
+

migration package

+
+
+ Builds a self-contained folder with the data, the images and an install.sh to + run on the target. No network between the hosts required. +
+ + setPackageName(e.target.value)} + /> + + + + + +
+
+ + {source?.inventory.warnings?.length ? ( +
+

source warnings

+
+ {source.inventory.warnings.map((w, i) => ( + {w} + ))} +
+
+ ) : null} + + {editing && ( + setEditing(null)} + onSaved={(c) => { + setEditing(null) + reloadConnections() + setActiveConn(c.id) + }} + onError={onError} + /> + )} + + {hostKey && ( + setHostKey(null)} + footer={ + <> + + + + } + > +
+ {hostKey.changed && ( + + The key presented by this host is different 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. + + )} + {hostKey.trusted && !hostKey.changed && This host key is already trusted.} +
+ Compare this with the output of ssh-keyscan -t {hostKey.keyType} {hostKey.host}{' '} + run on the target itself, or with ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub. +
+
+ {hostKey.keyType}
+ {hostKey.fingerprint} +
+
+
+ )} + + {preview && setPreview(null)} />} + + ) +} + +function ConnectionDialog({ + initial, onClose, onSaved, onError, +}: { + initial: Partial + onClose: () => void + onSaved: (c: Connection) => void + onError: (m: string) => void +}) { + const [c, setC] = useState>(initial) + const [saving, setSaving] = useState(false) + + function set(k: K, v: Connection[K]) { + setC((prev) => ({ ...prev, [k]: v })) + } + + return ( + + + + + } + > +
+
+ set('name', e.target.value)} /> + set('host', e.target.value)} /> +
+ + set('port', Number(e.target.value))} /> + +
+
+ +
+ set('user', e.target.value)} /> + + + +
+ + {c.auth === 'password' && ( + + set('password', e.target.value)} /> + + )} + + {c.auth === 'key' && ( + <> + + set('privateKeyPath', e.target.value)} /> + + +