diff --git a/Makefile b/Makefile index 5df2bdb..bc92af4 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,7 @@ LOCAL_ENV = \ DATABASE_URL=sqlite+aiosqlite:///./drukbox.db \ SERVICE_TOKENS=dev-token \ - DEFAULT_HOST_PROVIDER=docker \ - TAILSCALE_ENABLED=false + DEFAULT_HOST_PROVIDER=docker .PHONY: dev dev: diff --git a/docs/add-a-provider.md b/docs/add-a-provider.md index 0b20e79..f4e6301 100644 --- a/docs/add-a-provider.md +++ b/docs/add-a-provider.md @@ -98,6 +98,10 @@ instead. Set `max_lifetime` when the provider stops every VM after a fixed time. No lease then ends later than that time. +Set `tailnet = True` when the provider's hosts have no public SSH address. +A provider that offers both paths reads a `_TAILNET` setting into +`self.tailnet`, like `providers/hetzner/`. + ## 7. Tests - Unit-test the provider with a mocked api object diff --git a/docs/architecture.md b/docs/architecture.md index 15ad72c..0426546 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -221,9 +221,9 @@ customizes its host — `image`, `env`, `template`, `instance_type`, or ## Diagnostics `GET /doctor` runs one cheap, non-mutating probe per dependency — -database, active provider, secrets exchange, Tailscale when enabled — in -parallel with a -per-probe timeout. Providers own their probe (`diagnose()`) and their +database, active provider, secrets exchange, and Tailscale when the +active provider joins the tailnet — in parallel with a per-probe +timeout. Providers own their probe (`diagnose()`) and their remediation slug (`diagnose_hint`); the endpoint stays a thin orchestrator. It always returns 200; health is the `ok` field in the body. diff --git a/docs/boat.md b/docs/boat.md index 9707dc6..5e078ff 100644 --- a/docs/boat.md +++ b/docs/boat.md @@ -5,8 +5,8 @@ direct SSH coordinates. The response contains a per-host private key once. The provider uses the public Boat HTTP API. It needs no Boat CLI or SDK. Boat supplies an Ubuntu VM with passwordless sudo. The provider uses this -external SSH path even when `TAILSCALE_ENABLED` is true. The provider does not -join the tailnet. The secrets proxy must accept connections from the sandbox. +external SSH path and does not join the tailnet. The secrets proxy must accept +connections from the sandbox. ## Configuration diff --git a/docs/cloudflare.md b/docs/cloudflare.md index 7fb0ba1..2c96739 100644 --- a/docs/cloudflare.md +++ b/docs/cloudflare.md @@ -2,7 +2,7 @@ `POST /hosts` with `{"provider": "cloudflare"}` starts a Cloudflare container, joins it to Tailscale, and returns `internal_ssh_host`. The container has no -public SSH address, so this provider requires `TAILSCALE_ENABLED=true`. +public SSH address, so this provider requires the Tailscale settings. Cloudflare starts containers only from a Durable Object in a Worker. Drukbox calls the Worker in `deploy/cloudflare`, which you deploy to your account. @@ -25,7 +25,6 @@ callers reach the sandbox tag as `root`. ```dotenv CLOUDFLARE_WORKER_URL=https://drukbox-sandboxes.YOUR-SUBDOMAIN.workers.dev CLOUDFLARE_WORKER_TOKEN=YOUR-DEDICATED-TOKEN -TAILSCALE_ENABLED=true ``` The token can also come from `/run/secrets/CLOUDFLARE_WORKER_TOKEN`. diff --git a/docs/daytona.md b/docs/daytona.md index 5808d24..caa774b 100644 --- a/docs/daytona.md +++ b/docs/daytona.md @@ -55,7 +55,6 @@ Image builds, stored snapshots, and sandboxes use provider resources. | `DAYTONA_API_TIMEOUT` | API request timeout in seconds; default `150` | | `DAYTONA_LIFECYCLE_TIMEOUT_SECONDS` | Start or deletion polling budget; default `180` | | `DAYTONA_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | SSH readiness budget; default `120` | -| `TAILSCALE_ENABLED` | Required: `true` | Configure the tailnet and Tailscale credentials as described in [Networking](networking.md). Hosts use Tailscale SSH as `root`; they have no diff --git a/docs/deploy.md b/docs/deploy.md index 4bcdf8c..4f38718 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -116,9 +116,8 @@ all provider extras. The `docker` provider runs each sandbox as a local container with sshd, so you can try drukbox with no cloud account or API token. -Set `DEFAULT_HOST_PROVIDER=docker`, -`TAILSCALE_ENABLED=false`, and `UVICORN_HOST=127.0.0.1` in `drukbox.env`, -then run: +Set `DEFAULT_HOST_PROVIDER=docker` and `UVICORN_HOST=127.0.0.1` in +`drukbox.env`, then run: ```bash # Linux: match the host socket's group. macOS: use group 0 (see below). @@ -346,16 +345,22 @@ reads the same `drukbox.env` and connects to the same database. ## Choose a networking mode -`TAILSCALE_ENABLED=false` (default): callers reach sandboxes over the -provider's public path. On AWS this means per-VM keypairs and the -managed security group — see -[Networking](networking.md#tailscale-off-public-path-key-only-auth). +Each provider decides whether its hosts join your tailnet. Cloudflare, +Daytona, E2B, and Vercel always join it. Docker, docker-sbx, and Boat +never do. AWS, Hetzner, Exoscale, and exe.dev join it when +`AWS_TAILNET`, `HETZNER_TAILNET`, `EXOSCALE_TAILNET`, or `EXE_TAILNET` +is `true` (default `false`). -`TAILSCALE_ENABLED=true`: sandboxes join your tailnet at boot and -callers connect over the overlay. Requires a Tailscale OAuth client -with auth-key write scope, and tailnet ACLs that (a) own the tags in -`TAILSCALE_AUTH_TAGS` and (b) permit tailscaled-SSH to the tagged -nodes. +Off the tailnet, callers reach sandboxes over the provider's public +path. On AWS this means per-VM keypairs and the managed security +group — see +[Networking](networking.md#off-the-tailnet-public-path-key-only-auth). + +On the tailnet, sandboxes join at boot and callers connect over the +overlay. This requires the `TAILSCALE_` settings, a Tailscale OAuth +client with auth-key write scope, and tailnet ACLs that (a) own the +tags in `TAILSCALE_AUTH_TAGS` and (b) permit tailscaled-SSH to the +tagged nodes. ## Private image registry @@ -393,7 +398,7 @@ AWS credentials come from the SDK's default chain (instance profile, `~/.aws`, or env) — drukbox never plumbs them through its own settings. The policy needs `ec2:RunInstances`, `ec2:TerminateInstances`, `ec2:DescribeInstances`, `ec2:CreateTags`, -`sts:GetCallerIdentity`, plus — with Tailscale off — +`sts:GetCallerIdentity`, plus — off the tailnet — `ec2:ImportKeyPair`, `ec2:DeleteKeyPair`, `ec2:CreateSecurityGroup`, `ec2:DescribeSecurityGroups`, `ec2:AuthorizeSecurityGroupIngress`, `ec2:DescribeSubnets` (when `AWS_SUBNET_ID` is set), and @@ -547,10 +552,10 @@ curl -fsS -H "Authorization: Bearer $TOKEN" http://localhost:8780/doctor ``` `/doctor` runs one read-only probe per dependency: database, active -provider, secrets exchange, and Tailscale when enabled. It reports per-check -status, latency, and a remediation hint on failures. It always returns 200 — -health is the `ok` field. `GET /healthz` is the unauthenticated -liveness probe. +provider, secrets exchange, and Tailscale when the active provider joins +the tailnet. It reports per-check status, latency, and a remediation hint +on failures. It always returns 200 — health is the `ok` field. +`GET /healthz` is the unauthenticated liveness probe. For a full end-to-end check, run the black-box suite against the deployment (it provisions and destroys a real host — disposable @@ -605,11 +610,11 @@ Secrets exchange: | `SECRETS_EXCHANGE_BIND_HOST` | `127.0.0.1` | Loopback listener for the exchange. The API reads it to reach the exchange. | | `SECRETS_EXCHANGE_PORT` | `8781` | Port the exchange process listens on. The API reads it to reach the exchange. | -Tailscale (required when `TAILSCALE_ENABLED=true`): +Tailscale (required when a provider joins the tailnet; set the first four +together): | Variable | Default | Purpose | | --- | --- | --- | -| `TAILSCALE_ENABLED` | `false` | Provision hosts onto a tailnet. | | `TAILSCALE_TAILNET` | — | Tailnet DNS suffix for sandbox MagicDNS hostnames. | | `TAILSCALE_AUTH_TAGS` | — | Comma-separated tags applied to minted auth keys. | | `TAILSCALE_OAUTH_CLIENT_ID` | — | OAuth client ID. | @@ -632,6 +637,7 @@ exe.dev provider: | `EXE_API_TIMEOUT` | `30.0` | Timeout for exe.dev API calls. | | `EXE_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `30.0` | ssh-keyscan retry budget for a fresh exe.dev sandbox. | | `EXE_SSH_USERNAME` | `exedev` | In-VM user callers SSH as. | +| `EXE_TAILNET` | `false` | Join each host to the tailnet. The host keeps its exe.dev SSH address. | AWS provider: @@ -647,6 +653,7 @@ AWS provider: | `AWS_INSTANCE_PROFILE` | — | Optional IAM instance profile attached to sandboxes. | | `AWS_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120.0` | ssh-keyscan retry budget for a fresh EC2 instance. | | `AWS_SSH_USERNAME` | `ubuntu` | In-VM user callers SSH as. | +| `AWS_TAILNET` | `false` | Join each host to the tailnet. The host then has no key pair, security group, or public IP. | Hetzner provider: @@ -659,9 +666,10 @@ Hetzner provider: | `HETZNER_API_TIMEOUT` | `30.0` | Timeout for Hetzner API calls. | | `HETZNER_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120.0` | ssh-keyscan retry budget for a fresh server. | | `HETZNER_SSH_USERNAME` | `root` | In-VM user callers SSH as. | +| `HETZNER_TAILNET` | `false` | Join each host to the tailnet. The host keeps its key-locked public IP. | A fresh Hetzner server has no firewall — port 22 is open and SSH is -key-only. Drukbox mints a per-VM ed25519 key in both networking modes; +key-only. Drukbox mints a per-VM ed25519 key on or off the tailnet; there is no security-group or ingress-CIDR configuration to manage. Exoscale provider: @@ -677,6 +685,7 @@ Exoscale provider: | `EXOSCALE_API_TIMEOUT` | `30.0` | Timeout for Exoscale API calls. | | `EXOSCALE_BOOTSTRAP_SSH_TIMEOUT_SECONDS` | `120.0` | ssh-keyscan retry budget for a fresh instance. | | `EXOSCALE_SSH_USERNAME` | `ubuntu` | In-VM user callers SSH as. Exoscale Ubuntu templates default to `ubuntu`. | +| `EXOSCALE_TAILNET` | `false` | Join each host to the tailnet. The host keeps its key-locked public IP. | The API key's IAM role must allow the compute operations `list-templates` and `list-instance-types` in addition to the instance and SSH-key operations: diff --git a/docs/e2b.md b/docs/e2b.md index 522e5e1..c18c148 100644 --- a/docs/e2b.md +++ b/docs/e2b.md @@ -2,7 +2,7 @@ `POST /hosts` with `{"provider": "e2b"}` starts an E2B sandbox, joins it to Tailscale, and returns `internal_ssh_host`. The sandbox has no public SSH -address, so this provider requires `TAILSCALE_ENABLED=true`. +address, so this provider requires the Tailscale settings. ## Prepare the template @@ -24,7 +24,6 @@ callers reach the sandbox tag as `root`. ```dotenv E2B_API_KEY=YOUR-API-KEY E2B_DEFAULT_IMAGE=drukbox -TAILSCALE_ENABLED=true ``` The key can also come from `/run/secrets/E2B_API_KEY`. diff --git a/docs/networking.md b/docs/networking.md index 5d7a8fc..304ab32 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -1,29 +1,25 @@ # Networking and reachability This page explains how a caller reaches a sandbox and why each path is -shaped the way it is. For turning these modes on, read -[Deploy](deploy.md). +shaped the way it is. For the settings, read +[Deploy](deploy.md#choose-a-networking-mode). -## Two modes +## Two paths -`TAILSCALE_ENABLED` selects between two networking models. A provider -whose hosts cannot join a tailnet (docker — containers published on the -daemon host, -docker-sbx — local microVMs) always takes the external path, -whatever the mode. The API response carries -both addresses; which is populated depends on the mode and the +Each provider decides whether its hosts join the tailnet. The API +response carries both addresses; which is populated depends on the provider: - `external_ssh_host` / `external_ssh_port` — the provider-given - public path. Always present (empty for an AWS host with Tailscale - on, which has no public path at all). -- `internal_ssh_host` — the tailnet MagicDNS name, populated only with - Tailscale on. The internal path is always port 22 by Tailscale - convention, so there is no internal port field. + public path. Always present (empty for a host with no public path, + such as an AWS host on the tailnet). +- `internal_ssh_host` — the tailnet MagicDNS name, populated only for + a host on the tailnet. The internal path is always port 22 by + Tailscale convention, so there is no internal port field. Callers pick whichever path they can reach and dial it themselves. -## Tailscale on: the overlay is the security model +## On the tailnet: the overlay is the security model Per sandbox, drukbox mints an ephemeral, tag-scoped Tailscale auth key and injects it at boot. The sandbox joins the tailnet, drukbox @@ -31,13 +27,13 @@ discovers the device by hostname (one shared poller serves all concurrent provisions), stores the device ID, and ssh-keyscans over the tailnet path. -On AWS this mode has no public surface at all: no keypair is imported, -no security group is managed, and no public IP is attached — -authentication and reachability are tailnet ACLs plus tailscaled-SSH, -and `external_ssh_host` comes back empty. +On AWS a host on the tailnet has no public surface at all: no keypair +is imported, no security group is managed, and no public IP is +attached — authentication and reachability are tailnet ACLs plus +tailscaled-SSH, and `external_ssh_host` comes back empty. Hetzner can't drop the public IP that cheaply (a server needs one to -reach the tailnet), so a tailscale-on Hetzner box still has a public +reach the tailnet), so a Hetzner box on the tailnet still has a public IPv4 — but it is key-locked with the per-VM key drukbox always mints, and `external_ssh_host` carries that public path alongside the tailnet one. The tailnet remains the intended path; the public port just isn't @@ -47,9 +43,9 @@ Teardown releases the Tailscale device by its stored ID — never by hostname — and treats a 404 as success, because ephemeral devices self-delete and operator cleanup can race the API. -## Tailscale off: public path, key-only auth +## Off the tailnet: public path, key-only auth -With Tailscale off the provider's public path is the only path. +Off the tailnet the provider's public path is the only path. On exe.dev, SSH terminates at exe's edge (`ssh_dest`), and exe owns authentication; drukbox returns no key material. @@ -95,7 +91,8 @@ key material, scanned against the preferred path (`internal_ssh_host` when present, else `external_ssh_host`) and keyed to whichever address was scanned. -With Tailscale off, the first-time keyscan runs over the public +Off the tailnet, the first-time keyscan runs over the public network and carries the same TCP-session MITM window any first-time -keyscan has. Operators who need stronger guarantees should enable -Tailscale and let the scan run over the authenticated overlay. +keyscan has. Operators who need stronger guarantees should put the +provider on the tailnet and let the scan run over the authenticated +overlay. diff --git a/docs/security.md b/docs/security.md index 636a783..13731e9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -53,7 +53,7 @@ no rate limiting (see [Resource exhaustion](#resource-exhaustion)). How a caller reaches a sandbox, and the tradeoffs of each path, are covered in [Networking](networking.md). The security-relevant summary: -- **Per-VM keys.** On AWS (Tailscale off), Hetzner, docker, and +- **Per-VM keys.** On AWS (off the tailnet), Hetzner, docker, and docker-sbx, drukbox mints a fresh ed25519 keypair per VM, returns the private half **once** in the create response, and never persists it — a later `GET /hosts/{id}` returns `private_key: null`. The key @@ -72,10 +72,10 @@ covered in [Networking](networking.md). The security-relevant summary: non-loopback `DOCKER_SSH_HOST` is reachable by everything that reaches that address. On a tailnet it has no device of its own, so ACL tags cannot scope it. Each sandbox's key is the only boundary. -- **First-keyscan MITM window.** With Tailscale off, the `known_hosts` +- **First-keyscan MITM window.** Off the tailnet, the `known_hosts` material is scanned over the public network and carries the usual - trust-on-first-use window. Enable Tailscale to run the scan over the - authenticated overlay. + trust-on-first-use window. Put the provider on the tailnet to run the + scan over the authenticated overlay. ## Secrets: which field to use diff --git a/docs/vercel.md b/docs/vercel.md index f2d94ec..e23a691 100644 --- a/docs/vercel.md +++ b/docs/vercel.md @@ -2,7 +2,7 @@ `POST /hosts` with `{"provider": "vercel"}` starts a Vercel sandbox, joins it to Tailscale, and returns `internal_ssh_host`. The sandbox has no public SSH -address, so this provider requires `TAILSCALE_ENABLED=true`. +address, so this provider requires the Tailscale settings. ## Prepare the image @@ -29,7 +29,6 @@ VERCEL_TOKEN=YOUR-ACCESS-TOKEN VERCEL_TEAM_ID=team_YOUR_TEAM VERCEL_PROJECT_ID=prj_YOUR_PROJECT VERCEL_DEFAULT_IMAGE=drukbox-sandbox:v1 -TAILSCALE_ENABLED=true ``` The token can also come from `/run/secrets/VERCEL_TOKEN`. diff --git a/env/test.env b/env/test.env index 584d8b0..c0749fb 100644 --- a/env/test.env +++ b/env/test.env @@ -3,7 +3,7 @@ SECRETS_KEY=MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA= DEVICE_DISCOVERY_TIMEOUT_SECONDS=2 EXE_API_TOKEN=exe-token EXE_DEFAULT_IMAGE=ghcr.io/drukbox/sandbox:exeuntu-latest -TAILSCALE_ENABLED=true +EXE_TAILNET=true TAILSCALE_AUTH_TAGS=tag:sandbox TAILSCALE_OAUTH_CLIENT_ID=tailscale-client-id TAILSCALE_OAUTH_CLIENT_SECRET=tailscale-client-secret diff --git a/src/api/app.py b/src/api/app.py index f563d97..bfe7c1e 100644 --- a/src/api/app.py +++ b/src/api/app.py @@ -9,7 +9,6 @@ from core.database import close_database from core.exceptions import AppException -from core.settings import get_settings from diagnostics.api import router as diagnostics_router from hosts.api import router as hosts_router from http_proxies.api import router as http_proxies_router @@ -39,13 +38,10 @@ def filter(self, record: logging.LogRecord) -> bool: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: - # Process-wide Tailscale client when enabled: reusing one - # httpx.AsyncClient amortises the TLS handshake and OAuth token cache - # across every provision instead of paying both per request. When - # Tailscale is disabled the slot stays None and request handlers skip - # the network layer entirely. - settings = get_settings() - tailscale: Tailscale | None = Tailscale.from_settings() if settings.tailscale_enabled else None + # Process-wide Tailscale client: reusing one httpx.AsyncClient amortises + # the TLS handshake and OAuth token cache across every provision instead + # of paying both per request. Without a tailnet the slot stays None. + tailscale = Tailscale.from_settings() app.state.tailscale = tailscale try: yield diff --git a/src/conftest.py b/src/conftest.py index 78b97b6..01ae334 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -4,6 +4,7 @@ import pytest from httpx import ASGITransport, AsyncClient +from sqlalchemy import insert TEST_ENV_PATH = Path(__file__).resolve().parents[1] / "env" / "test.env" @@ -39,8 +40,6 @@ def load_test_env() -> None: @pytest.fixture(autouse=True) async def reset_database() -> AsyncGenerator[None]: - from sqlalchemy import insert - from core.database import Base, engine from hosts import models # noqa: F401 from service_accounts.models import ServiceAccount @@ -64,3 +63,14 @@ async def client() -> AsyncGenerator[AsyncClient]: base_url="https://drukbox.example", ) as client: yield client + + +@pytest.fixture +def no_tailscale_settings(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "TAILSCALE_TAILNET", + "TAILSCALE_AUTH_TAGS", + "TAILSCALE_OAUTH_CLIENT_ID", + "TAILSCALE_OAUTH_CLIENT_SECRET", + ): + monkeypatch.delenv(key) diff --git a/src/core/settings.py b/src/core/settings.py index dd278e2..a5426ba 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -52,11 +52,6 @@ class Settings(BaseSettings): validation_alias="DEFAULT_HOST_PROVIDER", description="Name of the VM provider used when callers don't specify one.", ) - default_network_provider: str = Field( - default="tailscale", - validation_alias="DEFAULT_NETWORK_PROVIDER", - description="Name of the network provider used when callers don't specify one.", - ) secrets_proxy_url: str = Field( default="", @@ -81,11 +76,6 @@ class Settings(BaseSettings): description="Identifier stamped onto provider resources (e.g. VM tags).", ) - tailscale_enabled: bool = Field( - default=False, - validation_alias="TAILSCALE_ENABLED", - description="Enable Tailscale-based reachability for provisioned hosts.", - ) device_discovery_timeout_seconds: float = Field( default=180.0, gt=0, diff --git a/src/core/tests/test_settings.py b/src/core/tests/test_settings.py index 7510245..dc1e7dd 100644 --- a/src/core/tests/test_settings.py +++ b/src/core/tests/test_settings.py @@ -73,19 +73,6 @@ def test_secrets_key_rejects_invalid_key_without_echoing_it( assert invalid_key not in str(error.value) -def test_tailscale_disabled_by_default_with_no_credentials(monkeypatch: pytest.MonkeyPatch) -> None: - env: dict[str, str | None] = { - **_base_env(), - "TAILSCALE_ENABLED": None, - "TAILSCALE_TAILNET": None, - "TAILSCALE_AUTH_TAGS": None, - "TAILSCALE_OAUTH_CLIENT_ID": None, - "TAILSCALE_OAUTH_CLIENT_SECRET": None, - } - settings = _settings_with(monkeypatch, env) - assert settings.tailscale_enabled is False - - def test_tailscale_settings_requires_all_credentials(monkeypatch: pytest.MonkeyPatch) -> None: missing = ("TAILSCALE_AUTH_TAGS", "TAILSCALE_OAUTH_CLIENT_ID", "TAILSCALE_OAUTH_CLIENT_SECRET") for key in missing: @@ -93,7 +80,7 @@ def test_tailscale_settings_requires_all_credentials(monkeypatch: pytest.MonkeyP monkeypatch.setenv("TAILSCALE_TAILNET", "example.ts.net") with pytest.raises(ValueError) as excinfo: - TailscaleSettings() # pyright: ignore[reportCallIssue] + TailscaleSettings() message = str(excinfo.value) assert "TAILSCALE_AUTH_TAGS" in message assert "TAILSCALE_OAUTH_CLIENT_ID" in message @@ -108,7 +95,7 @@ def test_tailscale_settings_with_all_credentials_constructs_ok( monkeypatch.setenv("TAILSCALE_OAUTH_CLIENT_ID", "client-id") monkeypatch.setenv("TAILSCALE_OAUTH_CLIENT_SECRET", "client-secret") - ts = TailscaleSettings() # pyright: ignore[reportCallIssue] + ts = TailscaleSettings() assert ts.tailnet == "example.ts.net" assert ts.auth_tags == ("tag:sandbox",) @@ -165,16 +152,6 @@ def test_provider_settings_errors_hide_secrets( assert "input_value" not in str(error.value) -def test_tailscale_disabled_ignores_missing_credentials(monkeypatch: pytest.MonkeyPatch) -> None: - env: dict[str, str | None] = { - **_base_env(), - "TAILSCALE_ENABLED": "false", - "TAILSCALE_TAILNET": None, - } - settings = _settings_with(monkeypatch, env) - assert settings.tailscale_enabled is False - - @pytest.mark.parametrize( "key", [ @@ -239,9 +216,9 @@ def test_pool_sizes_rejects_negative_targets(monkeypatch: pytest.MonkeyPatch) -> def test_load_test_env_overrides_ambient_values(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("TAILSCALE_ENABLED", "false") + monkeypatch.setenv("EXE_TAILNET", "false") conftest.load_test_env() - assert os.environ["TAILSCALE_ENABLED"] == "true" + assert os.environ["EXE_TAILNET"] == "true" def _registry_env(**overrides: str) -> dict[str, str | None]: diff --git a/src/diagnostics/api.py b/src/diagnostics/api.py index 7d52b1d..66f0280 100644 --- a/src/diagnostics/api.py +++ b/src/diagnostics/api.py @@ -10,7 +10,7 @@ from core.settings import get_settings from diagnostics.checks import DEFAULT_CHECK_TIMEOUT_SECONDS, Check, CheckStatus, run_check from hosts.auth import require_auth -from networking.tailscale import Tailscale +from networking.tailscale import NetworkError, Tailscale from providers.registry import get_default_vm_provider from secrets_exchange.client import SecretsExchange @@ -30,7 +30,7 @@ class CheckOut(BaseModel): class DoctorOut(BaseModel): ok: bool active_provider: str - tailscale_enabled: bool + tailnet: bool checks: list[CheckOut] @@ -44,6 +44,7 @@ async def doctor( provider = get_default_vm_provider() provider_hint = provider.diagnose_hint provider_timeout = provider.diagnose_timeout_seconds + tailnet = provider.tailnet except Exception: # The provider can't even be constructed (bad config / missing dep). Use a # generic hint; the probe below re-resolves it so the construction error @@ -51,6 +52,7 @@ async def doctor( # the point of /doctor. provider_hint = "check_provider_configuration" provider_timeout = DEFAULT_CHECK_TIMEOUT_SECONDS + tailnet = False async def _provider_probe() -> str: return await get_default_vm_provider().diagnose() @@ -59,10 +61,13 @@ async def _tailscale_probe() -> str: # Resolve lazily inside the probe (reusing the lifespan-installed client # when present) so a Tailscale construction failure becomes a failed # check rather than a 500 — same reasoning as the provider probe above. - tailscale: Tailscale | None = getattr(request.app.state, "tailscale", None) + tailscale = getattr(request.app.state, "tailscale", None) or Tailscale.from_settings() if not tailscale: - tailscale = Tailscale.from_settings() - request.app.state.tailscale = tailscale + raise NetworkError( + f"provider {settings.default_host_provider!r} joins the tailnet; set " + "TAILSCALE_TAILNET, TAILSCALE_AUTH_TAGS, TAILSCALE_OAUTH_CLIENT_ID and " + "TAILSCALE_OAUTH_CLIENT_SECRET" + ) return await tailscale.diagnose() tasks: list[asyncio.Future[Check]] = [ @@ -80,7 +85,7 @@ async def _tailscale_probe() -> str: ), ), ] - if settings.tailscale_enabled: + if tailnet: tasks.append( asyncio.ensure_future( run_check("tailscale", _tailscale_probe, hint=Tailscale.diagnose_hint), @@ -91,7 +96,7 @@ async def _tailscale_probe() -> str: return DoctorOut( ok=ok, active_provider=settings.default_host_provider, - tailscale_enabled=settings.tailscale_enabled, + tailnet=tailnet, checks=[CheckOut.model_validate(check) for check in checks], ) diff --git a/src/diagnostics/tests/test_doctor.py b/src/diagnostics/tests/test_doctor.py index 9ca2ed2..e4823af 100644 --- a/src/diagnostics/tests/test_doctor.py +++ b/src/diagnostics/tests/test_doctor.py @@ -6,7 +6,6 @@ import pytest from api.app import app -from core import settings as settings_module from networking.tailscale import Tailscale from providers.exe.provider import ExeProvider from providers.registry import reset_vm_provider_cache @@ -54,7 +53,7 @@ async def test_doctor_reports_ok_when_all_probes_pass(client) -> None: assert response.status_code == 200 assert body["ok"] is True assert body["active_provider"] == "exe" - assert body["tailscale_enabled"] is True + assert body["tailnet"] is True assert [check["name"] for check in body["checks"]] == [ "db", "provider", @@ -89,10 +88,9 @@ async def test_doctor_propagates_failure_with_owner_hint(client) -> None: assert tailscale["hint"] == "check_tailscale_oauth_and_api_reachability" -async def test_doctor_omits_tailscale_when_disabled(client, monkeypatch) -> None: - """With TAILSCALE_ENABLED=false there is no tailscale row at all.""" - monkeypatch.setenv("TAILSCALE_ENABLED", "false") - settings_module.get_settings.cache_clear() +async def test_doctor_omits_tailscale_off_the_tailnet(client, monkeypatch) -> None: + """A provider off the tailnet has no tailscale row at all.""" + monkeypatch.setenv("EXE_TAILNET", "false") with patch.object(ExeProvider, "diagnose", new=AsyncMock(return_value="exe ok")): response = await client.get( @@ -101,9 +99,25 @@ async def test_doctor_omits_tailscale_when_disabled(client, monkeypatch) -> None ) body = response.json() - assert body["tailscale_enabled"] is False + assert body["tailnet"] is False assert [check["name"] for check in body["checks"]] == ["db", "provider", "exchange"] - settings_module.get_settings.cache_clear() + + +async def test_doctor_fails_tailscale_without_tailscale_settings( + client, no_tailscale_settings: None +) -> None: + """A provider on the tailnet fails the tailscale check when the settings are unset.""" + with patch.object(ExeProvider, "diagnose", new=AsyncMock(return_value="exe ok")): + response = await client.get( + "/doctor", + headers={"Authorization": "Bearer service-token"}, + ) + + body = response.json() + assert body["ok"] is False + tailscale = next(check for check in body["checks"] if check["name"] == "tailscale") + assert tailscale["status"] == "fail" + assert "TAILSCALE_TAILNET" in tailscale["detail"] async def test_doctor_reports_provider_construction_failure(client) -> None: diff --git a/src/hosts/deps.py b/src/hosts/deps.py index 21191bf..737306e 100644 --- a/src/hosts/deps.py +++ b/src/hosts/deps.py @@ -4,9 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.database import get_session -from core.settings import get_settings from hosts.service import HostService -from networking.tailscale import Tailscale async def get_host_service( @@ -15,12 +13,6 @@ async def get_host_service( ) -> HostService: # The lifespan installs a process-wide Tailscale client on app.state so # every request shares one httpx connection pool + OAuth token cache. - # Test harnesses that bypass lifespan (ASGITransport) lazily get their - # own here; class-level patches on Tailscale still apply. With - # tailscale_enabled=False the slot stays None and HostService skips the - # network layer entirely. - tailscale: Tailscale | None = getattr(request.app.state, "tailscale", None) - if not tailscale and get_settings().tailscale_enabled: - tailscale = Tailscale.from_settings() - request.app.state.tailscale = tailscale - return HostService(session, tailscale=tailscale) + # Test harnesses that bypass lifespan (ASGITransport) leave the slot + # empty, and HostService builds its own. + return HostService(session, tailscale=getattr(request.app.state, "tailscale", None)) diff --git a/src/hosts/janitor.py b/src/hosts/janitor.py index fa4c901..735270c 100644 --- a/src/hosts/janitor.py +++ b/src/hosts/janitor.py @@ -5,7 +5,6 @@ from core.database import async_session_factory from core.exceptions import ResourceNotFoundError -from core.settings import get_settings from hosts.models import Host from hosts.service import HostService, utc_now from networking.tailscale import Tailscale @@ -33,9 +32,7 @@ async def reap_expired_hosts() -> list[uuid.UUID]: expired_ids = list(result.scalars()) reaped: list[uuid.UUID] = [] - tailscale: Tailscale | None = ( - Tailscale.from_settings() if get_settings().tailscale_enabled else None - ) + tailscale = Tailscale.from_settings() try: for host_id in expired_ids: @@ -50,14 +47,12 @@ async def reap_expired_hosts() -> list[uuid.UUID]: except Exception: logger.exception("janitor: failed to reap expired host: host_id=%s", host_id) continue - else: - if not deleted: - # Renewed between selection and the locked delete — spared. - continue + # A host renewed between selection and the locked delete is spared. + if deleted: logger.info("janitor: reaped expired host: host_id=%s", host_id) reaped.append(host_id) finally: - if tailscale is not None: + if tailscale: await tailscale.aclose() return reaped diff --git a/src/hosts/pool.py b/src/hosts/pool.py index 7947911..8e71b16 100644 --- a/src/hosts/pool.py +++ b/src/hosts/pool.py @@ -39,11 +39,11 @@ async def maintain_pool() -> PoolMaintenanceSummary: targets = settings.get_pool_targets() if not targets: return PoolMaintenanceSummary() - tailscale: Tailscale | None = Tailscale.from_settings() if settings.tailscale_enabled else None + tailscale = Tailscale.from_settings() try: return await _maintain(settings, targets, tailscale) finally: - if tailscale is not None: + if tailscale: await tailscale.aclose() diff --git a/src/hosts/service.py b/src/hosts/service.py index 92bd3c6..6a41d9b 100644 --- a/src/hosts/service.py +++ b/src/hosts/service.py @@ -87,17 +87,7 @@ def __init__( ) -> None: self.session = session self.settings = settings or get_settings() - # Construct Tailscale only when explicitly enabled. Settings' - # model_validator guarantees the credentials are present whenever - # tailscale_enabled is true. Tests can inject a mock Tailscale via - # the kwarg regardless of the flag — useful for exercising the - # tailnet path without real credentials. - if tailscale: - self.tailscale: Tailscale | None = tailscale - elif self.settings.tailscale_enabled: - self.tailscale = Tailscale.from_settings() - else: - self.tailscale = None + self.tailscale = tailscale or Tailscale.from_settings() def _default_lease_expires_at(self) -> datetime: return utc_now() + timedelta(seconds=self.settings.lease_default_ttl) @@ -514,9 +504,9 @@ async def delete_host( if host.tailscale_device_id and self.tailscale: # Clear and commit the device_id before deleting the VM: # a later delete_vm transport error must not retry the - # already-completed release. Hosts provisioned under - # Tailscale but reaped after the operator turned it off - # fall through and let the auth-key TTL expire the device. + # already-completed release. Hosts provisioned on the + # tailnet but reaped after the operator removed the Tailscale + # settings fall through and let the auth-key TTL expire the device. await self.tailscale.release_device(host.tailscale_device_id) host.tailscale_device_id = None host.updated_at = utc_now() @@ -553,15 +543,23 @@ async def provision(self, host_id: str) -> None: await self.session.commit() vm = get_vm_provider(host.provider) - tailscale: Tailscale | None = None - if vm.supports_tailnet: - tailscale = self.tailscale + if vm.tailnet and not self.tailscale: + await self.mark_failed( + host, + ProvisioningFailedError( + f"provider {vm.name!r} joins the tailnet; set TAILSCALE_TAILNET, " + "TAILSCALE_AUTH_TAGS, TAILSCALE_OAUTH_CLIENT_ID and " + "TAILSCALE_OAUTH_CLIENT_SECRET" + ), + ) + return + tailscale = self.tailscale if vm.tailnet else None environment = dict(host.env) setup_script: str | None = None if tailscale: # The bootstrap script hard-requires TAILSCALE_AUTHKEY; only - # deliver it (and mint a key) when Tailscale is in play. + # deliver it (and mint a key) when the host joins the tailnet. try: join_credentials = await tailscale.issue_join_credentials(host_name=host.name) except NetworkError as exc: diff --git a/src/hosts/tests/test_api.py b/src/hosts/tests/test_api.py index ea0cf67..3bc8392 100644 --- a/src/hosts/tests/test_api.py +++ b/src/hosts/tests/test_api.py @@ -1,17 +1,17 @@ import uuid -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from unittest.mock import AsyncMock from sqlalchemy import func, select +from sqlalchemy.exc import SQLAlchemyError from uuid6 import uuid7 from core.database import async_session_factory from core.settings import get_settings -from hosts.models import Host, HostStatus +from hosts.models import Host, HostStatus, IdempotencyKey from hosts.service import HostService, utc_now from networking.tailscale import NetworkTransportError -from networking.tailscale_settings import TailscaleSettings -from providers.exceptions import ProviderTransportError +from providers.exceptions import ProviderNotFoundError, ProviderTransportError from providers.exe.settings import ExeSettings @@ -161,8 +161,6 @@ async def test_create_host_stores_and_returns_custom_image(client, monkeypatch): async def test_create_host_stores_and_returns_expires_at(client, monkeypatch): - from datetime import UTC, datetime, timedelta - mocked_provision = AsyncMock() monkeypatch.setattr("hosts.service.HostService.provision", mocked_provision) expires_at = datetime.now(UTC) + timedelta(hours=1) @@ -422,8 +420,6 @@ async def test_idempotency_loser_in_provisioning_is_marked_for_janitor(monkeypat # Race-loser that's fresh-created (in PROVISIONING) cannot be delete_host'd # (it's in DELETE_BLOCKED_STATUSES). The cleanup path must mark it with # expires_at=now so the janitor reaps it on the next cycle. - from datetime import UTC, datetime - monkeypatch.setattr("hosts.service.HostService.provision", AsyncMock()) async with async_session_factory() as session: @@ -443,8 +439,6 @@ async def test_idempotency_loser_in_provisioning_is_marked_for_janitor(monkeypat async def test_idempotency_loser_that_claimed_pool_host_is_returned_to_pool(monkeypatch): # Race-loser that had claimed a pool host should be UN-claimed (returned # to the pool with a fresh max-age TTL), NOT deleted. - from datetime import UTC, datetime, timedelta - monkeypatch.setenv("POOL_SIZE", "1") monkeypatch.setenv("POOL_HOST_MAX_AGE_HOURS", "4") get_settings.cache_clear() @@ -510,10 +504,6 @@ async def test_record_idempotency_key_returns_false_on_duplicate(monkeypatch): async def test_create_host_idempotency_key_expired_creates_new_host(client, monkeypatch): # Manually insert an idempotency-key row pointing at a deleted host with # an expired timestamp; a fresh POST with the same key should NOT reuse it. - from datetime import UTC, datetime, timedelta - - from hosts.models import IdempotencyKey - mocked_provision = AsyncMock() monkeypatch.setattr("hosts.service.HostService.provision", mocked_provision) @@ -951,8 +941,6 @@ async def test_delete_host_clears_tailscale_device_id_when_vm_teardown_fails(cli async def test_delete_host_succeeds_when_vm_already_absent_at_provider(client, monkeypatch): # If exe.dev evicted the VM out from under us (or a previous delete # partially completed), the next delete should still clean up the DB row. - from providers.exceptions import ProviderNotFoundError - mocked_delete_device = AsyncMock() mocked_delete_vm = AsyncMock( side_effect=ProviderNotFoundError("vm 'lb-sandbox-test' not found") @@ -1009,7 +997,6 @@ async def create_host_record( name: str, status: str, external_ssh_host: str = "", - internal_ssh_host: str | None = None, known_hosts: str = "", env: dict[str, str] | None = None, image: str | None = None, @@ -1019,19 +1006,13 @@ async def create_host_record( pool_member: bool = False, ) -> Host: now = utc_now() - settings = get_settings() - # Default the internal hostname to the tailnet form so existing test - # call sites (which were written for the Tailscale-on world) keep their - # original semantics without explicitly passing it. - if internal_ssh_host is None and settings.tailscale_enabled: - internal_ssh_host = f"{name}.{TailscaleSettings().tailnet}" # pyright: ignore[reportCallIssue] host = Host( id=id or uuid7(), name=name, status=status, image=image or ExeSettings().default_image, # pyright: ignore[reportCallIssue] external_ssh_host=external_ssh_host, - internal_ssh_host=internal_ssh_host, + internal_ssh_host=f"{name}.example.ts.net", known_hosts=known_hosts, tailscale_device_id=tailscale_device_id, env=env or {}, @@ -1226,8 +1207,6 @@ async def test_renew_host_rejects_missing_or_bad_service_auth(client): async def test_delete_host_maps_database_failure_to_503(client, monkeypatch): # A SQLAlchemyError during teardown must surface as a controlled 503, like # create_host's DB-failure path, not an unhandled 500. - from sqlalchemy.exc import SQLAlchemyError - monkeypatch.setattr( "hosts.service.HostService.delete_host", AsyncMock(side_effect=SQLAlchemyError("db down")), diff --git a/src/hosts/tests/test_gateway_path.py b/src/hosts/tests/test_gateway_path.py index 92595b9..c8235af 100644 --- a/src/hosts/tests/test_gateway_path.py +++ b/src/hosts/tests/test_gateway_path.py @@ -5,31 +5,24 @@ a configured gateway. """ -from collections.abc import Generator from unittest.mock import AsyncMock import pytest from core.database import async_session_factory -from core.settings import Settings, get_settings from hosts.exceptions import ProvisioningFailedError from hosts.models import HostStatus from hosts.service import HostService from providers.base import VMCreateResult +from providers.registry import get_vm_provider MODULE_DOCKER_SBX = "providers.docker_sbx.provider.DockerSbxProvider" @pytest.fixture -def gateway_configured_settings( - monkeypatch: pytest.MonkeyPatch, -) -> Generator[Settings, None, None]: - monkeypatch.setenv("TAILSCALE_ENABLED", "false") +def gateway_configured(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GATEWAY_SSH_HOST", "gateway.example.com") monkeypatch.setenv("GATEWAY_SSH_PORT", "2222") - get_settings.cache_clear() - yield get_settings() - get_settings.cache_clear() def _sbx_create_result() -> VMCreateResult: @@ -45,7 +38,7 @@ def _sbx_create_result() -> VMCreateResult: async def test_gateway_provider_hosts_advertise_the_gateway( - gateway_configured_settings: Settings, monkeypatch: pytest.MonkeyPatch + gateway_configured: None, monkeypatch: pytest.MonkeyPatch ) -> None: create_vm = AsyncMock(return_value=_sbx_create_result()) monkeypatch.setattr(f"{MODULE_DOCKER_SBX}.create_vm", create_vm) @@ -53,7 +46,7 @@ async def test_gateway_provider_hosts_advertise_the_gateway( monkeypatch.setattr("hosts.service.HostService.scan_known_hosts", scan) async with async_session_factory() as session: - service = HostService(session, settings=gateway_configured_settings) + service = HostService(session) host = await service.create_host(env={}, image=None, provider="docker-sbx") assert host.status == HostStatus.ACTIVE.value @@ -71,25 +64,23 @@ async def test_gateway_provider_hosts_fail_cleanly_without_an_address( # A gateway-provider host is reachable only through the gateway. Without # an address, provisioning must fail loudly, not hand out dead # coordinates. - monkeypatch.setenv("TAILSCALE_ENABLED", "false") monkeypatch.delenv("GATEWAY_SSH_HOST", raising=False) - get_settings.cache_clear() create_vm = AsyncMock(return_value=_sbx_create_result()) monkeypatch.setattr(f"{MODULE_DOCKER_SBX}.create_vm", create_vm) async with async_session_factory() as session: - service = HostService(session, settings=get_settings()) + service = HostService(session) with pytest.raises(ProvisioningFailedError, match="GATEWAY_SSH_HOST"): await service.create_host(env={}, image=None, provider="docker-sbx") - get_settings.cache_clear() create_vm.assert_not_awaited() async def test_direct_dial_provider_hosts_ignore_the_gateway( - gateway_configured_settings: Settings, monkeypatch: pytest.MonkeyPatch + gateway_configured: None, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setattr(get_vm_provider("exe"), "tailnet", False) create_vm = AsyncMock( return_value=VMCreateResult( provider_id="vm-1", @@ -104,7 +95,7 @@ async def test_direct_dial_provider_hosts_ignore_the_gateway( monkeypatch.setattr("hosts.service.HostService.scan_known_hosts", scan) async with async_session_factory() as session: - service = HostService(session, settings=gateway_configured_settings) + service = HostService(session) host = await service.create_host(env={}, image=None, provider="exe") assert host.external_ssh_host == "vm-1.public.example.com" diff --git a/src/hosts/tests/test_secrets.py b/src/hosts/tests/test_secrets.py index 8e7fd4c..ae1399c 100644 --- a/src/hosts/tests/test_secrets.py +++ b/src/hosts/tests/test_secrets.py @@ -56,7 +56,6 @@ async def delete_secrets(self, *, vm: str) -> None: @pytest.fixture def settings(monkeypatch: pytest.MonkeyPatch) -> Generator[Settings, None, None]: - monkeypatch.setenv("TAILSCALE_ENABLED", "false") monkeypatch.setenv("SECRETS_PROXY_URL", "http://proxy.test:8880") monkeypatch.setenv("SECRETS_PROXY_CA_FILE", "env/test-proxy-ca.pem") get_settings.cache_clear() diff --git a/src/hosts/tests/test_tailscale_optional.py b/src/hosts/tests/test_tailscale_optional.py index b3fda4d..3262438 100644 --- a/src/hosts/tests/test_tailscale_optional.py +++ b/src/hosts/tests/test_tailscale_optional.py @@ -1,48 +1,22 @@ -"""End-to-end-ish tests for provisioning without the tailnet. +"""Hosts off the tailnet, and tailnet hosts without Tailscale settings.""" -These exercise HostService.provision() with Tailscale out of play — the -service disabled entirely, or a provider whose hosts can't join (docker): -no auth-key minting, no device discovery, no setup script delivered to -the VM, no tailnet device released on teardown. The external_ssh_host -comes from the VM provider; the keyscan runs against that address -directly. -""" - -from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock import pytest from core.database import async_session_factory -from core.settings import Settings, get_settings +from hosts.exceptions import ProvisioningFailedError from hosts.models import Host, HostStatus from hosts.service import HostService from providers.base import VMCreateResult +from providers.registry import get_vm_provider -@pytest.fixture -def tailscale_disabled_settings( - monkeypatch: pytest.MonkeyPatch, -) -> Generator[Settings, None, None]: - monkeypatch.setenv("TAILSCALE_ENABLED", "false") - # Clear the four credential vars so this test fixture really exercises - # the "no Tailscale credentials at all" deployment shape. - for key in ( - "TAILSCALE_TAILNET", - "TAILSCALE_AUTH_TAGS", - "TAILSCALE_OAUTH_CLIENT_ID", - "TAILSCALE_OAUTH_CLIENT_SECRET", - ): - monkeypatch.delenv(key, raising=False) - get_settings.cache_clear() - yield get_settings() - get_settings.cache_clear() - - -async def test_provision_skips_tailscale_when_disabled( - tailscale_disabled_settings: Settings, monkeypatch: pytest.MonkeyPatch +async def test_provision_skips_tailscale_off_the_tailnet( + no_tailscale_settings: None, monkeypatch: pytest.MonkeyPatch ) -> None: + monkeypatch.setattr(get_vm_provider("exe"), "tailnet", False) # Mocks for the Tailscale module: we assert they were NOT called. issue_join = AsyncMock() wait_for_device = AsyncMock() @@ -66,8 +40,7 @@ async def test_provision_skips_tailscale_when_disabled( monkeypatch.setattr("hosts.service.HostService.scan_known_hosts", scan) async with async_session_factory() as session: - service = HostService(session, settings=tailscale_disabled_settings) - host = await service.create_host(env={}, image=None) + host = await HostService(session).create_host(env={}, image=None) assert host.status == HostStatus.ACTIVE.value assert host.external_ssh_host == "vm-1.public.example.com" @@ -82,18 +55,30 @@ async def test_provision_skips_tailscale_when_disabled( # Provider was called WITHOUT a setup script (the bootstrap script # hard-requires TAILSCALE_AUTHKEY and would error on a stock image). - assert create_vm.await_args is not None + assert create_vm.await_args call_kwargs = create_vm.await_args.kwargs assert call_kwargs["setup_script"] is None assert "TAILSCALE_AUTHKEY" not in (call_kwargs.get("env") or {}) -async def test_docker_host_stays_local_on_a_tailnet_mode_service( - monkeypatch: pytest.MonkeyPatch, +async def test_tailnet_provider_fails_without_tailscale_settings( + no_tailscale_settings: None, monkeypatch: pytest.MonkeyPatch ) -> None: - # TAILSCALE_ENABLED=true serves remote VMs over the tailnet, but a local - # container has no path onto it — a docker host skips the join entirely - # and keeps its published port as the only path. + # The exe provider joins the tailnet in the test env. + create_vm = AsyncMock() + monkeypatch.setattr("providers.exe.provider.ExeProvider.create_vm", create_vm) + + async with async_session_factory() as session: + with pytest.raises(ProvisioningFailedError, match="TAILSCALE_TAILNET"): + await HostService(session).create_host(env={}, image=None) + + create_vm.assert_not_awaited() + + +async def test_docker_host_stays_off_the_tailnet(monkeypatch: pytest.MonkeyPatch) -> None: + # A local container has no path onto the tailnet: a docker host skips + # the join even when the service has a Tailscale client, and keeps its + # published port as the only path. tailscale = AsyncMock() create_vm = AsyncMock( @@ -122,14 +107,14 @@ async def test_docker_host_stays_local_on_a_tailnet_mode_service( # The tailnet layer never came into play for this host. tailscale.issue_join_credentials.assert_not_awaited() tailscale.wait_for_device.assert_not_awaited() - assert create_vm.await_args is not None + assert create_vm.await_args call_kwargs = create_vm.await_args.kwargs assert call_kwargs["setup_script"] is None assert "TAILSCALE_AUTHKEY" not in (call_kwargs.get("env") or {}) -async def test_delete_skips_tailscale_release_when_disabled( - tailscale_disabled_settings: Settings, monkeypatch: pytest.MonkeyPatch +async def test_delete_skips_tailscale_release_without_tailscale_settings( + no_tailscale_settings: None, monkeypatch: pytest.MonkeyPatch ) -> None: # A host that was somehow created without Tailscale shouldn't have a # device_id, so release_device is never called. This locks that in. @@ -159,7 +144,6 @@ async def test_delete_skips_tailscale_release_when_disabled( host_id = host.id async with async_session_factory() as session: - service = HostService(session, settings=tailscale_disabled_settings) - await service.delete_host(host_id) + await HostService(session).delete_host(host_id) release.assert_not_awaited() diff --git a/src/networking/tailscale.py b/src/networking/tailscale.py index 467b63f..f2f8fd7 100644 --- a/src/networking/tailscale.py +++ b/src/networking/tailscale.py @@ -77,12 +77,7 @@ def __init__( self._access_token_expires_at = 0.0 @classmethod - def from_settings(cls) -> Self: - # pydantic-settings resolves required fields from env at runtime; - # pyright can't see that statically. If TAILSCALE_ENABLED=true but - # any of the required envs are missing, this raises ValidationError - # at construction with a clear field-by-field message. - settings = TailscaleSettings() # pyright: ignore[reportCallIssue] + def from_settings(cls, settings: TailscaleSettings) -> Self: return cls( oauth_client_id=settings.oauth_client_id, oauth_client_secret=settings.oauth_client_secret, @@ -210,11 +205,9 @@ async def delete_device(self, device_id: str) -> None: raise NetworkTransportError(detail) async def aclose(self) -> None: - if not self._client: - return - - await self._client.aclose() - self._client = None + if self._client: + await self._client.aclose() + self._client = None async def _request( self, @@ -284,7 +277,7 @@ def __init__(self, api: TailscaleAPI, *, poll_interval: float = 2.0) -> None: self._task: asyncio.Task[None] | None = None async def wait_for(self, host_name: str, timeout: float) -> str: - if self._task is None or self._task.done(): + if not self._task or self._task.done(): self._task = asyncio.create_task(self._poll()) fut: asyncio.Future[str] = asyncio.get_running_loop().create_future() # Hostnames are uuid7-derived and unique per provision; concurrent @@ -321,7 +314,7 @@ async def _poll(self) -> None: # can't race the resolution. for device in devices: fut = self._waiters.get(device.hostname) - if fut is not None and not fut.done(): + if fut and not fut.done(): fut.set_result(device.id) await asyncio.sleep(self._poll_interval) @@ -331,7 +324,7 @@ def _fail_waiters(self, exc: Exception) -> None: fut.set_exception(exc) async def aclose(self) -> None: - if self._task is not None: + if self._task: self._task.cancel() with contextlib.suppress(asyncio.CancelledError): await self._task @@ -342,9 +335,7 @@ class Tailscale: """High-level Tailscale operations consumed by HostService. Wraps :class:`TailscaleAPI` so callers think in terms of - "issue join credentials" / "release device" rather than raw HTTP. There's - only one network backend right now; if/when Modal-style direct SSH lands, - re-introduce a NetworkProvider ABC. + "issue join credentials" / "release device" rather than raw HTTP. """ diagnose_hint = "check_tailscale_oauth_and_api_reachability" @@ -354,8 +345,11 @@ def __init__(self, api: TailscaleAPI, *, poll_interval: float = 2.0) -> None: self._waiter = _DeviceWaiter(api, poll_interval=poll_interval) @classmethod - def from_settings(cls) -> Self: - return cls(TailscaleAPI.from_settings()) + def from_settings(cls) -> Self | None: + """None when the deployment has no tailnet.""" + settings = TailscaleSettings() + if settings.tailnet: + return cls(TailscaleAPI.from_settings(settings)) @property def tailnet(self) -> str: diff --git a/src/networking/tailscale_settings.py b/src/networking/tailscale_settings.py index a8f7867..08333a4 100644 --- a/src/networking/tailscale_settings.py +++ b/src/networking/tailscale_settings.py @@ -1,11 +1,14 @@ -from pydantic import Field +from typing import Self + +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from core.settings import CsvTuple, get_secrets_dir class TailscaleSettings(BaseSettings): - """Tailscale OAuth credentials and tailnet identity.""" + """Tailscale OAuth credentials and tailnet identity. Without them, + drukbox has no tailnet.""" model_config = SettingsConfigDict( env_file=".env", @@ -17,18 +20,22 @@ class TailscaleSettings(BaseSettings): ) tailnet: str = Field( + default="", validation_alias="TAILSCALE_TAILNET", description="Tailnet DNS suffix used to build sandbox MagicDNS hostnames.", ) auth_tags: CsvTuple = Field( + default=(), validation_alias="TAILSCALE_AUTH_TAGS", description="Tags applied to Tailscale auth keys created for hosts (comma-separated).", ) oauth_client_id: str = Field( + default="", validation_alias="TAILSCALE_OAUTH_CLIENT_ID", description="OAuth client ID used for Tailscale API authentication.", ) oauth_client_secret: str = Field( + default="", validation_alias="TAILSCALE_OAUTH_CLIENT_SECRET", description="OAuth client secret used for Tailscale API authentication.", ) @@ -36,3 +43,16 @@ class TailscaleSettings(BaseSettings): default=30.0, description="Timeout in seconds for Tailscale API calls.", ) + + @model_validator(mode="after") + def validate_credentials(self) -> Self: + credentials = { + "TAILSCALE_TAILNET": self.tailnet, + "TAILSCALE_AUTH_TAGS": self.auth_tags, + "TAILSCALE_OAUTH_CLIENT_ID": self.oauth_client_id, + "TAILSCALE_OAUTH_CLIENT_SECRET": self.oauth_client_secret, + } + if any(credentials.values()) and not all(credentials.values()): + missing = ", ".join(name for name, value in credentials.items() if not value) + raise ValueError(f"Tailscale settings are incomplete. Set: {missing}") + return self diff --git a/src/networking/tests/test_tailscale.py b/src/networking/tests/test_tailscale.py index c454942..ae05b87 100644 --- a/src/networking/tests/test_tailscale.py +++ b/src/networking/tests/test_tailscale.py @@ -232,6 +232,7 @@ async def test_aclose_delegates_to_api() -> None: def test_from_settings_constructs_with_tailscale_api() -> None: network = Tailscale.from_settings() + assert network assert isinstance(network.api, TailscaleAPI) diff --git a/src/providers/aws/provider.py b/src/providers/aws/provider.py index 74e0530..188f493 100644 --- a/src/providers/aws/provider.py +++ b/src/providers/aws/provider.py @@ -31,12 +31,11 @@ def __init__( api: AwsAPI, settings: AwsSettings, *, - tailscale_enabled: bool, service_label: str = "drukbox", ) -> None: self.api = api self.settings = settings - self._tailscale_enabled = tailscale_enabled + self.tailnet = settings.tailnet self._service_label = service_label self._cached_sg_id: str | None = settings.security_group_id @@ -47,7 +46,6 @@ def from_settings(cls) -> Self: return cls( AwsAPI.from_settings(aws_settings), aws_settings, - tailscale_enabled=core.tailscale_enabled, service_label=core.service_label, ) @@ -83,10 +81,10 @@ async def create_vm( "drukbox-host-name": name, } - # With Tailscale on, SSH access is via tailscaled-SSH using tailnet + # On the tailnet, SSH access is via tailscaled-SSH using tailnet # ACLs; we don't need an EC2 KeyName at all, and no public path # means no SG ingress to manage. - if self._tailscale_enabled: + if self.tailnet: key_name: str | None = None private_key: str | None = None associate_public_ip = False @@ -123,11 +121,11 @@ async def create_vm( instance_profile=self.settings.instance_profile, ) except AwsProviderError as exc: - if key_name is not None: + if key_name: await self.api.delete_key_pair(key_name) raise ProviderTransportError(str(exc)) from exc - if self._tailscale_enabled: + if self.tailnet: ssh_host = "" else: try: @@ -159,7 +157,7 @@ async def delete_vm(self, name: str) -> None: instance_id = await self.api.find_instance_id_by_tag_name( name, managed_by=self._service_label ) - if instance_id is None: + if not instance_id: raise ProviderNotFoundError(f"aws VM '{name}' was not found") await self.api.terminate_instance(instance_id) await self.api.delete_key_pair(f"drukbox-{name}") @@ -169,7 +167,7 @@ async def delete_vm(self, name: str) -> None: raise ProviderTransportError(str(exc)) from exc async def aclose(self) -> None: - return None + """Each AWS call opens and closes its own client.""" async def diagnose(self) -> str: identity = await self.api.get_caller_identity() @@ -220,11 +218,10 @@ async def _detect_outbound_ipv4(timeout: float = 5.0) -> str | None: response = await client.get("https://checkip.amazonaws.com") response.raise_for_status() except httpx.HTTPError: - return None + return try: addr = ipaddress.ip_address(response.text.strip()) except ValueError: - return None - if not isinstance(addr, ipaddress.IPv4Address): - return None - return str(addr) + return + if isinstance(addr, ipaddress.IPv4Address): + return str(addr) diff --git a/src/providers/aws/settings.py b/src/providers/aws/settings.py index 6eda9a7..85d9857 100644 --- a/src/providers/aws/settings.py +++ b/src/providers/aws/settings.py @@ -47,3 +47,10 @@ class AwsSettings(BaseSettings): default="ubuntu", description="In-VM user callers SSH as. Override if the AMI uses a non-default user.", ) + tailnet: bool = Field( + default=False, + description=( + "Join each host to the tailnet. The host then has no key pair, " + "security group, or public IP." + ), + ) diff --git a/src/providers/aws/tests/test_diagnose.py b/src/providers/aws/tests/test_diagnose.py index faf6b1a..89389e5 100644 --- a/src/providers/aws/tests/test_diagnose.py +++ b/src/providers/aws/tests/test_diagnose.py @@ -21,7 +21,7 @@ async def test_diagnose_returns_account_and_arn_from_caller_identity() -> None: "arn": "arn:aws:iam::111122223333:user/drukbox", } ) - provider = AWSProvider(api, _settings(), tailscale_enabled=True) + provider = AWSProvider(api, _settings()) detail = await provider.diagnose() assert "account=111122223333" in detail @@ -35,7 +35,7 @@ async def test_diagnose_raises_on_caller_identity_failure() -> None: api.get_caller_identity = AsyncMock( side_effect=AwsTransportError("AccessDenied: not authorized") ) - provider = AWSProvider(api, _settings(), tailscale_enabled=True) + provider = AWSProvider(api, _settings()) with pytest.raises(AwsTransportError, match="AccessDenied"): await provider.diagnose() diff --git a/src/providers/aws/tests/test_provider.py b/src/providers/aws/tests/test_provider.py index c823a80..6a37530 100644 --- a/src/providers/aws/tests/test_provider.py +++ b/src/providers/aws/tests/test_provider.py @@ -3,6 +3,7 @@ import pytest +from providers.aws.exceptions import AwsTransportError from providers.aws.provider import AWSProvider from providers.aws.settings import AwsSettings from providers.exceptions import ProviderNotFoundError, ProviderTransportError @@ -32,9 +33,9 @@ def _api_mock() -> MagicMock: @pytest.mark.asyncio -async def test_create_vm_with_tailscale_on_skips_keypair_sg_and_public_ip(): +async def test_create_vm_on_the_tailnet_skips_keypair_sg_and_public_ip(): api = _api_mock() - provider = AWSProvider(api, _settings(), tailscale_enabled=True) + provider = AWSProvider(api, _settings(tailnet=True)) result = await provider.create_vm( name="sb-test", @@ -59,9 +60,9 @@ async def test_create_vm_with_tailscale_on_skips_keypair_sg_and_public_ip(): @pytest.mark.asyncio -async def test_create_vm_with_tailscale_off_provisions_keypair_and_security_group(monkeypatch): +async def test_create_vm_off_the_tailnet_provisions_keypair_and_security_group(monkeypatch): api = _api_mock() - provider = AWSProvider(api, _settings(), tailscale_enabled=False) + provider = AWSProvider(api, _settings()) async def _detect() -> str | None: return "198.51.100.7" @@ -94,9 +95,7 @@ async def test_managed_sg_is_created_in_the_subnets_vpc(monkeypatch): # rejects the launch. Resolve the subnet's VPC and pass it through. api = _api_mock() api.resolve_subnet_vpc = AsyncMock(return_value="vpc-abc") - provider = AWSProvider( - api, _settings(subnet_id="subnet-xyz", ssh_cidrs="10.0.0.0/8"), tailscale_enabled=False - ) + provider = AWSProvider(api, _settings(subnet_id="subnet-xyz", ssh_cidrs="10.0.0.0/8")) await provider.create_vm(name="sb-test", image="ami-deadbeef", env={}, setup_script="echo hi") @@ -107,10 +106,10 @@ async def test_managed_sg_is_created_in_the_subnets_vpc(monkeypatch): @pytest.mark.asyncio async def test_create_vm_falls_back_to_world_open_when_detection_fails(monkeypatch): api = _api_mock() - provider = AWSProvider(api, _settings(), tailscale_enabled=False) + provider = AWSProvider(api, _settings()) async def _no_detect() -> str | None: - return None + return monkeypatch.setattr("providers.aws.provider._detect_outbound_ipv4", _no_detect) @@ -125,7 +124,7 @@ async def test_world_open_fallback_is_not_cached_and_self_heals(monkeypatch): # A transient detection failure must not pin the shared SG to 0.0.0.0/0 for # the provider's lifetime: the next create re-detects and narrows it. api = _api_mock() - provider = AWSProvider(api, _settings(), tailscale_enabled=False) + provider = AWSProvider(api, _settings()) detected: list[str | None] = [None, "198.51.100.7"] async def _detect() -> str | None: @@ -146,7 +145,7 @@ async def _detect() -> str | None: async def test_create_vm_resolves_ssm_path_to_ami_id(): api = _api_mock() api.resolve_ssm_parameter.return_value = "ami-resolved" - provider = AWSProvider(api, _settings(), tailscale_enabled=True) + provider = AWSProvider(api, _settings(tailnet=True)) await provider.create_vm( name="sb-test", @@ -163,14 +162,12 @@ async def test_create_vm_resolves_ssm_path_to_ami_id(): @pytest.mark.asyncio async def test_create_vm_cleans_up_keypair_when_run_instance_fails(monkeypatch): - from providers.aws.exceptions import AwsTransportError - api = _api_mock() api.run_instance.side_effect = AwsTransportError("boom") - provider = AWSProvider(api, _settings(), tailscale_enabled=False) + provider = AWSProvider(api, _settings()) async def _no_detect() -> str | None: - return None + return monkeypatch.setattr("providers.aws.provider._detect_outbound_ipv4", _no_detect) @@ -185,7 +182,7 @@ async def _no_detect() -> str | None: async def test_delete_vm_terminates_instance_and_deletes_keypair(): api = _api_mock() api.find_instance_id_by_tag_name.return_value = "i-real" - provider = AWSProvider(api, _settings(), tailscale_enabled=True) + provider = AWSProvider(api, _settings()) await provider.delete_vm("sb-test") @@ -200,7 +197,7 @@ async def test_delete_vm_scopes_lookup_to_custom_service_label(): # custom SERVICE_LABEL deployment can't find (and silently orphans) its VMs. api = _api_mock() api.find_instance_id_by_tag_name.return_value = "i-real" - provider = AWSProvider(api, _settings(), tailscale_enabled=True, service_label="staging") + provider = AWSProvider(api, _settings(), service_label="staging") await provider.delete_vm("sb-test") @@ -212,7 +209,7 @@ async def test_delete_vm_scopes_lookup_to_custom_service_label(): async def test_delete_vm_raises_provider_not_found_when_instance_missing(): api = _api_mock() api.find_instance_id_by_tag_name.return_value = None - provider = AWSProvider(api, _settings(), tailscale_enabled=True) + provider = AWSProvider(api, _settings()) with pytest.raises(ProviderNotFoundError): await provider.delete_vm("sb-missing") @@ -223,11 +220,7 @@ async def test_delete_vm_raises_provider_not_found_when_instance_missing(): @pytest.mark.asyncio async def test_create_vm_with_ssh_cidrs_set_does_not_auto_detect(monkeypatch): api = _api_mock() - provider = AWSProvider( - api, - _settings(ssh_cidrs="10.0.0.0/8, 192.168.1.0/24"), - tailscale_enabled=False, - ) + provider = AWSProvider(api, _settings(ssh_cidrs="10.0.0.0/8, 192.168.1.0/24")) detect_calls = 0 @@ -248,7 +241,7 @@ async def _spy_detect() -> str | None: @pytest.mark.asyncio async def test_create_vm_passes_custom_root_gb_to_run_instance(): api = _api_mock() - provider = AWSProvider(api, _settings(root_gb=250), tailscale_enabled=True) + provider = AWSProvider(api, _settings(root_gb=250, tailnet=True)) await provider.create_vm(name="sb-test", image="ami-deadbeef", env={}, setup_script="echo hi") assert api.run_instance.await_args.kwargs["root_gb"] == 250 @@ -257,7 +250,7 @@ async def test_create_vm_passes_custom_root_gb_to_run_instance(): @pytest.mark.asyncio async def test_create_vm_honors_per_request_sizing_over_settings(): api = _api_mock() - provider = AWSProvider(api, _settings(), tailscale_enabled=True) + provider = AWSProvider(api, _settings(tailnet=True)) await provider.create_vm( name="sb-test", @@ -276,7 +269,7 @@ async def test_create_vm_honors_per_request_sizing_over_settings(): @pytest.mark.asyncio async def test_create_vm_populates_ssh_username_from_settings(): api = _api_mock() - provider = AWSProvider(api, _settings(ssh_username="ec2-user"), tailscale_enabled=True) + provider = AWSProvider(api, _settings(ssh_username="ec2-user", tailnet=True)) result = await provider.create_vm( name="sb-test", image="ami-deadbeef", env={}, setup_script="echo hi" @@ -286,9 +279,5 @@ async def test_create_vm_populates_ssh_username_from_settings(): def test_default_image_reads_from_aws_settings(): api = _api_mock() - provider = AWSProvider( - api, - _settings(default_image="ami-fallback"), - tailscale_enabled=True, - ) + provider = AWSProvider(api, _settings(default_image="ami-fallback")) assert provider.default_image == "ami-fallback" diff --git a/src/providers/base.py b/src/providers/base.py index 1849867..a576ed7 100644 --- a/src/providers/base.py +++ b/src/providers/base.py @@ -78,8 +78,8 @@ class VMProvider(abc.ABC): # HostService rejects a sized request before any row or VM exists. supports_instance_type: ClassVar[bool] = False supports_disk_gb: ClassVar[bool] = False - # A local provider's hosts keep the external path only. - supports_tailnet: ClassVar[bool] = True + # Hosts join the tailnet at boot and callers reach them over it. + tailnet: bool = False # Set when the provider stops every VM this long after create. max_lifetime: timedelta | None = None diff --git a/src/providers/boat/provider.py b/src/providers/boat/provider.py index 4798be4..e3020ce 100644 --- a/src/providers/boat/provider.py +++ b/src/providers/boat/provider.py @@ -16,7 +16,6 @@ class BoatProvider(VMProvider): name: ClassVar[str] = "boat" diagnose_hint: ClassVar[str] = "check_boat_api_token_and_sandbox_permissions" - supports_tailnet = False supports_instance_type = True def __init__(self, api: BoatAPI, settings: BoatSettings, *, service_label: str) -> None: diff --git a/src/providers/cloudflare/provider.py b/src/providers/cloudflare/provider.py index 7b20f82..5b9890a 100644 --- a/src/providers/cloudflare/provider.py +++ b/src/providers/cloudflare/provider.py @@ -3,7 +3,6 @@ from core.settings import get_settings from providers import environment from providers.base import VMCreateResult, VMProvider -from providers.exceptions import ProviderCommandError from .api import CloudflareAPI from .settings import CloudflareSettings @@ -12,6 +11,8 @@ class CloudflareProvider(VMProvider): name: ClassVar[str] = "cloudflare" diagnose_hint: ClassVar[str] = "check_cloudflare_worker_and_tailscale_settings" + # The container has no public SSH address. + tailnet = True supports_instance_type = True def __init__( @@ -44,13 +45,11 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - if not setup_script: - raise ProviderCommandError("Cloudflare requires TAILSCALE_ENABLED=true") await self.api.create_sandbox( name, image=image, instance=instance_type or self.settings.instance_type, - script=environment.get_cloud_init(setup_script, env), + script=environment.get_cloud_init(setup_script or "", env), label=self.service_label, ) return VMCreateResult(provider_id=name, name=name, ssh_username="root") @@ -59,8 +58,6 @@ async def delete_vm(self, name: str) -> None: await self.api.delete_sandbox(name) async def diagnose(self) -> str: - if not get_settings().tailscale_enabled: - raise ProviderCommandError("Cloudflare requires TAILSCALE_ENABLED=true") return await self.api.diagnose() async def aclose(self) -> None: diff --git a/src/providers/cloudflare/tests/test_provider.py b/src/providers/cloudflare/tests/test_provider.py index 205358d..ea86cc0 100644 --- a/src/providers/cloudflare/tests/test_provider.py +++ b/src/providers/cloudflare/tests/test_provider.py @@ -51,13 +51,6 @@ async def test_create_uses_named_image_and_returns_tailnet_only_host(provider: C assert not result.private_key -@respx.mock -async def test_requires_tailscale_before_any_request(provider: CloudflareProvider): - with pytest.raises(ProviderCommandError, match="TAILSCALE_ENABLED"): - await provider.create_vm(name="sb-test", image="base") - assert not respx.calls - - @respx.mock @pytest.mark.parametrize("status", [429, 500]) async def test_worker_failure_is_a_transport_error_without_its_body( diff --git a/src/providers/daytona/provider.py b/src/providers/daytona/provider.py index 07b15fa..bd0dc0e 100644 --- a/src/providers/daytona/provider.py +++ b/src/providers/daytona/provider.py @@ -3,7 +3,6 @@ from core.settings import get_settings from providers import environment from providers.base import VMCreateResult, VMProvider -from providers.exceptions import ProviderCommandError from .api import DaytonaAPI from .settings import DaytonaSettings @@ -12,6 +11,8 @@ class DaytonaProvider(VMProvider): name: ClassVar[str] = "daytona" diagnose_hint: ClassVar[str] = "check_daytona_snapshot_and_tailscale_settings" + # The sandbox has no public SSH address. + tailnet = True def __init__(self, api: DaytonaAPI, settings: DaytonaSettings, *, service_label: str) -> None: self.api = api @@ -41,20 +42,16 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - if not setup_script: - raise ProviderCommandError("Daytona requires TAILSCALE_ENABLED=true") await self.api.check_snapshot(image) await self.api.create_sandbox(name, image=image, label=self.service_label) sandbox = await self.api.wait_for_started(name) - await self.api.bootstrap(sandbox, environment.get_cloud_init(setup_script, env)) + await self.api.bootstrap(sandbox, environment.get_cloud_init(setup_script or "", env)) return VMCreateResult(provider_id=sandbox["id"], name=name, ssh_username="root") async def delete_vm(self, name: str) -> None: await self.api.delete_sandbox(name, label=self.service_label) async def diagnose(self) -> str: - if not get_settings().tailscale_enabled: - raise ProviderCommandError("Daytona requires TAILSCALE_ENABLED=true") return await self.api.diagnose() async def aclose(self) -> None: diff --git a/src/providers/daytona/tests/test_provider.py b/src/providers/daytona/tests/test_provider.py index 4cd4fe3..f7b96b3 100644 --- a/src/providers/daytona/tests/test_provider.py +++ b/src/providers/daytona/tests/test_provider.py @@ -85,13 +85,6 @@ async def test_create_uses_linux_snapshot_and_bootstraps_tailnet( assert not result.ssh_host -@respx.mock -async def test_requires_tailscale_before_api_calls(provider: DaytonaProvider): - with pytest.raises(ProviderCommandError, match="TAILSCALE_ENABLED"): - await provider.create_vm(name="sb-test", image="snapshot") - assert not respx.calls - - @respx.mock @pytest.mark.parametrize( "snapshot", diff --git a/src/providers/docker/provider.py b/src/providers/docker/provider.py index 27123f0..2465853 100644 --- a/src/providers/docker/provider.py +++ b/src/providers/docker/provider.py @@ -28,7 +28,6 @@ class DockerProvider(VMProvider, TemplateCapability): # providers without an sshd of their own. name: ClassVar[str] = "docker" diagnose_hint: ClassVar[str] = "check_docker_daemon_is_running" - supports_tailnet: ClassVar[bool] = False def __init__( self, @@ -68,15 +67,6 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - # A setup script only ever arrives when Tailscale is enabled, and a - # local container has no path onto the tailnet. Fail loud rather than - # silently start a box that never joins. - if setup_script: - raise ProviderCommandError( - "docker provider runs sandboxes locally and does not support " - "Tailscale networking; set TAILSCALE_ENABLED=false" - ) - caller_env = env or {} # These names carry the per-VM public key, the SSH user, and the env-key # manifest the entrypoint reads; a caller-supplied value would clobber diff --git a/src/providers/docker/tests/test_provider.py b/src/providers/docker/tests/test_provider.py index 3652861..d3a775a 100644 --- a/src/providers/docker/tests/test_provider.py +++ b/src/providers/docker/tests/test_provider.py @@ -108,16 +108,6 @@ async def test_create_vm_names_the_ssh_user_for_the_entrypoint(): assert result.ssh_username == "druks" -@pytest.mark.asyncio -async def test_create_vm_rejects_setup_script_because_tailscale_is_unsupported(): - api = _api_mock() - provider = DockerProvider(api, _settings()) - - with pytest.raises(ProviderCommandError): - await provider.create_vm(name="sb-test", image="img", env={}, setup_script="#!/bin/sh\n") - api.run_container.assert_not_called() - - @pytest.mark.parametrize("key", ["DRUKBOX_AUTHORIZED_KEY", "DRUKBOX_SSH_USER", "DRUKBOX_ENV_KEYS"]) @pytest.mark.asyncio async def test_create_vm_rejects_caller_env_that_collides_with_reserved_keys(key: str): diff --git a/src/providers/docker_sbx/provider.py b/src/providers/docker_sbx/provider.py index b057992..2a3a7e7 100644 --- a/src/providers/docker_sbx/provider.py +++ b/src/providers/docker_sbx/provider.py @@ -32,7 +32,6 @@ class DockerSbxProvider(VMProvider, TemplateCapability): name: ClassVar[str] = "docker-sbx" diagnose_hint: ClassVar[str] = "check_sandboxd_is_running_and_logged_in" gateway_process_class = SbxExecProcess - supports_tailnet: ClassVar[bool] = False # sbx spends about 3 seconds on startup per call. diagnose_timeout_seconds: ClassVar[float] = 15.0 @@ -77,13 +76,6 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - # A script here is a caller defect: supports_tailnet is False. - if setup_script: - raise ProviderCommandError( - "docker-sbx provider runs sandboxes locally and does not " - "support Tailscale networking" - ) - caller_env = env or {} try: environment.get_persist(caller_env) diff --git a/src/providers/docker_sbx/tests/test_provider.py b/src/providers/docker_sbx/tests/test_provider.py index c69238a..c3febe7 100644 --- a/src/providers/docker_sbx/tests/test_provider.py +++ b/src/providers/docker_sbx/tests/test_provider.py @@ -104,16 +104,6 @@ async def test_create_vm_installs_the_key_for_the_configured_ssh_user(tmp_path): assert result.ssh_username == "dev" -@pytest.mark.asyncio -async def test_create_vm_rejects_setup_script_because_tailscale_is_unsupported(tmp_path): - api = _api_mock() - provider = _provider(api, _settings(tmp_path)) - - with pytest.raises(ProviderCommandError, match="Tailscale"): - await provider.create_vm(name="sb-test", image="img", env={}, setup_script="#!/bin/sh\n") - api.create_sandbox.assert_not_called() - - @pytest.mark.asyncio async def test_create_vm_rejects_env_values_that_would_forge_environment_entries(tmp_path): api = _api_mock() diff --git a/src/providers/e2b/provider.py b/src/providers/e2b/provider.py index 32e0366..12400b7 100644 --- a/src/providers/e2b/provider.py +++ b/src/providers/e2b/provider.py @@ -4,7 +4,6 @@ from core.settings import get_settings from providers import environment from providers.base import VMCreateResult, VMProvider -from providers.exceptions import ProviderCommandError from .api import E2BAPI from .settings import E2BSettings @@ -13,6 +12,8 @@ class E2BProvider(VMProvider): name: ClassVar[str] = "e2b" diagnose_hint: ClassVar[str] = "check_e2b_and_tailscale_settings" + # The sandbox has no public SSH address. + tailnet = True def __init__(self, api: E2BAPI, settings: E2BSettings, *, service_label: str) -> None: self.api = api @@ -43,18 +44,14 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - if not setup_script: - raise ProviderCommandError("E2B requires TAILSCALE_ENABLED=true") sandbox = await self.api.create_sandbox(name, image=image, label=self.service_label) - await self.api.bootstrap(sandbox, environment.get_cloud_init(setup_script, env)) + await self.api.bootstrap(sandbox, environment.get_cloud_init(setup_script or "", env)) return VMCreateResult(provider_id=sandbox.sandbox_id, name=name, ssh_username="root") async def delete_vm(self, name: str) -> None: await self.api.delete_sandbox(name, label=self.service_label) async def diagnose(self) -> str: - if not get_settings().tailscale_enabled: - raise ProviderCommandError("E2B requires TAILSCALE_ENABLED=true") return await self.api.diagnose() async def aclose(self) -> None: diff --git a/src/providers/e2b/tests/test_provider.py b/src/providers/e2b/tests/test_provider.py index c2aeeac..5743fe5 100644 --- a/src/providers/e2b/tests/test_provider.py +++ b/src/providers/e2b/tests/test_provider.py @@ -67,13 +67,6 @@ async def test_create_bootstraps_sandbox_on_the_tailnet(provider: E2BProvider): assert provider.max_lifetime == timedelta(hours=1) -@respx.mock -async def test_requires_tailscale_before_api_calls(provider: E2BProvider): - with pytest.raises(ProviderCommandError, match="TAILSCALE_ENABLED"): - await provider.create_vm(name="sb-test", image="drukbox:v1") - assert not respx.calls - - @respx.mock async def test_rejected_create_names_e2b_reason(provider: E2BProvider): respx.post(BASE + "/v2/sandboxes").respond( diff --git a/src/providers/exe/provider.py b/src/providers/exe/provider.py index 4033b72..4ffa35e 100644 --- a/src/providers/exe/provider.py +++ b/src/providers/exe/provider.py @@ -37,6 +37,7 @@ def __init__( ) -> None: self.api = api self.settings = settings + self.tailnet = settings.tailnet self.docker = docker self._service_label = service_label diff --git a/src/providers/exe/settings.py b/src/providers/exe/settings.py index c67a727..46e467f 100644 --- a/src/providers/exe/settings.py +++ b/src/providers/exe/settings.py @@ -38,3 +38,7 @@ class ExeSettings(BaseSettings): default="exedev", description="In-VM user callers SSH as. Override if the image uses a non-default user.", ) + tailnet: bool = Field( + default=False, + description="Join each host to the tailnet. The host keeps its exe.dev SSH address.", + ) diff --git a/src/providers/exoscale/provider.py b/src/providers/exoscale/provider.py index 050775b..499dd95 100644 --- a/src/providers/exoscale/provider.py +++ b/src/providers/exoscale/provider.py @@ -26,6 +26,7 @@ def __init__( ) -> None: self.api = api self.settings = settings + self.tailnet = settings.tailnet self._service_label = service_label @classmethod @@ -103,7 +104,7 @@ async def delete_vm(self, name: str) -> None: try: await self.api.delete_ssh_key(f"drukbox-{name}") instance_id = await self.api.find_instance_id_by_name(name) - if instance_id is None: + if not instance_id: raise ProviderNotFoundError(f"exoscale VM '{name}' was not found") await self.api.delete_instance(instance_id) except ExoscaleProviderError as exc: diff --git a/src/providers/exoscale/settings.py b/src/providers/exoscale/settings.py index fe585ad..210279d 100644 --- a/src/providers/exoscale/settings.py +++ b/src/providers/exoscale/settings.py @@ -49,3 +49,7 @@ class ExoscaleSettings(BaseSettings): default="ubuntu", description="In-VM user callers SSH as. Exoscale Ubuntu templates default to ubuntu.", ) + tailnet: bool = Field( + default=False, + description="Join each host to the tailnet. The host keeps its key-locked public IP.", + ) diff --git a/src/providers/hetzner/provider.py b/src/providers/hetzner/provider.py index 32962a1..625787f 100644 --- a/src/providers/hetzner/provider.py +++ b/src/providers/hetzner/provider.py @@ -27,6 +27,7 @@ def __init__( ) -> None: self.api = api self.settings = settings + self.tailnet = settings.tailnet self._service_label = service_label @classmethod @@ -57,11 +58,11 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - # Always mint a per-VM key, in both networking modes: Hetzner servers + # Always mint a per-VM key, on or off the tailnet: Hetzner servers # have a public IP and no firewall by default, and attaching a key both # locks port 22 and stops Hetzner emailing a root password (which it - # does when no key is attached). Tailscale, when on, is layered on by - # the caller's bootstrap script, so the provider needs no branch. + # does when no key is attached). The caller's bootstrap script does the + # tailnet join, so the provider needs no branch. key_name = f"drukbox-{name}" private_key, public_key = generate_ed25519_keypair() labels = {"managed-by": self._service_label, "drukbox-host-name": name} @@ -108,7 +109,7 @@ async def delete_vm(self, name: str) -> None: try: await self.api.delete_ssh_key(f"drukbox-{name}") server_id = await self.api.find_server_id_by_name(name) - if server_id is None: + if not server_id: raise ProviderNotFoundError(f"hetzner VM '{name}' was not found") await self.api.delete_server(server_id) except HetznerProviderError as exc: diff --git a/src/providers/hetzner/settings.py b/src/providers/hetzner/settings.py index 562624b..b5ba3fc 100644 --- a/src/providers/hetzner/settings.py +++ b/src/providers/hetzner/settings.py @@ -42,3 +42,7 @@ class HetznerSettings(BaseSettings): default="root", description="In-VM user callers SSH as. Hetzner cloud images default to root.", ) + tailnet: bool = Field( + default=False, + description="Join each host to the tailnet. The host keeps its key-locked public IP.", + ) diff --git a/src/providers/vercel/provider.py b/src/providers/vercel/provider.py index a66acc3..ff218d9 100644 --- a/src/providers/vercel/provider.py +++ b/src/providers/vercel/provider.py @@ -13,6 +13,8 @@ class VercelProvider(VMProvider): name: ClassVar[str] = "vercel" diagnose_hint: ClassVar[str] = "check_vercel_and_tailscale_settings" + # The sandbox has no public SSH address. + tailnet = True supports_instance_type = True def __init__(self, api: VercelAPI, settings: VercelSettings, *, service_label: str) -> None: @@ -44,8 +46,6 @@ async def create_vm( instance_type: str | None = None, disk_gb: int | None = None, ) -> VMCreateResult: - if not setup_script: - raise ProviderCommandError("Vercel requires TAILSCALE_ENABLED=true") try: vcpus = int(instance_type or self.settings.vcpus) except ValueError: @@ -53,15 +53,13 @@ async def create_vm( session_id = await self.api.create_sandbox( name, image=image, vcpus=vcpus, label=self.service_label ) - await self.api.bootstrap(session_id, environment.get_cloud_init(setup_script, env)) + await self.api.bootstrap(session_id, environment.get_cloud_init(setup_script or "", env)) return VMCreateResult(provider_id=session_id, name=name, ssh_username="root") async def delete_vm(self, name: str) -> None: await self.api.delete_sandbox(name) async def diagnose(self) -> str: - if not get_settings().tailscale_enabled: - raise ProviderCommandError("Vercel requires TAILSCALE_ENABLED=true") return await self.api.diagnose() async def aclose(self) -> None: diff --git a/src/providers/vercel/tests/test_provider.py b/src/providers/vercel/tests/test_provider.py index f318b31..8a977a4 100644 --- a/src/providers/vercel/tests/test_provider.py +++ b/src/providers/vercel/tests/test_provider.py @@ -65,13 +65,6 @@ async def test_create_bootstraps_sandbox_on_the_tailnet( assert not result.ssh_host -@respx.mock -async def test_requires_tailscale_before_api_calls(provider: VercelProvider): - with pytest.raises(ProviderCommandError, match="TAILSCALE_ENABLED"): - await provider.create_vm(name="sb-test", image="sandbox:v1") - assert not respx.calls - - @respx.mock async def test_rejects_instance_type_that_is_not_a_vcpu_count(provider: VercelProvider): with pytest.raises(ProviderCommandError, match="vCPU count"):