Files
roshan 1af99d61cb chore(craft): improve Kubernetes startup, image cleanup, and local debugging (#16376)
## Description

Craft development required repeated image builds, copied cluster
settings, and editor-specific Telepresence commands. Startup could
report success before services were usable.

- Share configuration resolution and launch commands between Make and VS
Code. Read deployed ConfigMaps and Secrets in memory, with personal
`.env.k8s` overrides.
- Add separate infrastructure and application readiness checks,
`craft-doctor`, and shared Telepresence connection/intercept handling.
- Reuse images, detect source changes, and provide checkout-scoped
cleanup previews. Add pause/resume and migration commands with
database-target checks.
- Default Kubernetes sandbox callbacks to the internal API Service.
Preserve explicit `ONYX_SERVER_URL` overrides and bump the Helm chart to
0.9.20.
- Update the local Kubernetes guide, Craft development skill, and
feature map.
- Give Helm chart CI a 100 GB runner disk. The previous runner exhausted
disk while unpacking OpenSearch.
- Upgrade the Storybook package family to 10.6.2 to fix the critical
audit finding GHSA-f2ph-cmg5-wxc8.

Existing `.env.k8s` files remain untouched; old service entries continue
to override chart settings. Image refresh updates the PodTemplate for
future sandboxes; existing sandbox pods retain their images.

## Review guide

[Read or download the Markdown review
guide](https://github.com/onyx-dot-app/onyx/blob/d3cbc42d89bfb150d1cfc815638645043c34b69e/docs/craft/dev/kubernetes-review-guide.md).

## How Has This Been Tested?

- Live kind and Telepresence: startup, intercept reuse, doctor, API and
worker hot reload, readiness success/failure, and migration
status/upgrade.
- Three real Craft smoke tests: sandbox provisioning, file
upload/readback, and web app preview through the frontend. No real LLM
calls were exercised.
- 76 Craft Helm tests and 27 development-tool regression tests passed.
- Pre-commit checks, explicit development-tool type checking, frontend
type coverage, and skill validation passed.
- The local web audit passes its critical-severity gate without
suppressions. The Storybook production build passes.
- Removed the disposable test cluster, local processes, and temporary
files.

## Additional Options

- [ ] [Optional] Please cherry-pick this PR to the latest release
version.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Harden local Craft Kubernetes development by sharing configuration and
launch logic between Make and VS Code, and by checking service readiness
before startup reports success.

**What’s included**
- Resolve deployed ConfigMaps, Secrets, and container env in memory
through a shared launcher; personal `.env.k8s` values take precedence,
and resolved secrets never touch disk or terminal output.
- Add shared lifecycle commands (`craft-doctor`, staged
infrastructure/application readiness, Telepresence connect and
intercept, pause, resume, migration, cleanup previews) used by both Make
and VS Code.
- Reuse built images when source is unchanged, force a rebuild with
`--rebuild-sandbox-image`, and keep probe and command invocations
bounded while preserving actionable errors.
- Default sandbox callbacks to the internal API Service, preserving
explicit `ONYX_SERVER_URL` overrides; drop the now-unneeded validation
and bump the Helm chart to 0.9.20.
- Give the Helm CI runner a 100 GB disk, since Docker and kind each
store images and OpenSearch exhausted the default disk. Bump Storybook
tooling to 10.6.2 to patch a development server vulnerability.
- Update the local Kubernetes guide, Craft development skill, feature
map, and add a downloadable PR review guide.

**Validation**
- Passed 76 Craft Helm tests, 27 development-tool regression tests, and
new unit tests for the shared runtime and lifecycle CLI.
- Tested startup, readiness, Telepresence, hot reload, migrations, and
three Craft smoke tests against kind; no real LLM calls were exercised.

<sup>Written for commit d3cbc42d89.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/onyx-dot-app/onyx/pull/16376?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="View guided diff"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>
<a
href="https://www.cubic.dev/action/auto-fix/pr/onyx-dot-app/onyx/16376?returnTo=https%3A%2F%2Fgithub.com%2Fonyx-dot-app%2Fonyx%2Fpull%2F16376&source=description"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"><img
alt="Turn on auto-fix"
src="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

- **New Features**
- Added a unified local Kubernetes workflow for Craft, including API and
worker launching, connectivity, readiness checks, migrations,
pause/resume, diagnostics, cleanup, and disk management.
- Added sandbox image reuse, rebuilding, loading, and configurable image
selection.
- Sandbox callbacks now default to the internal API service URL, with
support for explicit overrides.

- **Documentation**
- Added guidance for local Kubernetes setup, configuration, lifecycle
management, troubleshooting, and verification.

- **Bug Fixes**
- Improved readiness validation and stopped sensitive generated
credentials from being printed.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-10-21 23:15:16 +00:00

492 lines
18 KiB
Python

"""Shared Kubernetes configuration and readiness for local Craft processes."""
import base64
import ipaddress
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
@dataclass(frozen=True)
class Cluster:
root: Path
name: str = "onyx-dev"
namespace: str = "onyx"
sandbox_namespace: str = "onyx-sandboxes"
@property
def context(self) -> str:
"""Return the explicit kubectl context for this kind cluster."""
return f"kind-{self.name}"
def command(self, *args: str, capture: bool = True, timeout: float = 30) -> str:
"""Run a bounded command, keeping captured configuration and secrets out of terminal output."""
try:
result = subprocess.run(
args,
cwd=self.root,
check=True,
text=True,
timeout=timeout,
stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
if capture and exc.stderr:
message = exc.stderr
if isinstance(message, bytes):
message = message.decode(errors="replace")
print(message.strip(), file=sys.stderr)
raise
return result.stdout.strip() if capture else ""
def kube(
self, *args: str, namespace: str | None = None, timeout: float = 30
) -> str:
"""Run kubectl with an explicit context, namespace, and request timeout."""
return self.command(
"kubectl",
"--context",
self.context,
"--request-timeout=10s",
"-n",
namespace or self.namespace,
*args,
timeout=timeout,
)
def api_workload(self) -> dict[str, Any]:
"""Find the single API deployment that defines local process configuration."""
items = json.loads(
self.kube(
"get",
"deployments",
"-l",
"app=api-server,app.kubernetes.io/instance=onyx",
"-o",
"json",
)
)["items"]
if len(items) != 1:
raise ValueError("Expected one Onyx API deployment; run make craft-up")
return items[0]
def deployment_env(self) -> dict[str, str]:
"""Resolve the API container's Kubernetes envFrom and env precedence."""
workload = self.api_workload()
container = next(
(
c
for c in workload["spec"]["template"]["spec"]["containers"]
if c["name"] == "api-server"
),
None,
)
if container is None:
raise ValueError("API deployment has no api-server container")
cache: dict[tuple[str, str], dict[str, str] | None] = {}
def source(kind: str, name: str, optional: bool = False) -> dict[str, str]:
"""Read and cache referenced configuration, respecting optional Kubernetes sources."""
key = (kind, name)
if key not in cache:
raw = self.kube("get", kind, name, "--ignore-not-found", "-o", "json")
if not raw:
cache[key] = None
else:
data = json.loads(raw).get("data", {})
cache[key] = (
{
k: base64.b64decode(v, validate=True).decode()
for k, v in data.items()
}
if kind == "secret"
else data
)
values = cache[key]
if values is None:
if optional:
return {}
raise ValueError(f"Missing {kind} {name!r} in {self.namespace}")
return values
env: dict[str, str] = {}
for entry in container.get("envFrom", []):
for ref, kind in (("configMapRef", "configmap"), ("secretRef", "secret")):
if ref in entry:
config = entry[ref]
env.update(
{
entry.get("prefix", "") + k: v
for k, v in source(
kind, config["name"], config.get("optional", False)
).items()
}
)
for entry in container.get("env", []):
name = entry["name"]
if "value" in entry:
value = str(entry["value"])
# Kubernetes expands earlier bindings, retaining unknown references.
env[name] = re.sub(
r"\$\((\w+)\)",
lambda m: env.get(m[1], m[0]),
value.replace("$$", "\0"),
).replace("\0", "$")
continue
value_from = entry.get("valueFrom", {})
if not value_from:
env[name] = ""
continue
for ref, kind in (
("configMapKeyRef", "configmap"),
("secretKeyRef", "secret"),
):
if ref not in value_from:
continue
config = value_from[ref]
values = source(kind, config["name"], config.get("optional", False))
if config["key"] not in values and not config.get("optional", False):
raise ValueError(
f"Missing key {config['key']!r} in {kind} {config['name']!r}"
)
if config["key"] in values:
env[name] = values[config["key"]]
break
else:
if (
value_from.get("fieldRef", {}).get("fieldPath")
== "metadata.namespace"
):
env[name] = self.namespace
else:
raise ValueError(
f"Cannot use pod-specific valueFrom for local variable {name}"
)
return env
def environment(
self, env_file: Path | None = None, *, settings: dict[str, str] | None = None
) -> dict[str, str]:
"""Combine deployed settings and personal overrides, qualifying cluster service hosts."""
from dotenv import dotenv_values
settings = dict(self.deployment_env() if settings is None else settings)
result = dict(os.environ)
result.update(settings)
path = env_file or self.root / ".vscode/.env.k8s"
if path.is_file():
result.update(
{
k: v
for k, v in dotenv_values(path, interpolate=False).items()
if v is not None and not v.startswith("<")
}
)
for key in (
"POSTGRES_HOST",
"REDIS_HOST",
"MODEL_SERVER_HOST",
"INDEXING_MODEL_SERVER_HOST",
"OPENSEARCH_HOST",
"SANDBOX_PROXY_HOST",
):
if result.get(key):
result[key] = self.qualify(result[key])
for key in (
"S3_ENDPOINT_URL",
"S3_LEGACY_ENDPOINT_URL",
"INTERNAL_URL",
"ONYX_SERVER_URL",
):
if result.get(key):
url = urlsplit(result[key])
if url.hostname and url.netloc == url.hostname + (
f":{url.port}" if url.port else ""
):
result[key] = urlunsplit(
url._replace(
netloc=self.qualify(url.hostname)
+ (f":{url.port}" if url.port else "")
)
)
result.update(
K8S_CONTEXT=self.context,
SANDBOX_BACKEND="kubernetes",
SANDBOX_NAMESPACE=self.sandbox_namespace,
)
return result
def database_environment(self, env_file: Path | None = None) -> dict[str, str]:
"""Resolve migration settings and reject changes to the deployed database target."""
settings = self.deployment_env()
env = self.environment(env_file, settings=settings)
defaults = {
"POSTGRES_HOST": "localhost",
"POSTGRES_PORT": "5432",
"POSTGRES_DB": "postgres",
}
for key, default in defaults.items():
declared = settings.get(key) or default
actual = env.get(key) or default
if key == "POSTGRES_HOST":
declared, actual = self.qualify(declared), self.qualify(actual)
if actual != declared:
raise ValueError(
f"{key} override differs from the selected cluster's database; remove it before migrations"
)
return env
def qualify(self, host: str) -> str:
"""Expand a bare service name while retaining addresses and qualified hosts."""
if host == "localhost" or "." in host or ":" in host:
return host
try:
ipaddress.ip_address(host)
return host
except ValueError:
return f"{host}.{self.namespace}.svc.cluster.local"
def infrastructure_ready(self, timeout: float = 600) -> None:
"""Wait for active Onyx workloads and the installed Telepresence manager."""
deadline = time.monotonic() + timeout
workloads = json.loads(
self.kube(
"get",
"deployments,statefulsets",
"-o",
"json",
)
)["items"]
workloads = [
w
for w in workloads
if w["metadata"].get("annotations", {}).get("meta.helm.sh/release-name")
== "onyx"
or w["metadata"].get("labels", {}).get("app.kubernetes.io/instance")
== "onyx"
or w["metadata"].get("labels", {}).get("release") == "onyx"
]
if not workloads:
raise ValueError("No Onyx workloads found; run make craft-up")
for workload in workloads:
if workload["spec"].get("replicas", 1) == 0:
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ValueError("Timed out waiting for Onyx infrastructure")
name = f"{workload['kind'].lower()}/{workload['metadata']['name']}"
print(f"Waiting for {name} ...", flush=True)
self.kube(
"rollout",
"status",
name,
f"--timeout={int(remaining)}s",
f"--request-timeout={int(remaining) + 5}s",
timeout=remaining + 10,
)
manager = self.kube(
"get",
"deployment",
"traffic-manager",
"--ignore-not-found",
"-o",
"name",
namespace="ambassador",
)
if manager:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ValueError("Timed out waiting for Telepresence infrastructure")
self.kube(
"rollout",
"status",
"deployment/traffic-manager",
f"--timeout={int(remaining)}s",
f"--request-timeout={int(remaining) + 5}s",
namespace="ambassador",
timeout=remaining + 10,
)
print(
"Infrastructure ready. Local API and frontend readiness are checked separately.",
flush=True,
)
def connect(self) -> None:
"""Connect Telepresence to the selected cluster and application namespace."""
self.kube("get", "namespace", self.namespace, "-o", "name")
self.command(
"telepresence",
"connect",
"--context",
self.context,
"-n",
self.namespace,
capture=False,
timeout=120,
)
def intercept_active(self, name: str) -> bool:
"""Check for an active API intercept in this cluster and namespace."""
status = json.loads(self.command("telepresence", "status", "--format", "json"))
user = status.get("user_daemon", {})
if (
user.get("kubernetes_context") != self.context
or user.get("namespace") != self.namespace
):
return False
workloads = json.loads(
self.command(
"telepresence",
"list",
"--namespace",
self.namespace,
"--format",
"json",
)
)
# Telepresence's InterceptDispositionType.ACTIVE is protobuf enum value 1.
return any(
info.get("disposition") == 1 and info.get("spec", {}).get("name") == name
for workload in workloads
if workload.get("name") == name
for info in workload.get("intercept_info", [])
)
def application_ready(self, frontend_url: str, timeout: float = 180) -> None:
name = self.api_workload()["metadata"]["name"]
if not self.intercept_active(name):
raise ValueError(
"No active API intercept for the selected cluster; run make craft-intercept first"
)
application_ready(frontend_url, timeout)
def intercept(self) -> None:
"""Reuse or create the API intercept, recovering a known stale agent once."""
self.connect()
name = self.api_workload()["metadata"]["name"]
if self.intercept_active(name):
print(f"Reusing API intercept {name}", flush=True)
else:
try:
self.command(
"telepresence",
"intercept",
name,
"--namespace",
self.namespace,
"--port",
"8080:8080",
"--mount=false",
capture=False,
timeout=150,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
# Retry once only for the known stale-agent identity failure.
try:
logs = self.kube(
"logs",
"-l",
"app=api-server,app.kubernetes.io/instance=onyx",
"-c",
"traffic-agent",
"--since=2m",
"--tail=1",
"--prefix=true",
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
logs = ""
if not re.search(
r'code = NotFound desc = Session "agent:|code = PermissionDenied desc = agent session .*bound to another workload identity',
logs,
):
raise
self.kube("rollout", "restart", f"deployment/{name}")
self.kube(
"rollout",
"status",
f"deployment/{name}",
"--timeout=120s",
"--request-timeout=130s",
timeout=135,
)
self.command(
"telepresence",
"intercept",
name,
"--namespace",
self.namespace,
"--port",
"8080:8080",
"--mount=false",
capture=False,
timeout=150,
)
self.kube(
"rollout",
"status",
f"deployment/{name}",
"--timeout=120s",
"--request-timeout=130s",
timeout=135,
)
print(
"API intercept ready. Start the local API, then run make craft-ready after starting the frontend.",
flush=True,
)
def application_ready(frontend_url: str, timeout: float = 180) -> None:
"""Verify real API readiness through the frontend, then the frontend page."""
url = urlsplit(frontend_url)
if (
url.scheme not in {"http", "https"}
or not url.netloc
or url.username
or url.password
):
raise ValueError("Frontend URL must be an HTTP(S) URL without credentials")
base = frontend_url.rstrip("/")
deadline = time.monotonic() + timeout
api_reported = False
while time.monotonic() < deadline:
try:
with urllib.request.urlopen( # noqa: S310 - HTTP(S) scheme validated above
f"{base}/api/health/ready",
timeout=max(0.1, min(5, deadline - time.monotonic())),
) as response:
body = json.load(response)
api_ready = (
response.status == 200
and isinstance(body, dict)
and body.get("success") is True
)
if api_ready:
if not api_reported:
print("API ready through the frontend proxy.", flush=True)
api_reported = True
with urllib.request.urlopen( # noqa: S310 - HTTP(S) scheme validated above
base, timeout=max(0.1, min(5, deadline - time.monotonic()))
) as response:
if (
response.status == 200
and response.headers.get_content_type() == "text/html"
):
print(f"Frontend ready: {base}", flush=True)
return
except (OSError, ValueError, urllib.error.URLError):
pass
time.sleep(max(0, min(1, deadline - time.monotonic())))
raise ValueError(
"Timed out waiting for the local API and frontend; check their logs and the API intercept, then rerun make craft-ready"
)