Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 27 additions & 23 deletions src/hosts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
55 changes: 48 additions & 7 deletions src/hosts/tests/test_tailscale_optional.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/providers/docker/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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