Skip to content

Instantly share code, notes, and snippets.

@r33drichards
Created July 28, 2026 22:34
Show Gist options
  • Select an option

  • Save r33drichards/08d21217181ceb3b6244ed6b95af5af8 to your computer and use it in GitHub Desktop.

Select an option

Save r33drichards/08d21217181ceb3b6244ed6b95af5af8 to your computer and use it in GitHub Desktop.
Provision and control a Windows Cua Fleet with Terraform and cua-fleet

Provision and Control a Windows Cua Fleet with Terraform and cua-fleet

This guide provisions the Windows Fleet configuration from Cua's Terraform how-to guide, then uses the PyPI cua-fleet package to claim a sandbox, initialize its computer-server MCP endpoint, and release the claim.

The Terraform resource owns the Fleet pool. The Python program only borrows a sandbox from that existing pool, so it never deletes the pool or namespace.

What you need

  • Terraform (or OpenTofu) and Go to install the temporary local trycua/fleets Terraform provider.
  • A run.cua.ai OAuth user key. Use a user key, not a pool key, because claims need an identity that is allowed to create Kubernetes resources.
  • Python 3.10+ and uv.

The trycua/fleets provider is temporarily installed from a local filesystem mirror. Follow the provider README's temporary-local-installation instructions before running terraform init.

1. Configure Terraform and authentication

Set the Terraform CLI configuration file created by the provider installation:

export TF_CLI_CONFIG_FILE="$HOME/.terraformrc-fleets-local"

Use the run.cua.ai endpoint and OAuth user-key credentials. Keep secrets out of source control and Terraform variables.

export CYCLOPS_ENDPOINT='https://run.cua.ai'
export CYCLOPS_CLIENT_ID='ukey-...'
export CYCLOPS_CLIENT_SECRET='...'
export CYCLOPS_TOKEN_URL='https://auth.cua.ai/realms/cyclops-cs/protocol/openid-connect/token'

The Python client below uses the same OAuth values. It accepts the CUA_* variables used by the original SDK example, with CYCLOPS_* as fallbacks.

export CUA_BASE_URL="$CYCLOPS_ENDPOINT"
export CUA_CLIENT_ID="$CYCLOPS_CLIENT_ID"
export CUA_CLIENT_SECRET="$CYCLOPS_CLIENT_SECRET"
export CUA_TOKEN_URL="$CYCLOPS_TOKEN_URL"

2. Provision the Windows Fleet

Create an empty directory, then save the following as main.tf. The Windows computer-server image requires UEFI, which is why firmware = "efi" is required. The readiness probe and computer-server service both use port 8000.

terraform {
  required_providers {
    fleets = {
      source  = "trycua/fleets"
      version = "1.0.0"
    }
  }
}

provider "fleets" {
  endpoint = "https://run.cua.ai"
}

resource "fleets_pool" "windows" {
  name                 = "windows-fleet"
  replicas             = 0
  cpu_cores            = 4
  memory               = "4Gi"
  container_disk_image = "296062593712.dkr.ecr.us-west-2.amazonaws.com/cua-server-windows:latest"
  runtime              = "kubevirt"
  firmware             = "efi"

  readiness_probe_json = jsonencode({
    tcpSocket           = { port = 8000 }
    initialDelaySeconds = 60
    periodSeconds       = 5
    timeoutSeconds      = 3
    failureThreshold    = 120
  })

  service {
    name        = "computer-server"
    target_port = 8000
    protocol    = "TCP"
  }

  autoscaling {
    min_pool_size     = 0
    initial_pool_size = 1
    max_pool_size     = 5
  }
}

output "windows_fleet" {
  value = {
    name      = fleets_pool.windows.name
    namespace = fleets_pool.windows.namespace
    phase     = fleets_pool.windows.phase
  }
}

Initialize and apply it:

rm -rf .terraform .terraform.lock.hcl
terraform init
terraform fmt -check
terraform validate
terraform plan
terraform apply
terraform output

Set the pool name for the Python program. The Terraform provider uses the pool's name as its namespace, so the defaults are both windows-fleet.

export CUA_POOL='windows-fleet'
export CYCLOPS_NAMESPACE="$CUA_POOL"

3. Claim, initialize, and release a Windows sandbox

Save this as live_app_controlled.py:

#!/usr/bin/env python3
import asyncio
import json
import os
import time
import urllib.error
import urllib.request

from cyclops_sdk import (
    CreateClaimRequest,
    CyclopsClient,
    CyclopsConfiguration,
    CyclopsCredentials,
    HttpClient,
    HttpHeader,
    HttpRequest,
    HttpResponse,
)


class UrlLibHttpClient(HttpClient):
    async def execute(self, request: HttpRequest) -> HttpResponse:
        return await asyncio.to_thread(self._execute, request)

    def _execute(self, request: HttpRequest) -> HttpResponse:
        native = urllib.request.Request(
            request.url,
            data=request.body,
            method=request.method,
            headers={header.name: header.value for header in request.headers},
        )
        try:
            with urllib.request.urlopen(native, timeout=60) as response:
                return HttpResponse(
                    status=response.status,
                    headers=[
                        HttpHeader(name=name, value=value)
                        for name, value in response.headers.items()
                    ],
                    body=response.read(),
                )
        except urllib.error.HTTPError as error:
            return HttpResponse(
                status=error.code,
                headers=[
                    HttpHeader(name=name, value=value)
                    for name, value in error.headers.items()
                ],
                body=error.read(),
            )


async def initialize_mcp(client: CyclopsClient, sandbox) -> int:
    body = json.dumps(
        {
            "jsonrpc": "2.0",
            "id": 1,
            "method": "initialize",
            "params": {
                "protocolVersion": "2025-03-26",
                "capabilities": {},
                "clientInfo": {
                    "name": "cua-fleet-windows-live-control",
                    "version": "0.1.0",
                },
            },
        },
        separators=(",", ":"),
    ).encode()
    deadline = time.monotonic() + 300

    while True:
        response = await client.service_request(
            sandbox,
            "computer-server",
            "/mcp",
            HttpRequest(
                method="POST",
                url="https://ignored.invalid/mcp",
                headers=[
                    HttpHeader(
                        name="accept",
                        value="application/json, text/event-stream",
                    ),
                    HttpHeader(name="content-type", value="application/json"),
                ],
                body=body,
            ),
        )
        if 200 <= response.status < 300:
            return response.status
        if response.status in (502, 503, 504) and time.monotonic() < deadline:
            print(f"computer-server is starting: HTTP {response.status}")
            await asyncio.sleep(5)
            continue
        raise RuntimeError(
            f"MCP initialize failed with HTTP {response.status}: {response.body!r}"
        )


async def main() -> None:
    client_id = os.environ["CUA_CLIENT_ID"]
    client_secret = os.environ["CUA_CLIENT_SECRET"]
    base_url = os.environ["CUA_BASE_URL"]
    token_url = os.environ["CUA_TOKEN_URL"]
    namespace = os.environ["CYCLOPS_NAMESPACE"]
    pool_name = os.environ["CUA_POOL"]

    client = CyclopsClient.connect(
        CyclopsConfiguration(
            base_url=base_url,
            token_url=token_url,
            credentials=CyclopsCredentials(client_id, client_secret),
            pool_poll_interval_ms=5000,
            pool_poll_limit=120,
            claim_poll_interval_ms=5000,
            claim_poll_limit=120,
        ),
        UrlLibHttpClient(),
    )

    pools = await client.list_pools(namespace)
    pool = next((candidate for candidate in pools if candidate.metadata.name == pool_name), None)
    if pool is None:
        available = ", ".join(candidate.metadata.name for candidate in pools) or "(none)"
        raise RuntimeError(
            f"Pool {pool_name!r} was not found in namespace {namespace!r}; "
            f"available: {available}"
        )

    claim = None
    try:
        claim = await client.create_claim(CreateClaimRequest(pool=pool, spec=None))
        sandbox = await client.wait_claim(claim)
        mcp_status = await initialize_mcp(client, sandbox)
        print(
            json.dumps(
                {
                    "namespace": namespace,
                    "pool": pool.metadata.name,
                    "claim": claim.metadata.name,
                    "sandbox": sandbox.name,
                    "mcp_status": mcp_status,
                },
                sort_keys=True,
            )
        )
    finally:
        if claim is not None:
            await client.delete_claim(claim)


asyncio.run(main())

Run it without creating a virtual environment or a pyproject.toml:

uv run --with cua-fleet==0.0.5 python live_app_controlled.py

Expected output is a JSON object containing the pool, temporary claim, assigned sandbox, and an HTTP 2xx MCP status. The finally block releases the claim even if initialization fails; the Terraform Fleet remains ready for the next claim.

4. Tear down the Fleet

Destroy the Terraform-managed Fleet when you are finished. This removes the pool and its same-named namespace.

terraform destroy

Troubleshooting

  • A 403 during pool creation generally means the image reference or image-pull-secret policy is not allowed. The provider defaults image_pull_secret to ecr-credentials.
  • A Windows VM that does not boot normally has an incorrect firmware setting; the Windows image requires firmware = "efi".
  • If the claim binds but MCP returns 502, 503, or 504, keep polling. The script waits up to five minutes for the guest's computer-server to finish starting.
  • The pool name must be a lowercase DNS label with at most 63 characters. It also determines the namespace.
  • Protect shell history, Terraform state, client secrets, and access tokens.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment