diff --git a/.github/workflows/docker-development.yml b/.github/workflows/docker-development.yml new file mode 100644 index 0000000..ae4c984 --- /dev/null +++ b/.github/workflows/docker-development.yml @@ -0,0 +1,31 @@ +name: Docker development + +on: + push: + branches: [main] + paths: + - "docker/**" + - "tests/test_dev_compose_linux.py" + - ".github/workflows/docker-development.yml" + pull_request: + paths: + - "docker/**" + - "tests/test_dev_compose_linux.py" + - ".github/workflows/docker-development.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + docker: + name: Linux Docker Engine lifecycle + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - run: pip install -r requirements-dev.txt + - run: python -m pytest tests/test_dev_compose_linux.py -m docker -v diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index cf5d94f..d741fd5 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -62,7 +62,7 @@ jobs: # sono esclusi di default in locale ma qui girano: -m sovrascrive addopts. # Coverage (lines + branches) of custom_components/vimar_intercom: the # threshold is fail_under in pyproject.toml, below it the job fails. - - run: python -m pytest tests -m "not live and not media and not browser" --cov --cov-report=term-missing + - run: python -m pytest tests -m "not live and not media and not browser and not docker" --cov --cov-report=term-missing media-browser: # A richiesta (workflow_dispatch): media vero (ffmpeg, aiohttp) e la card in diff --git a/.gitignore b/.gitignore index 2fd931e..1b553c5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .ruff_cache/ .coverage .venv/ +.dev-ha/ diff --git a/AGENTS.md b/AGENTS.md index bff08ea..1bf4bab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ package `__init__`. `tests/harness/` is a fake panel on 127.0.0.1 for the SIP en pip install -r requirements-dev.txt python -m pytest tests # default run, without the slow e2e tests (~1 min) python -m pytest tests -m slow # the slow e2e tests (~2 min) -python -m pytest tests -m "not live and not media and not browser" # both, what CI runs +python -m pytest tests -m "not live and not media and not browser and not docker" # both, what CI runs python -m pytest tests/test_srtp.py # one file python -m pytest tests -k manifest # by name python -m pytest tests -m media # real ffmpeg/aiohttp media (needs ffmpeg, aiohttp) @@ -80,7 +80,7 @@ python -m pytest tests -m browser # the card in Chromium/WebKit (needs .claude/skills/card-browser-tests/run.sh # the same, in a disposable Playwright container ``` -`live`, `media` and `browser` are excluded by default (`addopts` in `pyproject.toml`). Missing +`live`, `media`, `browser` and `docker` are excluded by default (`addopts` in `pyproject.toml`). Missing tools make `media`/`browser` tests skip. `live` needs the real intercom: see the safety rules. ### Coverage rule (mandatory, enforced in CI) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index efba17c..025016d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,10 @@ in its description. ## Development setup +For an onboarded, isolated Home Assistant instance using the current checkout, +run `./docker/dev.sh`. See [Docker Compose development](docs/DEVELOPMENT.md). +The unit tests below still run independently of Home Assistant. + ```bash git clone https://github.com/ha-vimar/ha-vimar-intercom cd ha-vimar-intercom @@ -60,7 +64,7 @@ add a test — those are the parts that break silently on other plants. - `python -m pytest -m slow` runs the SIP end-to-end tests (`tests/test_e2e_sip.py`, `tests/test_e2e_blind.py`, about two minutes): the real `sip_client`, hub, media and HTTP views against a fake panel on 127.0.0.1 (`tests/harness/`). The default run leaves them out; CI runs them. -- `python -m pytest tests -m "not live and not media and not browser" --cov --cov-report=term-missing` is the +- `python -m pytest tests -m "not live and not media and not browser and not docker" --cov --cov-report=term-missing` is the default run plus the slow tests, with coverage, as CI runs it: lines and branches of `custom_components/vimar_intercom`, and it fails below the `fail_under` threshold in `pyproject.toml` (95%). New code comes with tests that keep it there. - `python -m pytest -m media` adds real media (`tests/test_e2e_media.py`): H.264 from ffmpeg, diff --git a/README.it.md b/README.it.md index b9091a8..884297a 100644 --- a/README.it.md +++ b/README.it.md @@ -76,6 +76,9 @@ la rubrica: [Configurazione](https://github.com/ha-vimar/ha-vimar-intercom/blob/ ## Supporto e contributi +Per avviare Home Assistant locale per lo sviluppo, con onboarding automatico, +esegui `./docker/dev.sh`. Vedi [sviluppo con Docker Compose](docs/DEVELOPMENT.it.md). + Domande e problemi: [issue](https://github.com/ha-vimar/ha-vimar-intercom/issues). Problemi di sicurezza: in privato, vedi [SECURITY.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/SECURITY.md). I contributi sono benvenuti, vedi [CONTRIBUTING.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/CONTRIBUTING.md): mai indovinare comandi SIP (uno sbagliato può aprire una porta), niente credenziali nel repo, e scrivi su che hardware hai provato. diff --git a/README.md b/README.md index 8902b58..d185073 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ the phonebook: [Configuration](https://github.com/ha-vimar/ha-vimar-intercom/blo ## Support and contributing +For a local Home Assistant development instance with automatic onboarding, run +`./docker/dev.sh`. See [Docker Compose development](docs/DEVELOPMENT.md). + Questions and bugs: [issues](https://github.com/ha-vimar/ha-vimar-intercom/issues). Security problems: privately, see [SECURITY.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/SECURITY.md). Contributions are welcome, see [CONTRIBUTING.md](https://github.com/ha-vimar/ha-vimar-intercom/blob/main/CONTRIBUTING.md): never guess SIP commands (a wrong one can open a door), keep credentials out of the repo, and say which hardware you tested on. diff --git a/docker/bootstrap.py b/docker/bootstrap.py new file mode 100644 index 0000000..368415d --- /dev/null +++ b/docker/bootstrap.py @@ -0,0 +1,77 @@ +"""Complete local owner onboarding and verify the installed integration's form.""" +import asyncio +import json +import os +from pathlib import Path + +import aiohttp + +BASE = "http://web" +CLIENT = f"http://localhost:{int(os.environ.get("HA_DEV_PORT", "18123"))}/" + + +async def bootstrap() -> None: + credentials = json.loads(Path("/state/credentials.json").read_text()) + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60)) as session: + async def request(method, path, **kwargs): + async with session.request(method, BASE + path, **kwargs) as response: + if response.status >= 400: + raise RuntimeError(f"Development HA {method} {path}: HTTP {response.status}") + body = await response.text() + return json.loads(body) if body else {} + + async with session.get(BASE + "/api/onboarding") as response: + steps = await response.json() if response.status == 200 else [] + if response.status not in (200, 404): + raise RuntimeError(f"Onboarding status: HTTP {response.status}") + if any(step["step"] == "user" and not step["done"] for step in steps): + result = await request("POST", "/api/onboarding/users", json={ + **credentials, "name": "Developer", "client_id": CLIENT, "language": "en", + }) + code = result["auth_code"] + else: + flow = await request("POST", "/auth/login_flow", json={ + "client_id": CLIENT, "handler": ["homeassistant", None], "redirect_uri": CLIENT, + }) + result = await request("POST", "/auth/login_flow/" + flow["flow_id"], json={ + **credentials, "client_id": CLIENT, + }) + if result["type"] != "create_entry": + raise RuntimeError("Saved development login failed; credentials were not reset.") + code = result["result"] + token = await request("POST", "/auth/token", data={ + "grant_type": "authorization_code", "code": code, "client_id": CLIENT, + }) + headers = {"Authorization": "Bearer " + token["access_token"]} + entries = await request("GET", "/api/config/config_entries/entry", headers=headers) + configured = any(entry["domain"] == "vimar_intercom" for entry in entries) + if not configured: + # This initial form does not probe SIP. Delete it without submitting + # credentials, so no unfinished flow remains in the user's dashboard. + flow = await request("POST", "/api/config/config_entries/flow", headers=headers, + json={"handler": "vimar_intercom", "show_advanced_options": True}) + if flow.get("type") != "form" or flow.get("step_id") != "user": + raise RuntimeError("Vimar setup form did not load.") + await request("DELETE", "/api/config/config_entries/flow/" + flow["flow_id"], headers=headers) + async with session.ws_connect(BASE + "/api/websocket") as ws: + await ws.receive_json() + await ws.send_json({"type": "auth", "access_token": token["access_token"]}) + if (await ws.receive_json())["type"] != "auth_ok": + raise RuntimeError("Development frontend WebSocket authentication failed.") + await ws.send_json({"id": 1, "type": "lovelace/config", "url_path": "vimar-dev"}) + if not (await ws.receive_json()).get("success"): + raise RuntimeError("Development dashboard did not load.") + # Revoke this short-lived bootstrap session; browser login uses its own. + await request("POST", "/auth/token", data={ + "action": "revoke", "token": token["refresh_token"], + }) + print("Ready: " + CLIENT + "vimar-dev/development") + print("Username: " + credentials["username"]) + print("Password: " + credentials["password"]) + print("Credentials saved in .dev-ha/credentials.json (private, ignored by Git).") + print("Existing Vimar configuration retained; bootstrap made no calls." if configured else + "Vimar setup verified. No intercom registration or calls made.") + + +if __name__ == "__main__": + asyncio.run(bootstrap()) diff --git a/docker/configuration.yaml b/docker/configuration.yaml new file mode 100644 index 0000000..cd668b8 --- /dev/null +++ b/docker/configuration.yaml @@ -0,0 +1,24 @@ +homeassistant: + name: Vimar Development + latitude: 0 + longitude: 0 + elevation: 0 + unit_system: metric + time_zone: UTC + country: US + +frontend: +config: +stream: +http: + server_port: 8123 +lovelace: + dashboards: + vimar-dev: + mode: yaml + title: Vimar Development + icon: mdi:flask-outline + show_in_sidebar: true + filename: ui-lovelace.yaml +logger: + default: warning diff --git a/docker/dev.sh b/docker/dev.sh new file mode 100755 index 0000000..e4fa601 --- /dev/null +++ b/docker/dev.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Start or update the development instance using the current checkout. +set -euo pipefail +umask 077 +repo=$(cd "$(dirname "$0")/.." && pwd) +cd "$repo" +compose=(docker compose --project-directory "$repo/docker" -f "$repo/docker/docker-compose.yaml") +if [[ $# -gt 1 || "${1:-up}" != up && "${1:-up}" != reset ]]; then + printf 'Usage: %s [up|reset]\n' "$0" >&2 + exit 2 +fi +if [[ -L .dev-ha ]]; then + printf 'Refusing a symlinked .dev-ha directory.\n' >&2 + exit 1 +fi +if [[ "${1:-up}" == reset ]]; then + "${compose[@]}" down + if [[ -d .dev-ha ]]; then + "${compose[@]}" run --rm --no-deps --entrypoint python seed /dev-tools/reset.py + rmdir .dev-ha + fi + printf 'Development environment reset.\n' + exit 0 +fi +mkdir -p .dev-ha +chmod 700 .dev-ha +"${compose[@]}" up -d --force-recreate +if ! "${compose[@]}" wait bootstrap; then + "${compose[@]}" logs --no-log-prefix bootstrap >&2 + exit 1 +fi +"${compose[@]}" logs --no-log-prefix bootstrap diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 0000000..ed018e9 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,71 @@ +name: vimar-intercom-dev + +x-ha-image: &ha-image + # Official Docker Hub mirror, pinned to Home Assistant 2026.10.1. + image: homeassistant/home-assistant@sha256:130241f28d01fa80dfa3b44f7d89781c188d8642ecab9024f079f19f9f82baa0 + environment: + TZ: UTC + PYTHONDONTWRITEBYTECODE: "1" + +services: + seed: + <<: *ha-image + entrypoint: [python, /dev-tools/seed.py] + network_mode: none + volumes: + - ../.dev-ha:/state + - .:/dev-tools:ro + restart: "no" + + homeassistant: + <<: *ha-image + depends_on: + seed: + condition: service_completed_successfully + volumes: + - ../.dev-ha/config:/config + - ../custom_components/vimar_intercom:/config/custom_components/vimar_intercom:ro + networks: [sandbox] + restart: "no" + healthcheck: + test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8123/', timeout=5)"] + interval: 5s + timeout: 10s + retries: 36 + start_period: 20s + + web: + image: nginx@sha256:8541484afbc9c8a5a8a99b379568ebbc957f658583ec9448fc43104229c03cf8 + depends_on: + homeassistant: + condition: service_healthy + ports: + - "127.0.0.1:${HA_DEV_PORT:-18123}:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + networks: [sandbox, web] + restart: "no" + healthcheck: + test: [CMD, curl, --fail, --silent, http://localhost/] + interval: 5s + timeout: 10s + retries: 12 + + bootstrap: + <<: *ha-image + environment: + HA_DEV_PORT: ${HA_DEV_PORT:-18123} + entrypoint: [python, /dev-tools/bootstrap.py] + depends_on: + web: + condition: service_healthy + volumes: + - ../.dev-ha:/state + - .:/dev-tools:ro + networks: [sandbox] + restart: "no" + +networks: + sandbox: + internal: true + web: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..f923b44 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,19 @@ +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name localhost; + + location / { + proxy_pass http://homeassistant:8123; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-For ""; + proxy_read_timeout 3600s; + } +} diff --git a/docker/reset.py b/docker/reset.py new file mode 100644 index 0000000..901f809 --- /dev/null +++ b/docker/reset.py @@ -0,0 +1,18 @@ +"""Remove only the mounted development state from a one-shot offline container.""" +import shutil +from pathlib import Path + + +def reset(state: Path) -> None: + if not state.is_mount(): + raise ValueError("Reset requires the development state mount.") + for child in state.iterdir(): + if child.is_dir() and not child.is_symlink(): + shutil.rmtree(child) + else: + child.unlink() + print("Development state cleared.") + + +if __name__ == "__main__": + reset(Path("/state")) diff --git a/docker/seed.py b/docker/seed.py new file mode 100644 index 0000000..01220bc --- /dev/null +++ b/docker/seed.py @@ -0,0 +1,40 @@ +"""Initialize only the private development config before Home Assistant starts.""" +import json +import os +import shutil +from pathlib import Path + +from homeassistant.components.onboarding import STORAGE_KEY, STORAGE_VERSION +from homeassistant.components.onboarding.const import STEP_USER, STEPS + + +def seed(state: Path, templates: Path) -> None: + os.umask(0o077) + state.mkdir(mode=0o700, exist_ok=True) + state.chmod(0o700) + config = state / "config" + config.mkdir(mode=0o700, exist_ok=True) + for name in ("configuration.yaml", "ui-lovelace.yaml"): + if not (config / name).exists(): + shutil.copyfile(templates / name, config / name) + credentials = state / "credentials.json" + if not credentials.exists(): + credentials.write_text(json.dumps({ + "username": "developer", "password": "developer", + }) + "\n") + credentials.chmod(0o600) + storage = config / ".storage" + storage.mkdir(mode=0o700, exist_ok=True) + onboarding = storage / STORAGE_KEY + if not onboarding.exists(): + # Skip location/analytics/discovery onboarding; the HTTP user endpoint + # still creates the actual owner and hashes the password normally. + onboarding.write_text(json.dumps({ + "version": STORAGE_VERSION, "minor_version": 1, "key": STORAGE_KEY, + "data": {"done": [step for step in STEPS if step != STEP_USER]}, + }) + "\n") + print("Development configuration ready; no intercom entry or credentials imported.") + + +if __name__ == "__main__": + seed(Path("/state"), Path("/dev-tools")) diff --git a/docker/ui-lovelace.yaml b/docker/ui-lovelace.yaml new file mode 100644 index 0000000..2d7b2e7 --- /dev/null +++ b/docker/ui-lovelace.yaml @@ -0,0 +1,17 @@ +title: Vimar Development +views: + - title: Development + path: development + cards: + - type: markdown + title: Vimar Intercom development + content: | + This instance loads the current checkout and keeps its own configuration. + + No production credentials, automations or intercom configuration are + seeded. The default network blocks outbound LAN and internet access. + + [Open integrations](/config/integrations) + + Add Vimar Intercom to inspect its setup form. Registration, calls and + media require a separate, deliberately configured network environment. diff --git a/docs/DEVELOPMENT.it.md b/docs/DEVELOPMENT.it.md new file mode 100644 index 0000000..532ebaf --- /dev/null +++ b/docs/DEVELOPMENT.it.md @@ -0,0 +1,100 @@ +# Sviluppo Home Assistant con Docker Compose + +Dalla radice del repository: + +```bash +./docker/dev.sh +``` + +Servono Docker con il plugin Compose, Bash per il launcher e accesso a internet +per scaricare le immagini al primo avvio. Non servono Python sul computer host +o HACS. Il comando avvia `vimar-intercom-dev`, completa l'onboarding HA, verifica accesso e modulo iniziale +Vimar e stampa URL e credenziali. Apri +**http://localhost:18123/vimar-dev/development** e accedi con +**`developer` / `developer`**. Lo stack compare anche in Docker Desktop. + +## Modifiche e aggiornamenti + +L'integrazione e il frontend sono montati in sola lettura dal checkout corrente. +Dopo modifiche Python o un cambio di branch, esegui nuovamente `./docker/dev.sh`: +i container vengono ricreati e caricano il codice aggiornato, conservando dati e +accesso. Il codice Python non viene ricaricato automaticamente. + +Dalla radice del repository puoi usare direttamente il file Docker Compose: + +```bash +docker compose -f docker/docker-compose.yaml up -d --force-recreate +docker compose -f docker/docker-compose.yaml logs bootstrap # stato, URL e credenziali +docker compose -f docker/docker-compose.yaml logs -f homeassistant # log applicativi +docker compose -f docker/docker-compose.yaml down # arresta, conservando i dati +``` + +Lo script riunisce avvio, attesa del bootstrap e stampa delle credenziali; +restituisce un errore se l'avvio o il bootstrap fallisce. Per un'altra porta: + +```bash +HA_DEV_PORT=18124 ./docker/dev.sh +``` + +Mantieni la stessa variabile per i comandi Compose successivi. Per più checkout +contemporanei, assegna anche un `COMPOSE_PROJECT_NAME` distinto a ciascuno. +Ogni checkout conserva i propri dati. + +## File e dati persistenti + +`docker/docker-compose.yaml` definisce immagini, servizi, controlli di avvio e reti. `docker/` +contiene launcher, configurazione HA minima, dashboard, proxy Nginx e script di +inizializzazione. `seed.py` crea solo i file mancanti; `bootstrap.py` crea +l'utente tramite l'API di onboarding HA, verifica accesso, modulo e dashboard, +quindi revoca la propria sessione temporanea. + +`.dev-ha/` è esclusa da Git e separata dalla produzione. Contiene configurazione +e credenziali (`credentials.json`). L'accesso fisso è destinato solo all'istanza +locale; le credenziali compaiono anche nei log del bootstrap. Non condividere +directory o log. Le modifiche ai template non sovrascrivono configurazioni già +esistenti. + +Su Linux Docker Engine, HA gira come root: i file dentro `.dev-ha/` possono +appartenere a root e non essere leggibili dall'utente host. Il launcher stampa +comunque le credenziali. Per cancellare i dati di sviluppo e ripartire da zero: + +```bash +./docker/dev.sh reset +``` + +Il reset arresta/rimuove container e reti di questo progetto Compose, poi svuota +solo `.dev-ha/` con un container temporaneo senza rete e rimuove la directory +vuota. **Cancella accesso, configurazione e cronologia dell'istanza di sviluppo.** +Salva prima i dati che vuoi conservare. Usa gli stessi `COMPOSE_PROJECT_NAME` e +`HA_DEV_PORT` dell'avvio. Non servono `sudo` sull'host e non vengono coinvolti +produzione o altri progetti Compose. Una `.dev-ha` che sia un link simbolico viene +rifiutata. + +Poi `./docker/dev.sh` crea una nuova istanza. Per conservare i dati, usa invece +`docker compose -f docker/docker-compose.yaml down` oppure arresta i container. +La suite pytest include, sui runner Linux temporanei di GitHub Actions, una +verifica con Docker Engine: onboarding, persistenza al riavvio, reset dei dati +di root e nuovo onboarding dopo il reset. Il marker `docker` la esclude dai test +predefiniti e dai job Python obbligatori. Un workflow separato la esegue una sola +volta quando cambiano `docker/**`, il test o il relativo workflow. I test locali +non avviano Docker automaticamente. + +## Isolamento e limiti + +Solo Nginx pubblica una porta, su `127.0.0.1`. HA utilizza una rete Docker interna +senza accesso in uscita a LAN o internet. Il proxy consente l'accesso dal browser, +non l'uscita da HA. La configurazione minima omette `default_config` e discovery. + +Al primo avvio il bootstrap apre e annulla il modulo iniziale Vimar senza QR, +credenziali SIP o registrazione. Non importa configurazioni o automazioni dalla +produzione. Un'integrazione già configurata viene conservata; HA ne esegue le +normali operazioni iniziali nei limiti della rete isolata. + +L'ambiente serve per sviluppo di moduli, opzioni e frontend; non simula un +citofono e non verifica chiamate, squilli o media reali. Questi test richiedono +una rete configurata separatamente e il consenso del proprietario per le azioni +specifiche. Non sono inclusi inoltri LAN, rete host o modifiche SIP sperimentali. + +Le immagini sono fissate per ripetibilità. Quando aggiorni HA, verifica onboarding +da zero e un secondo avvio con dati conservati: `seed.py` usa le costanti di +archiviazione onboarding di quella versione. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..eb20cef --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,109 @@ +# Home Assistant development with Docker Compose + +From the repository root: + +```bash +./docker/dev.sh +``` + +Requires Docker with the Compose plugin, Bash for the launcher, and internet +access to download images on first use. No host Python environment or HACS +installation is needed. The command starts the `vimar-intercom-dev` Compose application, completes HA +onboarding, verifies login and the installed Vimar setup form, and prints the URL +and credentials. Open **http://localhost:18123/vimar-dev/development** and log in +with **`developer` / `developer`**. The stack also appears in Docker Desktop. + +## Edit and update + +The integration, including its frontend assets, is mounted read-only from the +current checkout. Run `./docker/dev.sh` again after Python edits or switching +branches: containers are recreated to load the new code, preserving development +settings and login. Python edits are not hot-reloaded. + +From the repository root, use the file directly with Docker Compose: + +```bash +docker compose -f docker/docker-compose.yaml up -d --force-recreate +docker compose -f docker/docker-compose.yaml logs bootstrap # readiness, URL and login +docker compose -f docker/docker-compose.yaml logs -f homeassistant # application logs +docker compose -f docker/docker-compose.yaml down # stop; retain development settings +``` + +The script combines startup, waiting for bootstrap, and showing credentials. It +reports failure if startup or bootstrap fails. For another web port: + +```bash +HA_DEV_PORT=18124 ./docker/dev.sh +``` + +Use the same variable for subsequent Compose commands. For concurrent checkouts, +also set a distinct `COMPOSE_PROJECT_NAME` and port for each. Each checkout keeps +its own state directory. + +## Files and persistent state + +| Path | Purpose | +|---|---| +| `docker/docker-compose.yaml` | Pinned HA/Nginx images, startup ordering, health checks and networks | +| `docker/dev.sh` | One-command start/update and readiness output | +| `docker/seed.py` | Creates missing development configuration files | +| `docker/bootstrap.py` | Creates the owner through HA's onboarding API and checks login, setup and dashboard | +| `docker/configuration.yaml`, `docker/ui-lovelace.yaml` | Minimal HA configuration and development landing page | +| `docker/nginx.conf` | Local browser access, including WebSockets | +| `.dev-ha/` | Persistent development configuration and credentials; ignored by Git | + +The fixed login is for this localhost-only development instance. Credentials are +stored in `.dev-ha/credentials.json` and printed in bootstrap logs; do not share +that directory or those logs. No production configuration is copied. Bootstrap +revokes its temporary authentication session after verification. + +On Linux Docker Engine, the HA image runs as root, so files inside `.dev-ha/` +can be root-owned and unreadable by the host user. The launcher still prints the +login. To delete this development configuration and start from scratch, use: + +```bash +./docker/dev.sh reset +``` + +Reset stops/removes this Compose project's containers and networks, then clears +only `.dev-ha/` through a one-shot, network-disabled container and removes the +empty directory. **It deletes the development login, configuration and history.** +Back up any development state you want to keep first. Use the same +`COMPOSE_PROJECT_NAME` and `HA_DEV_PORT` settings as at startup. No host `sudo`, +production configuration or other Compose project is involved. Symlinked +`.dev-ha` directories are refused. + +Run `./docker/dev.sh` again to create a fresh instance. To retain state instead, +use `docker compose -f docker/docker-compose.yaml down` or stop the containers. +Templates are copied only when missing: edits to templates do not overwrite an +existing development configuration. + +The pytest suite includes a Linux Docker Engine lifecycle check on disposable +GitHub Actions runners: fresh onboarding, restart persistence, deletion of +root-owned state and fresh onboarding after reset. It has the `docker` marker, +excluded from default runs and the required Python jobs. A separate workflow +runs it once when `docker/**`, the lifecycle test or its workflow changes. +Local unit-test runs never start Docker automatically. + +## Network isolation and scope + +Only Nginx publishes a port, bound to `127.0.0.1`. HA runs on an internal Docker +network without outbound LAN or internet access. Nginx provides browser access, +not an outbound proxy for HA. The minimal configuration omits `default_config` +and automatic discovery. + +On a fresh start, bootstrap opens and cancels Vimar's initial setup form without +supplying QR data or SIP credentials. It seeds no intercom entry or automations. +An existing Vimar entry is retained and bootstrap skips starting another setup +flow; HA still performs that entry's normal startup operations within the +isolated network. + +This environment supports setup/options and frontend development. It does not +simulate an intercom or verify real calls, doorbell events or media. Hardware +tests need a separately configured network and the owner's permission for the +specific actions. No LAN forwarding, host networking or experimental SIP changes +are included. + +Images are pinned for repeatable onboarding. When updating the HA image, verify +both fresh onboarding and a second start with retained state: `seed.py` uses +that version's onboarding storage constants. diff --git a/pyproject.toml b/pyproject.toml index 0e4093c..0d6ba7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,11 +6,12 @@ requires-python = ">=3.13" [tool.pytest.ini_options] testpaths = ["tests"] -addopts = "-m 'not live and not media and not browser and not slow'" +addopts = "-m 'not live and not media and not browser and not slow and not docker'" markers = [ "live: richiede il Tab reale in rete (escluso di default)", - "slow: end-to-end SIP and call-flow tests, about two minutes together (excluded by default; CI runs them: -m 'not live and not media and not browser')", + "slow: end-to-end SIP and call-flow tests, about two minutes together (excluded by default; CI runs them: -m 'not live and not media and not browser and not docker')", "media: media vero con ffmpeg e aiohttp (escluso di default: -m media)", + "docker: Linux Docker Engine lifecycle on disposable GitHub Actions runners (excluded by default: -m docker)", "browser: la card in Chromium/WebKit con playwright e aiohttp (escluso di default: -m browser)", ] diff --git a/tests/test_dev_compose_linux.py b/tests/test_dev_compose_linux.py new file mode 100644 index 0000000..bd9c765 --- /dev/null +++ b/tests/test_dev_compose_linux.py @@ -0,0 +1,76 @@ +"""Exercise Linux Docker Engine ownership/reset in a disposable CI checkout. + +A dedicated, path-filtered GitHub Actions workflow selects the docker marker. +Local unit tests must not start Docker or operate a developer's retained +Home Assistant configuration. +""" +import json +import os +import shutil +import subprocess +import sys +import uuid +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.docker +@pytest.mark.skipif( + sys.platform != "linux" or os.environ.get("GITHUB_ACTIONS") != "true", + reason="Linux Docker Engine lifecycle check runs only on disposable GitHub Actions runners", +) +def test_linux_engine_onboarding_persistence_and_root_owned_reset(tmp_path): + checkout = tmp_path / "checkout" + shutil.copytree(ROOT / "docker", checkout / "docker") + shutil.copytree(ROOT / "custom_components/vimar_intercom", checkout / "custom_components/vimar_intercom") + project = "vimar-dev-ci-" + uuid.uuid4().hex[:10] + env = {**os.environ, "COMPOSE_PROJECT_NAME": project, "HA_DEV_PORT": "18123"} + compose = ["docker", "compose", "-f", "docker/docker-compose.yaml"] + + def run(*args, timeout=300): + result = subprocess.run(args, cwd=checkout, env=env, capture_output=True, text=True, timeout=timeout) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + info = json.loads(run("docker", "info", "--format", "{{json .}}")) + assert info["OSType"] == "linux" and "Docker Desktop" not in info["OperatingSystem"] + engine = info["ServerVersion"] + try: + run("./docker/dev.sh") + # Do not read the private files as the host user: this is the root-owned + # Linux case. Hash them through an offline seed container instead. + snapshot = """ +import hashlib, json +from pathlib import Path +p = Path('/state') +names = ['credentials.json', 'config/.storage/auth_provider.homeassistant', + 'config/configuration.yaml', 'config/ui-lovelace.yaml'] +print(json.dumps({n: hashlib.sha256((p/n).read_bytes()).hexdigest() for n in names})) +""" + before = run(*compose, "run", "--rm", "--no-deps", "--entrypoint", "python", "seed", "-c", snapshot) + run("./docker/dev.sh") + after = run(*compose, "run", "--rm", "--no-deps", "--entrypoint", "python", "seed", "-c", snapshot) + assert json.loads(before) == json.loads(after) + # Assert this really exercised the reviewer's root ownership case. + owner = run(*compose, "run", "--rm", "--no-deps", "--entrypoint", "python", "seed", "-c", + "from pathlib import Path; print(Path('/state/config').stat().st_uid)") + assert owner.strip() == "0" + run("./docker/dev.sh", "reset") + assert not (checkout / ".dev-ha").exists() + assert not run("docker", "ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).strip() + # Startup after reset must create a usable new owner/configuration too. + run("./docker/dev.sh") + run("./docker/dev.sh", "reset") + assert not (checkout / ".dev-ha").exists() + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with Path(summary).open("a") as output: + output.write(f"\nLinux Docker Engine {engine}: fresh onboarding, restart persistence, " + "root-owned state reset and fresh onboarding after reset passed.\n") + finally: + # Teardown touches only the unique project in this disposable checkout. + subprocess.run(compose + ["logs", "--no-color", "--tail", "50"], cwd=checkout, env=env, check=False) + subprocess.run(["./docker/dev.sh", "reset"], cwd=checkout, env=env, check=False, timeout=120) diff --git a/tests/test_dev_reset.py b/tests/test_dev_reset.py new file mode 100644 index 0000000..7d079a2 --- /dev/null +++ b/tests/test_dev_reset.py @@ -0,0 +1,68 @@ +"""Reset deletes development state without following links outside its mount.""" +import importlib.util +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions, symlinks and bash") + +SPEC = importlib.util.spec_from_file_location( + "dev_reset", Path(__file__).resolve().parents[1] / "docker/reset.py") +reset_module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(reset_module) + + +def test_reset_refuses_an_unmounted_directory(tmp_path): + saved = tmp_path / "keep.txt" + saved.write_text("keep") + with pytest.raises(ValueError, match="state mount"): + reset_module.reset(tmp_path) + assert saved.read_text() == "keep" + + +def test_reset_clears_nested_state_and_does_not_follow_symlinks(tmp_path, monkeypatch): + state = tmp_path / "state" + storage = state / "config/.storage" + storage.mkdir(parents=True) + (storage / "auth").write_text("synthetic") + (state / "credentials.json").write_text("synthetic") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "keep.txt").write_text("keep") + (state / "linked-directory").symlink_to(outside, target_is_directory=True) + (state / "linked-file").symlink_to(outside / "keep.txt") + (state / "broken-link").symlink_to(tmp_path / "missing") + monkeypatch.setattr(Path, "is_mount", lambda path: path == state) + reset_module.reset(state) + assert list(state.iterdir()) == [] + assert (outside / "keep.txt").read_text() == "keep" + reset_module.reset(state) # Clearing an already-empty mount also succeeds. + + +@pytest.mark.parametrize("argument", ["up", "reset", "invalid"]) +def test_launcher_refuses_unsafe_state_or_bad_arguments_before_docker(tmp_path, argument): + tools = tmp_path / "docker" + tools.mkdir() + launcher = tools / "dev.sh" + shutil.copyfile(Path(__file__).resolve().parents[1] / "docker/dev.sh", launcher) + outside = tmp_path / "outside" + outside.mkdir() + (outside / "keep.txt").write_text("keep") + (tmp_path / ".dev-ha").symlink_to(outside, target_is_directory=True) + binary = tmp_path / "bin" + binary.mkdir() + marker = tmp_path / "docker-was-called" + fake_docker = binary / "docker" + fake_docker.write_text('#!/bin/sh\ntouch "$DOCKER_MARKER"\n') + fake_docker.chmod(0o755) + result = subprocess.run( + ["bash", str(launcher), argument], capture_output=True, text=True, + env={**os.environ, "PATH": str(binary) + os.pathsep + os.environ["PATH"], "DOCKER_MARKER": str(marker)}, + ) + assert result.returncode == (2 if argument == "invalid" else 1) + assert not marker.exists() + assert (outside / "keep.txt").read_text() == "keep" diff --git a/tests/test_dev_seed.py b/tests/test_dev_seed.py new file mode 100644 index 0000000..28a0d47 --- /dev/null +++ b/tests/test_dev_seed.py @@ -0,0 +1,73 @@ +"""Development startup must never overwrite an existing HA configuration.""" +import importlib.util +import json +import os +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX permissions, symlinks and bash") + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def seed(monkeypatch): + onboarding = ModuleType("homeassistant.components.onboarding") + onboarding.STORAGE_KEY = "onboarding" + onboarding.STORAGE_VERSION = 4 + const = ModuleType("homeassistant.components.onboarding.const") + const.STEP_USER = "user" + const.STEPS = ["user", "core_config", "integration", "analytics"] + monkeypatch.setitem(sys.modules, onboarding.__name__, onboarding) + monkeypatch.setitem(sys.modules, const.__name__, const) + spec = importlib.util.spec_from_file_location("dev_seed", ROOT / "docker" / "seed.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + previous_umask = os.umask(0o077) + try: + yield module.seed + finally: + os.umask(previous_umask) + + +def test_fresh_start_leaves_owner_creation_to_the_http_api(seed, tmp_path): + state = tmp_path / "state" + seed(state, ROOT / "docker") + assert json.loads((state / "credentials.json").read_text()) == { + "username": "developer", "password": "developer", + } + storage = state / "config" / ".storage" + onboarding = json.loads((storage / "onboarding").read_text()) + assert set(onboarding["data"]["done"]) == {"core_config", "integration", "analytics"} + assert not (storage / "auth").exists() + assert not (storage / "core.config_entries").exists() + assert (state.stat().st_mode & 0o777) == 0o700 + assert ((state / "credentials.json").stat().st_mode & 0o777) == 0o600 + + +def test_updates_preserve_credentials_onboarding_and_user_configuration(seed, tmp_path): + state = tmp_path / "state" + seed(state, ROOT / "docker") + saved = { + "credentials.json": '{"username": "existing", "password": "synthetic-test-password"}\n', + "config/.storage/onboarding": '{"data": {"done": ["user"]}}\n', + "config/.storage/auth": '{"users": ["existing-owner"]}\n', + "config/.storage/core.config_entries": '{"entries": ["existing-entry"]}\n', + "config/configuration.yaml": "homeassistant:\n name: Custom development\n", + "config/ui-lovelace.yaml": "title: My dashboard\n", + } + for name, contents in saved.items(): + (state / name).write_text(contents) + seed(state, ROOT / "docker") + assert {name: (state / name).read_text() for name in saved} == saved + + +def test_missing_template_is_restored_without_resetting_other_files(seed, tmp_path): + state = tmp_path / "state" + seed(state, ROOT / "docker") + (state / "config" / "ui-lovelace.yaml").unlink() + seed(state, ROOT / "docker") + assert (state / "config" / "ui-lovelace.yaml").read_bytes() == (ROOT / "docker" / "ui-lovelace.yaml").read_bytes()