Compare commits
6
Commits
v1.0
...
17a83e737f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17a83e737f | ||
|
|
9b354636bb | ||
|
|
0a97b78d63 | ||
|
|
05db8bfeb9 | ||
|
|
dccb98ce18 | ||
|
|
895858197e |
No files matched your search
@@ -0,0 +1,72 @@
|
|||||||
|
name: Sync Gitea releases to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.azuze.fr
|
||||||
|
GITEA_OWNER: kawa
|
||||||
|
GITEA_REPO: DockMV
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-releases:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install jq
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y jq
|
||||||
|
|
||||||
|
- name: Fetch Gitea releases and sync to GitHub
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
AUTH_HEADER=()
|
||||||
|
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||||
|
AUTH_HEADER=(-H "Authorization: token ${GITEA_TOKEN}")
|
||||||
|
fi
|
||||||
|
# Récupère toutes les releases Gitea (pagination simple, 50 max, ajuster si besoin)
|
||||||
|
releases=$(curl -sf "${AUTH_HEADER[@]}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50")
|
||||||
|
echo "$releases" | jq -c '.[]' | while read -r release; do
|
||||||
|
tag=$(echo "$release" | jq -r '.tag_name')
|
||||||
|
name=$(echo "$release" | jq -r '.name // .tag_name')
|
||||||
|
body=$(echo "$release" | jq -r '.body // ""')
|
||||||
|
prerelease=$(echo "$release" | jq -r '.prerelease')
|
||||||
|
draft=$(echo "$release" | jq -r '.draft')
|
||||||
|
# Skip si la release existe déjà sur GitHub
|
||||||
|
if gh release view "$tag" >/dev/null 2>&1; then
|
||||||
|
echo "Release $tag existe déjà sur GitHub, on passe."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "Création de la release $tag sur GitHub..."
|
||||||
|
flags=()
|
||||||
|
[ "$prerelease" = "true" ] && flags+=(--prerelease)
|
||||||
|
[ "$draft" = "true" ] && flags+=(--draft)
|
||||||
|
# Le tag doit exister sur le repo GitHub ; s'il n'existe pas encore,
|
||||||
|
# on utilise --notes-file avec target par défaut (branche par défaut)
|
||||||
|
gh release create "$tag" \
|
||||||
|
--title "$name" \
|
||||||
|
--notes "$body" \
|
||||||
|
"${flags[@]}"
|
||||||
|
# Télécharge et attache les assets de la release Gitea
|
||||||
|
asset_count=$(echo "$release" | jq '.assets | length')
|
||||||
|
if [ "$asset_count" -gt 0 ]; then
|
||||||
|
tmpdir=$(mktemp -d)
|
||||||
|
echo "$release" | jq -c '.assets[]' | while read -r asset; do
|
||||||
|
asset_name=$(echo "$asset" | jq -r '.name')
|
||||||
|
asset_url=$(echo "$asset" | jq -r '.browser_download_url')
|
||||||
|
echo "Téléchargement de $asset_name..."
|
||||||
|
curl -sfL "${AUTH_HEADER[@]}" -o "${tmpdir}/${asset_name}" "$asset_url"
|
||||||
|
gh release upload "$tag" "${tmpdir}/${asset_name}" --clobber
|
||||||
|
done
|
||||||
|
rm -rf "$tmpdir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<p align="center"><img src="assets/dockmv-logo-full.png" alt="DockMV" width="480"></p>
|
<p align="center"><img src="assets/dockmv-logo-full.png" alt="DockMV" width="480"></p>
|
||||||
|
|
||||||
Move Docker containers — and their data — from one host to another, from a web UI, in a few clicks.
|
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,
|
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
|
user-defined networks, published ports, environment, capabilities, restart policy, healthchecks and
|
||||||
@@ -10,148 +10,202 @@ Two ways to move things:
|
|||||||
|
|
||||||
| Mode | What happens | When to use it |
|
| 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. |
|
| **Host to host over SSH** | The source streams everything straight into the target's `docker cp` / `docker load`. Nothing touches disk in between. | The two hosts can reach each other. |
|
||||||
| **Migration package** | Builds a self-contained folder or `.tar` holding the data, the images, and a plain-bash `install.sh`. Carry it on a disk, run the script on the target. | Air-gapped targets, or when you want the move reviewed and replayed later. |
|
| **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`
|
The target needs **nothing installed**: no agent, no Python, no Go — just `sshd`, `docker`, `bash` and `gzip`.
|
||||||
CLI, `bash` and `gzip`.
|
|
||||||
|
The **source** is picked in the UI: the daemon DockMV runs next to, another daemon by address, or a
|
||||||
|
remote host over SSH — which needs nothing installed either. One DockMV can therefore move containers
|
||||||
|
between any two of your hosts.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick start
|
## Install
|
||||||
|
|
||||||
### Run it in a container (recommended)
|
Run DockMV on the **source** host (the one holding the containers to move), or anywhere that can
|
||||||
|
reach it — see [sources](#sources).
|
||||||
|
|
||||||
On the **source** host:
|
### Docker Compose — recommended
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
|
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
|
||||||
docker compose up -d --build
|
docker compose up -d
|
||||||
docker compose logs dockmv # prints the URL, including the access token
|
docker compose logs dockmv # 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:
|
Open the printed URL. It binds to `127.0.0.1` only; reach it from your laptop with a tunnel:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -L 8080:127.0.0.1:8080 you@source-host
|
ssh -L 8080:127.0.0.1:8080 you@source-host
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run it bare-metal
|
Uses the published image `git.azuze.fr/kawa/dockmv:latest`. Pin a version with `VERSION=v1.2.0 docker compose up -d`,
|
||||||
|
and set a fixed token with `DOCKMV_TOKEN` in the compose file to keep the same URL across restarts.
|
||||||
|
|
||||||
The binary is fully static and embeds the web UI, so there is nothing to install alongside it.
|
### Prebuilt binary
|
||||||
|
|
||||||
|
Grab the archive for your platform from the [releases](https://git.azuze.fr/kawa/DockMV/releases)
|
||||||
|
(`dockmv-<os>-<arch>.tar.gz`, `.zip` on Windows). The binary is static and embeds the web UI —
|
||||||
|
nothing to install alongside it.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # needs Go 1.25+ and Node 20+ ... or just `go build .` if you skip the UI rebuild
|
tar xzf dockmv-linux-amd64.tar.gz
|
||||||
|
./dockmv-linux-amd64 serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
|
||||||
|
make build # rebuilds the UI, then the binary
|
||||||
./dockmv serve
|
./dockmv serve
|
||||||
```
|
```
|
||||||
|
|
||||||
Prebuilt for several platforms:
|
`go build .` alone also works — the built UI is committed, so the Node toolchain is optional.
|
||||||
|
|
||||||
```bash
|
> DockMV needs access to the Docker socket on the source host: run it as a user in the `docker`
|
||||||
make release # dist/dockmv-linux-amd64, -linux-arm64, -darwin-arm64, -windows-amd64
|
> group, or as root. That is equivalent to root on that host, so keep the UI on loopback.
|
||||||
```
|
|
||||||
|
|
||||||
`dockmv` needs access to the Docker socket on the source host, so run it as a user in the
|
---
|
||||||
`docker` group (or as root).
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Source host — where the containers are
|
||||||
|
|
||||||
|
| Requirement | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| Docker daemon + access to `/var/run/docker.sock` | reads containers and streams their data |
|
||||||
|
| Docker Compose | only for the compose install |
|
||||||
|
|
||||||
|
Nothing else. The binary is static: no libc, no runtime, no Python.
|
||||||
|
|
||||||
|
A **remote** source needs the same as a target — `sshd` and a `docker` CLI of 18.09 or newer, since
|
||||||
|
the Engine API is tunnelled through `docker system dial-stdio`. Nothing is installed there either.
|
||||||
|
|
||||||
|
### Target host — where containers land
|
||||||
|
|
||||||
|
| Requirement | Why |
|
||||||
|
| --- | --- |
|
||||||
|
| `sshd`, reachable from the source | transport |
|
||||||
|
| `docker` CLI + a working daemon | everything |
|
||||||
|
| the login user can use docker | either in the `docker` group, or tick *run docker through `sudo -n`* |
|
||||||
|
| `gzip` | compressed transfers; without it the tool falls back to uncompressed |
|
||||||
|
| `bash` | only for the migration package installer |
|
||||||
|
|
||||||
|
The **connect** button runs a preflight and tells you which of these are missing, plus the target's
|
||||||
|
free disk space and architecture.
|
||||||
|
|
||||||
|
### Building from source
|
||||||
|
|
||||||
|
| Tool | Version |
|
||||||
|
| --- | --- |
|
||||||
|
| Go | 1.25+ |
|
||||||
|
| Node | 20+ (22 in CI) — only to rebuild the UI |
|
||||||
|
| `make` | optional, wraps the two above |
|
||||||
|
| PowerShell 7+ | only for `make publish` / `make release` |
|
||||||
|
|
||||||
|
Libraries: [`docker/docker`](https://github.com/docker/docker) v28.3.3 and `golang.org/x/crypto`
|
||||||
|
on the Go side; React 19, Vite 7 and TypeScript 5.9 on the UI side. That is the whole list.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
|
|
||||||
1. **Containers tab** — everything on the source host, grouped by compose project.
|
1. **Source host** — top of the right panel. Defaults to the daemon DockMV runs next to; pick another
|
||||||
Tick the ones to move. Use *select all*, *select running*, or the compose-project checkbox for
|
one to read a different host. See [sources](#sources).
|
||||||
batch selection.
|
2. **Containers tab** — everything on the source host, grouped by compose project. Tick what to move.
|
||||||
2. **Expand a row** (`▸`) to choose per-container details: the name on the target, whether the image
|
3. **Expand a row** (`▸`) for per-container details: target name, image pulled or transferred,
|
||||||
is pulled or transferred, whether networks and ports come along, and — per mount — whether to
|
networks and ports, and — per mount — **copy the data**, **create it empty**, or **do not mount it**.
|
||||||
**copy the data**, **create it empty**, or **not mount it at all**. Bind mounts can be relocated to
|
Bind mounts can be relocated; named volumes renamed.
|
||||||
a different path on the target; named volumes can be renamed.
|
4. **Apply to selected** does the same thing to every selected container at once.
|
||||||
3. **Apply to selected** in the toolbar does the same thing to every selected container at once
|
5. **Right panel** — add the target host, *connect*, then **migrate over SSH** or **build a package**.
|
||||||
(*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.
|
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
|
Start with **dry run** ticked: it runs every check and prints every command without touching the target.
|
||||||
anything on the target.
|
**Preview the commands** shows the exact `docker` invocations that will run. Nothing is hidden.
|
||||||
|
|
||||||
---
|
### Sources
|
||||||
|
|
||||||
## How the data is actually moved
|
Three kinds, all interchangeable once selected — the container list, the preview, the migration and
|
||||||
|
the package build all read from whichever source is active:
|
||||||
|
|
||||||
The interesting part is that there is exactly **one** mechanism for every kind of data location:
|
| Kind | How it is reached | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **this host** | the socket in `DOCKER_HOST`, or `--docker-host` | always present; cannot be edited or removed |
|
||||||
|
| **docker address** | `tcp://host:2375`, or another `unix://` socket | TLS uses the certificates from `DOCKER_CERT_PATH` in DockMV's own environment. A plain `tcp://` daemon is unauthenticated — anyone who reaches that port is root on that host |
|
||||||
|
| **ssh** | the remote host's own docker CLI, through `docker system dial-stdio` | host keys are verified and credentials handled exactly like a target's |
|
||||||
|
|
||||||
|
The selected source is remembered in `<data-dir>/sources.json` and reselected on the next start; an
|
||||||
|
explicit `--docker-host` on the command line overrides it for that run. Saved sources whose
|
||||||
|
credentials you chose not to remember ask for them again after a restart.
|
||||||
|
|
||||||
|
With a remote source the data relays through DockMV — source → this host → target — so it crosses the
|
||||||
|
network twice. Running DockMV on the source host keeps it to one hop.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>How the data is actually moved</b></summary>
|
||||||
|
|
||||||
|
One mechanism for every kind of data location:
|
||||||
|
|
||||||
```
|
```
|
||||||
source daemon ──CopyFromContainer(/mount/path)──▶ tar stream ──gzip──▶ ssh ──▶ docker cp -a - ctr:/parent
|
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
|
The mount is read through the Docker archive API — the same thing `docker cp` uses. So:
|
||||||
`docker cp` uses. That means:
|
|
||||||
|
|
||||||
- named volumes, anonymous volumes and bind mounts are all handled identically;
|
- named volumes, anonymous volumes and bind mounts are handled identically;
|
||||||
- no helper image is pulled, and the container's image does not need `tar` inside it;
|
- no helper image is pulled, and the image does not need `tar` inside it;
|
||||||
- it works whether the container is running or stopped;
|
- it works whether the container is running or stopped;
|
||||||
- file ownership, permissions, symlinks and hardlinks are preserved (`docker cp -a`).
|
- 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
|
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
|
materialise the volumes and bind directories; data is copied into the stopped container, then it
|
||||||
container, and only then is it started.
|
starts. Mounts declared **read-only** get a throwaway container (never started) with the volume
|
||||||
|
attached writable, and it is removed straight after.
|
||||||
|
|
||||||
A mount the container declares **read-only** cannot be written through the container itself. For
|
**Faithfully reproduced:** image (pull or layer transfer), command, entrypoint, environment, labels,
|
||||||
those, a throwaway container is created (never started) with the same volume attached writable, the
|
working directory, user, hostname, published and exposed ports, all mount types, user-defined
|
||||||
data is copied into it, and it is removed straight after.
|
networks with subnets and aliases, DNS, extra hosts, capabilities, devices, sysctls, ulimits,
|
||||||
|
security options, restart policy, stop signal and timeout, healthcheck, log driver, memory/CPU/pids
|
||||||
|
limits, privileged, read-only rootfs, init, and the PID/IPC/UTS/userns modes.
|
||||||
|
|
||||||
### What is faithfully reproduced
|
Settings that come from the **image** are deliberately not re-emitted, so the recreated container
|
||||||
|
carries only genuine run-time overrides and keeps working when the image is updated.
|
||||||
|
|
||||||
Image (by pull or by layer transfer), command, entrypoint, environment, labels, working directory,
|
**What it will not do:**
|
||||||
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
|
- `--rm` is never reapplied — a container that deletes itself cannot be inspected.
|
||||||
carries only genuine run-time overrides, so it stays readable and keeps working when the image is
|
- `--volumes-from` and `--network container:other` are not reproduced; you are warned.
|
||||||
later updated.
|
- Swarm services are out of scope. Plain containers only.
|
||||||
|
- **Live databases**: copying a running database's files is crash-consistent at best. The default
|
||||||
|
stops the source container while copying — leave it on, or migrate a dump instead.
|
||||||
|
- **Cross-architecture**: an `amd64` image will not run on `arm64`. The preflight shows the target's arch.
|
||||||
|
|
||||||
### What it will not do for you
|
</details>
|
||||||
|
|
||||||
- **`--rm` is never reapplied.** A migrated container that deletes itself cannot be inspected.
|
<details>
|
||||||
- **`--volumes-from` is not reproduced.** You are warned; migrate the other container and mount
|
<summary><b>Safety</b></summary>
|
||||||
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
|
The tool can stop containers and read every volume on the host, so it is treated as a privileged
|
||||||
admin tool:
|
admin tool:
|
||||||
|
|
||||||
- It binds to **`127.0.0.1` by default**. Binding anywhere else automatically generates an access
|
- Binds to **`127.0.0.1` by default**. Binding elsewhere auto-generates an access token and prints it.
|
||||||
token and prints it.
|
- **SSH host keys are verified** like OpenSSH, for sources as well as targets. An unknown key is
|
||||||
- **SSH host keys are verified** exactly like OpenSSH. An unknown key is refused until you approve
|
refused until you approve the fingerprint in the UI; a *changed* key is refused outright. Trusted
|
||||||
the fingerprint in the UI; a *changed* key is refused outright until you explicitly replace it.
|
keys go to `<data-dir>/known_hosts`.
|
||||||
Trusted keys go to `<data-dir>/known_hosts`.
|
- **Credentials are not persisted unless you ask.** *Remember* writes them to
|
||||||
- **Credentials are not persisted unless you ask.** By default the password or key lives in memory
|
`<data-dir>/connections.json` for targets and `<data-dir>/sources.json` for sources, mode `0600`.
|
||||||
for the session. Ticking *remember* writes it to `<data-dir>/connections.json`, mode `0600`.
|
- **Nothing on the target is overwritten by default.** An existing container name fails the item;
|
||||||
- **Nothing on the target is overwritten by default.** If a container name already exists the item
|
you pick *skip*, *rename* or *replace*. An existing volume is reused and merged into, never
|
||||||
fails; you choose *skip*, *rename* or *replace* explicitly. An existing **volume** is reused and
|
silently deleted, unless you pick *replace*.
|
||||||
merged into, never silently deleted, unless you pick *replace*.
|
|
||||||
- Every command that runs on the target is echoed into the job log.
|
- 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
|
- Bind mounts of `/var/run/docker.sock`, `/proc`, `/sys`, `/dev` and `/` are flagged; a mount at `/` is refused.
|
||||||
`/` is refused outright.
|
|
||||||
|
|
||||||
---
|
</details>
|
||||||
|
|
||||||
## The migration package
|
<details>
|
||||||
|
<summary><b>The migration package</b></summary>
|
||||||
|
|
||||||
`build package` produces:
|
`build package` produces:
|
||||||
|
|
||||||
@@ -171,8 +225,8 @@ On the target:
|
|||||||
./install.sh # restore
|
./install.sh # restore
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer never parses the manifest — every command is written out literally, so it can be read
|
The installer never parses the manifest — every command is written out literally, so it can be
|
||||||
and audited before running. It checksums each payload before feeding it to Docker, and supports:
|
audited before running. It checksums each payload, and supports:
|
||||||
|
|
||||||
```
|
```
|
||||||
--dry-run print every command without changing anything
|
--dry-run print every command without changing anything
|
||||||
@@ -186,24 +240,10 @@ and audited before running. It checksums each payload before feeding it to Docke
|
|||||||
--sudo prefix docker with sudo -n
|
--sudo prefix docker with sudo -n
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
</details>
|
||||||
|
|
||||||
## Target host requirements
|
<details>
|
||||||
|
<summary><b>Command line and HTTP API</b></summary>
|
||||||
| 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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
dockmv [serve] [flags] start the web interface (default)
|
dockmv [serve] [flags] start the web interface (default)
|
||||||
@@ -216,9 +256,10 @@ dockmv version
|
|||||||
```
|
```
|
||||||
--addr string address to listen on (default "127.0.0.1:8080")
|
--addr string address to listen on (default "127.0.0.1:8080")
|
||||||
--token string require this token on every request; "auto" generates one
|
--token string require this token on every request; "auto" generates one
|
||||||
--data-dir string connections and trusted host keys (default: OS config dir)
|
--data-dir string sources, connections and trusted host keys (default: OS config dir)
|
||||||
--package-dir string where migration packages are written (default <data-dir>/packages)
|
--package-dir string where migration packages are written (default <data-dir>/packages)
|
||||||
--docker-host string source docker daemon (default: the DOCKER_HOST environment)
|
--docker-host string local source docker daemon (default: the DOCKER_HOST environment);
|
||||||
|
given explicitly, it overrides the remembered source
|
||||||
-v verbose logging
|
-v verbose logging
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -228,16 +269,18 @@ dockmv version
|
|||||||
dockmv inspect --sizes | jq '.containers[] | {name, image, mounts}'
|
dockmv 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.
|
Everything the UI does is available over HTTP. Pass the token as `X-Auth-Token` when one is set.
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/health
|
GET /api/health
|
||||||
GET /api/source inventory + default selections
|
GET /api/source inventory + default selections
|
||||||
GET /api/source/sizes volume sizes (slow)
|
GET /api/source/sizes volume sizes (slow)
|
||||||
|
GET /api/sources known sources + which one is selected
|
||||||
|
POST /api/sources
|
||||||
|
DELETE /api/sources/{id}
|
||||||
|
POST /api/sources/{id}/select switch the source everything reads from
|
||||||
|
POST /api/sources/{id}/probe read the SSH host key fingerprint
|
||||||
|
POST /api/sources/{id}/trust approve that fingerprint
|
||||||
GET /api/connections
|
GET /api/connections
|
||||||
POST /api/connections
|
POST /api/connections
|
||||||
DELETE /api/connections/{id}
|
DELETE /api/connections/{id}
|
||||||
@@ -254,15 +297,18 @@ POST /api/jobs/{id}/cancel
|
|||||||
GET /api/packages, /api/packages/{name}/download
|
GET /api/packages, /api/packages/{name}/download
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
</details>
|
||||||
|
|
||||||
## Development
|
<details>
|
||||||
|
<summary><b>Development</b></summary>
|
||||||
|
|
||||||
```
|
```
|
||||||
web/ React + TypeScript UI (vite)
|
web/ React + TypeScript UI (vite)
|
||||||
internal/spec/ the transport model: a container, and how to render it back into docker flags
|
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/dkr/ source Docker daemon: inventory, archive streams, image save
|
||||||
internal/sshx/ SSH transport, host key trust, driving the target's docker CLI
|
internal/sshx/ SSH transport, host key trust, driving the target's docker CLI, and
|
||||||
|
tunnelling a remote source's API through `docker system dial-stdio`
|
||||||
|
internal/store/ saved sources and target connections, and which source is selected
|
||||||
internal/migrate/ the two engines: SSH streaming, and package + installer generation
|
internal/migrate/ the two engines: SSH streaming, and package + installer generation
|
||||||
internal/job/ progress tracking for long-running work
|
internal/job/ progress tracking for long-running work
|
||||||
internal/api/ HTTP handlers and SSE
|
internal/api/ HTTP handlers and SSE
|
||||||
@@ -276,30 +322,20 @@ make ui # rebuild the embedded UI
|
|||||||
cd web && npm run dev # UI dev server on :5173, proxying /api to :8080
|
cd web && npm run dev # UI dev server on :5173, proxying /api to :8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### Verifying it works
|
`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/`.
|
||||||
|
|
||||||
On a Linux host with Docker (a VM is fine):
|
End-to-end tests, on a Linux host with Docker (a VM is fine):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./... # unit tests: command rendering, plan resolution,
|
go test ./... # unit tests: command rendering, plan resolution,
|
||||||
# and the generated installer, checked with bash
|
# and the generated installer, checked with bash
|
||||||
|
go test -tags e2e ./test/... -v # end to end, against the real daemon
|
||||||
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.
|
- `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
|
- `TestSSHMigration` — drives the host-to-host engine over a genuine SSH connection to `127.0.0.1`,
|
||||||
`127.0.0.1`, so the whole transport (ssh, gzip streaming, the target's docker CLI, the
|
exercising the whole transport. Needs `DM_SSH_HOST`, `DM_SSH_USER` and `DM_SSH_KEY`, skips without them:
|
||||||
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
|
```bash
|
||||||
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dm_loop
|
ssh-keygen -t ed25519 -N '' -f ~/.ssh/dm_loop
|
||||||
@@ -308,8 +344,7 @@ themselves, so a single machine is enough:
|
|||||||
go test -tags e2e ./test/... -run TestSSHMigration -v
|
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
|
They restore onto the same daemon under a suffixed name and clean up, so one machine is enough.
|
||||||
UI covering the trust prompt, a batch migration and a package build.
|
Both were run against Debian 13 with Docker 29.7.2, alongside a browser pass over the web UI.
|
||||||
|
|
||||||
`internal/webui/dist` is committed so that a plain `go build .` produces a working binary without a
|
</details>
|
||||||
Node toolchain. Rerun `make ui` after changing anything under `web/`.
|
|
||||||
+1
-6
@@ -9,11 +9,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
dockmv:
|
dockmv:
|
||||||
build:
|
image: git.azuze.fr/kawa/dockmv:${VERSION:-latest}
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
VERSION: ${VERSION:-dev}
|
|
||||||
image: dockmv:latest
|
|
||||||
container_name: dockmv
|
container_name: dockmv
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -31,7 +27,6 @@ services:
|
|||||||
command:
|
command:
|
||||||
- serve
|
- serve
|
||||||
- --addr=0.0.0.0:8080
|
- --addr=0.0.0.0:8080
|
||||||
- --token=auto
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
migrate-data:
|
migrate-data:
|
||||||
+76
-14
@@ -21,23 +21,39 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"dockerHost": s.docker.Endpoint,
|
|
||||||
"packageDir": s.cfg.PackageDir,
|
"packageDir": s.cfg.PackageDir,
|
||||||
"dataDir": s.cfg.DataDir,
|
"dataDir": s.cfg.DataDir,
|
||||||
"knownHosts": s.hosts.Path(),
|
"knownHosts": s.hosts.Path(),
|
||||||
"authRequired": s.cfg.Token != "",
|
"authRequired": s.cfg.Token != "",
|
||||||
}
|
}
|
||||||
if v, err := s.docker.Ping(ctx); err != nil {
|
|
||||||
|
// Health is also what connects to the selected source on a fresh start, so
|
||||||
|
// the UI learns straight away when the remembered source is unreachable.
|
||||||
|
conn, release, err := s.source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
selected, _ := s.sources.Get(s.sources.Selected())
|
||||||
body["ok"] = false
|
body["ok"] = false
|
||||||
body["dockerError"] = err.Error()
|
body["dockerError"] = err.Error()
|
||||||
} else {
|
endpoint := sourceEndpoint(selected)
|
||||||
body["dockerVersion"] = v
|
body["dockerHost"] = endpoint
|
||||||
|
body["source"] = sourceStatus{
|
||||||
|
ID: selected.ID, Name: selected.Name, Kind: selected.Kind,
|
||||||
|
Endpoint: endpoint, Error: err.Error(),
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, body)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
st := conn.status()
|
||||||
|
body["dockerHost"] = st.Endpoint
|
||||||
|
body["dockerVersion"] = st.DockerVersion
|
||||||
|
body["source"] = st
|
||||||
writeJSON(w, http.StatusOK, body)
|
writeJSON(w, http.StatusOK, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +63,14 @@ func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
inv, err := s.docker.Inventory(ctx)
|
conn, release, err := s.source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
inv, err := conn.docker.Inventory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "%v", err)
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
return
|
return
|
||||||
@@ -69,7 +92,14 @@ func (s *Server) handleSourceSizes(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
sizes, err := s.docker.VolumeSizes(ctx)
|
conn, release, err := s.source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
sizes, err := conn.docker.VolumeSizes(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "%v", err)
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
return
|
return
|
||||||
@@ -209,7 +239,14 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
inv, err := s.docker.Inventory(ctx)
|
conn, release, err := s.source(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
inv, err := conn.docker.Inventory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadGateway, "%v", err)
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
return
|
return
|
||||||
@@ -299,12 +336,23 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The source is held for the whole job: picking another source in the UI
|
||||||
|
// while this runs must not close the socket it is reading from.
|
||||||
|
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
src, release, err := s.source(srcCtx)
|
||||||
|
srcCancel()
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// The inventory is re-read now so the plan is applied to current state
|
// The inventory is re-read now so the plan is applied to current state
|
||||||
// rather than to whatever the browser last loaded.
|
// rather than to whatever the browser last loaded.
|
||||||
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
inv, err := s.docker.Inventory(invCtx)
|
inv, err := src.docker.Inventory(invCtx)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
release()
|
||||||
writeError(w, http.StatusBadGateway, "%v", err)
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -315,13 +363,14 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
|
client, err := sshx.Dial(dialCtx, cfg, s.hosts)
|
||||||
dialCancel()
|
dialCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
release()
|
||||||
s.writeDialError(w, err)
|
s.writeDialError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
title := fmt.Sprintf("%d container(s) to %s", countIncluded(req.Plan), cfg.Name)
|
title := fmt.Sprintf("%d container(s) from %s to %s", countIncluded(req.Plan), src.src.Name, cfg.Name)
|
||||||
runner := &migrate.SSHRunner{
|
runner := &migrate.SSHRunner{
|
||||||
Src: s.docker,
|
Src: src.docker,
|
||||||
Dst: sshx.NewRemoteDocker(client),
|
Dst: sshx.NewRemoteDocker(client),
|
||||||
Containers: inv.Containers,
|
Containers: inv.Containers,
|
||||||
Volumes: inv.Volumes,
|
Volumes: inv.Volumes,
|
||||||
@@ -331,8 +380,10 @@ func (s *Server) handleMigrateSSH(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
|
j := s.jobs.Run(context.Background(), job.KindSSH, title, req.Plan.Options.DryRun,
|
||||||
func(ctx context.Context, j *job.Job) error {
|
func(ctx context.Context, j *job.Job) error {
|
||||||
|
defer release()
|
||||||
defer client.Close()
|
defer client.Close()
|
||||||
j.Logf(job.LevelInfo, "", "migrating to %s@%s over ssh", cfg.User, cfg.Host)
|
j.Logf(job.LevelInfo, "", "migrating from %s to %s@%s over ssh",
|
||||||
|
src.docker.Endpoint, cfg.User, cfg.Host)
|
||||||
return runner.Run(ctx, j)
|
return runner.Run(ctx, j)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -362,16 +413,25 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
srcCtx, srcCancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
src, release, err := s.source(srcCtx)
|
||||||
|
srcCancel()
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
invCtx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
|
||||||
inv, err := s.docker.Inventory(invCtx)
|
inv, err := src.docker.Inventory(invCtx)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
release()
|
||||||
writeError(w, http.StatusBadGateway, "%v", err)
|
writeError(w, http.StatusBadGateway, "%v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
packager := &migrate.Packager{
|
packager := &migrate.Packager{
|
||||||
Src: s.docker,
|
Src: src.docker,
|
||||||
Containers: inv.Containers,
|
Containers: inv.Containers,
|
||||||
Volumes: inv.Volumes,
|
Volumes: inv.Volumes,
|
||||||
Networks: inv.Networks,
|
Networks: inv.Networks,
|
||||||
@@ -384,6 +444,8 @@ func (s *Server) handleBuildPackage(w http.ResponseWriter, r *http.Request) {
|
|||||||
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
|
title := fmt.Sprintf("package of %d container(s)", countIncluded(req.Plan))
|
||||||
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
|
j := s.jobs.Run(context.Background(), job.KindPackage, title, req.Plan.Options.DryRun,
|
||||||
func(ctx context.Context, j *job.Job) error {
|
func(ctx context.Context, j *job.Job) error {
|
||||||
|
defer release()
|
||||||
|
j.Logf(job.LevelInfo, "", "reading from %s", src.docker.Endpoint)
|
||||||
res, err := packager.Run(ctx, j)
|
res, err := packager.Run(ctx, j)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+46
-17
@@ -11,9 +11,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/arescom/dockmv/internal/dkr"
|
|
||||||
"github.com/arescom/dockmv/internal/job"
|
"github.com/arescom/dockmv/internal/job"
|
||||||
"github.com/arescom/dockmv/internal/sshx"
|
"github.com/arescom/dockmv/internal/sshx"
|
||||||
"github.com/arescom/dockmv/internal/store"
|
"github.com/arescom/dockmv/internal/store"
|
||||||
@@ -29,23 +29,31 @@ type Config struct {
|
|||||||
DataDir string
|
DataDir string
|
||||||
// PackageDir is where migration packages are written.
|
// PackageDir is where migration packages are written.
|
||||||
PackageDir string
|
PackageDir string
|
||||||
// DockerHost overrides the source daemon address.
|
// DockerHost overrides the local source daemon address.
|
||||||
DockerHost string
|
DockerHost string
|
||||||
|
// DockerHostSet reports that DockerHost was given explicitly on the command
|
||||||
|
// line. It then wins over the source remembered from the last run.
|
||||||
|
DockerHostSet bool
|
||||||
// UI is the embedded web app; nil disables the UI.
|
// UI is the embedded web app; nil disables the UI.
|
||||||
UI fs.FS
|
UI fs.FS
|
||||||
// Logger receives request and error logs.
|
// Logger receives request and error logs.
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server ties the Docker client, connection store and job manager to HTTP.
|
// Server ties the source daemon, connection store and job manager to HTTP.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
docker *dkr.Client
|
sources *store.Sources
|
||||||
conns *store.Connections
|
conns *store.Connections
|
||||||
hosts *sshx.KnownHosts
|
hosts *sshx.KnownHosts
|
||||||
jobs *job.Manager
|
jobs *job.Manager
|
||||||
mux *http.ServeMux
|
mux *http.ServeMux
|
||||||
|
|
||||||
|
// srcMu guards cur, the connection to the selected source. It is opened on
|
||||||
|
// first use and replaced when the operator picks another source.
|
||||||
|
srcMu sync.Mutex
|
||||||
|
cur *sourceConn
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server and everything it owns.
|
// New builds the server and everything it owns.
|
||||||
@@ -60,10 +68,6 @@ func New(cfg Config) (*Server, error) {
|
|||||||
return nil, fmt.Errorf("create package directory: %w", err)
|
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"))
|
conns, err := store.NewConnections(filepath.Join(cfg.DataDir, "connections.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -72,17 +76,35 @@ func New(cfg Config) (*Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
local := store.Source{Name: "this host", DockerHost: cfg.DockerHost}
|
||||||
|
if local.DockerHost == "" {
|
||||||
|
local.DockerHost = os.Getenv("DOCKER_HOST")
|
||||||
|
}
|
||||||
|
sources, err := store.NewSources(filepath.Join(cfg.DataDir, "sources.json"), local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// An explicit --docker-host is an instruction for this run, so it overrides
|
||||||
|
// the source remembered from the last one.
|
||||||
|
if cfg.DockerHostSet {
|
||||||
|
if err := sources.Select(store.LocalSourceID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
cfg: cfg, log: cfg.Logger, docker: docker,
|
cfg: cfg, log: cfg.Logger, sources: sources,
|
||||||
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
|
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
|
||||||
}
|
}
|
||||||
s.routes()
|
s.routes()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases the Docker connection.
|
// Close releases the connection to the current source.
|
||||||
func (s *Server) Close() error { return s.docker.Close() }
|
func (s *Server) Close() error {
|
||||||
|
s.invalidateSource("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Handler returns the root HTTP handler.
|
// Handler returns the root HTTP handler.
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
@@ -96,6 +118,13 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET /api/source", s.handleSource)
|
m.HandleFunc("GET /api/source", s.handleSource)
|
||||||
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
|
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
|
||||||
|
|
||||||
|
m.HandleFunc("GET /api/sources", s.handleListSources)
|
||||||
|
m.HandleFunc("POST /api/sources", s.handleSaveSource)
|
||||||
|
m.HandleFunc("DELETE /api/sources/{id}", s.handleDeleteSource)
|
||||||
|
m.HandleFunc("POST /api/sources/{id}/select", s.handleSelectSource)
|
||||||
|
m.HandleFunc("POST /api/sources/{id}/probe", s.handleSourceProbe)
|
||||||
|
m.HandleFunc("POST /api/sources/{id}/trust", s.handleSourceTrust)
|
||||||
|
|
||||||
m.HandleFunc("GET /api/connections", s.handleListConnections)
|
m.HandleFunc("GET /api/connections", s.handleListConnections)
|
||||||
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
|
m.HandleFunc("POST /api/connections", s.handleSaveConnection)
|
||||||
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
|
m.HandleFunc("DELETE /api/connections/{id}", s.handleDeleteConnection)
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/arescom/dockmv/internal/dkr"
|
||||||
|
"github.com/arescom/dockmv/internal/sshx"
|
||||||
|
"github.com/arescom/dockmv/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sourceConn is a live connection to one source daemon.
|
||||||
|
//
|
||||||
|
// It is reference counted rather than closed eagerly: a migration holds its
|
||||||
|
// source for as long as it runs, so switching source in the UI half way through
|
||||||
|
// a transfer must not pull the socket out from under it. The connection is
|
||||||
|
// closed once it is both replaced and unused.
|
||||||
|
type sourceConn struct {
|
||||||
|
src store.Source
|
||||||
|
docker *dkr.Client
|
||||||
|
ssh *sshx.Client
|
||||||
|
version string
|
||||||
|
|
||||||
|
refs int
|
||||||
|
stale bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sourceConn) close() {
|
||||||
|
if c.docker != nil {
|
||||||
|
_ = c.docker.Close()
|
||||||
|
}
|
||||||
|
if c.ssh != nil {
|
||||||
|
_ = c.ssh.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceStatus is what the UI shows about the source it is looking at.
|
||||||
|
type sourceStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind store.SourceKind `json:"kind"`
|
||||||
|
Endpoint string `json:"endpoint"`
|
||||||
|
DockerVersion string `json:"dockerVersion,omitempty"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sourceConn) status() sourceStatus {
|
||||||
|
return sourceStatus{
|
||||||
|
ID: c.src.ID, Name: c.src.Name, Kind: c.src.Kind,
|
||||||
|
Endpoint: c.docker.Endpoint, DockerVersion: c.version, Connected: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// source returns the current source, connecting to it on first use. The
|
||||||
|
// returned release function must be called when the caller is done with it —
|
||||||
|
// for a job, when the job finishes.
|
||||||
|
func (s *Server) source(ctx context.Context) (*sourceConn, func(), error) {
|
||||||
|
s.srcMu.Lock()
|
||||||
|
if cur := s.cur; cur != nil {
|
||||||
|
cur.refs++
|
||||||
|
s.srcMu.Unlock()
|
||||||
|
return cur, func() { s.releaseSource(cur) }, nil
|
||||||
|
}
|
||||||
|
id := s.sources.Selected()
|
||||||
|
s.srcMu.Unlock()
|
||||||
|
|
||||||
|
conn, err := s.dialSource(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.srcMu.Lock()
|
||||||
|
defer s.srcMu.Unlock()
|
||||||
|
// Another request may have connected while this one was dialling; one
|
||||||
|
// connection is enough, so the loser is dropped.
|
||||||
|
if s.cur != nil {
|
||||||
|
conn.close()
|
||||||
|
conn = s.cur
|
||||||
|
} else {
|
||||||
|
s.cur = conn
|
||||||
|
}
|
||||||
|
conn.refs++
|
||||||
|
return conn, func() { s.releaseSource(conn) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) releaseSource(c *sourceConn) {
|
||||||
|
s.srcMu.Lock()
|
||||||
|
defer s.srcMu.Unlock()
|
||||||
|
c.refs--
|
||||||
|
if c.refs <= 0 && c.stale {
|
||||||
|
c.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectSource connects to a source and, once that worked, makes it the current
|
||||||
|
// one and records the choice for the next run.
|
||||||
|
func (s *Server) selectSource(ctx context.Context, id string) (sourceStatus, error) {
|
||||||
|
conn, err := s.dialSource(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return sourceStatus{}, err
|
||||||
|
}
|
||||||
|
st := conn.status()
|
||||||
|
|
||||||
|
s.srcMu.Lock()
|
||||||
|
old := s.cur
|
||||||
|
s.cur = conn
|
||||||
|
if old != nil {
|
||||||
|
old.stale = true
|
||||||
|
if old.refs <= 0 {
|
||||||
|
old.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.srcMu.Unlock()
|
||||||
|
|
||||||
|
if err := s.sources.Select(conn.src.ID); err != nil {
|
||||||
|
return st, fmt.Errorf("remember the selected source: %w", err)
|
||||||
|
}
|
||||||
|
s.log.Info("source selected", "id", conn.src.ID, "endpoint", conn.docker.Endpoint)
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidateSource drops the cached connection when the source behind it has
|
||||||
|
// been edited or removed. An empty id invalidates whatever is current.
|
||||||
|
func (s *Server) invalidateSource(id string) {
|
||||||
|
s.srcMu.Lock()
|
||||||
|
defer s.srcMu.Unlock()
|
||||||
|
if s.cur == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id != "" && s.cur.src.ID != id {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cur.stale = true
|
||||||
|
if s.cur.refs <= 0 {
|
||||||
|
s.cur.close()
|
||||||
|
}
|
||||||
|
s.cur = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialSource opens a connection to one source and verifies the daemon answers.
|
||||||
|
func (s *Server) dialSource(ctx context.Context, id string) (*sourceConn, error) {
|
||||||
|
src, err := s.sources.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := &sourceConn{src: src}
|
||||||
|
switch src.Kind {
|
||||||
|
case store.SourceLocal, store.SourceDocker:
|
||||||
|
c, err := dkr.New(src.DockerHost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn.docker = c
|
||||||
|
case store.SourceSSH:
|
||||||
|
if src.SSH == nil {
|
||||||
|
return nil, errors.New("source has no ssh configuration")
|
||||||
|
}
|
||||||
|
client, err := sshx.Dial(ctx, *src.SSH, s.hosts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rd := sshx.NewRemoteDocker(client)
|
||||||
|
// The CLI is checked first: a missing binary or a user outside the
|
||||||
|
// docker group is a readable error here, and an unexplained broken
|
||||||
|
// socket if it is left to the tunnel.
|
||||||
|
if _, err := rd.ProbeCLI(ctx); err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, err := dkr.NewTunnel(sourceEndpoint(src), func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return rd.DialAPI(ctx)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn.ssh, conn.docker = client, c
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown source kind %q", src.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
pingCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
version, err := conn.docker.Ping(pingCtx)
|
||||||
|
if err != nil {
|
||||||
|
conn.close()
|
||||||
|
return nil, fmt.Errorf("connect to docker at %s: %w", conn.docker.Endpoint, err)
|
||||||
|
}
|
||||||
|
conn.version = version
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceEndpoint describes where a source lives, before and after it is
|
||||||
|
// connected to.
|
||||||
|
func sourceEndpoint(src store.Source) string {
|
||||||
|
if src.Kind == store.SourceSSH && src.SSH != nil {
|
||||||
|
port := src.SSH.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("ssh://%s@%s", src.SSH.User, net.JoinHostPort(src.SSH.Host, strconv.Itoa(port)))
|
||||||
|
}
|
||||||
|
return src.DockerHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSources lists the sources without connecting to any of them; the
|
||||||
|
// state of the current one comes from /api/health.
|
||||||
|
func (s *Server) handleListSources(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.srcMu.Lock()
|
||||||
|
var current *sourceStatus
|
||||||
|
if s.cur != nil {
|
||||||
|
st := s.cur.status()
|
||||||
|
current = &st
|
||||||
|
}
|
||||||
|
s.srcMu.Unlock()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"sources": s.sources.List(),
|
||||||
|
"selected": s.sources.Selected(),
|
||||||
|
"current": current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSaveSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var src store.Source
|
||||||
|
if err := decode(r, &src); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saved, err := s.sources.Save(src)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Editing the source in use means the live connection describes the old
|
||||||
|
// settings; drop it so the next call reconnects.
|
||||||
|
s.invalidateSource(saved.ID)
|
||||||
|
writeJSON(w, http.StatusOK, saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if err := s.sources.Delete(id); err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
writeError(w, http.StatusNotFound, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusBadRequest, "%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.invalidateSource(id)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSelectSource switches the source the whole UI works against.
|
||||||
|
func (s *Server) handleSelectSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
st, err := s.selectSource(ctx, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
s.writeDialError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSourceProbe reads the SSH host key of a source host, so its fingerprint
|
||||||
|
// can be approved the same way a target's is.
|
||||||
|
func (s *Server) handleSourceProbe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := s.sourceSSH(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSourceTrust(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cfg, err := s.sourceSSH(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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceSSH returns the SSH configuration of a source, refusing the ones that
|
||||||
|
// are not reached over SSH.
|
||||||
|
func (s *Server) sourceSSH(id string) (sshx.Config, error) {
|
||||||
|
src, err := s.sources.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return sshx.Config{}, err
|
||||||
|
}
|
||||||
|
if src.Kind != store.SourceSSH || src.SSH == nil {
|
||||||
|
return sshx.Config{}, fmt.Errorf("source %s is not reached over ssh", src.Name)
|
||||||
|
}
|
||||||
|
return *src.SSH, nil
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ package dkr
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/system"
|
"github.com/docker/docker/api/types/system"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
@@ -33,6 +36,43 @@ func New(host string) (*Client, error) {
|
|||||||
return &Client{api: api, Endpoint: api.DaemonHost()}, nil
|
return &Client{api: api, Endpoint: api.DaemonHost()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dialer opens one connection to a daemon's API socket.
|
||||||
|
type Dialer func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
|
||||||
|
// NewTunnel connects to a daemon that is only reachable through dial, such as a
|
||||||
|
// remote daemon behind an SSH connection. The HTTP host is a placeholder: every
|
||||||
|
// connection comes from dial, so the address is never resolved.
|
||||||
|
//
|
||||||
|
// endpoint is what the UI displays, e.g. ssh://root@10.0.0.5.
|
||||||
|
func NewTunnel(endpoint string, dial Dialer) (*Client, error) {
|
||||||
|
// The transport is ours so that WithHost cannot leave a TCP dialer or the
|
||||||
|
// environment's HTTP proxy in place; either would send API calls somewhere
|
||||||
|
// other than through the tunnel.
|
||||||
|
tr := &http.Transport{
|
||||||
|
DisableCompression: true,
|
||||||
|
// Every connection through the tunnel costs one SSH channel, and sshd
|
||||||
|
// allows ten per connection by default (MaxSessions). Capping the pool
|
||||||
|
// keeps a parallel migration from exhausting them; extra calls wait.
|
||||||
|
MaxConnsPerHost: 8,
|
||||||
|
MaxIdleConnsPerHost: 4,
|
||||||
|
IdleConnTimeout: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
api, err := client.NewClientWithOpts(
|
||||||
|
client.WithHTTPClient(&http.Client{Transport: tr}),
|
||||||
|
client.WithHost("http://docker.tunnel.invalid"),
|
||||||
|
client.WithAPIVersionNegotiation(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create docker client: %w", err)
|
||||||
|
}
|
||||||
|
tr.Proxy = nil
|
||||||
|
tr.DialContext = dial
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = "tunnel"
|
||||||
|
}
|
||||||
|
return &Client{api: api, Endpoint: endpoint}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// API exposes the underlying SDK client for callers that need an operation
|
// API exposes the underlying SDK client for callers that need an operation
|
||||||
// this package does not wrap.
|
// this package does not wrap.
|
||||||
func (c *Client) API() *client.Client { return c.api }
|
func (c *Client) API() *client.Client { return c.api }
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package sshx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DialAPI opens one connection to the remote daemon's API by running
|
||||||
|
// `docker system dial-stdio` over SSH and treating that session's stdin and
|
||||||
|
// stdout as a socket. It is the same mechanism `docker -H ssh://…` uses, so the
|
||||||
|
// remote host still needs nothing but sshd and the docker CLI.
|
||||||
|
//
|
||||||
|
// The returned connection is what dkr.NewTunnel dials through: from there on the
|
||||||
|
// whole Docker Engine API — inventory, archive streams, image save — is
|
||||||
|
// available on a remote source host exactly as it is on a local one.
|
||||||
|
func (r *RemoteDocker) DialAPI(ctx context.Context) (net.Conn, error) {
|
||||||
|
sess, err := r.c.conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open ssh session: %w", err)
|
||||||
|
}
|
||||||
|
stdin, err := sess.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
sess.Close()
|
||||||
|
return nil, fmt.Errorf("attach to remote stdin: %w", err)
|
||||||
|
}
|
||||||
|
stdout, err := sess.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
sess.Close()
|
||||||
|
return nil, fmt.Errorf("attach to remote stdout: %w", err)
|
||||||
|
}
|
||||||
|
errBuf := &syncBuffer{}
|
||||||
|
sess.Stderr = errBuf
|
||||||
|
|
||||||
|
cmd := r.Cmd("system", "dial-stdio")
|
||||||
|
if err := sess.Start(cmd); err != nil {
|
||||||
|
sess.Close()
|
||||||
|
return nil, fmt.Errorf("start %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := r.c.Config()
|
||||||
|
// The connection deliberately outlives ctx: the HTTP transport keeps it in
|
||||||
|
// its idle pool between API calls, and closes it itself when a request is
|
||||||
|
// cancelled or the client is closed.
|
||||||
|
return &apiConn{
|
||||||
|
sess: sess, stdin: stdin, stdout: stdout, stderr: errBuf,
|
||||||
|
remote: apiAddr(fmt.Sprintf("%s@%s", cfg.User, cfg.addr())),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeCLI checks that the remote docker CLI is usable before the API is
|
||||||
|
// tunnelled through it, so a missing binary or a permission problem is reported
|
||||||
|
// as itself rather than as a broken socket. It returns the daemon version.
|
||||||
|
func (r *RemoteDocker) ProbeCLI(ctx context.Context) (string, error) {
|
||||||
|
out, ok, err := r.Try(ctx, "version", "--format", "{{.Server.Version}}")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if v := strings.TrimSpace(out); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := r.c.Run(ctx, r.Cmd("version"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
msg := strings.TrimSpace(res.Stderr)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(msg, "permission denied"):
|
||||||
|
return "", errors.New("the login user cannot talk to the docker daemon; " +
|
||||||
|
"add it to the docker group, or enable sudo -n for this source")
|
||||||
|
case msg != "":
|
||||||
|
return "", errors.New("docker is not usable on that host: " + firstLine(msg))
|
||||||
|
default:
|
||||||
|
return "", errors.New("docker is not installed or not on PATH on that host")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiConn adapts an SSH session to net.Conn.
|
||||||
|
type apiConn struct {
|
||||||
|
sess *ssh.Session
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.Reader
|
||||||
|
stderr *syncBuffer
|
||||||
|
remote apiAddr
|
||||||
|
|
||||||
|
once sync.Once
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiConn) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.stdout.Read(p)
|
||||||
|
if err != nil {
|
||||||
|
return n, c.wrap(err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiConn) Write(p []byte) (int, error) {
|
||||||
|
n, err := c.stdin.Write(p)
|
||||||
|
if err != nil {
|
||||||
|
return n, c.wrap(err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrap replaces the bare EOF a failed remote command produces with whatever it
|
||||||
|
// printed on stderr, which is the only place the reason appears.
|
||||||
|
func (c *apiConn) wrap(err error) error {
|
||||||
|
if msg := strings.TrimSpace(c.stderr.String()); msg != "" {
|
||||||
|
return fmt.Errorf("docker system dial-stdio on the remote host failed: %s", firstLine(msg))
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiConn) Close() error {
|
||||||
|
c.once.Do(func() {
|
||||||
|
// Closing stdin lets the remote docker exit cleanly; the session is torn
|
||||||
|
// down straight after either way.
|
||||||
|
_ = c.stdin.Close()
|
||||||
|
c.err = c.sess.Close()
|
||||||
|
if errors.Is(c.err, io.EOF) {
|
||||||
|
c.err = nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *apiConn) LocalAddr() net.Addr { return apiAddr("dockmv") }
|
||||||
|
func (c *apiConn) RemoteAddr() net.Addr { return c.remote }
|
||||||
|
|
||||||
|
// The deadline calls are no-ops: an SSH channel has no deadline of its own, and
|
||||||
|
// the Docker client relies on context cancellation rather than on these. This
|
||||||
|
// mirrors what the docker CLI's own ssh:// transport does.
|
||||||
|
func (c *apiConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (c *apiConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (c *apiConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
|
type apiAddr string
|
||||||
|
|
||||||
|
func (a apiAddr) Network() string { return "ssh" }
|
||||||
|
func (a apiAddr) String() string { return string(a) }
|
||||||
|
|
||||||
|
// syncBuffer collects remote stderr, which the ssh session writes from its own
|
||||||
|
// goroutine while the connection is being read.
|
||||||
|
type syncBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *syncBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sshx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDialAPICommand pins the command the tunnel runs on the source host: it is
|
||||||
|
// the same one `docker -H ssh://…` uses, and the sudo / custom binary settings
|
||||||
|
// have to reach it.
|
||||||
|
func TestDialAPICommand(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
rd *RemoteDocker
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"plain", &RemoteDocker{binary: "docker"}, "docker system dial-stdio"},
|
||||||
|
{"sudo", &RemoteDocker{binary: "docker", sudo: true}, "sudo -n docker system dial-stdio"},
|
||||||
|
{"podman", &RemoteDocker{binary: "podman"}, "podman system dial-stdio"},
|
||||||
|
{"path with a space", &RemoteDocker{binary: "/opt/my docker/bin/docker"}, "'/opt/my docker/bin/docker' system dial-stdio"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := tc.rd.Cmd("system", "dial-stdio"); got != tc.want {
|
||||||
|
t.Fatalf("command = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPIConnSurfacesRemoteStderr covers the failure that would otherwise reach
|
||||||
|
// the Docker client as a bare EOF: the remote docker printing a reason and
|
||||||
|
// exiting.
|
||||||
|
func TestAPIConnSurfacesRemoteStderr(t *testing.T) {
|
||||||
|
errBuf := &syncBuffer{}
|
||||||
|
errBuf.Write([]byte("docker: 'system dial-stdio' is not a docker command\n"))
|
||||||
|
c := &apiConn{
|
||||||
|
stdout: strings.NewReader(""),
|
||||||
|
stdin: nopWriteCloser{io.Discard},
|
||||||
|
stderr: errBuf,
|
||||||
|
}
|
||||||
|
_, err := c.Read(make([]byte, 8))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a closed stream with remote stderr should be an error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "is not a docker command") {
|
||||||
|
t.Fatalf("error = %v, want the remote stderr in it", err)
|
||||||
|
}
|
||||||
|
// Without stderr the plain EOF must survive, or the HTTP transport cannot
|
||||||
|
// tell a finished response from a broken one.
|
||||||
|
quiet := &apiConn{stdout: strings.NewReader(""), stdin: nopWriteCloser{io.Discard}, stderr: &syncBuffer{}}
|
||||||
|
if _, err := quiet.Read(make([]byte, 8)); !errors.Is(err, io.EOF) {
|
||||||
|
t.Fatalf("error = %v, want io.EOF", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIConnDeadlinesAreNoops(t *testing.T) {
|
||||||
|
var c net.Conn = &apiConn{stdout: strings.NewReader(""), stdin: nopWriteCloser{io.Discard}, stderr: &syncBuffer{}}
|
||||||
|
now := time.Now()
|
||||||
|
if err := c.SetDeadline(now); err != nil {
|
||||||
|
t.Fatalf("SetDeadline: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.SetReadDeadline(now); err != nil {
|
||||||
|
t.Fatalf("SetReadDeadline: %v", err)
|
||||||
|
}
|
||||||
|
if err := c.SetWriteDeadline(now); err != nil {
|
||||||
|
t.Fatalf("SetWriteDeadline: %v", err)
|
||||||
|
}
|
||||||
|
if c.RemoteAddr().Network() != "ssh" {
|
||||||
|
t.Fatalf("network = %q, want ssh", c.RemoteAddr().Network())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type nopWriteCloser struct{ io.Writer }
|
||||||
|
|
||||||
|
func (nopWriteCloser) Close() error { return nil }
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/arescom/dockmv/internal/sshx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceKind says how a source daemon is reached.
|
||||||
|
type SourceKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SourceLocal is the daemon this process talks to by default: the socket in
|
||||||
|
// DOCKER_HOST, or whatever --docker-host was given.
|
||||||
|
SourceLocal SourceKind = "local"
|
||||||
|
// SourceDocker is an explicit daemon address, e.g. tcp://10.0.0.5:2375.
|
||||||
|
SourceDocker SourceKind = "docker"
|
||||||
|
// SourceSSH is a remote daemon reached over SSH, driven through the remote
|
||||||
|
// host's own docker CLI.
|
||||||
|
SourceSSH SourceKind = "ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalSourceID identifies the built-in local source. It is always listed, and
|
||||||
|
// cannot be edited or deleted.
|
||||||
|
const LocalSourceID = "local"
|
||||||
|
|
||||||
|
// Source is one place containers can be read from.
|
||||||
|
type Source struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind SourceKind `json:"kind"`
|
||||||
|
// DockerHost is the daemon address for SourceDocker, and the address the
|
||||||
|
// local source resolved to for SourceLocal (read-only in that case).
|
||||||
|
DockerHost string `json:"dockerHost,omitempty"`
|
||||||
|
// SSH describes the remote host for SourceSSH.
|
||||||
|
SSH *sshx.Config `json:"ssh,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources is a JSON-backed list of source daemons plus the one currently
|
||||||
|
// selected, so a restart comes back to the host the operator was working on.
|
||||||
|
//
|
||||||
|
// Like Connections, SSH credentials only reach the file when the operator ticks
|
||||||
|
// "remember"; otherwise they live in memory for this process only.
|
||||||
|
type Sources struct {
|
||||||
|
path string
|
||||||
|
local Source
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]Source
|
||||||
|
secrets map[string]secret
|
||||||
|
selected string
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourcesFile is the on-disk shape.
|
||||||
|
type sourcesFile struct {
|
||||||
|
Selected string `json:"selected,omitempty"`
|
||||||
|
Sources []Source `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSources loads (or creates) the source file at path. local describes the
|
||||||
|
// built-in local source, which is not persisted.
|
||||||
|
func NewSources(path string, local Source) (*Sources, error) {
|
||||||
|
local.ID = LocalSourceID
|
||||||
|
local.Kind = SourceLocal
|
||||||
|
if local.Name == "" {
|
||||||
|
local.Name = "this host"
|
||||||
|
}
|
||||||
|
s := &Sources{
|
||||||
|
path: path, local: local,
|
||||||
|
items: map[string]Source{}, secrets: map[string]secret{},
|
||||||
|
selected: LocalSourceID,
|
||||||
|
}
|
||||||
|
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 s, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read sources: %w", err)
|
||||||
|
}
|
||||||
|
var f sourcesFile
|
||||||
|
if err := json.Unmarshal(b, &f); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse sources file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
for _, src := range f.Sources {
|
||||||
|
if src.ID == "" || src.ID == LocalSourceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.items[src.ID] = src
|
||||||
|
}
|
||||||
|
if f.Selected != "" {
|
||||||
|
if _, ok := s.items[f.Selected]; ok || f.Selected == LocalSourceID {
|
||||||
|
s.selected = f.Selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local returns the built-in local source.
|
||||||
|
func (s *Sources) Local() Source { return s.local }
|
||||||
|
|
||||||
|
// List returns the local source followed by the saved ones, credentials
|
||||||
|
// stripped, in name order.
|
||||||
|
func (s *Sources) List() []Source {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]Source, 0, len(s.items)+1)
|
||||||
|
for _, src := range s.items {
|
||||||
|
out = append(out, redactSource(src))
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||||
|
return append([]Source{s.local}, out...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a source ready to connect to, with credentials filled back in.
|
||||||
|
func (s *Sources) Get(id string) (Source, error) {
|
||||||
|
if id == "" || id == LocalSourceID {
|
||||||
|
return s.local, nil
|
||||||
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
src, ok := s.items[id]
|
||||||
|
if !ok {
|
||||||
|
return Source{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if src.SSH != nil {
|
||||||
|
cfg := *src.SSH
|
||||||
|
if sec, ok := s.secrets[id]; ok {
|
||||||
|
if cfg.Password == "" {
|
||||||
|
cfg.Password = sec.Password
|
||||||
|
}
|
||||||
|
if cfg.PrivateKey == "" {
|
||||||
|
cfg.PrivateKey = sec.PrivateKey
|
||||||
|
}
|
||||||
|
if cfg.Passphrase == "" {
|
||||||
|
cfg.Passphrase = sec.Passphrase
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src.SSH = &cfg
|
||||||
|
}
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected returns the id of the current source, falling back to the local one.
|
||||||
|
func (s *Sources) Selected() string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.selected
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select records which source is in use. It does not connect: that is the
|
||||||
|
// caller's job, so a selection is only stored once it has been shown to work.
|
||||||
|
func (s *Sources) Select(id string) error {
|
||||||
|
if id == "" {
|
||||||
|
id = LocalSourceID
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if id != LocalSourceID {
|
||||||
|
if _, ok := s.items[id]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.selected == id {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.selected = id
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save inserts or updates a source and returns the stored, redacted form.
|
||||||
|
//
|
||||||
|
// As with connections, an update that omits credentials keeps the ones already
|
||||||
|
// held, so a source can be edited without re-entering a password.
|
||||||
|
func (s *Sources) Save(src Source) (Source, error) {
|
||||||
|
if src.ID == LocalSourceID {
|
||||||
|
return Source{}, errors.New("the local source cannot be edited")
|
||||||
|
}
|
||||||
|
switch src.Kind {
|
||||||
|
case SourceDocker:
|
||||||
|
src.DockerHost = strings.TrimSpace(src.DockerHost)
|
||||||
|
if src.DockerHost == "" {
|
||||||
|
return Source{}, errors.New("a docker address is required, e.g. tcp://10.0.0.5:2375")
|
||||||
|
}
|
||||||
|
if !strings.Contains(src.DockerHost, "://") {
|
||||||
|
return Source{}, fmt.Errorf("%q is not a docker address; it needs a scheme, e.g. tcp://%s",
|
||||||
|
src.DockerHost, src.DockerHost)
|
||||||
|
}
|
||||||
|
src.SSH = nil
|
||||||
|
if src.Name == "" {
|
||||||
|
src.Name = src.DockerHost
|
||||||
|
}
|
||||||
|
case SourceSSH:
|
||||||
|
if src.SSH == nil || src.SSH.Host == "" {
|
||||||
|
return Source{}, errors.New("host is required")
|
||||||
|
}
|
||||||
|
if src.SSH.User == "" {
|
||||||
|
return Source{}, errors.New("user is required")
|
||||||
|
}
|
||||||
|
if src.SSH.Port == 0 {
|
||||||
|
src.SSH.Port = 22
|
||||||
|
}
|
||||||
|
src.DockerHost = ""
|
||||||
|
if src.Name == "" {
|
||||||
|
src.Name = src.SSH.Host
|
||||||
|
}
|
||||||
|
case SourceLocal:
|
||||||
|
return Source{}, errors.New("there is only one local source")
|
||||||
|
default:
|
||||||
|
return Source{}, fmt.Errorf("unknown source kind %q", src.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if src.ID == "" {
|
||||||
|
src.ID = newID()
|
||||||
|
}
|
||||||
|
if src.SSH != nil {
|
||||||
|
prev := s.items[src.ID]
|
||||||
|
prevSecret := s.secrets[src.ID]
|
||||||
|
var prevSSH sshx.Config
|
||||||
|
if prev.SSH != nil {
|
||||||
|
prevSSH = *prev.SSH
|
||||||
|
}
|
||||||
|
if src.SSH.Password == "" {
|
||||||
|
src.SSH.Password = firstNonEmpty(prevSSH.Password, prevSecret.Password)
|
||||||
|
}
|
||||||
|
if src.SSH.PrivateKey == "" {
|
||||||
|
src.SSH.PrivateKey = firstNonEmpty(prevSSH.PrivateKey, prevSecret.PrivateKey)
|
||||||
|
}
|
||||||
|
if src.SSH.Passphrase == "" {
|
||||||
|
src.SSH.Passphrase = firstNonEmpty(prevSSH.Passphrase, prevSecret.Passphrase)
|
||||||
|
}
|
||||||
|
// The SSH id is only meaningful inside the source that owns it.
|
||||||
|
src.SSH.ID = src.ID
|
||||||
|
src.SSH.Name = src.Name
|
||||||
|
|
||||||
|
if src.SSH.SaveSecrets {
|
||||||
|
delete(s.secrets, src.ID)
|
||||||
|
s.items[src.ID] = src
|
||||||
|
} else {
|
||||||
|
s.secrets[src.ID] = secret{
|
||||||
|
Password: src.SSH.Password,
|
||||||
|
PrivateKey: src.SSH.PrivateKey,
|
||||||
|
Passphrase: src.SSH.Passphrase,
|
||||||
|
}
|
||||||
|
s.items[src.ID] = redactSource(src)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.items[src.ID] = src
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.flush(); err != nil {
|
||||||
|
return Source{}, err
|
||||||
|
}
|
||||||
|
return redactSource(s.items[src.ID]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a source, falling back to the local one when the source being
|
||||||
|
// removed is the selected one.
|
||||||
|
func (s *Sources) Delete(id string) error {
|
||||||
|
if id == LocalSourceID {
|
||||||
|
return errors.New("the local source cannot be deleted")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if _, ok := s.items[id]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
delete(s.items, id)
|
||||||
|
delete(s.secrets, id)
|
||||||
|
if s.selected == id {
|
||||||
|
s.selected = LocalSourceID
|
||||||
|
}
|
||||||
|
return s.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush writes the file. The caller must hold the write lock.
|
||||||
|
func (s *Sources) flush() error {
|
||||||
|
f := sourcesFile{Selected: s.selected, Sources: make([]Source, 0, len(s.items))}
|
||||||
|
for _, src := range s.items {
|
||||||
|
f.Sources = append(f.Sources, src)
|
||||||
|
}
|
||||||
|
sort.Slice(f.Sources, func(i, j int) bool { return f.Sources[i].ID < f.Sources[j].ID })
|
||||||
|
b, err := json.MarshalIndent(f, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write sources: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, s.path); err != nil {
|
||||||
|
return fmt.Errorf("replace sources file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func redactSource(src Source) Source {
|
||||||
|
if src.SSH != nil {
|
||||||
|
cfg := redact(*src.SSH)
|
||||||
|
src.SSH = &cfg
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/arescom/dockmv/internal/sshx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestSources(t *testing.T) (*Sources, string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "sources.json")
|
||||||
|
s, err := NewSources(path, Source{Name: "this host", DockerHost: "unix:///var/run/docker.sock"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSources: %v", err)
|
||||||
|
}
|
||||||
|
return s, path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourcesLocalIsAlwaysPresent(t *testing.T) {
|
||||||
|
s, _ := newTestSources(t)
|
||||||
|
|
||||||
|
list := s.List()
|
||||||
|
if len(list) != 1 || list[0].ID != LocalSourceID || list[0].Kind != SourceLocal {
|
||||||
|
t.Fatalf("expected only the local source, got %+v", list)
|
||||||
|
}
|
||||||
|
if got := s.Selected(); got != LocalSourceID {
|
||||||
|
t.Fatalf("selected = %q, want %q", got, LocalSourceID)
|
||||||
|
}
|
||||||
|
if _, err := s.Save(Source{ID: LocalSourceID, Kind: SourceDocker, DockerHost: "tcp://x:2375"}); err == nil {
|
||||||
|
t.Fatal("editing the local source should be refused")
|
||||||
|
}
|
||||||
|
if err := s.Delete(LocalSourceID); err == nil {
|
||||||
|
t.Fatal("deleting the local source should be refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourcesValidation(t *testing.T) {
|
||||||
|
s, _ := newTestSources(t)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
src Source
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no docker address", Source{Kind: SourceDocker}, "docker address is required"},
|
||||||
|
{"address without scheme", Source{Kind: SourceDocker, DockerHost: "10.0.0.5:2375"}, "needs a scheme"},
|
||||||
|
{"ssh without host", Source{Kind: SourceSSH, SSH: &sshx.Config{User: "root"}}, "host is required"},
|
||||||
|
{"ssh without user", Source{Kind: SourceSSH, SSH: &sshx.Config{Host: "h"}}, "user is required"},
|
||||||
|
{"unknown kind", Source{Kind: "carrier-pigeon"}, "unknown source kind"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if _, err := s.Save(tc.src); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("error = %v, want it to mention %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourcesSaveDefaults(t *testing.T) {
|
||||||
|
s, _ := newTestSources(t)
|
||||||
|
|
||||||
|
saved, err := s.Save(Source{Kind: SourceSSH, SSH: &sshx.Config{Host: "10.0.0.9", User: "root"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
if saved.ID == "" {
|
||||||
|
t.Fatal("an id should have been generated")
|
||||||
|
}
|
||||||
|
if saved.Name != "10.0.0.9" {
|
||||||
|
t.Fatalf("name = %q, want the host as a fallback", saved.Name)
|
||||||
|
}
|
||||||
|
if saved.SSH.Port != 22 {
|
||||||
|
t.Fatalf("port = %d, want 22", saved.SSH.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A docker source drops any ssh configuration, and the other way round.
|
||||||
|
dock, err := s.Save(Source{Kind: SourceDocker, DockerHost: "tcp://10.0.0.5:2375", SSH: &sshx.Config{Host: "x", User: "y"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
if dock.SSH != nil {
|
||||||
|
t.Fatalf("ssh configuration should be dropped for a docker source: %+v", dock.SSH)
|
||||||
|
}
|
||||||
|
if dock.Name != "tcp://10.0.0.5:2375" {
|
||||||
|
t.Fatalf("name = %q, want the address as a fallback", dock.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourcesSecretsStayOffDiskUnlessAsked(t *testing.T) {
|
||||||
|
s, path := newTestSources(t)
|
||||||
|
|
||||||
|
kept, err := s.Save(Source{
|
||||||
|
Kind: SourceSSH,
|
||||||
|
SSH: &sshx.Config{Host: "h1", User: "root", Auth: sshx.AuthPassword, Password: "in-memory"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
if kept.SSH.Password != "" {
|
||||||
|
t.Fatal("the returned form must be redacted")
|
||||||
|
}
|
||||||
|
if lst := s.List(); lst[1].SSH.Password != "" {
|
||||||
|
t.Fatal("List must not hand out credentials")
|
||||||
|
}
|
||||||
|
// Get is the dialling path, so it does see the password.
|
||||||
|
got, err := s.Get(kept.ID)
|
||||||
|
if err != nil || got.SSH.Password != "in-memory" {
|
||||||
|
t.Fatalf("Get password = %q (err %v), want the in-memory secret", got.SSH.Password, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remembered, err := s.Save(Source{
|
||||||
|
Kind: SourceSSH,
|
||||||
|
SSH: &sshx.Config{Host: "h2", User: "root", Auth: sshx.AuthPassword, Password: "on-disk", SaveSecrets: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read file: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "in-memory") {
|
||||||
|
t.Fatal("a secret the operator did not want persisted reached the disk")
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), "on-disk") {
|
||||||
|
t.Fatal("a remembered secret should have been written")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An update that omits the password keeps the one already held.
|
||||||
|
again, err := s.Save(Source{ID: remembered.ID, Kind: SourceSSH, SSH: &sshx.Config{Host: "h2", User: "admin", SaveSecrets: true}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
reloaded, err := s.Get(again.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.SSH.Password != "on-disk" {
|
||||||
|
t.Fatalf("password = %q, want it carried forward", reloaded.SSH.Password)
|
||||||
|
}
|
||||||
|
if reloaded.SSH.User != "admin" {
|
||||||
|
t.Fatalf("user = %q, want the update to apply", reloaded.SSH.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourcesSelectionSurvivesRestart(t *testing.T) {
|
||||||
|
s, path := newTestSources(t)
|
||||||
|
|
||||||
|
saved, err := s.Save(Source{Name: "prod", Kind: SourceSSH, SSH: &sshx.Config{Host: "10.0.0.9", User: "root", SaveSecrets: true}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Select("nope"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("Select of an unknown id = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := s.Select(saved.ID); err != nil {
|
||||||
|
t.Fatalf("Select: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reopened, err := NewSources(path, Source{Name: "this host"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSources: %v", err)
|
||||||
|
}
|
||||||
|
if got := reopened.Selected(); got != saved.ID {
|
||||||
|
t.Fatalf("selected after restart = %q, want %q", got, saved.ID)
|
||||||
|
}
|
||||||
|
if len(reopened.List()) != 2 {
|
||||||
|
t.Fatalf("sources after restart = %+v", reopened.List())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting the selected source falls back to the local one.
|
||||||
|
if err := reopened.Delete(saved.ID); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
if got := reopened.Selected(); got != LocalSourceID {
|
||||||
|
t.Fatalf("selected after delete = %q, want %q", got, LocalSourceID)
|
||||||
|
}
|
||||||
|
if _, err := reopened.Get(saved.ID); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("Get after delete = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourcesUnknownSelectionIgnoredOnLoad(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sources.json")
|
||||||
|
body := `{"selected":"gone","sources":[{"id":"gone-too","name":"x","kind":"docker","dockerHost":"tcp://h:2375"}]}`
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
s, err := NewSources(path, Source{Name: "this host"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSources: %v", err)
|
||||||
|
}
|
||||||
|
if got := s.Selected(); got != LocalSourceID {
|
||||||
|
t.Fatalf("selected = %q, want the local fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
(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<d.length;Ul++)Y=d[Ul],P=lt+Zl(Y,Ul),ml+=p(Y,x,R,P,W);else if(Ul=dl(d),typeof Ul=="function")for(d=Ul.call(d),Ul=0;!(Y=d.next()).done;)Y=Y.value,P=lt+Zl(Y,Ul++),ml+=p(Y,x,R,P,W);else if(P==="object"){if(typeof d.then=="function")return p(Ll(d),x,R,Y,W);throw x=String(d),Error("Objects are not valid as a React child (found: "+(x==="[object Object]"?"object with keys {"+Object.keys(d).join(", ")+"}":x)+"). If you meant to render a collection of children, use an array instead.")}return ml}function C(d,x,R){if(d==null)return d;var Y=[],W=0;return p(d,Y,"","",function(P){return x.call(R,P,W++)}),Y}function M(d){if(d._status===-1){var x=d._result;x=x(),x.then(function(R){(d._status===0||d._status===-1)&&(d._status=1,d._result=R)},function(R){(d._status===0||d._status===-1)&&(d._status=2,d._result=R)}),d._status===-1&&(d._status=0,d._result=x)}if(d._status===1)return d._result.default;throw d._result}var V=tyLine truncated
|
|
||||||
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
|
|
||||||
`+$u+l+Tf}var Wu=!1;function Fu(l,t){if(!l||Wu)return"";Wu=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(t){var T=function(){throw Error()};if(Object.defineProperty(T.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(T,[])}catch(S){var b=S}Reflect.construct(l,[],T)}else{try{T.call()}catch(S){b=S}l.call(T.prototype)}}else{try{throw Error()}catch(S){b=S}(T=l())&&typeof T.catch=="function"&&T.catch(function(){})}}catch(S){if(S&&b&&typeof S.stack=="string")return[S.stack,b.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),i=u[0],f=u[1];if(i&&f){var o=i.split(`
|
|
||||||
`),v=f.split(`
|
|
||||||
`);for(n=a=0;a<o.length&&!o[a].includes("DetermineComponentFrameRoot");)a++;for(;n<v.length&&!v[n].includes("DetermineComponentFrameRoot");)n++;if(a===o.length||n===v.length)for(a=o.length-1,n=v.length-1;1<=a&&0<=n&&o[a]!==v[n];)n--;for(;1<=a&&0<=n;a--,n--)if(o[a]!==v[n]){if(a!==1||n!==1)do if(a--,n--,0>n||o[a]!==v[n]){var j=`
|
|
||||||
`+o[a].replace(" at new "," at ");return l.displayName&&j.includes("<anonymous>")&&(j=j.replace("<anonymous>",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<e;){var j=31-rt(e),T=1<<j;f[j]=0,o[j]=-1;var b=v[j];if(b!==null)for(v[j]=null,j=0;j<b.length;j++){var S=b[j];S!==null&&(S.lane&=-536870913)}e&=~T}a!==0&&Mf(l,a,0),u!==0&&n===0&&l.tag!==0&&(l.suspendedLanes|=u&~(i&~t))}function Mf(l,t,e){l.pendingLanes|=t,l.suspendedLanes&=~t;var a=31-rt(t);l.entangledLanes|=t,l.entanglements[a]=l.entanglements[a]|1073741824|e&261930}function Df(l,t){var e=l.entangledLanes|=t;for(l=l.entanglements;e;){var a=31-rt(e),n=1<<a;n&t|l[a]&t&&(l[a]|=t),e&=~n}}function Uf(l,t){var e=t&-t;return e=(e&42)!==0?1:ei(e),(e&(l.suspendedLanes|t))!==0?0:e}function ei(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;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:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function ai(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Cf(){var l=C.p;return l!==0?l:(l=window.event,l===void 0?32:Sr(l.type))}function Hf(l,t){var e=C.p;try{return C.p=l,t()}finally{C.p=e}}var ue=Math.random().toString(36).slice(2),Jl="__reactFiber$"+ue,et="__reactProps$"+ue,$e="__reactContainer$"+ue,ni="__reactEvents$"+ue,Wr="__reactListeners$"+ue,Fr="__reactHandles$"+ue,Rf="__reactResources$"+ue,Ya="__reactMarker$"+ue;function ui(l){delete l[Jl],delete l[et],delete l[ni],delete l[Wr],delete l[Fr]}function We(l){var t=l[Jl];if(t)return t;for(var e=l.parentNode;e;){if(t=e[$e]||e[Jl]){if(e=t.alternate,t.child!==null||e!==null&&e.child!==null)for(l=ar(l);l!==null;){if(e=l[Jl])return e;l=ar(l)}return t}l=e,e=l.parentNode}return null}function Fe(l){if(l=l[Jl]||l[$e]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function Ga(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(s(33))}function Ie(l){var t=l[Rf];return t||(t=l[Rf]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Vl(l){l[Ya]=!0}var qf=new Set,Bf={};function Ue(l,t){Pe(l,t),Pe(l+"Capture",t)}function Pe(l,t){for(Bf[l]=t,l=0;l<t.length;l++)qf.add(t[l])}var Ir=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21
Line truncated
|
|
||||||
`).replace(Om,"")}function wd(l,t){return t=Jd(t),Jd(l)===t}function Sl(l,t,e,a,n,u){switch(e){case"children":typeof a=="string"?t==="body"||t==="textarea"&&a===""||ta(l,a):(typeof a=="number"||typeof a=="bigint")&&t!=="body"&&ta(l,""+a);break;case"className":Yn(l,"class",a);break;case"tabIndex":Yn(l,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Yn(l,e,a);break;case"style":Jf(l,a,u);break;case"data":if(t!=="object"){Yn(l,"data",a);break}case"src":case"href":if(a===""&&(t!=="a"||e!=="href")){l.removeAttribute(e);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){l.removeAttribute(e);break}a=Xn(""+a),l.setAttribute(e,a);break;case"action":case"formAction":if(typeof a=="function"){l.setAttribute(e,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(e==="formAction"?(t!=="input"&&Sl(l,t,"name",n.name,n,null),Sl(l,t,"formEncType",n.formEncType,n,null),Sl(l,t,"formMethod",n.formMethod,n,null),Sl(l,t,"formTarget",n.formTarget,n,null)):(Sl(l,t,"encType",n.encType,n,null),Sl(l,t,"method",n.method,n,null),Sl(l,t,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){l.removeAttribute(e);break}a=Xn(""+a),l.setAttribute(e,a);break;case"onClick":a!=null&&(l.onclick=Xt);break;case"onScroll":a!=null&&tl("scroll",l);break;case"onScrollEnd":a!=null&&tl("scrollend",l);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(s(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(s(60));l.innerHTML=e}}break;case"multiple":l.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":l.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){l.removeAttribute("xlink:href");break}e=Xn(""+a),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,""+a):l.removeAttribute(e);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,""):l.removeAttribute(e);break;case"capture":case"download":a===!0?l.setAttribute(e,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,a):l.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?l.setAttribute(e,a):l.removeAttribute(e);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?l.removeAttribute(e):l.setAttribute(e,a);break;case"popover":tl("beforetoggle",l),tl("toggle",l),Bn(l,"popover",a);break;case"xlinkActuate":Gt(l,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Gt(l,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Gt(l,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Gt(l,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Gt(l,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Gt(l,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Gt(l,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Gt(l,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Gt(l,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Bn(l,"is",a);break;case"innerText":case"textContent":break;default:(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")&&(e=ah.get(e)||e,Bn(l,e,a))}}function $c(l,t,e,a,n,u){switch(e){case"style":Jf(l,a,u);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(s(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(s(60));l.innerHTML=e}}break;case"children":typeof a=="string"?ta(l,a):(typeof a=="number"||typeof a=="bigint")&&ta(l,""+a);break;case"onScroll":a!=null&&tl("scroll",l);break;case"onScrollEnd":a!=null&&tl("scrollend",l);break;case"onClick":a!=null&&(l.onclick=Xt);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne
Line truncated
|
|
||||||
`)})]}),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"
Line truncated
|
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const O of document.querySelectorAll('link[rel="modulepreload"]'))s(O);new MutationObserver(O=>{for(const D of O)if(D.type==="childList")for(const T of D.addedNodes)T.tagName==="LINK"&&T.rel==="modulepreload"&&s(T)}).observe(document,{childList:!0,subtree:!0});function z(O){const D={};return O.integrity&&(D.integrity=O.integrity),O.referrerPolicy&&(D.referrerPolicy=O.referrerPolicy),O.crossOrigin==="use-credentials"?D.credentials="include":O.crossOrigin==="anonymous"?D.credentials="omit":D.credentials="same-origin",D}function s(O){if(O.ep)return;O.ep=!0;const D=z(O);fetch(O.href,D)}})();var gf={exports:{}},Dn={};var Nr;function sy(){if(Nr)return Dn;Nr=1;var o=Symbol.for("react.transitional.element"),S=Symbol.for("react.fragment");function z(s,O,D){var T=null;if(D!==void 0&&(T=""+D),O.key!==void 0&&(T=""+O.key),"key"in O){D={};for(var w in O)w!=="key"&&(D[w]=O[w])}else D=O;return O=D.ref,{$$typeof:o,type:s,key:T,ref:O!==void 0?O:null,props:D}}return Dn.Fragment=S,Dn.jsx=z,Dn.jsxs=z,Dn}var Ar;function oy(){return Ar||(Ar=1,gf.exports=sy()),gf.exports}var c=oy(),Sf={exports:{}},I={};var _r;function dy(){if(_r)return I;_r=1;var o=Symbol.for("react.transitional.element"),S=Symbol.for("react.portal"),z=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),O=Symbol.for("react.profiler"),D=Symbol.for("react.consumer"),T=Symbol.for("react.context"),w=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),b=Symbol.for("react.memo"),V=Symbol.for("react.lazy"),E=Symbol.for("react.activity"),U=Symbol.iterator;function ll(h){return h===null||typeof h!="object"?null:(h=U&&h[U]||h["@@iterator"],typeof h=="function"?h:null)}var W={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},vl=Object.assign,Yl={};function gl(h,A,q){this.props=h,this.context=A,this.refs=Yl,this.updater=q||W}gl.prototype.isReactComponent={},gl.prototype.setState=function(h,A){if(typeof h!="object"&&typeof h!="function"&&h!=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,h,A,"setState")},gl.prototype.forceUpdate=function(h){this.updater.enqueueForceUpdate(this,h,"forceUpdate")};function _l(){}_l.prototype=gl.prototype;function dl(h,A,q){this.props=h,this.context=A,this.refs=Yl,this.updater=q||W}var el=dl.prototype=new _l;el.constructor=dl,vl(el,gl.prototype),el.isPureReactComponent=!0;var Sl=Array.isArray;function Nl(){}var C={H:null,A:null,T:null,S:null},Z=Object.prototype.hasOwnProperty;function B(h,A,q){var Y=q.ref;return{$$typeof:o,type:h,key:A,ref:Y!==void 0?Y:null,props:q}}function $(h,A){return B(h.type,A,h.props)}function sl(h){return typeof h=="object"&&h!==null&&h.$$typeof===o}function jl(h){var A={"=":"=0",":":"=2"};return"$"+h.replace(/[=:]/g,function(q){return A[q]})}var at=/\/+/g;function tt(h,A){return typeof h=="object"&&h!==null&&h.key!=null?jl(""+h.key):A.toString(36)}function Cl(h){switch(h.status){case"fulfilled":return h.value;case"rejected":throw h.reason;default:switch(typeof h.status=="string"?h.then(Nl,Nl):(h.status="pending",h.then(function(A){h.status==="pending"&&(h.status="fulfilled",h.value=A)},function(A){h.status==="pending"&&(h.status="rejected",h.reason=A)})),h.status){case"fulfilled":return h.value;case"rejected":throw h.reason}}throw h}function j(h,A,q,Y,F){var al=typeof h;(al==="undefined"||al==="boolean")&&(h=null);var rl=!1;if(h===null)rl=!0;else switch(al){case"bigint":case"string":case"number":rl=!0;break;case"object":switch(h.$$typeof){case o:case S:rl=!0;break;case V:return rl=h._init,j(rl(h._payload),A,q,Y,F)}}if(rl)return F=F(h),rl=Y===""?"."+tt(h,0):Y,Sl(F)?(q="",rl!=null&&(q=rl.replace(at,"$&/")+"/"),j(F,A,q,"",function(k){return k})):F!=null&&(sl(F)&&(F=$(F,q+(F.key==null||h&&h.key===F.key?"":(""+F.key).replace(at,"$&/")+"/")+rl)),A.push(F)),1;rl=0;var Kl=Y===""?".":Y+":";if(Sl(h))for(var Ul=0;Ul<h.length;Ul++)Y=h[Ul],al=Kl+tt(Y,Ul),rl+=j(Y,A,q,al,F);else if(Ul=ll(h),typeof Ul=="function")for(h=Ul.call(h),Ul=0;!(Y=h.next()).done;)Y=Y.value,al=Kl+tt(Y,Ul++),rl+=j(Y,A,q,al,F);else if(al==="object"){if(typeof h.then=="function")return j(Cl(h),A,q,Y,F);throw A=String(h),Error("Objects are not valid as a React child (found: "+(A==="[object Object]"?"object with keys {"+Object.keys(h).join(", ")+"}":A)+"). If you meant to render a collection of children, use an array instead.")}return rl}function R(h,A,q){if(h==null)return h;var Y=[],F=0;return j(h,Y,"","",function(al){return A.call(q,al,F++)}),Y}function M(h){if(h._status===-1){var A=h._result;A=A(),A.then(function(q){(h._status===0||h._status===-1)&&(h._status=1,h._result=q)},function(q){(h._status===0||h._status===-1)&&(h._status=2,h._result=q)}),h._status===-1&&(h._status=0,h._result=A)}if(h._status===1)return h._result.default;throw h._result}vaLine truncated
|
||||||
|
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||||
|
`+ce+l+Ie}var Pu=!1;function li(l,t){if(!l||Pu)return"";Pu=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(t){var _=function(){throw Error()};if(Object.defineProperty(_.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(_,[])}catch(p){var g=p}Reflect.construct(l,[],_)}else{try{_.call()}catch(p){g=p}l.call(_.prototype)}}else{try{throw Error()}catch(p){g=p}(_=l())&&typeof _.catch=="function"&&_.catch(function(){})}}catch(p){if(p&&g&&typeof p.stack=="string")return[p.stack,g.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),i=u[0],f=u[1];if(i&&f){var d=i.split(`
|
||||||
|
`),v=f.split(`
|
||||||
|
`);for(n=a=0;a<d.length&&!d[a].includes("DetermineComponentFrameRoot");)a++;for(;n<v.length&&!v[n].includes("DetermineComponentFrameRoot");)n++;if(a===d.length||n===v.length)for(a=d.length-1,n=v.length-1;1<=a&&0<=n&&d[a]!==v[n];)n--;for(;1<=a&&0<=n;a--,n--)if(d[a]!==v[n]){if(a!==1||n!==1)do if(a--,n--,0>n||d[a]!==v[n]){var x=`
|
||||||
|
`+d[a].replace(" at new "," at ");return l.displayName&&x.includes("<anonymous>")&&(x=x.replace("<anonymous>",l.displayName)),x}while(1<=a&&0<=n);break}}}finally{Pu=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?Ue(e):""}function Xr(l,t){switch(l.tag){case 26:case 27:case 5:return Ue(l.type);case 16:return Ue("Lazy");case 13:return l.child!==t&&t!==null?Ue("Suspense Fallback"):Ue("Suspense");case 19:return Ue("SuspenseList");case 0:case 15:return li(l.type,!1);case 11:return li(l.type.render,!1);case 1:return li(l.type,!0);case 31:return Ue("Activity");default:return""}}function Ef(l){try{var t="",e=null;do t+=Xr(l,e),e=l,l=l.return;while(l);return t}catch(a){return`
|
||||||
|
Error generating stack: `+a.message+`
|
||||||
|
`+a.stack}}var ti=Object.prototype.hasOwnProperty,ei=o.unstable_scheduleCallback,ai=o.unstable_cancelCallback,Qr=o.unstable_shouldYield,Zr=o.unstable_requestPaint,mt=o.unstable_now,Lr=o.unstable_getCurrentPriorityLevel,Nf=o.unstable_ImmediatePriority,Af=o.unstable_UserBlockingPriority,Hn=o.unstable_NormalPriority,Vr=o.unstable_LowPriority,_f=o.unstable_IdlePriority,Kr=o.log,Jr=o.unstable_setDisableYieldValue,Ya=null,yt=null;function fe(l){if(typeof Kr=="function"&&Jr(l),yt&&typeof yt.setStrictMode=="function")try{yt.setStrictMode(Ya,l)}catch{}}var vt=Math.clz32?Math.clz32:$r,wr=Math.log,kr=Math.LN2;function $r(l){return l>>>=0,l===0?32:31-(wr(l)/kr|0)|0}var Rn=256,qn=262144,Bn=4194304;function He(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 Yn(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=He(a):(i&=f,i!==0?n=He(i):e||(e=f&~l,e!==0&&(n=He(e))))):(f=a&~u,f!==0?n=He(f):i!==0?n=He(i):e||(e=a&~l,e!==0&&(n=He(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 Ga(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Wr(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=Bn;return Bn<<=1,(Bn&62914560)===0&&(Bn=4194304),l}function ni(l){for(var t=[],e=0;31>e;e++)t.push(l);return t}function Xa(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Fr(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,d=l.expirationTimes,v=l.hiddenUpdates;for(e=i&~e;0<e;){var x=31-vt(e),_=1<<x;f[x]=0,d[x]=-1;var g=v[x];if(g!==null)for(v[x]=null,x=0;x<g.length;x++){var p=g[x];p!==null&&(p.lane&=-536870913)}e&=~_}a!==0&&Mf(l,a,0),u!==0&&n===0&&l.tag!==0&&(l.suspendedLanes|=u&~(i&~t))}function Mf(l,t,e){l.pendingLanes|=t,l.suspendedLanes&=~t;var a=31-vt(t);l.entangledLanes|=t,l.entanglements[a]=l.entanglements[a]|1073741824|e&261930}function Df(l,t){var e=l.entangledLanes|=t;for(l=l.entanglements;e;){var a=31-vt(e),n=1<<a;n&t|l[a]&t&&(l[a]|=t),e&=~n}}function Cf(l,t){var e=t&-t;return e=(e&42)!==0?1:ui(e),(e&(l.suspendedLanes|t))!==0?0:e}function ui(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;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:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function ii(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Uf(){var l=R.p;return l!==0?l:(l=window.event,l===void 0?32:br(l.type))}function Hf(l,t){var e=R.p;try{return R.p=l,t()}finally{R.p=e}}var se=Math.random().toString(36).slice(2),$l="__reactFiber$"+se,nt="__reactProps$"+se,Pe="__reactContainer$"+se,ci="__reactEvents$"+se,Ir="__reactListeners$"+se,Pr="__reactHandles$"+se,Rf="__reactResources$"+se,Qa="__reactMarker$"+se;function fi(l){delete l[$l],delete l[nt],delete l[ci],delete l[Ir],delete l[Pr]}function la(l){var t=l[$l];if(t)return t;for(var e=l.parentNode;e;){if(t=e[Pe]||e[$l]){if(e=t.alternate,t.child!==null||e!==null&&e.child!==null)for(l=ar(l);l!==null;){if(e=l[$l])return e;l=ar(l)}return t}l=e,e=l.parentNode}return null}function ta(l){if(l=l[$l]||l[Pe]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function Za(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(s(33))}function ea(l){var t=l[Rf];return t||(t=l[Rf]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function wl(l){l[Qa]=!0}var qf=new Set,Bf={};function Re(l,t){aa(l,t),aa(l+"Capture",t)}function aa(l,t){for(Bf[l]=t,l=0;l<t.length;l++)qf.add(t[l])}var lh=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21
Line truncated
|
||||||
|
`).replace(Dm,"")}function wd(l,t){return t=Jd(t),Jd(l)===t}function zl(l,t,e,a,n,u){switch(e){case"children":typeof a=="string"?t==="body"||t==="textarea"&&a===""||ua(l,a):(typeof a=="number"||typeof a=="bigint")&&t!=="body"&&ua(l,""+a);break;case"className":Xn(l,"class",a);break;case"tabIndex":Xn(l,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Xn(l,e,a);break;case"style":Jf(l,a,u);break;case"data":if(t!=="object"){Xn(l,"data",a);break}case"src":case"href":if(a===""&&(t!=="a"||e!=="href")){l.removeAttribute(e);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){l.removeAttribute(e);break}a=Zn(""+a),l.setAttribute(e,a);break;case"action":case"formAction":if(typeof a=="function"){l.setAttribute(e,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(e==="formAction"?(t!=="input"&&zl(l,t,"name",n.name,n,null),zl(l,t,"formEncType",n.formEncType,n,null),zl(l,t,"formMethod",n.formMethod,n,null),zl(l,t,"formTarget",n.formTarget,n,null)):(zl(l,t,"encType",n.encType,n,null),zl(l,t,"method",n.method,n,null),zl(l,t,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){l.removeAttribute(e);break}a=Zn(""+a),l.setAttribute(e,a);break;case"onClick":a!=null&&(l.onclick=Lt);break;case"onScroll":a!=null&&il("scroll",l);break;case"onScrollEnd":a!=null&&il("scrollend",l);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(s(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(s(60));l.innerHTML=e}}break;case"multiple":l.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":l.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){l.removeAttribute("xlink:href");break}e=Zn(""+a),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,""+a):l.removeAttribute(e);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,""):l.removeAttribute(e);break;case"capture":case"download":a===!0?l.setAttribute(e,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,a):l.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?l.setAttribute(e,a):l.removeAttribute(e);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?l.removeAttribute(e):l.setAttribute(e,a);break;case"popover":il("beforetoggle",l),il("toggle",l),Gn(l,"popover",a);break;case"xlinkActuate":Zt(l,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Zt(l,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Zt(l,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Zt(l,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Zt(l,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Zt(l,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Zt(l,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Zt(l,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Zt(l,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Gn(l,"is",a);break;case"innerText":case"textContent":break;default:(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")&&(e=uh.get(e)||e,Gn(l,e,a))}}function Ic(l,t,e,a,n,u){switch(e){case"style":Jf(l,a,u);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(s(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(s(60));l.innerHTML=e}}break;case"children":typeof a=="string"?ua(l,a):(typeof a=="number"||typeof a=="bigint")&&ua(l,""+a);break;case"onScroll":a!=null&&il("scroll",l);break;case"onScrollEnd":a!=null&&il("scrollend",l);break;case"onClick":a!=null&&(l.onclick=Lt);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne
Line truncated
|
||||||
|
`)})]}),o.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((O,D)=>c.jsx(ht,{kind:"warn",children:O},`w${D}`)),(s.notes??[]).map((O,D)=>c.jsxs("div",{className:"small faint",children:["· ",O]},`n${D}`)),c.jsx("pre",{className:"cmdblock",children:(s.commands??[]).join(`
|
||||||
|
`)})]},s.containerId))]})})}function My({jobs:o,activeJob:S,setActiveJob:z,reload:s,reloadPackages:O}){const D=S||o[0]?.id||"";return o.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:o.map(T=>c.jsxs("div",{className:`jobcard${T.id===D?" active":""}`,onClick:()=>z(T.id),children:[c.jsxs("div",{className:"row",children:[c.jsx(Un,{state:T.state}),c.jsx("span",{className:"spacer"}),c.jsx("span",{className:"small faint",children:T.kind==="ssh"?"ssh":"package"})]}),c.jsx("div",{className:"truncate",style:{marginTop:2},children:T.title}),c.jsxs("div",{className:"small faint",children:[new Date(T.createdAt).toLocaleTimeString()," · ",qr(T.startedAt,T.endedAt),T.dryRun&&" · dry run"]}),c.jsx("div",{style:{marginTop:6},children:c.jsx(zf,{done:T.bytesDone,total:T.bytesTotal,state:T.state})})]},T.id))}),c.jsx("div",{style:{flex:1,minWidth:0,overflow:"auto",borderLeft:"1px solid var(--border)"},children:D&&c.jsx(Dy,{id:D,reload:s,reloadPackages:O})})]})}function Dy({id:o,reload:S,reloadPackages:z}){const[s,O]=Q.useState(null),[D,T]=Q.useState(!0),w=Q.useRef(null),H=Q.useRef(!0);Q.useEffect(()=>{O(null);let E=!1;Al.job(o).then(ll=>!E&&O(ll)).catch(()=>{});const U=Al.jobEvents(o);return U.onmessage=ll=>{try{O(JSON.parse(ll.data))}catch{}},U.addEventListener("done",()=>{U.close(),S(),z()}),U.onerror=()=>U.close(),()=>{E=!0,U.close()}},[o,S,z]);const b=Q.useMemo(()=>(s?.log??[]).filter(E=>D||E.level!=="cmd"),[s,D]);if(Q.useEffect(()=>{const E=w.current;E&&H.current&&(E.scrollTop=E.scrollHeight)},[b]),!s)return c.jsx("div",{className:"empty",children:"loading…"});const V=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(Un,{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:[ie(s.bytesDone),s.bytesTotal>0&&c.jsxs(c.Fragment,{children:[" of ",ie(s.bytesTotal)]})," · ",qr(s.startedAt,s.endedAt)]}),V?c.jsx("button",{className:"btn tiny danger",onClick:()=>Al.cancelJob(s.id).then(S),children:"cancel"}):c.jsx("button",{className:"btn tiny ghost",onClick:()=>Al.deleteJob(s.id).then(S),children:"remove"})]}),c.jsx(zf,{done:s.bytesDone,total:s.bytesTotal,state:s.state}),s.error&&c.jsx(ht,{kind:"err",children:s.error}),s.state==="succeeded"&&s.artifact&&c.jsxs(ht,{kind:"ok",children:["Package ready at ",c.jsx("span",{className:"mono",children:s.artifact})," (",ie(s.artifactBytes??0),")."," ","Open the Packages tab to download it."]}),s.items.map(E=>c.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:"8px 10px"},children:[c.jsxs("div",{className:"row",children:[c.jsx(Un,{state:E.state}),c.jsx("b",{children:E.name}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint",children:[E.steps.filter(U=>U.state==="succeeded").length,"/",E.steps.length," steps"]})]}),E.error&&c.jsx("div",{className:"small",style:{color:"var(--err)"},children:E.error}),(E.warnings??[]).map((U,ll)=>c.jsxs("div",{className:"small",style:{color:"var(--warn)"},children:["! ",U]},ll)),c.jsx("div",{className:"steps",children:E.steps.map(U=>c.jsxs("div",{className:`step ${U.state}`,children:[c.jsx(Un,{state:U.state,label:""}),c.jsx("span",{className:"label truncate",title:U.error||U.label,children:U.label}),c.jsx("span",{children:U.bytesTotal>0||U.bytesDone>0?c.jsx(zf,{done:U.bytesDone,total:U.bytesTotal,state:U.state}):null}),c.jsx("span",{className:"faint nowrap",style:{textAlign:"right"},children:U.bytesDone>0?ie(U.bytesDone):U.state==="skipped"?"skipped":""})]},U.id))})]},E.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:D,onChange:E=>T(E.target.checked)}),c.jsx("span",{children:"show commands"})]})]}),c.jsxs("div",{className:"log",ref:w,onScroll:E=>{const U=E.currentTarget;H.current=U.scrollHeight-U.scrollTop-U.clientHeight<24},children:[b.map(E=>c.jsxs("div",{className:`l-${E.level}`,children:[c.jsxs("span",{className:"ts",children:[new Date(E.at).toLocaleTimeString()," "]}),E.message]},E.seq)),b.length===0&&c.jsx("span",{className:"faint",children:"nothing logged yet"})]})]})}function Cy({packages:o,reload:S}){return o.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(ht,{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"
Line truncated
|
||||||
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<title>DockMV</title>
|
<title>DockMV</title>
|
||||||
<script type="module" crossorigin src="/assets/index-2w4y0Lpg.js"></script>
|
<script type="module" crossorigin src="/assets/index-qcSVszEj.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CKzWD9Xt.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CKzWD9Xt.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -81,10 +81,10 @@ Run "dockmv serve -h" for the server flags.
|
|||||||
func serve(args []string) error {
|
func serve(args []string) error {
|
||||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
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")
|
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")
|
token := fs.String("token", os.Getenv("DOCKMV_TOKEN"), "require this token on every request; \"auto\" generates one (default: $DOCKMV_TOKEN)")
|
||||||
dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys")
|
dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys")
|
||||||
pkgDir := fs.String("package-dir", "", "directory for migration packages (default <data-dir>/packages)")
|
pkgDir := fs.String("package-dir", "", "directory for migration packages (default <data-dir>/packages)")
|
||||||
dockerHost := fs.String("docker-host", "", "source docker daemon (default: the DOCKER_HOST environment)")
|
dockerHost := fs.String("docker-host", "", "local source docker daemon (default: the DOCKER_HOST environment)")
|
||||||
verbose := fs.Bool("v", false, "verbose logging")
|
verbose := fs.Bool("v", false, "verbose logging")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
fmt.Fprintln(os.Stderr, "Usage: dockmv serve [flags]\n\nFlags:")
|
fmt.Fprintln(os.Stderr, "Usage: dockmv serve [flags]\n\nFlags:")
|
||||||
@@ -93,6 +93,14 @@ func serve(args []string) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// A --docker-host given on the command line is an instruction for this run,
|
||||||
|
// and takes precedence over the source remembered from the last one.
|
||||||
|
dockerHostSet := false
|
||||||
|
fs.Visit(func(f *flag.Flag) {
|
||||||
|
if f.Name == "docker-host" {
|
||||||
|
dockerHostSet = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
level := slog.LevelInfo
|
level := slog.LevelInfo
|
||||||
if *verbose {
|
if *verbose {
|
||||||
@@ -113,13 +121,14 @@ func serve(args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := api.Config{
|
cfg := api.Config{
|
||||||
Addr: *addr,
|
Addr: *addr,
|
||||||
Token: authToken,
|
Token: authToken,
|
||||||
DataDir: *dataDir,
|
DataDir: *dataDir,
|
||||||
PackageDir: *pkgDir,
|
PackageDir: *pkgDir,
|
||||||
DockerHost: *dockerHost,
|
DockerHost: *dockerHost,
|
||||||
UI: webui.FS(),
|
DockerHostSet: dockerHostSet,
|
||||||
Logger: logger,
|
UI: webui.FS(),
|
||||||
|
Logger: logger,
|
||||||
}
|
}
|
||||||
srv, err := api.New(cfg)
|
srv, err := api.New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+24
-16
@@ -10,11 +10,13 @@
|
|||||||
|
|
||||||
It also cross-compiles the release binaries (scripts/build-release.ps1) from
|
It also cross-compiles the release binaries (scripts/build-release.ps1) from
|
||||||
the same commit, so both artifacts carry the same -Tag. Archives cannot live
|
the same commit, so both artifacts carry the same -Tag. Archives cannot live
|
||||||
in a container registry, so -PublishRelease attaches them to the Gitea
|
in a container registry, so they're attached to the Gitea release for that
|
||||||
release for that tag instead (creating the release if it does not exist).
|
tag instead (creating the release if it does not exist) — on by default,
|
||||||
|
since a run that builds binaries and doesn't publish them is the unusual
|
||||||
|
case. Pass -NoPublishRelease to build locally without uploading.
|
||||||
|
|
||||||
-BinariesOnly ships just the binaries: no docker build, no docker login, no
|
-BinariesOnly ships just the binaries: no docker build, no docker login, no
|
||||||
image push, and the release upload is implied.
|
image push.
|
||||||
|
|
||||||
Credentials are read, in order of precedence:
|
Credentials are read, in order of precedence:
|
||||||
1. -Username / -Password parameters
|
1. -Username / -Password parameters
|
||||||
@@ -27,11 +29,12 @@
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
./scripts/publish.ps1
|
./scripts/publish.ps1
|
||||||
Build and push :latest plus the git-describe tag; build dist/ binaries locally.
|
Full release: push the image (:latest plus the git-describe tag) and attach
|
||||||
|
every dist/ archive to the matching Gitea release.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
./scripts/publish.ps1 -Tag v1.2.0 -PublishRelease
|
./scripts/publish.ps1 -Tag v1.2.0 -NoPublishRelease
|
||||||
Full release: push the image and attach every dist/ archive to release v1.2.0.
|
Push the image and build dist/ binaries locally, but don't upload them.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
./scripts/publish.ps1 -BinariesOnly -Tag v1.2.0
|
./scripts/publish.ps1 -BinariesOnly -Tag v1.2.0
|
||||||
@@ -44,7 +47,7 @@
|
|||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
./scripts/publish.ps1 -NoBinaries
|
./scripts/publish.ps1 -NoBinaries
|
||||||
Container only — no cross-compile.
|
Container only — no cross-compile, nothing to publish as a release.
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
$env:GITEA_USER = "kawa"; $env:GITEA_TOKEN = "xxxx"; ./scripts/publish.ps1 -SkipLogin:$false
|
$env:GITEA_USER = "kawa"; $env:GITEA_TOKEN = "xxxx"; ./scripts/publish.ps1 -SkipLogin:$false
|
||||||
@@ -96,13 +99,12 @@ param(
|
|||||||
# retrying a failed upload without paying for the build again.
|
# retrying a failed upload without paying for the build again.
|
||||||
[switch]$NoBinaryBuild,
|
[switch]$NoBinaryBuild,
|
||||||
|
|
||||||
# Ship only the binaries: no docker build, login or push. Implies
|
# Ship only the binaries: no docker build, login or push.
|
||||||
# -PublishRelease, since building alone is what build-release.ps1 already does.
|
|
||||||
[switch]$BinariesOnly,
|
[switch]$BinariesOnly,
|
||||||
|
|
||||||
# Attach the release archives to the Gitea release for $Tag, creating the
|
# Skip attaching the release archives to the Gitea release for $Tag. The
|
||||||
# release if it is missing.
|
# upload happens by default whenever binaries are built.
|
||||||
[switch]$PublishRelease,
|
[switch]$NoPublishRelease,
|
||||||
|
|
||||||
# owner/repo holding the release. Defaults to $Owner/$Repo.
|
# owner/repo holding the release. Defaults to $Owner/$Repo.
|
||||||
[string]$ReleaseRepo,
|
[string]$ReleaseRepo,
|
||||||
@@ -282,15 +284,21 @@ try {
|
|||||||
if ($NoBinaryBuild -and $NoBinaries) {
|
if ($NoBinaryBuild -and $NoBinaries) {
|
||||||
throw "-NoBinaryBuild reuses the build that -NoBinaries skips entirely — pick one."
|
throw "-NoBinaryBuild reuses the build that -NoBinaries skips entirely — pick one."
|
||||||
}
|
}
|
||||||
|
if ($BinariesOnly -and $NoPublishRelease) {
|
||||||
|
throw "-BinariesOnly with -NoPublishRelease leaves nothing to do — pick one."
|
||||||
|
}
|
||||||
if ($BinariesOnly) {
|
if ($BinariesOnly) {
|
||||||
# Nothing to build, log into or push on the container side, and uploading
|
# Nothing to build, log into or push on the container side, and uploading
|
||||||
# is the whole point (build-release.ps1 alone covers "just build them").
|
# is the whole point (build-release.ps1 alone covers "just build them").
|
||||||
$NoBuild = $true
|
$NoBuild = $true
|
||||||
$SkipLogin = $true
|
$SkipLogin = $true
|
||||||
$PublishRelease = $true
|
|
||||||
}
|
}
|
||||||
$pushImage = -not $BinariesOnly
|
$pushImage = -not $BinariesOnly
|
||||||
|
|
||||||
|
# On by default: a run that builds binaries and doesn't publish them is the
|
||||||
|
# unusual case. -NoBinaries means there is nothing to publish either way.
|
||||||
|
$PublishRelease = (-not $NoPublishRelease) -and (-not $NoBinaries)
|
||||||
|
|
||||||
if (-not $ReleaseRepo) { $ReleaseRepo = "$Owner/$Repo" }
|
if (-not $ReleaseRepo) { $ReleaseRepo = "$Owner/$Repo" }
|
||||||
if (-not $ApiBase) { $ApiBase = "https://$Registry" }
|
if (-not $ApiBase) { $ApiBase = "https://$Registry" }
|
||||||
$apiRoot = "$($ApiBase.TrimEnd('/'))/api/v1"
|
$apiRoot = "$($ApiBase.TrimEnd('/'))/api/v1"
|
||||||
@@ -415,7 +423,7 @@ try {
|
|||||||
$releaseUrl = $null
|
$releaseUrl = $null
|
||||||
if ($PublishRelease) {
|
if ($PublishRelease) {
|
||||||
if (-not $artifacts.Count) {
|
if (-not $artifacts.Count) {
|
||||||
throw "-PublishRelease has nothing to upload (was -NoBinaries set?)."
|
throw "nothing to upload — no release archives were built (was -NoBinaries set?)."
|
||||||
}
|
}
|
||||||
Write-Host "Uploading binaries to release $Tag..." -ForegroundColor Cyan
|
Write-Host "Uploading binaries to release $Tag..." -ForegroundColor Cyan
|
||||||
$Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo"
|
$Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo"
|
||||||
|
|||||||
+57
-5
@@ -1,10 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import type {
|
import type {
|
||||||
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, SourceResponse, TargetInventory,
|
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, Source, SourceResponse,
|
||||||
|
SourceStatus, TargetInventory,
|
||||||
} from './types'
|
} from './types'
|
||||||
import { Notice } from './ui'
|
import { Notice } from './ui'
|
||||||
import { Containers } from './Containers'
|
import { Containers } from './Containers'
|
||||||
|
import { SourcePanel } from './SourcePanel'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { Jobs } from './Jobs'
|
import { Jobs } from './Jobs'
|
||||||
import { Packages } from './Packages'
|
import { Packages } from './Packages'
|
||||||
@@ -22,6 +24,9 @@ export default function App() {
|
|||||||
// Volume sizes come from a separate, slower endpoint so the container list
|
// Volume sizes come from a separate, slower endpoint so the container list
|
||||||
// can render immediately; they are merged into the rows when they arrive.
|
// can render immediately; they are merged into the rows when they arrive.
|
||||||
const [sizes, setSizes] = useState<Record<string, number>>({})
|
const [sizes, setSizes] = useState<Record<string, number>>({})
|
||||||
|
const [sources, setSources] = useState<Source[]>([])
|
||||||
|
const [selectedSource, setSelectedSource] = useState<string>('local')
|
||||||
|
const [sourceStatus, setSourceStatus] = useState<SourceStatus | null>(null)
|
||||||
const [connections, setConnections] = useState<Connection[]>([])
|
const [connections, setConnections] = useState<Connection[]>([])
|
||||||
const [activeConn, setActiveConn] = useState<string>('')
|
const [activeConn, setActiveConn] = useState<string>('')
|
||||||
const [targetInv, setTargetInv] = useState<TargetInventory | null>(null)
|
const [targetInv, setTargetInv] = useState<TargetInventory | null>(null)
|
||||||
@@ -57,6 +62,40 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadHealth = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const h = await api.health()
|
||||||
|
setHealth(h)
|
||||||
|
if (h.source) setSourceStatus(h.source)
|
||||||
|
} catch { /* the panel shows the source error on its own */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadSources = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.sources()
|
||||||
|
setSources(r.sources)
|
||||||
|
setSelectedSource(r.selected)
|
||||||
|
if (r.current) setSourceStatus(r.current)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// selectSource lets its error escape so the panel can turn an untrusted host
|
||||||
|
// key into a fingerprint prompt, the same way the target does.
|
||||||
|
const selectSource = useCallback(async (id: string) => {
|
||||||
|
const st = await api.selectSource(id)
|
||||||
|
setSelectedSource(id)
|
||||||
|
setSourceStatus(st)
|
||||||
|
// The selections describe containers on the host that was selected before,
|
||||||
|
// so they are dropped rather than carried over to a different inventory.
|
||||||
|
setSel({})
|
||||||
|
setSizes({})
|
||||||
|
setError('')
|
||||||
|
await loadSource()
|
||||||
|
await loadHealth()
|
||||||
|
}, [loadSource, loadHealth])
|
||||||
|
|
||||||
const loadConnections = useCallback(async () => {
|
const loadConnections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await api.connections()
|
const list = await api.connections()
|
||||||
@@ -80,12 +119,13 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.health().then(setHealth).catch(() => undefined)
|
loadHealth()
|
||||||
|
loadSources()
|
||||||
loadSource()
|
loadSource()
|
||||||
loadConnections()
|
loadConnections()
|
||||||
loadJobs()
|
loadJobs()
|
||||||
loadPackages()
|
loadPackages()
|
||||||
}, [loadSource, loadConnections, loadJobs, loadPackages])
|
}, [loadHealth, loadSources, loadSource, loadConnections, loadJobs, loadPackages])
|
||||||
|
|
||||||
// A slow poll keeps the job list current without holding a stream open for
|
// 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.
|
// every job; the detail view subscribes to its own live stream.
|
||||||
@@ -138,7 +178,9 @@ export default function App() {
|
|||||||
<div className="topbar-right">
|
<div className="topbar-right">
|
||||||
{health && (
|
{health && (
|
||||||
<span className="hostinfo">
|
<span className="hostinfo">
|
||||||
source <b>{source?.inventory.host || health.dockerHost}</b>
|
source <b>{source?.inventory.host || sourceStatus?.name || health.dockerHost}</b>
|
||||||
|
{sourceStatus?.kind === 'ssh' && <> · over ssh</>}
|
||||||
|
{sourceStatus?.kind === 'docker' && <> · {sourceStatus.endpoint}</>}
|
||||||
{health.dockerVersion && <> · docker {health.dockerVersion}</>}
|
{health.dockerVersion && <> · docker {health.dockerVersion}</>}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -160,8 +202,10 @@ export default function App() {
|
|||||||
{health && !health.ok && (
|
{health && !health.ok && (
|
||||||
<div style={{ padding: '10px 16px' }}>
|
<div style={{ padding: '10px 16px' }}>
|
||||||
<Notice kind="err">
|
<Notice kind="err">
|
||||||
Cannot reach the source Docker daemon at <span className="mono">{health.dockerHost}</span>
|
Cannot reach the source <b>{health.source?.name ?? 'docker daemon'}</b>
|
||||||
|
{health.dockerHost && <> at <span className="mono">{health.dockerHost}</span></>}
|
||||||
{health.dockerError && <> — {health.dockerError}</>}
|
{health.dockerError && <> — {health.dockerError}</>}
|
||||||
|
<div className="small">Pick another source in the panel on the right.</div>
|
||||||
</Notice>
|
</Notice>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -192,6 +236,14 @@ export default function App() {
|
|||||||
|
|
||||||
{view === 'containers' && (
|
{view === 'containers' && (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
|
<SourcePanel
|
||||||
|
sources={sources}
|
||||||
|
selected={selectedSource}
|
||||||
|
status={sourceStatus}
|
||||||
|
selectSource={selectSource}
|
||||||
|
reload={loadSources}
|
||||||
|
onError={setError}
|
||||||
|
/>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
source={source}
|
source={source}
|
||||||
plan={plan}
|
plan={plan}
|
||||||
|
|||||||
+4
-104
@@ -4,6 +4,7 @@ import type {
|
|||||||
Connection, HostKeyInfo, JobSnapshot, Options, Plan, PreviewResponse, SourceResponse, TargetInventory,
|
Connection, HostKeyInfo, JobSnapshot, Options, Plan, PreviewResponse, SourceResponse, TargetInventory,
|
||||||
} from './types'
|
} from './types'
|
||||||
import { Check, Field, humanBytes, Modal, Notice } from './ui'
|
import { Check, Field, humanBytes, Modal, Notice } from './ui'
|
||||||
|
import { HostKeyDialog, SshFields } from './SshFields'
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
source, plan, includedCount, options, setOptions,
|
source, plan, includedCount, options, setOptions,
|
||||||
@@ -302,37 +303,7 @@ export function Sidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hostKey && (
|
{hostKey && (
|
||||||
<Modal
|
<HostKeyDialog info={hostKey} onClose={() => setHostKey(null)} onTrust={trustHostKey} />
|
||||||
title="SSH host key"
|
|
||||||
onClose={() => setHostKey(null)}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<button className="btn" onClick={() => setHostKey(null)}>cancel</button>
|
|
||||||
<button className="btn primary" onClick={trustHostKey}>
|
|
||||||
{hostKey.changed ? 'replace the stored key and trust' : 'trust this host'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="stack">
|
|
||||||
{hostKey.changed && (
|
|
||||||
<Notice kind="err">
|
|
||||||
The key presented by this host is <b>different</b> from the one recorded earlier. This happens after a
|
|
||||||
reinstall — but it is also what a machine-in-the-middle looks like. Only continue if you know why it
|
|
||||||
changed.
|
|
||||||
</Notice>
|
|
||||||
)}
|
|
||||||
{hostKey.trusted && !hostKey.changed && <Notice kind="ok">This host key is already trusted.</Notice>}
|
|
||||||
<div className="small muted">
|
|
||||||
Compare this with the output of <span className="mono">ssh-keyscan -t {hostKey.keyType} {hostKey.host}</span>{' '}
|
|
||||||
run on the target itself, or with <span className="mono">ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub</span>.
|
|
||||||
</div>
|
|
||||||
<div className="fingerprint">
|
|
||||||
{hostKey.keyType}<br />
|
|
||||||
{hostKey.fingerprint}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{preview && <PreviewModal data={preview} onClose={() => setPreview(null)} />}
|
{preview && <PreviewModal data={preview} onClose={() => setPreview(null)} />}
|
||||||
@@ -382,79 +353,8 @@ function ConnectionDialog({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="stack" style={{ gap: 12 }}>
|
<div className="stack" style={{ gap: 12 }}>
|
||||||
<div className="row" style={{ gap: 12 }}>
|
<Field label="label"><input type="text" value={c.name ?? ''} onChange={(e) => set('name', e.target.value)} /></Field>
|
||||||
<Field label="label"><input type="text" value={c.name ?? ''} onChange={(e) => set('name', e.target.value)} /></Field>
|
<SshFields value={c} set={set} where="target" />
|
||||||
<Field label="host"><input type="text" value={c.host ?? ''} onChange={(e) => set('host', e.target.value)} /></Field>
|
|
||||||
<div style={{ width: 90 }}>
|
|
||||||
<Field label="port">
|
|
||||||
<input type="number" value={c.port ?? 22} onChange={(e) => set('port', Number(e.target.value))} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="row" style={{ gap: 12 }}>
|
|
||||||
<Field label="user"><input type="text" value={c.user ?? ''} onChange={(e) => set('user', e.target.value)} /></Field>
|
|
||||||
<Field label="authentication">
|
|
||||||
<select value={c.auth ?? 'password'} onChange={(e) => set('auth', e.target.value as Connection['auth'])}>
|
|
||||||
<option value="password">password</option>
|
|
||||||
<option value="key">private key</option>
|
|
||||||
<option value="agent">ssh agent</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{c.auth === 'password' && (
|
|
||||||
<Field label="password">
|
|
||||||
<input type="password" value={c.password ?? ''} onChange={(e) => set('password', e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{c.auth === 'key' && (
|
|
||||||
<>
|
|
||||||
<Field label="private key path on this machine (leave empty to paste the key below)">
|
|
||||||
<input type="text" placeholder="/root/.ssh/id_ed25519" value={c.privateKeyPath ?? ''} onChange={(e) => set('privateKeyPath', e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
<Field label="or paste the private key">
|
|
||||||
<textarea rows={5} value={c.privateKey ?? ''} onChange={(e) => set('privateKey', e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
|
|
||||||
</Field>
|
|
||||||
<Field label="passphrase (if the key is encrypted)">
|
|
||||||
<input type="password" value={c.passphrase ?? ''} onChange={(e) => set('passphrase', e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{c.auth === 'agent' && (
|
|
||||||
<div className="small muted">
|
|
||||||
Uses the agent at <span className="mono">$SSH_AUTH_SOCK</span> of the process running dockmv.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Check
|
|
||||||
checked={c.sudo ?? false}
|
|
||||||
onChange={(v) => set('sudo', v)}
|
|
||||||
label="run docker through sudo -n on the target"
|
|
||||||
title="Needed when the login user is not in the docker group. sudo must not ask for a password."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Field label="docker command on the target (optional)">
|
|
||||||
<input type="text" placeholder="docker" value={c.dockerCmd ?? ''} onChange={(e) => set('dockerCmd', e.target.value)} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Check
|
|
||||||
checked={c.saveSecrets ?? false}
|
|
||||||
onChange={(v) => set('saveSecrets', v)}
|
|
||||||
label="remember the password / key on disk"
|
|
||||||
/>
|
|
||||||
{c.saveSecrets ? (
|
|
||||||
<Notice kind="warn">
|
|
||||||
Credentials are stored in plain text in the connections file, readable only by this user. Leave this off to
|
|
||||||
keep them in memory for this session only.
|
|
||||||
</Notice>
|
|
||||||
) : (
|
|
||||||
<div className="small faint">
|
|
||||||
Credentials stay in memory and are lost when dockmv restarts.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { api, ApiError } from './api'
|
||||||
|
import type { Connection, HostKeyInfo, Source, SourceKind, SourceStatus } from './types'
|
||||||
|
import { Field, Modal, Notice } from './ui'
|
||||||
|
import { HostKeyDialog, SshFields } from './SshFields'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SourcePanel picks the host containers are read from: the daemon dockmv runs
|
||||||
|
* next to, another daemon by address, or a remote host over SSH.
|
||||||
|
*/
|
||||||
|
export function SourcePanel({
|
||||||
|
sources, selected, status, selectSource, reload, onError,
|
||||||
|
}: {
|
||||||
|
sources: Source[]
|
||||||
|
selected: string
|
||||||
|
status: SourceStatus | null
|
||||||
|
/** Switches the whole UI to another source; rejects so the caller can react. */
|
||||||
|
selectSource: (id: string) => Promise<void>
|
||||||
|
reload: () => Promise<void>
|
||||||
|
onError: (msg: string) => void
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState<Partial<Source> | null>(null)
|
||||||
|
const [hostKey, setHostKey] = useState<HostKeyInfo | null>(null)
|
||||||
|
const [busy, setBusy] = useState('')
|
||||||
|
const [trustFor, setTrustFor] = useState('')
|
||||||
|
|
||||||
|
const current = sources.find((s) => s.id === selected)
|
||||||
|
const isLocal = !current || current.kind === 'local'
|
||||||
|
const isSSH = current?.kind === 'ssh'
|
||||||
|
|
||||||
|
async function withBusy(what: string, id: string, fn: () => Promise<void>) {
|
||||||
|
setBusy(what)
|
||||||
|
try {
|
||||||
|
await fn()
|
||||||
|
} catch (e) {
|
||||||
|
// An untrusted host key is a decision for the operator, not an error.
|
||||||
|
if (e instanceof ApiError && e.needsTrust) {
|
||||||
|
setTrustFor(id)
|
||||||
|
await showHostKey(id)
|
||||||
|
} else {
|
||||||
|
onError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showHostKey(id: string) {
|
||||||
|
try {
|
||||||
|
setHostKey(await api.probeSource(id))
|
||||||
|
} catch (e) {
|
||||||
|
onError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trustHostKey() {
|
||||||
|
const id = trustFor || selected
|
||||||
|
if (!hostKey) return
|
||||||
|
await withBusy('trust', id, async () => {
|
||||||
|
await api.trustSource(id, hostKey.fingerprint)
|
||||||
|
setHostKey(null)
|
||||||
|
await selectSource(id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section">
|
||||||
|
<h3>source host</h3>
|
||||||
|
<div className="stack">
|
||||||
|
<div className="row">
|
||||||
|
<select
|
||||||
|
value={selected}
|
||||||
|
disabled={!!busy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value
|
||||||
|
void withBusy('select', id, () => selectSource(id))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{sourceLabel(s)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn tiny"
|
||||||
|
onClick={() => setEditing({ kind: 'ssh', ssh: { port: 22, auth: 'password', saveSecrets: false, sudo: false } as Connection })}
|
||||||
|
>
|
||||||
|
new
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row wrap" style={{ gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className="btn tiny"
|
||||||
|
disabled={!!busy}
|
||||||
|
onClick={() => void withBusy('select', selected, () => selectSource(selected))}
|
||||||
|
>
|
||||||
|
{busy === 'select' ? 'connecting…' : 'reconnect'}
|
||||||
|
</button>
|
||||||
|
{!isLocal && <button className="btn tiny" onClick={() => setEditing(current)}>edit</button>}
|
||||||
|
{isSSH && <button className="btn tiny" onClick={() => { setTrustFor(selected); void showHostKey(selected) }}>host key</button>}
|
||||||
|
{!isLocal && (
|
||||||
|
<button
|
||||||
|
className="btn tiny danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (!current || !confirm(`Delete source "${current.name}"?`)) return
|
||||||
|
void withBusy('del', current.id, async () => {
|
||||||
|
await api.deleteSource(current.id)
|
||||||
|
await reload()
|
||||||
|
// Deleting the source in use drops back to the local daemon.
|
||||||
|
if (selected === current.id) await selectSource('local')
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.error && <Notice kind="err">{status.error}</Notice>}
|
||||||
|
|
||||||
|
{status && !status.error && (
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>reached by</dt><dd className="mono">{status.endpoint}</dd>
|
||||||
|
{status.dockerVersion && <><dt>docker</dt><dd>{status.dockerVersion}</dd></>}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSSH && (
|
||||||
|
<div className="small faint">
|
||||||
|
Data is streamed through dockmv: source → this host → target. A local source moves it in one hop.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<SourceDialog
|
||||||
|
initial={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={async (s) => {
|
||||||
|
setEditing(null)
|
||||||
|
// The list is refreshed first: selecting an id the dropdown does not
|
||||||
|
// know about yet would leave it blank.
|
||||||
|
await reload()
|
||||||
|
await withBusy('select', s.id, () => selectSource(s.id))
|
||||||
|
}}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hostKey && (
|
||||||
|
<HostKeyDialog info={hostKey} onClose={() => setHostKey(null)} onTrust={trustHostKey} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(s: Source): string {
|
||||||
|
switch (s.kind) {
|
||||||
|
case 'local':
|
||||||
|
return `${s.name} (local docker)`
|
||||||
|
case 'docker':
|
||||||
|
return `${s.name} (${s.dockerHost})`
|
||||||
|
default:
|
||||||
|
return `${s.name} (ssh ${s.ssh?.user}@${s.ssh?.host})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceDialog({
|
||||||
|
initial, onClose, onSaved, onError,
|
||||||
|
}: {
|
||||||
|
initial: Partial<Source>
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (s: Source) => void | Promise<void>
|
||||||
|
onError: (m: string) => void
|
||||||
|
}) {
|
||||||
|
const [s, setS] = useState<Partial<Source>>(initial)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const kind: SourceKind = s.kind ?? 'ssh'
|
||||||
|
const ssh = (s.ssh ?? {}) as Partial<Connection>
|
||||||
|
|
||||||
|
function setSSH<K extends keyof Connection>(k: K, v: Connection[K]) {
|
||||||
|
setS((prev) => ({ ...prev, ssh: { ...(prev.ssh as Connection), [k]: v } as Connection }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = kind === 'ssh' ? !!ssh.host && !!ssh.user : !!s.dockerHost
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={initial.id ? `Edit ${initial.name}` : 'New source host'}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={onClose}>cancel</button>
|
||||||
|
<button
|
||||||
|
className="btn primary"
|
||||||
|
disabled={saving || !valid}
|
||||||
|
onClick={async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
// The body is built field by field: the API rejects unknown ones.
|
||||||
|
await onSaved(await api.saveSource(payload(s, kind)))
|
||||||
|
} catch (e) {
|
||||||
|
onError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? 'saving…' : 'save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="stack" style={{ gap: 12 }}>
|
||||||
|
<div className="row" style={{ gap: 12 }}>
|
||||||
|
<Field label="label">
|
||||||
|
<input type="text" value={s.name ?? ''} onChange={(e) => setS((p) => ({ ...p, name: e.target.value }))} />
|
||||||
|
</Field>
|
||||||
|
<Field label="reached by">
|
||||||
|
<select value={kind} onChange={(e) => setS((p) => ({ ...p, kind: e.target.value as SourceKind }))}>
|
||||||
|
<option value="ssh">ssh — remote host, driven through its docker CLI</option>
|
||||||
|
<option value="docker">docker address — a daemon this host can reach</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === 'docker' ? (
|
||||||
|
<>
|
||||||
|
<Field label="docker address">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="tcp://10.0.0.5:2375"
|
||||||
|
value={s.dockerHost ?? ''}
|
||||||
|
onChange={(e) => setS((p) => ({ ...p, dockerHost: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="small muted">
|
||||||
|
Any address the docker CLI accepts: <span className="mono">tcp://host:2375</span>, or another socket with{' '}
|
||||||
|
<span className="mono">unix:///path/docker.sock</span>. A TLS-protected daemon uses the certificates from{' '}
|
||||||
|
<span className="mono">DOCKER_CERT_PATH</span> in dockmv's own environment.
|
||||||
|
</div>
|
||||||
|
<Notice kind="warn">
|
||||||
|
A plain <span className="mono">tcp://</span> daemon is unauthenticated: anyone who can reach that port is
|
||||||
|
root on that host. Prefer an ssh source unless the port is already protected.
|
||||||
|
</Notice>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SshFields value={ssh} set={setSSH} where="source host" />
|
||||||
|
<div className="small muted">
|
||||||
|
Needs <span className="mono">sshd</span> and a docker CLI of 18.09 or newer on that host — the API is
|
||||||
|
tunnelled through <span className="mono">docker system dial-stdio</span>. Nothing is installed.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** payload keeps the request to the fields the server knows about. */
|
||||||
|
function payload(s: Partial<Source>, kind: SourceKind): Partial<Source> {
|
||||||
|
const out: Partial<Source> = { id: s.id, name: s.name, kind }
|
||||||
|
if (kind === 'docker') {
|
||||||
|
out.dockerHost = s.dockerHost
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
const c = (s.ssh ?? {}) as Partial<Connection>
|
||||||
|
out.ssh = {
|
||||||
|
host: c.host ?? '',
|
||||||
|
port: c.port ?? 22,
|
||||||
|
user: c.user ?? '',
|
||||||
|
auth: c.auth ?? 'password',
|
||||||
|
password: c.password,
|
||||||
|
privateKey: c.privateKey,
|
||||||
|
privateKeyPath: c.privateKeyPath,
|
||||||
|
passphrase: c.passphrase,
|
||||||
|
sudo: c.sudo ?? false,
|
||||||
|
dockerCmd: c.dockerCmd,
|
||||||
|
saveSecrets: c.saveSecrets ?? false,
|
||||||
|
} as Connection
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { Connection, HostKeyInfo } from './types'
|
||||||
|
import { Check, Field, Modal, Notice } from './ui'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SshFields is the credential form shared by target connections and SSH
|
||||||
|
* sources: both are the same sshx.Config on the server, so they are edited the
|
||||||
|
* same way. `where` only changes the wording.
|
||||||
|
*/
|
||||||
|
export function SshFields({
|
||||||
|
value, set, where,
|
||||||
|
}: {
|
||||||
|
value: Partial<Connection>
|
||||||
|
set: <K extends keyof Connection>(k: K, v: Connection[K]) => void
|
||||||
|
where: 'target' | 'source host'
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ gap: 12 }}>
|
||||||
|
<Field label="host"><input type="text" value={value.host ?? ''} onChange={(e) => set('host', e.target.value)} /></Field>
|
||||||
|
<div style={{ width: 90 }}>
|
||||||
|
<Field label="port">
|
||||||
|
<input type="number" value={value.port ?? 22} onChange={(e) => set('port', Number(e.target.value))} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row" style={{ gap: 12 }}>
|
||||||
|
<Field label="user"><input type="text" value={value.user ?? ''} onChange={(e) => set('user', e.target.value)} /></Field>
|
||||||
|
<Field label="authentication">
|
||||||
|
<select value={value.auth ?? 'password'} onChange={(e) => set('auth', e.target.value as Connection['auth'])}>
|
||||||
|
<option value="password">password</option>
|
||||||
|
<option value="key">private key</option>
|
||||||
|
<option value="agent">ssh agent</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{value.auth === 'password' && (
|
||||||
|
<Field label="password">
|
||||||
|
<input type="password" value={value.password ?? ''} onChange={(e) => set('password', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value.auth === 'key' && (
|
||||||
|
<>
|
||||||
|
<Field label="private key path on this machine (leave empty to paste the key below)">
|
||||||
|
<input type="text" placeholder="/root/.ssh/id_ed25519" value={value.privateKeyPath ?? ''} onChange={(e) => set('privateKeyPath', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="or paste the private key">
|
||||||
|
<textarea rows={5} value={value.privateKey ?? ''} onChange={(e) => set('privateKey', e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" />
|
||||||
|
</Field>
|
||||||
|
<Field label="passphrase (if the key is encrypted)">
|
||||||
|
<input type="password" value={value.passphrase ?? ''} onChange={(e) => set('passphrase', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{value.auth === 'agent' && (
|
||||||
|
<div className="small muted">
|
||||||
|
Uses the agent at <span className="mono">$SSH_AUTH_SOCK</span> of the process running dockmv.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Check
|
||||||
|
checked={value.sudo ?? false}
|
||||||
|
onChange={(v) => set('sudo', v)}
|
||||||
|
label={`run docker through sudo -n on the ${where}`}
|
||||||
|
title="Needed when the login user is not in the docker group. sudo must not ask for a password."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field label={`docker command on the ${where} (optional)`}>
|
||||||
|
<input type="text" placeholder="docker" value={value.dockerCmd ?? ''} onChange={(e) => set('dockerCmd', e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Check
|
||||||
|
checked={value.saveSecrets ?? false}
|
||||||
|
onChange={(v) => set('saveSecrets', v)}
|
||||||
|
label="remember the password / key on disk"
|
||||||
|
/>
|
||||||
|
{value.saveSecrets ? (
|
||||||
|
<Notice kind="warn">
|
||||||
|
Credentials are stored in plain text in dockmv's data directory, readable only by this user. Leave this off to
|
||||||
|
keep them in memory for this session only.
|
||||||
|
</Notice>
|
||||||
|
) : (
|
||||||
|
<div className="small faint">
|
||||||
|
Credentials stay in memory and are lost when dockmv restarts.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HostKeyDialog puts an unknown or changed SSH fingerprint in front of the
|
||||||
|
* operator. It is used for both source and target hosts.
|
||||||
|
*/
|
||||||
|
export function HostKeyDialog({
|
||||||
|
info, onClose, onTrust,
|
||||||
|
}: {
|
||||||
|
info: HostKeyInfo
|
||||||
|
onClose: () => void
|
||||||
|
onTrust: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="SSH host key"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={onClose}>cancel</button>
|
||||||
|
<button className="btn primary" onClick={onTrust}>
|
||||||
|
{info.changed ? 'replace the stored key and trust' : 'trust this host'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="stack">
|
||||||
|
{info.changed && (
|
||||||
|
<Notice kind="err">
|
||||||
|
The key presented by this host is <b>different</b> from the one recorded earlier. This happens after a
|
||||||
|
reinstall — but it is also what a machine-in-the-middle looks like. Only continue if you know why it
|
||||||
|
changed.
|
||||||
|
</Notice>
|
||||||
|
)}
|
||||||
|
{info.trusted && !info.changed && <Notice kind="ok">This host key is already trusted.</Notice>}
|
||||||
|
<div className="small muted">
|
||||||
|
Compare this with the output of <span className="mono">ssh-keyscan -t {info.keyType} {info.host}</span>{' '}
|
||||||
|
run on the host itself, or with <span className="mono">ssh-keygen -lf /etc/ssh/ssh_host_*_key.pub</span>.
|
||||||
|
</div>
|
||||||
|
<div className="fingerprint">
|
||||||
|
{info.keyType}<br />
|
||||||
|
{info.fingerprint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
|
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
|
||||||
Preflight, PreviewResponse, SourceResponse, TargetInventory,
|
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
// The token, when the server requires one, arrives as a query parameter the
|
// The token, when the server requires one, arrives as a query parameter the
|
||||||
@@ -65,6 +65,14 @@ export const api = {
|
|||||||
source: () => request<SourceResponse>('/api/source'),
|
source: () => request<SourceResponse>('/api/source'),
|
||||||
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
|
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
|
||||||
|
|
||||||
|
sources: () => request<SourcesResponse>('/api/sources'),
|
||||||
|
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
|
||||||
|
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
|
||||||
|
selectSource: (id: string) => post<SourceStatus>(`/api/sources/${id}/select`),
|
||||||
|
probeSource: (id: string) => post<HostKeyInfo>(`/api/sources/${id}/probe`),
|
||||||
|
trustSource: (id: string, fingerprint: string) =>
|
||||||
|
post<{ trusted: boolean }>(`/api/sources/${id}/trust`, { fingerprint }),
|
||||||
|
|
||||||
connections: () => request<Connection[]>('/api/connections'),
|
connections: () => request<Connection[]>('/api/connections'),
|
||||||
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
|
saveConnection: (c: Partial<Connection>) => post<Connection>('/api/connections', c),
|
||||||
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
|
deleteConnection: (id: string) => request<void>(`/api/connections/${id}`, { method: 'DELETE' }),
|
||||||
|
|||||||
@@ -120,6 +120,35 @@ export interface SourceResponse {
|
|||||||
options: Options
|
options: Options
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How a source daemon is reached. 'local' is the built-in, unremovable one. */
|
||||||
|
export type SourceKind = 'local' | 'docker' | 'ssh'
|
||||||
|
|
||||||
|
export interface Source {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: SourceKind
|
||||||
|
/** Daemon address for the 'docker' kind, e.g. tcp://10.0.0.5:2375. */
|
||||||
|
dockerHost?: string
|
||||||
|
/** Remote host for the 'ssh' kind; the same shape as a target connection. */
|
||||||
|
ssh?: Connection
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourceStatus {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: SourceKind
|
||||||
|
endpoint: string
|
||||||
|
dockerVersion?: string
|
||||||
|
connected: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SourcesResponse {
|
||||||
|
sources: Source[]
|
||||||
|
selected: string
|
||||||
|
current: SourceStatus | null
|
||||||
|
}
|
||||||
|
|
||||||
export type AuthMethod = 'password' | 'key' | 'agent'
|
export type AuthMethod = 'password' | 'key' | 'agent'
|
||||||
|
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
@@ -253,6 +282,7 @@ export interface Health {
|
|||||||
dockerHost: string
|
dockerHost: string
|
||||||
dockerVersion?: string
|
dockerVersion?: string
|
||||||
dockerError?: string
|
dockerError?: string
|
||||||
|
source?: SourceStatus
|
||||||
packageDir: string
|
packageDir: string
|
||||||
dataDir: string
|
dataDir: string
|
||||||
knownHosts: string
|
knownHosts: string
|
||||||
|
|||||||
Reference in new issue
Block a user