# Bring your own container

The Python client and CLI use the authenticated GFT HTTP API. They never invoke
Docker or Nebius on the customer's machine. The server owns admission, prepaid
budget reservation, workspace access, GPU limits, deadline and VM cleanup.
This implementation has local contract tests; it has not been smoke-tested on a
paid Nebius VM. It needs a server release and operator configuration before use.

## Supported contract

- Register public or private external images with `client.images.register/list`
  or `gft image add/list`. Registration stores metadata and encrypted credentials;
  it does not contact the registry or prove the image exists. The worker pulls it.
- Use an explicit immutable `registry/repository@sha256:<64 lowercase hex>`.
  For Docker Hub, use `docker.io/library/ubuntu@sha256:...`, not `ubuntu:latest`.
  Tag resolution, image building, registry hosting and image deletion are absent.
- Run `job` or `service` on a configured Nebius GPU tariff. Each run gets a VM.
  `command` replaces Docker's CMD and preserves the image's ENTRYPOINT. An empty
  command uses the image defaults. Arguments are passed without a shell.
- Read VM lifecycle `state` and separate `process_state`, `ready`, `exit_code`.
  `QUEUED` acknowledges a durable request; it does not mean the process is ready.
  Missing mirrored status is not success. Reuse the same `request_id` with exactly
  the same parameters after a timeout; changes cause a conflict, not a second VM.
- Environment values are encrypted in the backend vault. CLI values come from a
  JSON file; registry credentials use hidden prompts, not command-line flags.
  Private credentials/env require an HTTPS API URL in the SDK. Multiline values
  are unsupported. Use files in the image or download files at runtime instead.
- Containers can download weights and call public external APIs. Metadata,
  host access and private/VPC destinations are blocked. There is no host Docker
  socket or host credential mount, privileged mode, host networking or host PID
  namespace. No platform-wide cloud credentials are delivered to the VM; its
  host storage token is restricted to its own resource prefix and deadline.
- Logs are a bounded tail (up to 64 KiB / 1,000 lines), periodically mirrored;
  known environment and registry values are redacted. This is not a full log
  archive. User-generated outputs may still contain sensitive application data.
- Write result files to `/outputs`. After exit they are archived up to 64 MiB
  uncompressed; symlinks and non-regular files are excluded. Check
  `outputs_truncated`. `outputs()` returns an authenticated, 60-second signed
  download URL for the archive. Cancelled/forcibly terminated VMs may not finish
  mirroring results; there is no guarantee of artifacts after forced termination.
- Services expose one HTTP port, published only on VM loopback. A 2xx response
  from the readiness path enables requests. Clients use the authenticated GFT
  JSON HTTP envelope, not a directly exposed VM URL. The envelope contains
  `status_code`, selected response `headers`, and `body_base64`.
- Service HTTP uses the existing private object-storage broker for request/reply.
  It is intended for bounded requests, not low-latency or high-throughput serving:
  UTF-8 request body up to 1 MiB, response up to 2 MiB, upstream timeout 25 seconds,
  API wait up to 45 seconds, eight concurrent requests per workspace. No SSE,
  streaming, WebSockets, arbitrary TCP, multiple ports or autoscaling.
  HTTP mutations are not automatically retried. A timeout can mean that a request
  executed but its response was lost; use application idempotency when needed.

## CLI

```sh
export GFT_API_URL=https://studio.example.com
# Authenticate using the existing gft auth flow / GFT_API_KEY.
gft --json container capabilities  # choose an id from rates; runtime_available must be true
gft --json image add "ghcr.io/my-org/llm@$IMAGE_DIGEST" \
  --label llm --private --request-id register-llm-v1
gft --json image list
# Omit --private for a public image. Save the returned image ID. env.json is a private JSON object, e.g. HF_TOKEN.
gft --json container run "$IMAGE_ID" --rate "$CONTAINER_RATE_ID" \
  --mode service --port 8000 --readiness /health \
  --env-file ./env.json --runtime 14400 --budget 20 \
  --request-id llm-service-v1 --yes
gft --json container list
gft --json container get "$RUN_ID"
gft container logs "$RUN_ID"
gft --json container request "$RUN_ID" --method POST \
  --path /v1/chat/completions --body-file ./completion.json
# For a completed job with mirrored outputs:
gft --json container outputs "$COMPLETED_JOB_RUN_ID"
gft --json container stop "$RUN_ID"
```

Omit `--mode service --port ...` for a batch job. Repeat `--command=VALUE` for
individual command arguments, including flags beginning with `-`.

## Python

```python
from gft_client import Client

with Client() as client:
    capabilities = client.containers.capabilities()
    print(capabilities["rates"])  # choose a configured Nebius container tariff
    images = client.images.list()
    image = client.images.register(
        "docker.io/my-org/llm@sha256:" + digest_hex,
        label="LLM", request_id="image-v1",
        # Omit both registry fields for public images.
        registry_username=username, registry_password=registry_token,
    )
    run = client.containers.run(
        image["id"], rate_id=container_rate_id, mode="service", port=8000,
        env={"HF_TOKEN": hf_token}, max_runtime_seconds=14400,
        max_budget_micros=20_000_000, request_id="service-v1",
    )
    runs = client.containers.list()
    status = client.containers.get(run["id"])
    logs = client.containers.logs(run["id"])
    if status.get("ready"):
        response = client.containers.request(run["id"], method="POST",
            path="/v1/chat/completions", body=completion_json,
            headers={"Content-Type": "application/json"})
    # A completed job may have a mirrored output archive:
    if status.get("process_state") == "COMPLETED":
        archive = client.containers.outputs(run["id"])
    client.containers.stop(run["id"])
```

## Interactive terminal

The UI **Terminal** button and `gft container shell "$RUN_ID"` open an actual
PTY inside the running customer container. This is not SSH to the VM. The CLI
needs an interactive TTY; Ctrl+] disconnects. It forwards stdin and terminal
resize, streams stdout/stderr, and restores your local terminal on exit.

```python
import base64

with client.containers.shell(run_id, cols=100, rows=30) as terminal:
    terminal.send_input("nvidia-smi\r")
    while True:
        frame = terminal.recv(timeout=30)
        if frame["type"] == "output":
            print(base64.b64decode(frame["data_base64"]).decode(errors="replace"), end="")
        elif frame["type"] == "exit":
            print("Shell exit code:", frame["code"])
            break
```

Client ↔ Studio and agent ↔ broker use standard WebSocket over TLS (WSS).
The agent connects outbound to the existing workload broker; no VM ingress port,
public SSH daemon, password login or Docker socket mount is exposed. A small
JSON framing layer carries input, output, resize and exit. This terminal channel
is separate from the bounded, non-streaming HTTP request API for model inference.

Sessions require run.write, the active org/workspace and the same principal that
opened the session. Access is checked on attach, every action/output and while
idle. One session per run, four per organization; maximum ten minutes, 1 MiB
input, 2 MiB output, 16 KiB chunks and bounded socket queues. Stop, expiry,
revocation or disconnect closes the shell; reconnect explicitly creates a new
session. There is no transcript or replay: bytes are not written to logs, S3,
database or vault. Audit contains session IDs and byte counts only. Built-in
shell history and browser scrollback are disabled; commands or programs can
still write files, and deliberately detached processes can outlive the shell
until the container stops.

The API, broker and VM runtime must all be updated. Existing runs without
terminal_protocol=1 report unavailable and need a new launch. The current relay
uses the existing single-process broker (one worker, one replica); multiple
broker replicas require connection routing before they are supported.

## Server API

| Method | Path | Purpose |
| --- | --- | --- |
| POST / GET | `/v1/images` | Register/list workspace images |
| GET | `/v1/containers/capabilities` | Supported modes, limits and configured tariffs |
| POST / GET | `/v1/containers` | Admit a run / list workspace runs |
| GET | `/v1/containers/{id}` | Lifecycle and mirrored process status |
| GET | `/v1/containers/{id}/logs` | Bounded log tail |
| GET | `/v1/containers/{id}/outputs` | Temporary archive download URL |
| POST | `/v1/containers/{id}/stop` | Request cleanup through the watchdog |
| POST | `/v1/containers/{id}/http` | Authorized bounded service HTTP request |
| POST | `/v1/containers/{id}/terminals` | Create a bounded principal-owned terminal session |
| WS | `/v1/containers/{id}/terminals/{session}/ws` | Attach interactive PTY over WSS |
| DELETE | `/v1/containers/{id}/terminals/{session}` | End the shell session |

Creation, stop and HTTP execution require `run` API scope and `run.write`
permission. Reads require `read` scope and `run.read`. All lookups enforce the
active organization and workspace. Public responses omit vault references,
environment values and registry credentials.

## Operator prerequisites

Use the existing cloud worker, vault and workload-storage broker configuration.
Add a separate `kind: "container"` Nebius rate with `model: "container"`, a reviewed
preset and matching GPU count. Supply the full hourly VM/disk rate, source and
version as usual. Container runs require a GFT credits connection with
`workload_storage: true`; they reject direct BYOC/static cloud credentials.
Neither this change nor the CLI enables provisioning or changes live rates.

The server worker's Nebius profile needs the existing VM create/list/delete
permissions and access to the configured private artifact bucket. The VM needs
public egress to the registry, NVIDIA package repositories, configured weight
sources and the HTTPS storage broker. Registry and broker destinations must have
public addresses; host-side registry pulls also block private/metadata addresses. Bootstrap installs Docker/NVIDIA container
toolkit on the existing operator-selected GPU VM image. Network/GPU behavior and
package compatibility need a bounded operator smoke before production use.
The existing runtime and shutdown reserve apply; the minimum runtime limit is
one hour, with earlier stop permitted. Container jobs share the existing managed
training concurrency ceiling and organization GPU/resource limits.

Useful first smoke: public digest image printing a marker and writing one file;
then a private registry image with an env secret; then a service readiness and
HTTP request. Confirm metadata/host/private-network access is blocked, stdout
redaction and output contents, idempotent retries, deadline cleanup, provider VM
absence and final credit settlement. No such paid smoke is performed by the
local tests.
