diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fcf71d0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.pytest_cache +__pycache__ +*.pyc +*.pyo +*.pyd +build +dist +ci-logs +docs +tests +imports +site +voxvera/host +voxvera/resources/bin diff --git a/Dockerfile b/Dockerfile index 53be4c9..348eafb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,8 @@ FROM python:3.11-slim # persistent-host deployment target. # Install system dependencies (only runtime) -RUN apt-get update && apt-get install -y \ - tor onionshare-cli \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + tini tor onionshare-cli \ && rm -rf /var/lib/apt/lists/* # Copy VoxVera source @@ -13,12 +13,18 @@ COPY . /opt/voxvera WORKDIR /opt/voxvera # Install VoxVera and its Python dependencies -RUN pip install . +RUN pip install . \ + && chmod +x scripts/docker-entrypoint.sh scripts/docker-healthcheck.sh \ + && ln -sf /opt/voxvera/scripts/docker-entrypoint.sh /usr/local/bin/voxvera-docker-entrypoint \ + && ln -sf /opt/voxvera/scripts/docker-healthcheck.sh /usr/local/bin/voxvera-docker-healthcheck # Prepare flyers volume RUN mkdir /flyers ENV VOXVERA_DIR=/flyers +ENV VOXVERA_RUNTIME=docker VOLUME /flyers -# Seed a default config when the volume is empty, then retry startup periodically. -CMD ["sh", "-lc", "mkdir -p /flyers && if [ ! -f /flyers/config.json ]; then cp /opt/voxvera/voxvera/src/config.json /flyers/config.json; fi && voxvera quickstart --non-interactive && while true; do sleep 300; voxvera start-all || true; done"] +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 CMD ["voxvera-docker-healthcheck"] + +ENTRYPOINT ["tini", "--", "voxvera-docker-entrypoint"] +CMD [] diff --git a/docs/ar/docker.md b/docs/ar/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/ar/docker.md +++ b/docs/ar/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/de/docker.md b/docs/de/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/de/docker.md +++ b/docs/de/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/en/docker.md b/docs/en/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/en/docker.md +++ b/docs/en/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/es/docker.md b/docs/es/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/es/docker.md +++ b/docs/es/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/fa/docker.md b/docs/fa/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/fa/docker.md +++ b/docs/fa/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/fr/docker.md b/docs/fr/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/fr/docker.md +++ b/docs/fr/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/he/docker.md b/docs/he/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/he/docker.md +++ b/docs/he/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/hi/docker.md b/docs/hi/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/hi/docker.md +++ b/docs/hi/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/ja/docker.md b/docs/ja/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/ja/docker.md +++ b/docs/ja/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/macos-validation-checklist.md b/docs/macos-validation-checklist.md new file mode 100644 index 0000000..f4c7f2c --- /dev/null +++ b/docs/macos-validation-checklist.md @@ -0,0 +1,173 @@ +# macOS Validation Checklist + +This checklist is for validating the experimental macOS CLI path on a real macOS machine. + +Do not promote macOS above `experimental` until this checklist has been completed successfully on at least one native host and the results have been reviewed. + +## Goal + +Validate whether the current macOS runtime can: + +1. install cleanly +2. build and serve a flyer +3. preserve the OnionShare session identity +4. recover after login/reboot/offline periods +5. surface understandable logs and status + +## Environment Notes + +- This checklist assumes the CLI runtime, not the Electron GUI. +- Prefer a clean machine or disposable user account. +- Record the exact macOS version, machine type, and install path used. + +## 1. Install Surface + +Record which install path was used: + +- raw binary +- DMG +- `pipx` +- `install.sh` + +Capture: + +```bash +voxvera --version +voxvera platform-status --json +voxvera doctor --json +voxvera autostart status --json +``` + +Pass criteria: + +- VoxVera launches +- `platform-status` reports `macos_cli` +- `doctor` runs without crashing +- `autostart status` runs without crashing + +## 2. Build + Serve + +Create a fresh working directory and config: + +```bash +mkdir -p ~/voxvera-test +export VOXVERA_DIR=~/voxvera-test +voxvera init --non-interactive +voxvera build +voxvera serve +``` + +Capture: + +- whether `index.html` is created under `~/voxvera-test/host/voxvera/` +- whether `.onionshare-session` appears +- whether an `.onion` URL is printed +- whether `onionshare.log` is created + +Pass criteria: + +- build completes +- serve completes +- `.onionshare-session` exists +- `onionshare.log` exists + +## 3. Identity Persistence + +Stop and restart the site: + +```bash +voxvera stop-all +voxvera start-all +``` + +Capture: + +- whether the same `.onion` URL returns +- whether `.onionshare-session` remains unchanged + +Pass criteria: + +- the onion identity persists across restart + +## 4. Autostart Install + +Install the experimental `launchd` path: + +```bash +voxvera autostart +voxvera autostart status --json +``` + +Capture: + +- generated plist path +- any `launchctl` output or errors + +Pass criteria: + +- the plist is created +- `autostart status` reports installed artifacts + +## 5. Login / Reboot Recovery + +After autostart is installed: + +1. reboot the Mac or log out and back in +2. wait a few minutes +3. check: + +```bash +voxvera doctor --json +voxvera autostart status --json +voxvera start-all +``` + +Capture: + +- whether hosting resumed automatically +- whether the onion identity persisted +- whether the process had to be restarted manually + +## 6. Offline / Reconnect Recovery + +While a site is running: + +1. disconnect the machine from the network +2. wait at least 2 minutes +3. reconnect +4. wait a few minutes + +Capture: + +- whether hosting recovered automatically +- whether manual `voxvera start-all` was required +- any errors in `onionshare.log` + +## 7. Logs And Status + +Capture the practical debugging workflow: + +```bash +voxvera doctor --json +voxvera autostart status --json +ls -la ~/voxvera-test/host/voxvera +``` + +Document: + +- where logs live +- what failed if recovery did not work +- any permissions/path issues + +## Report Back + +A useful tester report should include: + +- macOS version +- install path used +- whether build worked +- whether serve worked +- whether the onion identity persisted +- whether login/reboot recovery worked +- whether offline/reconnect recovery worked +- exact command output or error text for failures diff --git a/docs/native-tester-guide.md b/docs/native-tester-guide.md new file mode 100644 index 0000000..a0344c3 --- /dev/null +++ b/docs/native-tester-guide.md @@ -0,0 +1,50 @@ +# Native Tester Guide + +Use this guide when asking someone on macOS or Windows to validate VoxVera. + +## Scope + +VoxVera is not just an installer test. The real question is whether it can function as a reliable Tor hidden-service host on that platform. + +That means every tester should be asked to validate: + +1. install +2. build +3. serve +4. onion identity persistence +5. login/reboot recovery +6. offline/reconnect recovery +7. logs and diagnostics + +## Canonical Checklists + +- macOS: `docs/macos-validation-checklist.md` +- Windows: `docs/windows-validation-checklist.md` + +## What To Ask Testers To Send Back + +- platform and OS version +- install path used +- `voxvera platform-status --json` +- `voxvera doctor --json` +- `voxvera autostart status --json` +- whether `voxvera serve` produced an `.onion` URL +- whether `.onionshare-session` persisted after restart +- whether hosting resumed after reboot/login +- whether hosting resumed after network reconnect +- exact error output for any failure + +## What Not To Conclude Prematurely + +- successful installation does not prove hosting works +- successful hosting once does not prove recovery works +- binary availability does not prove platform parity + +## Current Repo Position + +- Linux CLI with `systemd --user`: supported +- Docker CLI: experimental +- macOS CLI: experimental +- Windows CLI: experimental + +Do not promote macOS or Windows above `experimental` without native recovery evidence. diff --git a/docs/platform-support-matrix.md b/docs/platform-support-matrix.md index 6aec889..479d8c1 100644 --- a/docs/platform-support-matrix.md +++ b/docs/platform-support-matrix.md @@ -49,3 +49,11 @@ voxvera autostart ``` That is the current reference deployment model the rest of the platform work should converge toward. + +## Native Validation + +When validating the experimental non-Linux paths on real machines, use: + +- [macOS checklist](/home/user/Documents/GitHub/VoxVera/docs/macos-validation-checklist.md) +- [Windows checklist](/home/user/Documents/GitHub/VoxVera/docs/windows-validation-checklist.md) +- [Native tester guide](/home/user/Documents/GitHub/VoxVera/docs/native-tester-guide.md) diff --git a/docs/pt/docker.md b/docs/pt/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/pt/docker.md +++ b/docs/pt/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/roadmap-platform-parity.md b/docs/roadmap-platform-parity.md index 82be4f6..5842d7f 100644 --- a/docs/roadmap-platform-parity.md +++ b/docs/roadmap-platform-parity.md @@ -258,6 +258,16 @@ These target names are contract-level validation entry points. Only `linux-cli` 1. Add `voxvera doctor`. 2. Add `voxvera autostart status`. + +## Native Validation Assets + +The repo now includes concrete native-host validation docs for the two major experimental desktop targets: + +- `docs/macos-validation-checklist.md` +- `docs/windows-validation-checklist.md` +- `docs/native-tester-guide.md` + +These should be used for the first real macOS/Windows validation cycles so testers capture the runtime evidence needed for future tier promotion. 3. Add the platform adapter scaffold. 4. Capture Linux reboot/offline recovery validation steps in docs and CI notes. 5. Pick Docker's canonical runtime model. diff --git a/docs/ru/docker.md b/docs/ru/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/ru/docker.md +++ b/docs/ru/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/sw/docker.md b/docs/sw/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/sw/docker.md +++ b/docs/sw/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/templates/docker.md b/docs/templates/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/templates/docker.md +++ b/docs/templates/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/tr/docker.md b/docs/tr/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/tr/docker.md +++ b/docs/tr/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/docs/windows-validation-checklist.md b/docs/windows-validation-checklist.md new file mode 100644 index 0000000..a3a486d --- /dev/null +++ b/docs/windows-validation-checklist.md @@ -0,0 +1,172 @@ +# Windows Validation Checklist + +This checklist is for validating the experimental Windows CLI path on a real Windows machine. + +Do not promote Windows above `experimental` until this checklist has been completed successfully on at least one native host and the results have been reviewed. + +## Goal + +Validate whether the current Windows runtime can: + +1. install cleanly +2. build and serve a flyer +3. preserve the OnionShare session identity +4. recover after login/reboot/offline periods +5. surface understandable logs and status + +## Environment Notes + +- This checklist assumes the CLI runtime, not the Electron GUI. +- Prefer a clean Windows machine or VM snapshot. +- Record the exact Windows version and install path used. + +## 1. Install Surface + +Record which install path was used: + +- standalone `.exe` +- PowerShell installer +- Chocolatey + +Capture in PowerShell: + +```powershell +voxvera --version +voxvera platform-status --json +voxvera doctor --json +voxvera autostart status --json +``` + +Pass criteria: + +- VoxVera launches +- `platform-status` reports `windows_cli` +- `doctor` runs without crashing +- `autostart status` runs without crashing + +## 2. Build + Serve + +Create a fresh working directory and config: + +```powershell +$env:VOXVERA_DIR = "$HOME\\voxvera-test" +New-Item -ItemType Directory -Force $env:VOXVERA_DIR | Out-Null +voxvera init --non-interactive +voxvera build +voxvera serve +``` + +Capture: + +- whether `index.html` is created under `%USERPROFILE%\\voxvera-test\\host\\voxvera\\` +- whether `.onionshare-session` appears +- whether an `.onion` URL is printed +- whether `onionshare.log` is created + +Pass criteria: + +- build completes +- serve completes +- `.onionshare-session` exists +- `onionshare.log` exists + +## 3. Identity Persistence + +Stop and restart the site: + +```powershell +voxvera stop-all +voxvera start-all +``` + +Capture: + +- whether the same `.onion` URL returns +- whether `.onionshare-session` remains unchanged + +Pass criteria: + +- the onion identity persists across restart + +## 4. Autostart Install + +Install the experimental Task Scheduler path: + +```powershell +voxvera autostart +voxvera autostart status --json +``` + +Capture: + +- scheduled task name +- any `schtasks` output or errors + +Pass criteria: + +- scheduled task is created +- `autostart status` reports installed artifacts + +## 5. Login / Reboot Recovery + +After autostart is installed: + +1. reboot the machine or log out and back in +2. wait a few minutes +3. check: + +```powershell +voxvera doctor --json +voxvera autostart status --json +voxvera start-all +``` + +Capture: + +- whether hosting resumed automatically +- whether the onion identity persisted +- whether the process had to be restarted manually + +## 6. Offline / Reconnect Recovery + +While a site is running: + +1. disconnect the machine from the network +2. wait at least 2 minutes +3. reconnect +4. wait a few minutes + +Capture: + +- whether hosting recovered automatically +- whether manual `voxvera start-all` was required +- any errors in `onionshare.log` + +## 7. Logs And Status + +Capture the practical debugging workflow: + +```powershell +voxvera doctor --json +voxvera autostart status --json +Get-ChildItem "$HOME\\voxvera-test\\host\\voxvera" -Force +``` + +Document: + +- where logs live +- what failed if recovery did not work +- any permissions/path issues + +## Report Back + +A useful tester report should include: + +- Windows version +- install path used +- whether build worked +- whether serve worked +- whether the onion identity persisted +- whether login/reboot recovery worked +- whether offline/reconnect recovery worked +- exact command output or error text for failures diff --git a/docs/zh/docker.md b/docs/zh/docker.md index d7d7373..6eb3d1d 100644 --- a/docs/zh/docker.md +++ b/docs/zh/docker.md @@ -11,9 +11,38 @@ docker pull ghcr.io/voxvera/voxvera:latest # run the experimental container with flyers stored in ./flyers mkdir -p flyers -docker run -it --rm -v "$(pwd)/flyers:/flyers" ghcr.io/voxvera/voxvera +docker run -d \ + --name voxvera \ + --restart unless-stopped \ + -v "$(pwd)/flyers:/flyers" \ + ghcr.io/voxvera/voxvera:latest ``` -The container stores runtime state under `/flyers`, seeds a default `config.json` if needed, and retries `voxvera start-all` periodically after the initial `quickstart`. +The container now uses a dedicated entrypoint that: +- stores runtime state under `/flyers` +- seeds `/flyers/config.json` if needed +- starts an in-container Tor daemon on `127.0.0.1:9050/9051` +- bootstraps a default site when `/flyers/host` is empty +- runs `voxvera start-all` on startup and retries it periodically +- exposes a container healthcheck based on `voxvera doctor --json` + +Useful commands: + +```bash +docker logs -f voxvera +docker exec -it voxvera voxvera doctor --json +docker exec -it voxvera voxvera autostart status --json +``` + +If you prefer Compose: + +```yaml +services: + voxvera: + image: ghcr.io/voxvera/voxvera:latest + restart: unless-stopped + volumes: + - ./flyers:/flyers +``` Use this path for testing only until the container runtime and background recovery behavior are validated end-to-end. diff --git a/scripts/check.sh b/scripts/check.sh index 6b0b3cd..5a30b77 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -21,6 +21,10 @@ python3 -m voxvera.cli --lang en check if [[ "$(uname -s)" == "Linux" ]]; then echo "==> platform smoke" bash scripts/platform-smoke.sh linux-cli + if command -v docker >/dev/null 2>&1; then + echo "==> docker runtime smoke" + bash scripts/docker-runtime-smoke.sh + fi fi echo "==> build docs" diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..002657e --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +VOXVERA_DIR="${VOXVERA_DIR:-/flyers}" +VOXVERA_CONFIG_PATH="${VOXVERA_CONFIG_PATH:-$VOXVERA_DIR/config.json}" +VOXVERA_START_INTERVAL="${VOXVERA_START_INTERVAL:-300}" +VOXVERA_BOOTSTRAP_DEFAULT_SITE="${VOXVERA_BOOTSTRAP_DEFAULT_SITE:-1}" +TOR_SOCKS_PORT="${TOR_SOCKS_PORT:-9050}" +TOR_CONTROL_PORT="${TOR_CONTROL_PORT:-9051}" +VOXVERA_TOR_DIR="${VOXVERA_TOR_DIR:-/tmp/voxvera-tor}" + +TOR_PID="" +SLEEP_PID="" + +log() { + printf '[voxvera-docker] %s\n' "$*" +} + +cleanup() { + if [[ -n "$SLEEP_PID" ]] && kill -0 "$SLEEP_PID" 2>/dev/null; then + kill "$SLEEP_PID" 2>/dev/null || true + fi + voxvera stop-all >/dev/null 2>&1 || true + if [[ -n "$TOR_PID" ]] && kill -0 "$TOR_PID" 2>/dev/null; then + kill "$TOR_PID" 2>/dev/null || true + wait "$TOR_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +wait_for_port() { + local host="$1" + local port="$2" + python3 - "$host" "$port" <<'PY' +import socket +import sys +import time + +host = sys.argv[1] +port = int(sys.argv[2]) +deadline = time.time() + 60 + +while time.time() < deadline: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(1) + if sock.connect_ex((host, port)) == 0: + sys.exit(0) + time.sleep(1) + +sys.exit(1) +PY +} + +has_site_configs() { + find "$VOXVERA_DIR/host" -mindepth 2 -maxdepth 2 -name config.json -print -quit 2>/dev/null | grep -q . +} + +seed_default_config() { + mkdir -p "$VOXVERA_DIR" + if [[ ! -f "$VOXVERA_CONFIG_PATH" ]]; then + cp /opt/voxvera/voxvera/src/config.json "$VOXVERA_CONFIG_PATH" + log "Seeded default config at $VOXVERA_CONFIG_PATH" + fi +} + +bootstrap_default_site_if_needed() { + if [[ "$VOXVERA_BOOTSTRAP_DEFAULT_SITE" != "1" ]]; then + return + fi + + if has_site_configs; then + return + fi + + log "No sites found under $VOXVERA_DIR/host; bootstrapping default site" + voxvera --config "$VOXVERA_CONFIG_PATH" init --non-interactive +} + +start_tor() { + mkdir -p "$VOXVERA_TOR_DIR" + chmod 700 "$VOXVERA_TOR_DIR" + + cat >"$VOXVERA_TOR_DIR/torrc" </dev/null; then + log "Tor exited unexpectedly" + wait "$TOR_PID" + exit 1 + fi + + log "Running voxvera start-all" + voxvera start-all || true + + sleep "$VOXVERA_START_INTERVAL" & + SLEEP_PID="$!" + wait "$SLEEP_PID" + SLEEP_PID="" + done +} + +seed_default_config +start_tor +bootstrap_default_site_if_needed +start_loop diff --git a/scripts/docker-healthcheck.sh b/scripts/docker-healthcheck.sh new file mode 100644 index 0000000..39bb143 --- /dev/null +++ b/scripts/docker-healthcheck.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +VOXVERA_DIR="${VOXVERA_DIR:-/flyers}" +TMP_JSON="$(mktemp)" + +voxvera doctor --json >"$TMP_JSON" + +python3 - "$TMP_JSON" "$VOXVERA_DIR" <<'PY' +import json +import sys +from pathlib import Path + +doctor = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +voxvera_dir = Path(sys.argv[2]) +checks = {check["name"]: check for check in doctor.get("checks", [])} + +required = ("voxvera_cli", "onionshare_cli", "tor_socks_reachable", "host_root") +missing = [] +failing = [] + +for name in required: + check = checks.get(name) + if not check: + missing.append(name) + continue + if not check.get("ok"): + failing.append(f"{name}: {check.get('detail', '')}") + +site_configs = list((voxvera_dir / "host").glob("*/config.json")) +running_sites = [cfg.parent for cfg in site_configs if (cfg.parent / "server.pid").exists()] + +if missing or failing or not site_configs or not running_sites: + if missing: + print("Missing checks: " + ", ".join(missing), file=sys.stderr) + if failing: + print("Failing checks: " + "; ".join(failing), file=sys.stderr) + if not site_configs: + print("No configured sites found under host/", file=sys.stderr) + if site_configs and not running_sites: + print("Configured sites exist but no server.pid files are present", file=sys.stderr) + sys.exit(1) + +print("healthy") +PY diff --git a/scripts/docker-runtime-smoke.sh b/scripts/docker-runtime-smoke.sh new file mode 100644 index 0000000..ad20b5c --- /dev/null +++ b/scripts/docker-runtime-smoke.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is not installed; skipping Docker runtime smoke" + exit 0 +fi + +IMAGE_TAG="${VOXVERA_DOCKER_SMOKE_TAG:-voxvera:smoke}" +CONTAINER_NAME="${VOXVERA_DOCKER_SMOKE_CONTAINER:-voxvera-smoke}" +TMP_DIR="${VOXVERA_DOCKER_SMOKE_TMPDIR:-$(mktemp -d /tmp/voxvera-docker-smoke-XXXXXX)}" +FLYERS_DIR="$TMP_DIR/flyers" +mkdir -p "$FLYERS_DIR" + +cleanup() { + docker logs "$CONTAINER_NAME" >/dev/null 2>&1 || true + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [[ -z "${VOXVERA_DOCKER_SMOKE_TMPDIR:-}" ]]; then + rm -rf "$TMP_DIR" + fi +} +trap cleanup EXIT + +echo "==> building Docker image" +docker build -t "$IMAGE_TAG" . + +echo "==> starting Docker container" +docker run -d \ + --name "$CONTAINER_NAME" \ + --restart no \ + -e VOXVERA_START_INTERVAL=15 \ + -v "$FLYERS_DIR:/flyers" \ + "$IMAGE_TAG" >/dev/null + +echo "==> waiting for healthy container" +deadline=$((SECONDS + 240)) +while true; do + status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}' "$CONTAINER_NAME")" + case "$status" in + healthy) + break + ;; + unhealthy) + echo "Container became unhealthy" >&2 + docker logs "$CONTAINER_NAME" >&2 || true + exit 1 + ;; + esac + if (( SECONDS >= deadline )); then + echo "Timed out waiting for healthy Docker container" >&2 + docker logs "$CONTAINER_NAME" >&2 || true + exit 1 + fi + sleep 5 +done + +PLATFORM_JSON="$TMP_DIR/platform-status.json" +DOCTOR_JSON="$TMP_DIR/doctor.json" + +docker exec "$CONTAINER_NAME" voxvera platform-status --json >"$PLATFORM_JSON" +docker exec "$CONTAINER_NAME" voxvera doctor --json >"$DOCTOR_JSON" + +PLATFORM_JSON="$PLATFORM_JSON" \ +DOCTOR_JSON="$DOCTOR_JSON" \ +FLYERS_DIR="$FLYERS_DIR" \ +python3 - <<'PY' +import json +import os +import sys +from pathlib import Path + +platform_status = json.loads(Path(os.environ["PLATFORM_JSON"]).read_text(encoding="utf-8")) +doctor = json.loads(Path(os.environ["DOCTOR_JSON"]).read_text(encoding="utf-8")) +flyers_dir = Path(os.environ["FLYERS_DIR"]) + +errors = [] + +if platform_status.get("platform_id") != "docker_cli": + errors.append(f"platform_id mismatch: {platform_status.get('platform_id')}") +if platform_status.get("tier") != "experimental": + errors.append(f"tier mismatch: {platform_status.get('tier')}") + +checks = {check["name"]: check for check in doctor.get("checks", [])} +for required in ("container_marker", "docker_entrypoint", "docker_healthcheck", "tor_socks_reachable", "host_root"): + check = checks.get(required) + if not check: + errors.append(f"missing doctor check: {required}") + elif not check.get("ok"): + errors.append(f"doctor check failed: {required} ({check.get('detail')})") + +site_dir = flyers_dir / "host" / "voxvera" +for path in (flyers_dir / "config.json", site_dir / "config.json", site_dir / "index.html", site_dir / ".onionshare-session", site_dir / "server.pid"): + if not path.exists(): + errors.append(f"missing expected Docker runtime artifact: {path}") + +if errors: + print("Docker runtime smoke failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + sys.exit(1) + +print("Docker runtime smoke OK") +print(f"Platform: {platform_status['label']} [{platform_status['tier']}]") +print(f"Hosting model: {platform_status.get('hosting_model', '')}") +PY diff --git a/scripts/platform-smoke.sh b/scripts/platform-smoke.sh index 09c590a..cc66cc9 100644 --- a/scripts/platform-smoke.sh +++ b/scripts/platform-smoke.sh @@ -36,8 +36,8 @@ case "$TARGET" in REQUIRE_ONIONSHARE="${VOXVERA_REQUIRE_ONIONSHARE:-0}" ;; docker-cli) - EXPECTED_PLATFORM_ID="linux_cli_systemd" - EXPECTED_TIER="${VOXVERA_EXPECTED_TIER_OVERRIDE:-supported}" + EXPECTED_PLATFORM_ID="docker_cli" + EXPECTED_TIER="${VOXVERA_EXPECTED_TIER_OVERRIDE:-experimental}" REQUIRE_ONIONSHARE="${VOXVERA_REQUIRE_ONIONSHARE:-0}" ;; list) diff --git a/support-matrix.json b/support-matrix.json index 1903e1f..48f45a9 100644 --- a/support-matrix.json +++ b/support-matrix.json @@ -38,7 +38,7 @@ "id": "docker_cli", "label": "Docker CLI container", "tier": "experimental", - "hosting_model": "Containerized VoxVera runtime with state under /flyers and periodic start-all retries.", + "hosting_model": "Single-container VoxVera runtime with state under /flyers, an in-container Tor daemon, healthchecks, and periodic start-all retries.", "distribution_surfaces": [ "Dockerfile", "ghcr image" @@ -49,11 +49,11 @@ "validated_state_volume_layout" ], "current_notes": [ - "State now routes through VOXVERA_DIR=/flyers.", + "State routes through VOXVERA_DIR=/flyers.", + "Container entrypoint now starts Tor internally and retries start-all on a fixed interval.", "Not yet validated as a production-grade hidden-service host." ], "next_gaps": [ - "Decide whether the canonical model is single-container or compose-based.", "Validate restart behavior with real container restart policies." ] }, diff --git a/tests/test_cli.py b/tests/test_cli.py index 6a4670e..701ba11 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,7 @@ from voxvera.platforms import base as platform_base from voxvera.platforms import linux as platform_linux from voxvera.platforms import macos as platform_macos from voxvera.platforms import windows as platform_windows +from voxvera.platforms import docker as platform_docker sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) @@ -53,10 +54,20 @@ def test_help(capsys): def test_get_platform_adapter_linux(monkeypatch): monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.delenv("VOXVERA_RUNTIME", raising=False) + monkeypatch.setattr(platform_docker.DockerPlatformAdapter, "is_container_runtime", lambda self: False) adapter = get_platform_adapter(cli_module=cli) assert adapter.platform_id == "linux_cli_systemd" +def test_get_platform_adapter_docker(monkeypatch): + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setenv("VOXVERA_RUNTIME", "docker") + monkeypatch.setattr(platform_docker.DockerPlatformAdapter, "is_container_runtime", lambda self: True) + adapter = get_platform_adapter(cli_module=cli) + assert adapter.platform_id == "docker_cli" + + def test_get_platform_adapter_macos(monkeypatch): monkeypatch.setattr(platform, "system", lambda: "Darwin") adapter = get_platform_adapter(cli_module=cli) diff --git a/tests/test_integrity.py b/tests/test_integrity.py index b1944f4..7e786d1 100644 --- a/tests/test_integrity.py +++ b/tests/test_integrity.py @@ -76,6 +76,27 @@ def test_release_artifact_validation_is_wired_into_binaries_workflow(): assert "platform-status --json" in script_text assert "init --non-interactive" in script_text + +def test_docker_runtime_assets_are_present(): + dockerfile = (REPO_ROOT / "Dockerfile").read_text(encoding="utf-8") + dockerignore = REPO_ROOT / ".dockerignore" + entrypoint = REPO_ROOT / "scripts" / "docker-entrypoint.sh" + healthcheck = REPO_ROOT / "scripts" / "docker-healthcheck.sh" + runtime_smoke = REPO_ROOT / "scripts" / "docker-runtime-smoke.sh" + ci_workflow = (REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8") + docker_doc = (REPO_ROOT / "docs" / "templates" / "docker.md").read_text(encoding="utf-8") + + assert dockerignore.exists() + assert entrypoint.exists() + assert healthcheck.exists() + assert runtime_smoke.exists() + assert "tini" in dockerfile + assert "docker-entrypoint.sh" in dockerfile + assert "docker-healthcheck.sh" in dockerfile + assert "HEALTHCHECK" in dockerfile + assert "scripts/docker-runtime-smoke.sh" in ci_workflow + assert "starts an in-container Tor daemon" in docker_doc + def test_locale_completeness(): """Verify that all non-English locales have the same keys as English.""" locales_dir = REPO_ROOT / "voxvera" / "locales" diff --git a/voxvera/platforms/__init__.py b/voxvera/platforms/__init__.py index bfa0d9c..0a719fc 100644 --- a/voxvera/platforms/__init__.py +++ b/voxvera/platforms/__init__.py @@ -1,6 +1,7 @@ import platform from .base import PlatformAdapter +from .docker import DockerPlatformAdapter from .linux import LinuxPlatformAdapter from .macos import MacOSPlatformAdapter from .windows import WindowsPlatformAdapter @@ -9,6 +10,9 @@ from .windows import WindowsPlatformAdapter def get_platform_adapter(cli_module=None) -> PlatformAdapter: system = platform.system() if system == "Linux": + docker_adapter = DockerPlatformAdapter(cli_module=cli_module) + if docker_adapter.is_container_runtime(): + return docker_adapter return LinuxPlatformAdapter(cli_module=cli_module) if system == "Darwin": return MacOSPlatformAdapter(cli_module=cli_module) diff --git a/voxvera/platforms/docker.py b/voxvera/platforms/docker.py new file mode 100644 index 0000000..2e7ed92 --- /dev/null +++ b/voxvera/platforms/docker.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import os +import shutil +import socket +from pathlib import Path + +from .base import PlatformAdapter + + +class DockerPlatformAdapter(PlatformAdapter): + platform_id = "docker_cli" + tier = "experimental" + label = "Docker CLI container" + + @property + def container_marker_paths(self) -> list[Path]: + return [Path("/.dockerenv"), Path("/run/.containerenv")] + + def is_container_runtime(self) -> bool: + return any(path.exists() for path in self.container_marker_paths) or os.environ.get("VOXVERA_RUNTIME") == "docker" + + def autostart_status(self) -> dict: + artifacts = [] + if shutil.which("voxvera-docker-entrypoint"): + artifacts.append("voxvera-docker-entrypoint") + if shutil.which("voxvera-docker-healthcheck"): + artifacts.append("voxvera-docker-healthcheck") + + checks = [ + { + "name": "container_marker", + "ok": self.is_container_runtime(), + "detail": "container marker present" + if self.is_container_runtime() + else "no Docker marker detected", + }, + { + "name": "docker_entrypoint", + "ok": bool(shutil.which("voxvera-docker-entrypoint")), + "detail": shutil.which("voxvera-docker-entrypoint") or "voxvera-docker-entrypoint not found", + }, + { + "name": "docker_healthcheck", + "ok": bool(shutil.which("voxvera-docker-healthcheck")), + "detail": shutil.which("voxvera-docker-healthcheck") or "voxvera-docker-healthcheck not found", + }, + { + "name": "restart_policy_external", + "ok": True, + "detail": "Container restart behavior is managed by Docker/Compose, not VoxVera autostart.", + }, + ] + + return { + "platform_id": self.platform_id, + "label": self.label, + "tier": self.tier, + "supported": all(check["ok"] for check in checks[:3]), + "message": "Docker recovery depends on the container entrypoint plus an external restart policy.", + "artifacts": artifacts, + "checks": checks, + } + + def install_autostart(self) -> None: + raise RuntimeError("Docker autostart is managed by the container runtime. Use a restart policy such as '--restart unless-stopped'.") + + def uninstall_autostart(self) -> None: + raise RuntimeError("Docker autostart is managed by the container runtime. Remove or change the container restart policy instead.") + + def doctor_report(self) -> dict: + report = super().doctor_report() + checks = report["checks"] + + marker_present = self.is_container_runtime() + checks.append( + { + "name": "container_marker", + "ok": marker_present, + "detail": "Docker marker detected" if marker_present else "no Docker marker detected", + } + ) + + socks_port = os.getenv("TOR_SOCKS_PORT", "9050") + reachable = False + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(0.25) + reachable = sock.connect_ex(("127.0.0.1", int(socks_port))) == 0 + sock.close() + except Exception: + reachable = False + checks.append( + { + "name": "tor_socks_reachable", + "ok": reachable, + "detail": f"reachable on {socks_port}" if reachable else f"Tor SOCKS not reachable on {socks_port}", + } + ) + + host_root = self.data_dir / "host" + checks.append( + { + "name": "host_root", + "ok": host_root.exists(), + "detail": str(host_root) if host_root.exists() else f"missing: {host_root}", + } + ) + + status = self.autostart_status() + for name in ("docker_entrypoint", "docker_healthcheck"): + check = next((item for item in status["checks"] if item["name"] == name), None) + if check: + checks.append( + { + "name": name, + "ok": check["ok"], + "detail": check["detail"], + } + ) + + report["summary"] = { + "ok": all(check.get("ok") for check in checks), + "failing_checks": [check["name"] for check in checks if not check.get("ok")], + } + report["sections"] = { + "platform_contract": self._doctor_section("Platform contract", ["platform", "container_marker"], checks), + "dependencies": self._doctor_section("Dependencies", ["voxvera_cli", "onionshare_cli", "docker_entrypoint", "docker_healthcheck"], checks), + "network": self._doctor_section("Network", ["tor_socks_reachable"], checks), + "autostart": self._doctor_section("Autostart", ["docker_entrypoint", "docker_healthcheck"], checks), + "content_state": self._doctor_section("Content state", ["host_root"], checks), + } + return report