Skip to content

Concepts

Architecture

The services that make up Holtdroid and how a request flows through them.

Holtdroid is a handful of small services built from one Go binary (holtdroid <subcommand>), plus a heavier session image that contains the Android emulator.

flowchart LR
  user([Browser]) -->|forward-auth| ingress[Ingress]
  ingress --> api

  subgraph ns[holtdroid namespace]
    api["api<br/>UI + REST + session proxy"]
    prov["provider-emulator"]
    subgraph session["Session Pod"]
      agent["agent + emulator"]
      vpn["vpn (sing-box)"]
    end
  end

  api -- "list + download" --> sources[("Sources<br/>folder / S3")]
  sync["sync jobs<br/>(Slack, CI, …)"] -. "*.apk + .meta.json" .-> sources
  api -- Provider API --> prov
  api -- "Agent API via /s/{provider}/{id}/" --> agent
  prov --> k8s[(Kubernetes API)]
  agent -. shared network namespace .- vpn
  vpn --> internet[(Internet)]
Service Role
api Serves the web UI and its REST API, lists APKs from folder/S3 sources and takes uploads, caches and parses APKs, asks providers for sessions, proxies all session traffic, tracks activity and reaps idle sessions. Stateless.
sync jobs Independent of Holtdroid: anything that writes *.apk files (and optional .meta.json sidecars) into a source, e.g. a Slack channel sync.
provider-emulator A device provider: owns emulator sessions as Kubernetes Pods, accounts for slots, renders the VPN sidecar.
agent Runs inside each session: boots the device, installs and instruments the app, serves the screen, DevTools, clipboard and network controls.
  • Contracts between services. Providers and agents are HTTP APIs with a shared conformance suite; sources are a file layout. The api knows nothing about Slack, Kubernetes or ADB. See Contracts.

  • Stateless api. Nothing the api needs to keep sessions running lives only in its memory or on its disk:

    • sessions belong to providers — the emulator provider keeps them as labels and annotations on the session Pods it creates, so listing Pods is listing sessions;
    • sign-ins are signed cookies, verifiable by any replica holding the same auth.sessionKey;
    • user activity is flushed to the provider every 30 seconds;
    • downloaded APKs are a cache (filesystem or S3) that refills from the sources; uploads and their owner/visibility live as files in a source.

    Kill the api, reschedule it to another node or run three replicas — nobody notices. The api’s database holds history, not live state: in memory by default, or a SQLite file replicated to S3, which limits the api to one replica.

  • Same origin for everything. Browser traffic to a device goes through https://<portal>/s/<session>/…. That keeps forward-auth cookies working for WebSockets inside iframes and means users need nothing installed locally.

  • No lock-in. Auth, secrets and VPN are interfaces with vendor-neutral defaults.

sequenceDiagram
  participant B as Browser
  participant A as api
  participant S as source
  participant P as provider
  participant G as agent

  B->>A: POST /api/v1/sessions
  A->>S: download the APK revision (if not cached)
  A->>A: parse APK metadata
  A->>P: POST /v1/sessions
  P-->>A: session (provisioning)
  A-->>B: session id
  B->>A: GET /s/{provider}/{id}/v1/status (SSE)
  G->>A: fetch APK (internal port)
  G-->>B: booting → installing → instrumenting → ready