diff --git a/docs/deploy.md b/docs/deploy.md index b2389cd..f6b0bba 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -85,7 +85,10 @@ To customize it, build [images/local/](../images/local/) and point Containers publish sshd on a random `127.0.0.1` port and are reachable only from the host that runs drukbox; the per-host key is the auth boundary. Tailscale is not supported — a local container has no path -onto the tailnet, so `POST /hosts` fails fast if `TAILSCALE_ENABLED=true`. +onto the tailnet, so docker hosts stay local under a tailnet-mode +service: no join, no `internal_ssh_host`, the published port is the only +path. One drukbox can serve tailnet VMs and local containers side by +side. This provider is for local development and demos, not production: it talks to the host's Docker daemon, and granting drukbox access to that diff --git a/docs/networking.md b/docs/networking.md index da80d8c..c05c01a 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -6,9 +6,11 @@ shaped the way it is. For turning these modes on, read ## Two modes -`TAILSCALE_ENABLED` selects between two networking models. The API -response carries both addresses; which is populated depends on the -mode: +`TAILSCALE_ENABLED` selects between two networking models. A provider +whose hosts cannot join a tailnet (docker — local containers) always +takes the external path, whatever the mode. The API response carries +both addresses; which is populated depends on the mode and the +provider: - `external_ssh_host` / `external_ssh_port` — the provider-given public path. Always present (empty for an AWS host with Tailscale diff --git a/src/hosts/service.py b/src/hosts/service.py index 452c2fe..aabdc4c 100644 --- a/src/hosts/service.py +++ b/src/hosts/service.py @@ -76,7 +76,7 @@ def __init__( # 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 is not None: + if tailscale: self.tailscale: Tailscale | None = tailscale elif self.settings.tailscale_enabled: self.tailscale = Tailscale.from_settings() @@ -110,7 +110,7 @@ async def get_or_create_host( if idempotency_key: existing = await self._lookup_idempotency_key(idempotency_key) - if existing is not None: + if existing: return existing host: Host | None = None @@ -119,12 +119,12 @@ async def get_or_create_host( # doesn't customize the host: default image, no env, and no per-request # sizing — pool members are warmed at the provider's default size. requested_provider = provider or self.settings.default_host_provider - customized = env or image is not None or instance_type or disk_gb + customized = env or image or instance_type or disk_gb if not customized and self.settings.get_pool_targets().get(requested_provider): host = await self._try_claim_pool_host( provider=requested_provider, expires_at=expires_at ) - if host is None: + if not host: host = await self.create_host( env=env, image=image, @@ -143,7 +143,7 @@ async def get_or_create_host( ) await self._release_idempotency_loser(host) winner = await self._lookup_idempotency_key(idempotency_key) - if winner is None: + if not winner: raise HostStateError("idempotency race could not be resolved") from None return winner return host @@ -170,8 +170,8 @@ async def _try_claim_pool_host( .limit(1) ) ).scalar_one_or_none() - if candidate_id is None: - return None + if not candidate_id: + return if expires_at is ...: expires_at = self._default_lease_expires_at() @@ -187,9 +187,9 @@ async def _try_claim_pool_host( ) host = result.scalar_one_or_none() await self.session.commit() - if host is None: + if not host: # Lost the race to another claimant; let the caller fall through. - return None + return log.info("pool: claimed host_id=%s name=%s", host.id, host.name) return host @@ -279,12 +279,12 @@ async def _lookup_idempotency_key(self, key: str) -> Host | None: await self.session.execute(select(IdempotencyKey).where(IdempotencyKey.key == key)) ).scalar_one_or_none() - if record is None: + if not record: return if record.expires_at > utc_now(): host = await self.session.get(Host, record.host_id) - if host is not None: + if host: return host # Stale: expired, or the host vanished without the FK cascade firing. # GC in a dedicated session so we don't autoflush the caller's pending @@ -322,10 +322,10 @@ async def _release_idempotency_loser(self, host: Host) -> None: # janitor reaps it (delete_host refuses PROVISIONING). async with async_session_factory() as fix_session: fresh = await fix_session.get(Host, host.id) - if fresh is None: + if not fresh: return now = utc_now() - if fresh.claimed_at is not None: + if fresh.claimed_at: fresh.claimed_at = None fresh.expires_at = now + timedelta(hours=self.settings.pool_host_max_age_hours) fresh.updated_at = now @@ -358,7 +358,7 @@ async def list_hosts(self) -> list[Host]: async def renew_host(self, host_id: uuid.UUID, *, expires_at: datetime | None = None) -> Host: host = await self.get_host_for_update(host_id) - if host is None: + if not host: raise ResourceNotFoundError("host not found") if host.pool_member and not host.claimed_at: @@ -384,7 +384,7 @@ async def delete_host( """Delete the host; return False when a maintenance guard spared it.""" host = await self.get_host_for_update(host_id) - if host is None: + if not host: raise ResourceNotFoundError("host not found") if pool_shed and host.claimed_at: @@ -404,7 +404,7 @@ async def delete_host( # force is the janitor reaping an abandoned provision: attempt # teardown even from an early state, since a row stranded in # CREATING_VM may already have a VM (delete_vm no-ops if it doesn't). - if host.tailscale_device_id and self.tailscale is not None: + 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 @@ -435,20 +435,24 @@ async def delete_host( async def provision(self, host_id: str) -> None: host = await self.get_host(uuid.UUID(host_id)) - if host is None: + if not host: raise ResourceNotFoundError("host not found") host.status = HostStatus.CREATING_NETWORK.value host.updated_at = utc_now() await self.session.commit() + tailscale: Tailscale | None = None + if get_vm_provider(host.provider).supports_tailnet: + tailscale = self.tailscale + join_env: dict[str, str] = {} setup_script: str | None = None - if self.tailscale is not None: + if tailscale: # The bootstrap script hard-requires TAILSCALE_AUTHKEY; only # deliver it (and mint a key) when Tailscale is in play. try: - join_credentials = await self.tailscale.issue_join_credentials(host_name=host.name) + join_credentials = await tailscale.issue_join_credentials(host_name=host.name) except NetworkError as exc: await self.mark_failed(host, exc) return @@ -483,15 +487,15 @@ async def provision(self, host_id: str) -> None: # GET that loads a fresh row sees the class default (None) and # never echoes the key back. host.private_key = vm_result.private_key - if self.tailscale is not None: - host.internal_ssh_host = self.tailscale.build_ssh_host(host.name) + if tailscale: + host.internal_ssh_host = tailscale.build_ssh_host(host.name) host.status = HostStatus.BOOTSTRAPPING.value host.updated_at = utc_now() await self.session.commit() - if self.tailscale is not None: + if tailscale: try: - device_id = await self.tailscale.wait_for_device( + device_id = await tailscale.wait_for_device( host_name=host.name, timeout=self.settings.device_discovery_timeout_seconds, ) diff --git a/src/hosts/tests/test_tailscale_optional.py b/src/hosts/tests/test_tailscale_optional.py index 921f35a..b4a1524 100644 --- a/src/hosts/tests/test_tailscale_optional.py +++ b/src/hosts/tests/test_tailscale_optional.py @@ -1,10 +1,11 @@ -"""End-to-end-ish tests for the TAILSCALE_ENABLED=false provisioning path. - -These exercise HostService.provision() with the Tailscale dependency -removed: 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. +"""End-to-end-ish tests for provisioning without the tailnet. + +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 @@ -87,6 +88,46 @@ async def test_provision_skips_tailscale_when_disabled( 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, +) -> 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 127.0.0.1 port as the only path. + tailscale = AsyncMock() + + create_vm = AsyncMock( + return_value=VMCreateResult( + provider_id="vm-3", + name="vm-3", + ssh_port=32769, + ssh_username="sandbox", + ssh_host="127.0.0.1", + ) + ) + monkeypatch.setattr("providers.docker.provider.DockerProvider.create_vm", create_vm) + scan = AsyncMock(return_value=b"127.0.0.1 ssh-ed25519 AAAATEST\n") + monkeypatch.setattr("hosts.service.HostService.scan_known_hosts", scan) + + async with async_session_factory() as session: + service = HostService(session, tailscale=tailscale) + host = await service.create_host(env={}, image=None, provider="docker") + + assert host.status == HostStatus.ACTIVE.value + assert host.external_ssh_host == "127.0.0.1" + assert host.external_ssh_port == 32769 + assert host.internal_ssh_host is None + assert host.tailscale_device_id is None + + # 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 + 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 ) -> None: diff --git a/src/providers/base.py b/src/providers/base.py index 491d84c..c722373 100644 --- a/src/providers/base.py +++ b/src/providers/base.py @@ -23,6 +23,10 @@ class VMProvider(abc.ABC): # target provider leaves these False. supports_instance_type: ClassVar[bool] = False supports_disk_gb: ClassVar[bool] = False + # Whether this provider's hosts can join the tailnet when the service runs + # in Tailscale mode. A local provider leaves it False and its hosts keep + # the external path only, even on a tailnet-mode service. + supports_tailnet: ClassVar[bool] = True @classmethod @abc.abstractmethod diff --git a/src/providers/docker/provider.py b/src/providers/docker/provider.py index 92bf100..af1acac 100644 --- a/src/providers/docker/provider.py +++ b/src/providers/docker/provider.py @@ -22,6 +22,9 @@ class DockerProvider(VMProvider): name: ClassVar[str] = "docker" diagnose_hint: ClassVar[str] = "check_docker_daemon_is_running" + # A local container has no path onto the tailnet; its hosts keep the + # published 127.0.0.1 sshd port even on a tailnet-mode service. + supports_tailnet: ClassVar[bool] = False def __init__( self, @@ -64,7 +67,7 @@ async def create_vm( # 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 is not None: + if setup_script: raise ProviderCommandError( "docker provider runs sandboxes locally and does not support " "Tailscale networking; set TAILSCALE_ENABLED=false" @@ -128,4 +131,4 @@ async def diagnose(self) -> str: return f"docker server {await self.api.server_version()}" async def aclose(self) -> None: - return None + return