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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ operation fails; it never falls back to running on the host.

```bash
make bootstrap # create .venv and install SDK + test dependencies
make test # 844 unit and contract tests, no network, no cluster
make test # 870 unit and contract tests, no network, no cluster
make verify # complete Python, Console, manifest, Helm, wheel gate
make help # every Make target with its one-line description
```
Expand Down Expand Up @@ -201,7 +201,7 @@ it first rather than discovering a gap halfway through the VM build.

| Requirement | Detail |
| --- | --- |
| Commands on `PATH` | `docker` (daemon reachable), `limactl`, `kubectl`, `helm`, `python3`, `openssl` |
| Commands on `PATH` | `docker` (daemon reachable), `limactl`, `kubectl`, `helm`, `python3`, `openssl`; on Linux also `qemu-system-<arch>` and `shasum`. Lima has one vmType on Linux, qemu, and boots the VM with the host architecture's system emulator, which `limactl` does not ship; `shasum` is what the Cilium and Rook chart installers verify with. macOS uses its own hypervisor framework and needs neither. |
| Python | 3.11 or newer |
| Host OS | macOS or Linux |
| Host architecture | amd64 or arm64 (`scripts/local-cluster.yaml` pins Ubuntu images for both; gVisor is installed for `x86_64` and `aarch64`) |
Expand Down
31 changes: 29 additions & 2 deletions bench/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
ROOT = pathlib.Path(__file__).resolve().parents[1]
DEFAULT_THRESHOLDS = ROOT / "bench/excellent-thresholds.json"
PROTOCOL_VERSION = "2026-07-28"
# Captured command output is capped so one `kubectl get pods -o json` cannot
# dominate the report. Exceeding it is recorded rather than left to be
# discovered as a JSON decode error.
OUTPUT_CAP = 20_000


def utc_now() -> str:
Expand Down Expand Up @@ -160,11 +164,19 @@ def command_output(command: list[str]) -> dict[str, Any]:
except (OSError, subprocess.TimeoutExpired) as error:
return {"available": False, "error": type(error).__name__}
output = completed.stdout.strip() or completed.stderr.strip()
return {
# `kubectl get pods -o json` routinely passes the cap, and a JSON document
# cut mid-object is not distinguishable from a broken command once it is in
# the report: a reader parsing it gets a decode error and no reason for it.
# The cap stays; what changes is that the file now says so.
captured = {
"available": completed.returncode == 0,
"returnCode": completed.returncode,
"output": output[:20_000],
"output": output[:OUTPUT_CAP],
}
if len(output) > OUTPUT_CAP:
captured["truncated"] = True
captured["fullLength"] = len(output)
return captured


def environment_snapshot(base_url: str, kube_context: str | None) -> dict[str, Any]:
Expand Down Expand Up @@ -202,6 +214,13 @@ def __init__(self, client: ApiClient, sample_file: pathlib.Path, run_id: str) ->
self.sample_file = sample_file
self.run_id = run_id
self.samples: list[dict[str, Any]] = []
# Every Runtime lease reports the RuntimeClass the Control Plane placed
# it under. The environment snapshot cannot answer this: it is taken
# before the first Runtime exists, and the `gvisor` RuntimeClass object
# it dumps is a cluster fact, not a statement about what these
# measurements ran on. A deployment with SANDBOX_RUNTIME_CLASS empty
# has that object and still runs every Pod on the default runtime.
self.runtime_classes: set[str] = set()

def measure(self, metric: str, iteration: int, operation: Any) -> Any:
started = time.perf_counter()
Expand Down Expand Up @@ -304,6 +323,13 @@ def one_iteration(self, iteration: int) -> None:
)
sandbox_id = sandbox["id"]
sandbox_token = sandbox["access_token"]
# "cluster-default" here means the Pods these numbers describe ran
# without gVisor. Recorded per lease, so the summary states what
# was measured rather than what the cluster could have offered.
# "unreported" rather than a guess: a Control Plane too old to send
# the field is not evidence of gVisor, and it is not evidence
# against it either. Sorting the set needs a string for that case.
self.runtime_classes.add(sandbox.get("runtime_class") or "unreported")
mcp_payload = {
"jsonrpc": "2.0",
"id": f"bench-{iteration}",
Expand Down Expand Up @@ -411,6 +437,7 @@ def main() -> int:
"iterationFailures": failures,
"thresholdProfile": thresholds["profile"],
"metrics": summaries,
"observedRuntimeClasses": sorted(benchmark.runtime_classes),
"evaluation": evaluation,
}
(args.output_dir / "summary.json").write_text(
Expand Down
12 changes: 7 additions & 5 deletions console/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ RUN npm run build

FROM nginxinc/nginx-unprivileged:1.31-alpine3.24@sha256:d9083fe47768377ef55dedafd67d4da7c2f2bc2bece7554954f29359deb0dce9
# Temporary, until the base image is rebuilt: this digest ships libexpat
# 2.8.3-r0 (CVE-2026-66046, CVE-2026-76641) while Alpine 3.24 already carries
# the fixed 2.8.4-r0, so the image gate would refuse it. Remove the two lines
# below once a newer base digest passes `trivy image` on its own; they are
# the one place this build reaches the package index after the FROM.
# 2.8.3-r0 (CVE-2026-66046, CVE-2026-76641) and libuuid 2.42.1-r0
# (CVE-2026-53612/53613/53614, CVE-2026-76642, CVE-2026-78408/78409/78410)
# while Alpine 3.24 already carries the fixed 2.8.4-r0 and 2.42.3-r1, so the
# image gate would refuse it. Remove the two lines below once a newer base
# digest passes `trivy image` on its own; they are the one place this build
# reaches the package index after the FROM.
USER root
RUN apk upgrade --no-cache libexpat
RUN apk upgrade --no-cache libexpat libuuid
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY entrypoint.sh /usr/local/bin/sandbox-console-entrypoint
COPY --from=build /app/dist /usr/share/nginx/html
Expand Down
66 changes: 39 additions & 27 deletions console/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,40 +33,52 @@ export function formatUnix(
}).format(new Date(seconds * 1000));
}

const CATALOGS = { en, "zh-CN": zhCN } as const;

/**
* Renders one pluralised amount, for example "1 minute" or "12 minutes".
*
* The catalogs carry the unit as its own `.one`/`.other` family rather than
* baking it into a whole sentence: the compound form needs hours and minutes
* pluralised independently, and "in 5 hours 1 minutes" is exactly what a single
* sentence template cannot avoid. Locales without plural categories declare the
* same text twice, which is what Intl.PluralRules then never has to choose
* between.
*/
function amount(
locale: Locale,
unit: "seconds" | "minutes" | "hours",
count: number,
): string {
const catalog = CATALOGS[locale];
const category = new Intl.PluralRules(locale).select(count);
const message =
catalog[`relative.${unit}.${category}` as keyof typeof catalog]
?? catalog[`relative.${unit}.other` as keyof typeof catalog];
return String(message).replace("{count}", String(count));
}

/** Places one rendered amount in its past or future frame. */
function directed(locale: Locale, value: string, past: boolean): string {
const catalog = CATALOGS[locale];
return catalog[past ? "relative.past" : "relative.future"]
.replace("{value}", value);
}

function relativeMessage(
locale: Locale,
unit: "seconds" | "minutes" | "hours" | "compoundHours",
count: number,
past: boolean,
minutes = 0,
): string {
const messages = {
en: {
secondsPast: en["relative.secondsPast"],
secondsFuture: en["relative.secondsFuture"],
minutesPast: en["relative.minutesPast"],
minutesFuture: en["relative.minutesFuture"],
hoursPast: en["relative.hoursPast"],
hoursFuture: en["relative.hoursFuture"],
compoundHoursPast: en["relative.compoundHoursPast"],
compoundHoursFuture: en["relative.compoundHoursFuture"],
},
"zh-CN": {
secondsPast: zhCN["relative.secondsPast"],
secondsFuture: zhCN["relative.secondsFuture"],
minutesPast: zhCN["relative.minutesPast"],
minutesFuture: zhCN["relative.minutesFuture"],
hoursPast: zhCN["relative.hoursPast"],
hoursFuture: zhCN["relative.hoursFuture"],
compoundHoursPast: zhCN["relative.compoundHoursPast"],
compoundHoursFuture: zhCN["relative.compoundHoursFuture"],
},
}[locale];
const template = messages[`${unit}${past ? "Past" : "Future"}`];
return template
.replace("{count}", String(count))
.replace("{hours}", String(count))
.replace("{minutes}", String(minutes));
if (unit === "compoundHours") {
const value = CATALOGS[locale]["relative.compound"]
.replace("{hours}", amount(locale, "hours", count))
.replace("{minutes}", amount(locale, "minutes", minutes));
return directed(locale, value, past);
}
return directed(locale, amount(locale, unit, count), past);
}

/**
Expand Down
3 changes: 3 additions & 0 deletions console/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const PLURAL_KEYS = [
"tenants.keyCount",
"monitoring.nodes.count",
"monitoring.runtimes.count",
"relative.seconds",
"relative.minutes",
"relative.hours",
] as const;

export type PluralTranslationKey = (typeof PLURAL_KEYS)[number];
17 changes: 9 additions & 8 deletions console/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,15 @@ export const en = {
"templates.invalidInput": "Both template ID and image are required.",
"templates.confirmDelete": "Delete {scope} template {id}? Running sandboxes are unaffected, but future starts using this ID will fail.",

"relative.secondsFuture": "in {count} seconds",
"relative.secondsPast": "{count} seconds ago",
"relative.minutesFuture": "in {count} minutes",
"relative.minutesPast": "{count} minutes ago",
"relative.hoursFuture": "in {count} hours",
"relative.hoursPast": "{count} hours ago",
"relative.compoundHoursFuture": "in {hours} hours {minutes} minutes",
"relative.compoundHoursPast": "{hours} hours {minutes} minutes ago",
"relative.seconds.one": "{count} second",
"relative.seconds.other": "{count} seconds",
"relative.minutes.one": "{count} minute",
"relative.minutes.other": "{count} minutes",
"relative.hours.one": "{count} hour",
"relative.hours.other": "{count} hours",
"relative.compound": "{hours} {minutes}",
"relative.future": "in {value}",
"relative.past": "{value} ago",
"observability.title": "Platform metrics",
"observability.subtitle": "Live panels from the operator's Grafana, proxied on this origin. Read-only.",
"observability.crossTenantNotice": "These panels are platform-wide. The metrics behind them carry no tenant dimension, so nothing here can be filtered to one tenant.",
Expand Down
17 changes: 9 additions & 8 deletions console/src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,15 @@ export const zhCN: EnglishMessages = {
"templates.invalidInput": "模板 ID 和镜像都必须填。",
"templates.confirmDelete": "删除{scope}模板 {id}?已经在跑的沙箱不受影响,之后按这个 id 起沙箱会失败。",

"relative.secondsFuture": "{count} 秒后",
"relative.secondsPast": "{count} 秒前",
"relative.minutesFuture": "{count} 分钟后",
"relative.minutesPast": "{count} 分钟前",
"relative.hoursFuture": "{count} 小时后",
"relative.hoursPast": "{count} 小时前",
"relative.compoundHoursFuture": "{hours} 小时 {minutes} 分后",
"relative.compoundHoursPast": "{hours} 小时 {minutes} 分前",
"relative.seconds.one": "{count} 秒",
"relative.seconds.other": "{count} 秒",
"relative.minutes.one": "{count} 分钟",
"relative.minutes.other": "{count} 分钟",
"relative.hours.one": "{count} 小时",
"relative.hours.other": "{count} 小时",
"relative.compound": "{hours} {minutes}",
"relative.future": "{value}后",
"relative.past": "{value}前",
"observability.title": "平台指标",
"observability.subtitle": "运营方 Grafana 的实时面板,经本站同源反代,只读。",
"observability.crossTenantNotice": "这些面板是平台级的。背后的指标不带租户维度,因此无法按单个租户筛选。",
Expand Down
8 changes: 8 additions & 0 deletions control_plane/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
FROM python:3.14-alpine@sha256:c6ead215bfd31f1e433d968853b7a769989117115b728874824e6c0a27cb96fc

# Temporary, until the base image is rebuilt: this digest ships libuuid
# 2.42.1-r0 (CVE-2026-53612/53613/53614, CVE-2026-76642, CVE-2026-78408/78409/78410)
# while Alpine 3.24 already carries the fixed 2.42.3-r1, so the image gate would
# refuse it. Remove the two lines below once a newer base digest passes
# `trivy image` on its own; they are the one place this build reaches the
# package index after the FROM.
RUN apk upgrade --no-cache libuuid

RUN addgroup -g 65532 control-plane \
&& adduser -D -u 65532 -G control-plane -h /nonexistent control-plane

Expand Down
45 changes: 40 additions & 5 deletions control_plane/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,28 @@ def read_json(self) -> dict:
raise ValueError("request body must be a JSON object")
return payload

def read_optional_json(self) -> dict:
"""Body-or-empty, for a route whose request body the contract marks optional.

``read_json`` is right for every other POST: a missing body there is a
client that forgot the payload, and answering 400 says so. The
checkpoint-restore body carries one optional field, so the OpenAPI
declares ``required: false`` and a conforming client sends no body at
all - and used to be told ``Content-Length is required``.

🔴 Absent is not the same as unreadable. A chunked body has no
Content-Length either, and this server never decodes one; treating that
as "no sha256 supplied" would restore an archive **without** the
integrity check the caller asked for, which is the one thing this body
exists to request. Only a body that is genuinely absent becomes {}.
"""
if self.headers.get("Transfer-Encoding"):
raise ValueError("chunked request bodies are not supported")
raw_length = self.headers.get("Content-Length")
if raw_length is None or raw_length.strip() in {"", "0"}:
return {}
return self.read_json()

def bearer_token(self) -> str:
return control_plane.parse_bearer_token(self.headers.get("Authorization", ""))

Expand Down Expand Up @@ -1443,11 +1465,21 @@ def _workspace_owner_matches(self, workspace_id: str) -> bool | None:
self.send_store_outage(exc)
return None

def require_workspace_tenant(self, workspace_id: str) -> bool:
def require_workspace_tenant(
self, workspace_id: str, *, audit_denial: bool = True
) -> bool:
"""Before operating a Workspace by ID, confirm that it belongs to this tenant.

List filtering only blocks "seeing", but this block blocks "guessing the ID and then acting directly".
workspace_id is HMAC-derived and non-enumerable, but non-enumerability is not an access control."""
workspace_id is HMAC-derived and non-enumerable, but non-enumerability is not an access control.

audit_denial=False is for the one caller that does not receive an ID at
all: /v1/workspaces/resolve derives the ID from this tenant's own
credential, so a miss there means "this session has no Workspace yet",
never "someone is probing another tenant". Auditing it would file one
denial per Workspace ever created, in a row an operator cannot tell
apart from a real cross-tenant attempt - which is the only thing this
audit exists to surface. The ownership check itself still runs."""
if control_plane.STORE is None or self.tenant_id is None:
return True
matches = self._workspace_owner_matches(workspace_id)
Expand All @@ -1459,7 +1491,8 @@ def require_workspace_tenant(self, workspace_id: str) -> bool:
#That's a signal that can be used to enumerate.
#But the rejection itself leaves a mark - continuous rejection means someone is testing the ID, which is a precursor to an attack.
#Instead of noise, there is precisely nothing to say in the response.
self.audit("workspace.access", target=workspace_id, outcome="denied")
if audit_denial:
self.audit("workspace.access", target=workspace_id, outcome="denied")
self.send_json(HTTPStatus.NOT_FOUND, {"error": "workspace not found"})
return False

Expand Down Expand Up @@ -2423,7 +2456,7 @@ def do_POST(self) -> None:
if not self.require_workspace_tenant(workspace_id):
return
control_plane.touch_workspace(workspace_id)
payload = self.read_json()
payload = self.read_optional_json()
self.send_json(
HTTPStatus.OK,
control_plane.restore_workspace_checkpoint(
Expand Down Expand Up @@ -2808,7 +2841,9 @@ def do_POST(self) -> None:
principal_kind=principal_kind,
principal_id=principal_id,
)
if not self.require_workspace_tenant(workspace_id):
if not self.require_workspace_tenant(
workspace_id, audit_denial=False
):
return
status, listing, content_type = control_plane.volume_agent_request(
"GET", "/v1/workspaces"
Expand Down
33 changes: 29 additions & 4 deletions control_plane/kube.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,24 @@ def __init__(self, status: int, message: str, payload: object | None = None):


class KubeClient:
# SystemExit instead of KeyError/FileNotFoundError, for the same reason
# core.py states for the configuration block: what an operator wants out of
# a container that will not start is an instruction to follow, not a stack
# trace. All three inputs are absent together in the two situations that
# actually happen - the process was started outside a cluster, and the
# Deployment was given automountServiceAccountToken: false - and neither
# is diagnosable from `KeyError: 'KUBERNETES_SERVICE_HOST'`.
def __init__(self) -> None:
host = os.environ["KUBERNETES_SERVICE_HOST"]
host = os.getenv("KUBERNETES_SERVICE_HOST")
if not host:
raise SystemExit(
"control_plane: KUBERNETES_SERVICE_HOST is unset, so this "
"process is not running inside a Kubernetes Pod. The Control "
"Plane talks to the API server through its in-cluster service "
"account and has no kubeconfig path; deploy it to the cluster "
"(k8s/, overlays/, or charts/sandbox) rather than running it "
"on a workstation."
)
port = os.getenv("KUBERNETES_SERVICE_PORT_HTTPS", "443")
self.base_url = f"https://{host}:{port}"
token_path = os.getenv(
Expand All @@ -33,9 +49,18 @@ def __init__(self) -> None:
"KUBERNETES_CA_FILE",
"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt",
)
with open(token_path, encoding="utf-8") as handle:
self.token = handle.read().strip()
self.ssl_context = ssl.create_default_context(cafile=ca_path)
try:
with open(token_path, encoding="utf-8") as handle:
self.token = handle.read().strip()
self.ssl_context = ssl.create_default_context(cafile=ca_path)
except OSError as exc:
raise SystemExit(
f"control_plane: cannot read the service-account credential "
f"{exc.filename or token_path}: {exc.strerror}. The Pod needs "
"automountServiceAccountToken left on (the volume role is the "
"only one that turns it off), or KUBERNETES_TOKEN_FILE and "
"KUBERNETES_CA_FILE pointed at readable copies."
) from exc

def request(
self,
Expand Down
Loading
Loading