Release 0.2.0

See CHANGELOG.md for what shipped.
This commit is contained in:
G
2026-08-12 00:01:43 +02:00
parent 5731452338
commit d65ea43536
141 changed files with 22880 additions and 4946 deletions
-178
View File
@@ -1,178 +0,0 @@
{
"repo_notes": [
{
"content": "Start the wiki with an Architecture section that opens with diagrams explaining how Kubernetes (single-node K3s), Kata, and Firecracker fit together. Do NOT show multiple nodes yet — only a single K3s node is supported currently.",
"author": "Editors"
},
{
"content": "Katakate (k7) provides self-hosted secure VM sandboxes on Kubernetes using Kata + Firecracker. Code lives under src/k7 (CLI, API, core) and src/katakate (Python SDK). Use this as the source of truth for deep architecture and behavior; the Mintlify docs under docs/ are user-facing.",
"author": "Maintainers"
},
{
"content": "Security is central: VM isolation via Kata/Firecracker + Jailer, default capability drop, non-root options, Seccomp RuntimeDefault, deny-all ingress, optional egress whitelist with DNS allowance. Keep this model explicit and front-and-center.",
"author": "Maintainers"
},
{
"content": "Core flows to document deeply: sandbox lifecycle (create/list/delete), before_script execution and readiness probe, egress lockdown policy generation, metrics fetching via metrics.k8s.io. Implemented in src/k7/core/core.py.",
"author": "Maintainers"
},
{
"content": "API key management (generation, storage, expiry, last_used) lives in src/k7/api/main.py and CLI commands. Keys are stored at /etc/k7/api_keys.json (0600).",
"author": "Maintainers"
},
{
"content": "The API is deployed with Docker Compose using embedded compose/Dockerfile resolved at runtime by K7Core._get_embedded_docker_compose(). Explain kubeconfig override behavior and Cloudflared tunnel URL discovery.",
"author": "Maintainers"
},
{
"content": "Packaging: PyPI ships only src/katakate; CLI/API are packaged as .deb under dist/ via src/k7/cli/build.sh. Do not conflate these paths in installation instructions.",
"author": "Maintainers"
},
{
"content": "Examples and templates live in examples/ (sandbox YAMLs) and tutorials/ (LangChain agent). Reuse when explaining quickstarts.",
"author": "Maintainers"
},
{
"content": "Known issue: Jailer may be ignored by Kata despite configuration; see README Known issues. Mention as caveat.",
"author": "Maintainers"
}
],
"pages": [
{
"title": "Architecture",
"purpose": "Kubernetes + K3s, Kata, Firecracker, Devmapper thin-pool; how components interact (diagrams first)",
"parent": null,
"page_notes": [
{
"content": "Begin the page with a large, single-node diagram: one K3s node on the host OS. Inside it, depict a Kubernetes Pod configured with runtimeClass 'kata' that launches a Kata microVM (Firecracker). Inside the microVM, show the kata-agent, the guest rootfs, and the container root filesystem. Clearly label boundaries: Host OS, Kubernetes, VM boundary (Firecracker), and Container."
},
{
"content": "Show the storage path: container image layers resolved by containerd's devmapper snapshotter into a thin pool of logical volumes (LVs). Each sandbox gets an LV snapshot (thin-provisioned). Explain copy-on-write behavior at the disk block level (blocks are shared until written); memory is not shared across microVMs."
},
{
"content": "Illustrate how the snapshot LV is attached to the Firecracker microVM as a block device and becomes the container rootfs inside the guest. Call out where 'before_script' writes go (into the snapshot)."
},
{
"content": "Add a second, focused diagram for storage only: Image layers -> devmapper thin pool -> per-sandbox snapshot LV (CoW) -> Firecracker drive -> guest mount -> container rootfs."
},
{
"content": "Important: depict only a single node (single K3s). Avoid multi-node cluster visuals for now. Optionally add a small 'Coming soon' note about multi-node."
},
{
"content": "Make it clear the single node can run many sandbox pods concurrently (dozens per node) without drawing them all: add a small annotation/arrow like '... more kata pods' with a brief capacity note."
}
]
},
{
"title": "Katakate Overview",
"purpose": "What K7 is, capabilities, core value, links to docs and repo structure",
"parent": null
},
{
"title": "Installation & Node Setup",
"purpose": "Node requirements, APT install, Ansible-driven installer flow and progress events",
"parent": null
},
{
"title": "CLI Usage",
"purpose": "How to manage sandboxes from nodes; commands: install, create, list, delete, delete-all, logs, shell, top",
"parent": null,
"page_notes": [
{ "content": "Reference src/k7/cli/k7.py for exact options and behaviors." }
]
},
{
"title": "Sandbox Configuration (k7.yaml)",
"purpose": "Explain YAML fields (name, image, namespace, limits, env_file, before_script, egress_whitelist, security flags cap_add/cap_drop, non-root)",
"parent": "CLI Usage"
},
{
"title": "API Overview",
"purpose": "FastAPI service, auth via API keys, error schema, health",
"parent": null,
"page_notes": [
{ "content": "Entry point: src/k7/api/main.py; app title/version from k7.__version__." }
]
},
{
"title": "Authentication & API Keys",
"purpose": "Key creation/list/revoke, storage, expiry, last_used update, headers (X-API-Key or Bearer)",
"parent": "API Overview"
},
{
"title": "API Endpoints",
"purpose": "REST endpoints for sandboxes, exec, metrics, health with request/response shapes",
"parent": "API Overview"
},
{
"title": "API: Sandboxes",
"purpose": "POST /api/v1/sandboxes, GET list/get, DELETE single/all, Location header behavior",
"parent": "API Endpoints"
},
{
"title": "API: Exec",
"purpose": "POST /api/v1/sandboxes/{name}/exec to run commands; response fields",
"parent": "API Endpoints"
},
{
"title": "API: Metrics",
"purpose": "GET /api/v1/sandboxes/metrics; source: metrics.k8s.io; units parsing",
"parent": "API Endpoints"
},
{
"title": "API: Health",
"purpose": "GET /health and root",
"parent": "API Endpoints"
},
{
"title": "Python SDK",
"purpose": "Using katakate Client/AsyncClient to create/list/exec/delete sandboxes; install via pip",
"parent": null,
"page_notes": [
{ "content": "Point to src/katakate/client.py; mirror README examples and types." }
]
},
{
"title": "Security Model",
"purpose": "Explain VM isolation, seccomp, capabilities, non-root modes at pod/container level, network isolation strategy",
"parent": null
},
{
"title": "Network Policies",
"purpose": "Egress whitelist generation + kube-dns allow; deny-all ingress policy created for sandbox label selector",
"parent": "Security Model"
},
{
"title": "Before Script Lifecycle",
"purpose": "How before_script runs inside main container; readiness gating file; log streaming behavior in CLI",
"parent": "Sandbox Configuration (k7.yaml)"
},
{
"title": "Metrics and Monitoring",
"purpose": "How top command parses CPU n/u/m units and memory Ki/Mi/Gi; limitations",
"parent": null
},
{
"title": "Packaging & Releases",
"purpose": "Distribution strategy: PyPI for SDK, Debian for CLI/API; build and install flow",
"parent": null
},
{
"title": "Tutorials",
"purpose": "Walk through LangChain ReAct agent with K7 sandbox tool",
"parent": null
},
{
"title": "Development",
"purpose": "Build from source, API container build/run, repo layout, contribution pointers",
"parent": null
},
{
"title": "Known Issues & Caveats",
"purpose": "Document current limitations (Jailer ignore), roadmap items",
"parent": null
}
]
}
+15
View File
@@ -37,6 +37,10 @@ coverage.xml
# IDE/editor
.vscode/
.idea/
editor-config/plans/
# Temporary files
tmp/
# Work directories (ignore any 'work' dir at any depth)
**/work/**
@@ -44,3 +48,14 @@ debian/prebuilt/
# macOS metadata
**/.DS_Store
# Kata osbuilder (external dependency, cloned by build scripts)
osbuilder/
# Rootfs build artifacts
kata-rootfs/
rootfs-qemu-lh.img*
# SSH keys (temporary baked key, will move to K8s Secrets)
k7-vm-ssh-key
k7-vm-ssh-key.pub
+299
View File
@@ -0,0 +1,299 @@
# Challenges & solutions log
Tracking non-obvious bugs in k7 (the sandbox management layer) and how they
were solved. Same format as k7d-dev's `CHALLENGES.md`.
## 1. `k7 install --backend` extra-var silently clobbered inventory `k7_backends` (spec 18e)
**Symptom:** A 3-node HA install with an inventory declaring
`k7_backends=kfd,kql,k7d` per host would have provisioned only the two Kata
backends — the `k7d` backend would silently disappear from every node.
**Root cause:** `k7 install` always forwarded `k7_backends` as an Ansible
**extra-var** (built from the `--backend` option's *default* value even when
the user never passed `--backend`). Extra-vars have the highest precedence in
Ansible, so the CLI default overrode the per-host inventory var.
**Fix:** `install()` now checks `ctx.get_parameter_source("backend")`; when
the user supplied `-i <inventory>` without an explicit `--backend`, the
`k7_backends` extra-var is dropped so the inventory wins. An explicit
`--backend` alongside `-i` prints a warning that it overrides the inventory.
**Reference:** none (Ansible variable-precedence rules).
**Time lost:** caught in pre-flight review (spec 18e Phase 0), ~1 hour of
code reading. Would have cost a full reset+reinstall cycle if it had shipped.
---
## 2. Longhorn StorageClass `numberOfReplicas` parameter makes `default-replica-count` a no-op (spec 18e)
**Symptom:** `bench_docker_perf.py`'s r1/r2 legs patched Longhorn's
`default-replica-count` setting — but sandbox volumes kept the replica count
baked into the `longhorn` StorageClass (`numberOfReplicas: "3"` on the HA
cluster). The r1/r2/r3 benches would all have silently measured the same
replica count.
**Root cause:** Longhorn only consults the `default-replica-count` setting
when the StorageClass has **no** `numberOfReplicas` parameter. k7's install
playbook pins the parameter in the SC (topology-aware SC from the
`longhorn-storageclass` ConfigMap), so the setting never applies to k7
volumes.
**Fix:** the bench now patches `spec.numberOfReplicas` on the sandbox's
Longhorn **Volume CRs** after creation (`_set_sandbox_volume_replicas`),
waits until exactly N running replicas exist, and records the replica → node
placement in the log header as proof.
**Reference:** Longhorn docs (volume-level replica count update).
**Time lost:** ~1 hour. The old benches "passed" — the mismeasurement was
invisible without checking actual replica CRs.
---
## 3. k7d VM operations are node-local; multi-node scheduler breaks pause/fork tests (spec 18e)
**Symptom:** On the 3-node cluster, `test_k7d.py` pause/fork tests failed
with `sandbox X runs on node k7-node-02, but this k7 process runs on
k7-node-01; k7d VM operations must run on the pod's node`.
**Root cause:** k7d pause/resume/fork go through the **node-local**
`/run/k7d/k7d.sock`; cross-node VM ops are explicitly out of scope (k7d spec
9a M12) and core fails loudly on the mismatch. On a single-node cluster the
tests never noticed; with 3 schedulable nodes the sandbox lands anywhere.
**Fix:** tests that exercise VM ops pin their sandboxes with
`SandboxConfig(node_name=os.uname().nodename)` — the field that exists
precisely for host-side-inspection tests. The centralized-API implication
(k7-api pod can only pause/fork k7d sandboxes co-located on the first
master) is recorded as a release-readiness limitation.
**Reference:** k7d spec 9a M12 (cross-node fork out of scope).
**Time lost:** ~30 min (the loud error message made it easy).
---
## 4. kql fork loses guest writes made just before the fork (crash-consistency)
**Symptom:** `test_api.py::test_sdk_pause_resume_fork_round_trip` flaked on
the HA cluster: a file written via exec seconds before `fork()` did not
exist in the fork (`cat: can't open '/mnt/state/marker'`). The near-identical
`test_qemu.py::test_fork_clones_data` data check passed in the same run —
pure timing luck.
**Root cause:** the kql fork path cuts a **block-level** Longhorn
VolumeSnapshot of the source's root PVC. That is only crash-consistent: guest
writes still sitting in the VM's page cache are not on the block device yet
and are missing from the clone.
**Fix:** `fork_sandbox` now execs `sync` in the source sandbox before
creating the snapshot (only when the deployment has ready replicas — a
paused source has no writers), failing loudly if the flush fails.
**Reference:** none (standard crash-vs-application consistency).
**Time lost:** ~1 hour including re-runs. Note: *named* snapshots of live
sandboxes (`k7 snapshot`) remain crash-consistent by design — documented
behavior, unchanged.
---
## 5. NVMe enumeration swaps across reboots — hardcoded `k7_devmapper_disk` hit the OS disk
**Symptom:** The second full reset+reinstall loop of spec 18e failed on
k7-node-03: `Device '/dev/nvme1n1' has partitions; wipe it with
utils/wipe-disk.sh or choose another disk`. The identical inventory had just
worked on the first loop.
**Root cause:** Linux NVMe controller enumeration (`nvme0n1` vs `nvme1n1`)
is not stable across reboots. After the second reset, node-03 booted with
its OS on the disk now enumerated `nvme1n1`, and the raw spare as
`nvme0n1` — the inventory's hardcoded `k7_devmapper_disk=/dev/nvme1n1`
pointed at the OS disk. The playbook's safety checks caught it (fail-loud
worked as designed).
**Fix:** omit `k7_devmapper_disk` on identical dual-NVMe boxes — the
playbook's auto-detect ("first empty, non-removable, non-root whole disk")
is enumeration-proof. `inventory.ini.example` now documents this.
**Reference:** none (kernel device-naming behavior).
**Time lost:** ~30 min (one wasted install attempt + one extra
reset+reinstall loop of all three nodes).
---
## 6. Orphaned Firecracker microVMs leak on pod deletion and each burns a full CPU core
**Symptom:** During spec 18e Phase 3, the `k7-ql-r2` bench leg started
failing mid-run with `Pod is not running (status: Pending)` and the
`k7-ql-r3` leg failed entirely; longhorn-manager / cilium-envoy /
coredns readiness probes were flapping cluster-wide. `k7-node-03` had a
load average of ~21.
**Root cause:** 14 orphaned `/firecracker` processes (2 on node-01, 2 on
node-02, 10 on node-03) whose pods had been deleted hours earlier —
zero live Kata pods existed cluster-wide. Each orphan spun at ~97% CPU
(TIME ≈ ETIME in `ps`), starving Longhorn/Cilium/CoreDNS and the bench
sandbox itself. The kata-fc shim intermittently fails to kill the
microVM on pod deletion under parallel pod churn (~14 leaks over ~30
kfd pod deletions that day). Evidence:
`/tmp/leaked-firecracker-vms.txt` (agent run artifact).
**Fix (remediation):** verified no live Kata pods, then `pkill -9
firecracker` on all three nodes; loads recovered and the r2/r3 bench
legs were re-run green. **Root-cause fix still open** — tracked as a
release blocker in spec 18f-release-blockers (investigate
containerd-shim-kata-v2 / jailer cleanup path; add a leak-detection
integration test that asserts zero firecracker processes after suite
teardown).
**Reference:** none yet (kata-containers shim lifecycle).
**Time lost:** ~1.5 hours (failed bench legs + diagnosis + re-run).
---
## 7. Remote test loop tied to the SSH session died mid-run (Broken pipe)
**Symptom:** A multi-suite pytest loop launched over plain `ssh host 'for
f in ...; do pytest ...; done'` died silently when the SSH connection
dropped (`client_loop: send disconnect: Broken pipe`) — the remote shell got
SIGHUP'd between suites.
**Root cause:** the remote loop was a child of the SSH session; NAT idle
timeouts kill long-lived connections even with keepalives.
**Fix:** write the loop to a script on the node and launch it with
`setsid nohup ... < /dev/null &`, then poll a progress file. (Same class of
issue `utils/run-integration-tests.sh` already documents for its keepalive
settings.)
**Reference:** none.
**Time lost:** ~20 min (one interrupted suite sequence, `test_restore` had
finished right before the drop).
---
## 8. Firecracker leak root cause: `jailer --daemonize` makes the kata shim signal a dead PID (spec 18f)
**Symptom:** Follow-up to #6. Reproduced at will on the 18f run: create a
naked kfd pod (`sleep` workload), delete it — the pod terminates cleanly
but its `/firecracker` process survives with PPID 1 and climbs to ~100%
CPU. Two out of two attempts leaked. Shim logs at 18e leak time showed
`Agent did not stop sandbox: Dead agent` + `failed to ping agent:
CheckRequest timed out`.
**Root cause:** kata 3.24.0 `virtcontainers/fc.go`. When jailed (spec 8a
enabled the jailer), `fcInit` launches `jailer --daemonize`, which
double-forks — firecracker reparents to init immediately, and
`fc.info.PID = cmd.Process.Pid` records the **jailer's** PID, which is
already dead. `fcEnd()` then calls `WaitLocalProcess(pid, …, SIGTERM)` on
that stale PID: a no-op. The VMM normally exits because the in-guest agent
shuts the VM down; whenever that graceful path fails (dead/hung agent under
churn, wedged guest IO), nothing ever kills the firecracker process. The
`getting vm status failed … firecracker.socket: no such file or directory`
error seen at every kfd VM boot is a side effect of the same daemonize
handling (the shim polls the jailed API socket path before it exists) and
is harmless noise.
**Fix:** upstream fix belongs in kata (record the real VMM PID when
jailed). In k7: (a) `k7 install` now deploys a per-node systemd timer
`k7-vmm-reaper.timer` (1 min cadence) that SIGKILLs firecracker processes
whose 32-hex `--id` matches no live `containerd-shim-kata-v2 … -id`
(a live jailed firecracker always has PPID 1, so parentage cannot be used)
and qemu processes reparented to init; (b)
`tests/integration/test_zz_leaks.py` runs last in the suite and asserts
every node's VMM process count equals its live Kata pod count via hostPID
scan pods.
**Reference:** kata-containers `src/runtime/virtcontainers/fc.go`
(`fcInit`/`fcEnd`), firecracker jailer docs (`--daemonize`).
**Time lost:** ~1.5 h (live repro + kata source dive), on top of the ~1.5 h
in #6.
---
## 9. Cilium `matchPattern` `*` never crosses label boundaries — `*.docker.com` silently misses CDN blob hosts (spec 18f)
**Symptom:** `docker pull` inside a sandbox with
`--egress '*.docker.io' --egress '*.docker.com' --egress docker.io
--egress '*.cloudfront.net'` fetches the manifest fine but times out
downloading blobs (`dial tcp 108.156.22.x:443: i/o timeout`), even though
`cilium fqdn cache list` shows `production.cloudfront.docker.com` being
learned. Hubble showed the SYNs `Policy denied DROPPED` with the CloudFront
IPs still carrying identity `world`; `cilium ip list` had `fqdn:*.docker.io`
entries (single-label subdomain `registry-1`) but nothing for the blob host.
**Root cause:** in Cilium's FQDN `matchPattern` grammar
(`pkg/fqdn/matchpattern`), `*` expands to `[-a-zA-Z0-9_]*` — DNS characters
within a **single label**. `production.cloudfront.docker.com` therefore
does not match `*.docker.com` (and it is not under `cloudfront.net` at
all, so that entry never helped). The multi-label subdomain wildcard is the
non-obvious `**.` prefix form. Not a Cilium bug — a semantics trap between
k7's documented "wildcards like *.huggingface.co" UX and Cilium's grammar.
**Fix:** `K7Core._apply_cilium_egress_policy` now translates a leading `*.`
into `**.` (explicit `**.` and mid-label wildcards pass through). Verified
live: the same pull that timed out for 2m40s completes in ~8s. Also set
Cilium `dnsProxy.minTtl=3600` at install: CDN DNS TTLs are 30–60s while
dockerd's blob downloader keeps dialing its cached IP for minutes, so with
`minTtl=0` the learned FQDN→identity mapping can expire mid-download.
Integration coverage: `test_docker_pull_through_fqdn_whitelist`.
**Reference:** cilium `pkg/fqdn/matchpattern/matchpattern.go`
(`escapeRegexpCharacters`), Cilium docs "DNS based" policies.
**Time lost:** ~2 h (repro, hubble/ipcache/fqdn-cache spelunking, a wrong
first hypothesis on TTL expiry that the live test disproved).
## 10. kql-r3 dind IO wedge: single-threaded virtiofsd starves the kata-agent health ping (spec 18g)
**Symptom:** A kql (kata-qemu-longhorn) sandbox with the docker sidecar,
running the spec-10b `run_io` workload (2k files + 512 MB `dd conv=fsync`)
on an r=3 Longhorn volume, would intermittently (~50% per rep) "wedge":
exec 500s, both containers restarted, `Pod sandbox changed, it will be
killed and re-created`. First seen 2/2 in the spec-18e bench (`run_read`
unmeasurable on r3).
**Root cause:** Not the guest, not dockerd, not Longhorn. A `dmesg -c` +
`/proc/meminfo` stream running inside the guest right through the death
showed a healthy VM (load 0.4, 1.5 GB free, zero dirty/writeback, no OOM,
no hung tasks) — the last log lines were normal container veth setup. The
containerd log on the node had the smoking gun: floods of
`ttrpc: received message on inactive stream`, then
`failed to ping agent: CheckRequest timed out` → `Dead agent` →
`sandbox stopped unexpectedly` — the **kata shim killed a healthy VM**.
Kata's default virtiofsd runs `--thread-pool-size=1`, so ALL virtio-fs IO
(container rootfs + the Longhorn-PVC `/var/lib/docker`) serializes through
one thread. `docker run` on a vfs-driver dind copies the whole ~790 MB
image rootfs and then fsyncs 512 MB through that single thread against an
r=3 volume (60–80 s saturated). Agent RPCs that touch virtio-fs queue
behind the convoy; the shim's health ping starves and it declares the
agent dead. r≥2 matters only because Longhorn write amplification makes
the convoy long enough to exceed the ping deadline.
**Misdiagnoses ruled out on the way:** guest memory sizing (MemAvailable
1.8 GB throughout), vCPU count (4-vCPU guest wedged *faster*), Longhorn
backpressure/faults (volume `attached healthy`, no rebuilds), kubelet
exec-probe pressure (relaxing `docker info`/`true` probe timeouts from the
kubelet default 1 s reduced cancelled-ttrpc noise but did NOT stop the
kill).
**Fix:** playbook now sets
`virtio_fs_extra_args = ["--thread-pool-size=16", "--announce-submounts"]`
in `configuration-qemu.toml` (kata reads it per sandbox start, no restart
needed). Verified on the live 3-node HA cluster: 6/6 run_io reps +
run_read (~45 s) with zero VM restarts on the same r=3 volume. The probe
relaxations in `core.py` were kept as well (less cancelled-exec churn on
the shim↔agent ttrpc channel).
**Reference:** kata-containers virtiofsd integration (default
`--thread-pool-size=1`), virtiofsd docs on request queueing.
**Time lost:** ~3 h (bench-faithful repro, guest-side dmesg/meminfo
streaming, two disproven hypotheses, virtiofsd A/B).
+44
View File
@@ -0,0 +1,44 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] — 2026-08-11
First public release. Ships the CLI/API deb and PyPI `k7-sdk`.
The API image is built on the node by the install playbook; a prebuilt GHCR
image and the apt/PPA story are fast follow-ups.
### Added
- **Multiple sandbox backends** on one install / cluster — pick per sandbox
or specialize nodes:
- `kata-firecracker-devmapper` (`kfd`) — Firecracker + jailer + LVM
thin-pool
- `kata-qemu-longhorn` (`kql`) — **QEMU** via Kata + Longhorn PVC root
(named snapshots, restore, disk-only fork)
- `k7d` — Katakate Rust VMM / `runtimeClassName: k7` (warm CoW fork;
install via artifact URL / `--k7d-artifact` until the public
`Katakate/k7d` release is live)
- Multi-node / HA install (Ansible inventory, Longhorn topology)
- Cilium CNI with FQDN egress (`CiliumNetworkPolicy`)
- API + SDK parity for pause / resume / fork
- Snapshot lifecycle + GC CronJob; restore from VolumeSnapshot (`kql`)
- CLI talks to the API by default (`k7 api`, `k7 dev api rebuild`)
- Docker-in-VM sidecar + performance bench harness
- Firecracker jailer integration
- Python SDK published as **`k7-sdk`** (`k7_sdk`; `katakate` deprecated)
### Changed
- Node-local ops removed from `K7Core` (API/agent split)
## [0.0.3]
- Debian package packaging fixes (GHCR image name casing)
## [0.0.1]
- Initial tagged release
+58 -26
View File
@@ -1,39 +1,71 @@
# Contributing to K7
Thanks for your interest in contributing!
Thanks for your interest in contributing.
We chose a lean and minimal approach to make development on this project as simple as possible:
- Infra is handled by a single Ansible playbook
- The CLI and API are implemented respectively with Typer and FastAPI in Python, for simplicity. They both wrap over the same `core` module.
K7 stays lean on purpose:
## Project Direction
- Infra is a single Ansible playbook (`src/k7/deploy/`)
- CLI (Typer) and API (FastAPI) both wrap the same `core` module
- Tooling: **uv**, **ruff**, **ty**, **pytest**
Check out the [ROADMAP.md](ROADMAP.md) to see planned features, current priorities, and long-term goals. It's a great starting point if you're looking for areas to contribute!
## Project direction
## Repo Layout
- `src/k7/` CLI, core logic, API server
- `src/katakate/` Python SDK (published to PyPI as katakate)
- `src/k7/deploy/` Ansible playbook to install node
- `utils/` helper scripts
See [`ROADMAP.md`](ROADMAP.md) for priorities. Near-term focus is the
public release pipeline (PPA / GHCR / PyPI) after `Katakate/k7d` is
published.
## Repo layout
- `src/k7/` — CLI, core, API, Ansible playbook
- `src/k7_sdk/` — Python SDK (PyPI: **`k7-sdk`**)
- `src/katakate/` — deprecated import shim → `k7_sdk`
- `tests/` — unit + integration
- `utils/` — helper scripts
- `docs/BACKENDS.md` — backend comparison (full docs: https://docs.katakate.org)
## Packaging
- The root Python packaging (`setup.py` and `MANIFEST.in`) builds the `katakate` SDK for PyPI only.
- Assets under `src/k7/` (including `src/k7/deploy/*`) are not included in the PyPI package; they are used by the Debian/CLI packaging flow.
## Code Style
- Python: PEP8, explicit types for public APIs, early returns, no inline comments
- Lint/format with Ruff:
- Install: `pip install ruff`
- Check: `ruff check src`
- Format: `ruff format src`
- Root packaging (`setup.py`) builds the **`k7-sdk`** wheel for PyPI.
- CLI / playbook assets ship via the Debian package / install path, not
the PyPI SDK package.
## Building
- CLI deb helpers live in `src/k7/cli/` scripts
- Make targets may be available: `make` to list
## Code style
- Python: PEP 8, explicit types on public APIs, early returns
- Lint / format with Ruff via uv:
```bash
uv run ruff check src/ tests/
uv run ruff format src/ tests/
# or: make lint
```
Typecheck: `make typecheck`. Unit tests: `make test`.
## Fast CLI iteration (`dev.sh`)
Avoid `make build && make install` while hacking the CLI:
```bash
./src/k7/cli/dev.sh --help
./src/k7/cli/dev.sh list
./src/k7/cli/dev.sh create --name test --image alpine:latest
```
Same flags as the installed `k7` binary. For playbook / core / API
changes, escalate: `dev.sh` → `make test-integration-remote` →
`make build && make install` on a Linux node. Stack targets Linux x86;
do not deploy or run the full stack on macOS ARM.
## Releases
- Bump versions in `src/k7/__init__.py` and `src/katakate/__init__.py`
- Tag `vX.Y.Z` to build artifacts (CI may publish .deb and wheels)
## Reporting Issues
- Include steps, expected vs actual, logs, and environment (arch/OS/hardware)
- Keep versions aligned in `src/k7/__init__.py`, `src/k7_sdk/__init__.py`,
`setup.py`, `pyproject.toml`, and `debian/changelog`
- Tag `vX.Y.Z` once the public release pipeline is live
- See [`CHANGELOG.md`](CHANGELOG.md)
## Reporting issues
Include steps, expected vs actual, logs, and environment (OS, arch,
backend: `kfd` / `kql` / `k7d`, single- vs multi-node). Security reports:
see [`SECURITY.md`](SECURITY.md).
+1 -1
View File
@@ -187,7 +187,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [2025] [katakate.org]
Copyright 2026 Gary Becigneul
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+1
View File
@@ -1,5 +1,6 @@
include LICENSE
include README.md
recursive-include src/k7/deploy/manifests *.yaml
# Exclude common junk
global-exclude __pycache__ *.py[cod] *.so *.dylib *.dll .DS_Store .idea .vscode
+49 -5
View File
@@ -8,7 +8,13 @@ SUDO := $(shell command -v sudo >/dev/null 2>&1 && [ "$$(id -u)" -ne 0 ] && echo
BUILD_SCRIPT := src/k7/cli/build.sh
INSTALL_SCRIPT := src/k7/cli/install.sh
.PHONY: help build install uninstall api-build-local api-run-local
# All shell scripts for linting
SH_FILES := $(shell find src/ utils/ rootfs-build/ -name '*.sh' 2>/dev/null)
# Ansible playbooks
ANSIBLE_PLAYBOOKS := src/k7/deploy/k7-install-node.yaml
.PHONY: help build install uninstall api-build-local \
lint lint-shell lint-ansible typecheck test test-integration test-integration-remote rsync-all check
help: ## Show this help message
@echo "Available targets:"
@@ -32,7 +38,45 @@ api-build-local: ## Build the API container locally (dev tag)
@echo "Building local API image: k7-api:dev"
docker build -f src/k7/api/Dockerfile.api -t k7-api:dev .
api-run-local: ## Run API using the local image (no pull)
@echo "Starting API with local image (k7-api:dev)"
docker pull cloudflare/cloudflared:latest || true
K7_API_IMAGE=k7-api K7_API_TAG=dev k7 start-api --yes
# ── Lint ──────────────────────────────────────────────────────────
lint: ## Lint & format-check Python code (ruff)
uv run ruff check src/ tests/
uv run ruff format --check src/ tests/
lint-shell: ## Lint shell scripts (bash -n + shellcheck)
@echo "==> bash -n syntax check"
@for f in $(SH_FILES); do bash -n "$$f" || exit 1; done
@echo "==> shellcheck"
shellcheck -S warning $(SH_FILES)
lint-ansible: ## Lint Ansible playbooks (ansible-lint)
uv run ansible-lint --profile basic $(ANSIBLE_PLAYBOOKS)
# ── Typecheck ─────────────────────────────────────────────────────
typecheck: ## Type-check Python code (ty)
uv run ty check src/k7
# ── Test ──────────────────────────────────────────────────────────
test: ## Run unit tests (pytest, excludes integration)
uv run pytest
test-integration: ## Run integration tests (requires live k7 node)
uv run pytest -m integration
test-integration-remote: ## Run integration tests on remote k7 node via SSH (set K7_NODE_IP)
@echo "Run integration tests on the node itself: rsync this repo there, then 'make test-integration'." >&2; exit 1
rsync-all: ## Rsync repo to all nodes (K7_NODE_IPS=ip1,ip2,ip3)
@IFS=',' read -ra IPS <<< "$${K7_NODE_IPS:-$${K7_NODE_IP:?set K7_NODE_IP to your node IP}}"; \
for ip in "$${IPS[@]}"; do \
echo "==> Syncing to $${K7_NODE_USER:-root}@$$ip"; \
rsync -az --delete \
--exclude .venv --exclude .git --exclude __pycache__ \
--exclude '*.pyc' --exclude .ruff_cache --exclude .pytest_cache \
--exclude .mypy_cache --exclude .coverage --exclude uv.lock \
-e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i $${SSH_PRIVKEY:-$$HOME/.ssh/id_ed25519}" \
./ "$${K7_NODE_USER:-root}@$$ip:/root/k7/"; \
done
# ── Combined ──────────────────────────────────────────────────────
check: lint lint-shell lint-ansible typecheck test ## Run all lints + typecheck + unit tests
+250
View File
@@ -0,0 +1,250 @@
# Performance
## Backend lifecycle: kql vs k7d — 2026-08-10 (spec 9a M11)
Hetzner AX41 dedicated node (Ryzen 5 3600, 64 GiB, NVMe), Ubuntu 24.04,
kernel 6.8.0-137, k3s v1.36.3, flannel CNI, Longhorn 1.10 (r=1), k7d 0.1.0.
Single node, interleaved runs, `alpine:3.20` sandboxes; median of 3
(sidecar legs n=1). Raw samples in the JSON the bench writes. Reproduce
on a node with both backends installed:
```bash
K7_BENCH_BACKENDS=kata-qemu-longhorn,k7d K7_BENCH_REPS=3 \
uv run pytest -m bench tests/integration/bench_backend_lifecycle.py -v -s
```
| Operation | kql (kata-qemu-longhorn) | k7d | notes |
|-----------|--------------------------|-----|-------|
| create → pod Ready | 17.11 s (15.05–17.13) | **2.13 s** (2.13–2.16) | cold boot; kql pays Longhorn PVC provision + QEMU boot |
| exec round-trip | **0.04 s** (0.03–0.04) | 0.12 s (0.12–0.13) | k7d exec bridges through vsock |
| named snapshot ready | 6.51 s (2.72–6.52) | n/a | Longhorn-only by design; k7d rejects `k7 snapshot` loudly |
| fork (API call) | 7.78 s (7.77–7.80) | 2.15 s (2.14–2.16) | kql: Longhorn snapshot + PVC clone; k7d: CoW disk+memory `fork_vm` |
| **fork → forked pod Ready + exec** | 46.66 s (46.45–46.67) | **2.37 s** (2.37–2.38) | **~20×**: kql cold-boots a VM on the cloned disk; the k7d fork *inherits the source's live memory* (tmpfs, processes, page cache) |
| pause effective | 1.31 s (1.29–1.90) | **0.20 s** (0.20) | kql: scale-to-0, pods terminated; k7d: vCPUs frozen in place |
| resume → exec answers | 4.11 s (2.10–9.19) | **0.34 s** (0.33–0.38) | kql: reschedules pod + VM boot; k7d: restart vCPU loop, memory intact |
| delete | 0.05 s | 0.04 s | |
| docker sidecar: create → `docker info` | 21.11 s | **8.60 s** | dind in the same VM (k7d) / pod (kql) |
| docker sidecar: `docker pull alpine:3.21` | **2.35 s** | 8.21 s | **kql wins**: its dind unpacks onto the Longhorn ext4 volume, k7d's dind data dir sits on guest tmpfs behind virtio + NAT — pull streaming is the k7d sidecar's slow path today |
| docker sidecar: `docker run --rm alpine echo` | 0.91 s | **0.33 s** | |
Honest summary: k7d dominates every lifecycle operation (create ~8×,
fork-to-usable ~20×, pause ~6×, resume ~12×) and is the only backend whose
fork carries **memory state** — the forked sandbox resumes mid-thought
instead of cold-booting. kql keeps two real advantages: named snapshots
that persist after the sandbox dies (restore later, GC, cross-pod
persistence) and faster registry pulls inside the docker sidecar. Plain
exec is also ~3× faster on kql (80 ms absolute difference; both are
interactive-fast).
## Kata-qemu-longhorn backend baseline (2026-04-10)
Observed latencies on a single Hetzner dedicated node (3x NVMe, Ubuntu 24.04, Longhorn replicas=1).
| Operation | Latency | Limit |
|-----------|---------|-------|
| Cold create to pod ready | 15.20s | — |
| Snapshot ready | 6.98s | < 60s |
| Pause (scale to 0) | 0.13s | — |
| Resume (scale to 1 + pod ready) | 4.54s | < 60s |
| **Fork (total)** | **44.85s** | < 120s |
| — snapshot | 2.97s | |
| — clone PVC bound | 2.71s | |
| — deployment ready (pod + VM boot) | ~39.17s | |
Fork is ~3x slower than a cold create. The snapshot and clone PVC steps add ~6s, but the main cost is the forked deployment's VM boot (~39s vs ~15s for a fresh PVC) — likely due to Longhorn replaying cloned data on first attach.
Measured via integration tests (`tests/integration/test_qemu.py`) on 2026-04-10.
## Docker workloads inside sandboxes — 2026-05-29 (spec 10b)
Hetzner AX52 dedicated node (Ryzen 7 7700, 64 GiB, NVMe), Ubuntu 24.04,
kernel 6.8.0-100, k3s v1.35.5, docker host 29.5.2 / sandbox 27.5.1,
Longhorn 1.10. 3 runs per cell after one warm-up; median (range in parens).
⚠ marks cells whose (max−min)/median > 0.30 (range noise gate).
Workload is `bench/docker-perf/bench.Dockerfile`: pull `debian:12-slim`, a
no-cache build that does `apt install build-essential python3 git ca-certificates`,
`pip install numpy pandas requests pytest httpx pydantic`, a 256 MB
`dd ... conv=fsync`, and `pip check`. Run phase exercises a 10-s CPU loop,
a 2 000-small-files + 512 MB-fsync IO workload, and a cat-the-venv-tree
read workload. Reproduce with:
```bash
K7_BENCH_ENVS=host,k7-fd,k7-ql-r1,k7-ql-r2 K7_BENCH_REPS=3 \
K7_BENCH_OUT=/tmp/bench-out \
uv run pytest -m bench tests/integration/bench_docker_perf.py
uv run python bench/docker-perf/render.py -i /tmp/bench-out/bench-results-*.csv \
--title "Docker workloads ..." --hardware "<one-line note>"
```
| Operation | host | k7-fd | k7-ql-r1 | k7-ql-r2 |
|---------------------------|------|-------|----------|----------|
| pull debian:12-slim | 3.06 s (3.00 s–3.06 s) | 2.78 s (2.77 s–2.83 s) | 3.66 s (3.62 s–3.67 s) | 3.64 s (3.62 s–3.81 s) |
| build (no-cache) | 53.2 s (53.2 s–53.8 s) | 26.0 s (25.0 s–30.2 s) | 72.3 s (72.0 s–72.8 s) | 68.7 s (68.6 s–71.6 s) |
| build (cached) | ⚠ 714 ms (710 ms–936 ms) | 250 ms (250 ms–291 ms) | ⚠ 690 ms (469 ms–4.49 s) | ⚠ 448 ms (436 ms–663 ms) |
| run cpu (10s budget) | 10.6 s (10.5 s–10.6 s) | 10.2 s (10.2 s–10.2 s) | 27.6 s (26.0 s–29.7 s) | 27.3 s (25.9 s–29.1 s) |
| run io (2k small + 512 MB)| ⚠ 1.14 s (1.08 s–1.68 s) | 485 ms (475 ms–593 ms) | 37.3 s (37.2 s–37.7 s) | 37.4 s (36.9 s–37.6 s) |
| run read (venv tree cat) | 992 ms (989 ms–1.02 s) | 225 ms (213 ms–235 ms) | 16.4 s (16.3 s–16.9 s) | 16.4 s (16.4 s–16.9 s) |
Ratios to host (the number that matters for the sandbox tax story):
| Cell | k7-fd / host | k7-ql-r1 / host | k7-ql-r2 / host |
|---------------------------|--------------|-----------------|-----------------|
| build (no-cache) ratio | 0.49× | 1.36× | 1.29× |
| run io ratio | 0.42× | 32.6× | 32.7× |
| run cpu ratio | 0.96× | 2.62× | 2.59× |
### What this says
**k7-fd (Firecracker, docker daemon on emptyDir) is *faster* than the
host** on every operation that touches disk — `build (no-cache)` 0.49×,
`run io` 0.42×, `run read` 0.23×. CPU is host-parity (0.96×).
Why faster, when the FD path strictly has more layers (Firecracker VM +
kata virtio-fs sharing the emptyDir from `/var/lib/kubelet/.../sidecar-data/`
into the guest, vs the host docker daemon going straight to ext4 on
`/dev/md2`)? Four contributing factors, in roughly decreasing order of
size:
1. **Storage driver delta.** The host docker daemon picks `overlayfs`
(the legacy single-layer overlay implementation that ships with the
host kernel). The FD sandbox's docker daemon picks `overlay2`. On
metadata-heavy workloads — apt extracts ~5 600 files, pip+venv
extracts another few thousand — overlay2 is significantly faster
than overlayfs (different layer-handling code path, better
d_type/userxattr behaviour). The whole "build (no-cache)" column is
apt+pip metadata churn, which is exactly where this difference bites.
2. **Clean cache state.** The FD sandbox starts with an empty
`/var/lib/docker`. The host docker daemon has accumulated state from
prior `k7 install` builds, k3s-imported images, and the bench's own
warm-up — manifest lookups, dangling layer GC, layer dedup all run
on a populated tree.
3. **virtio-fs writeback caching.** Kata-fc shares the emptyDir into
the guest via virtio-fs (the default for shared filesystem mounts;
the alternative virtio-blk would require an explicit pod annotation
for "direct block device" which we don't set). virtio-fs runs a
writeback cache in virtiofsd on the host. Inside the guest VM,
`dd if=/dev/zero of=/tmp/x bs=1M count=256 conv=fsync` measured
1.3 GB/s — the fsync completes when virtiofsd acks, not necessarily
when the data is durable on NVMe. This is a weaker fsync than the
host gets directly on ext4. It's the same trade-off `cache=writeback`
gets you in qemu/9pfs setups: faster, less crash-safe.
4. **Devmapper snapshotter on the VM rootfs.** The Firecracker VM's
rootfs is a thin-provisioned LVM volume (containerd devmapper
snapshotter). The `dd` and IO ops the bench runs go through the
sandbox container's rootfs (devmapper) when writing inside the
docker daemon's overlay2 upperdir, which itself is on the emptyDir
(virtio-fs). LVM thin pools are read-cached aggressively at the
page-cache layer — read-heavy ops like `run read` (cat the venv
tree) benefit from page cache hits on the *host* even when the
guest thinks it's doing fresh reads.
**Net of all of this:** k7-fd's 0.4–0.5× ratios *do* reflect a real
performance win for build/dev workloads on this hardware, but it would
be wrong to attribute the win to "VM is faster than bare metal".
Honest framing: *host docker is using a slow storage driver against a
populated daemon, and the FD sandbox is using a fast storage driver
against a clean daemon, with virtio-fs writeback caching softening the
guest's fsync semantics*. A cleaner future bench would (a) run host
docker with `--storage-driver=overlay2` and a fresh `/var/lib/docker`,
(b) note the virtio-fs cache mode explicitly, and (c) report the
device-level bandwidth so the absolute numbers are anchorable.
**k7-ql (qemu, docker daemon on a Longhorn PVC sub_path) pays a steep
storage tax** — `build (no-cache)` 1.3×, `run cpu` 2.6×, `run io` ~33×,
`run read` ~16×. Two things stacked here: (1) docker auto-picks the **vfs**
storage driver inside the qemu VM because Longhorn's iSCSI-attached block
device doesn't expose the filesystem features overlay2 wants; vfs copies
entire layer trees on every operation, so cached-build and image-layer
work get hit hard, and (2) reads/writes go disk → iSCSI → qemu virtio →
guest, where the host went directly to the page cache. The `run io`
column (~37 s for 2 000 small files plus a 512 MB fsync) is the worst-case
shape — pure disk-bound work with no compute.
**r=2 vs r=1 is statistically indistinguishable** (build 68.7 s vs 72.3 s,
io 37.4 s vs 37.3 s). The second Longhorn replica adds one cross-node
sync but the dominant cost on this hardware is vfs inside the guest, not
the replica copy on the wire. Useful negative result — picking r=2 for
durability does not double the cost on these workloads.
### Known confounds
- **Storage driver mismatch (the big one).** Host uses overlayfs; k7-fd
uses overlay2 on a virtio-fs-shared emptyDir; k7-ql uses vfs. Every
cross-env comparison is also a "different docker storage driver"
comparison. We cannot cleanly isolate "VM tax" from "driver tax"
without rebuilding the docker daemon image inside the guest with a
matching driver, which the spec deliberately doesn't attempt.
- **Asymmetric daemon state.** Host docker has accumulated images,
layers, and dangling refs from prior `k7 install` runs. Each sandbox
starts with a fresh daemon. We're partly measuring "warm vs cold
daemon".
- **virtio-fs writeback caching changes fsync semantics.** Guest fsync
acks when virtiofsd has the data, not when NVMe has it. Compare the
k7-fd dd fsync (1.3 GB/s) with the host's bare dd fsync (not
benchmarked here — would need a follow-up rep).
- **Build noise.** `build (cached)` is sub-second everywhere and lands
in the noise floor; the ⚠ flags on host and k7-ql-r1 are real
variance (4.5 s outlier on k7-ql-r1 rep 3) but not a signal about the
backend — they're "this op is too fast to time meaningfully with 3 reps."
- **Same physical disk for everything.** k7-fd's `/var/lib/docker`
emptyDir (which lives as a regular directory under
`/var/lib/kubelet/pods/.../volumes/kubernetes.io~empty-dir/` on the
host ext4 root), k7-ql's Longhorn PV, and the host's docker root all
live on the same `/dev/md2` (RAID1 NVMe pair). We're not measuring
cross-disk effects. Note: emptyDir without `medium: Memory` is *not*
tmpfs — it's a plain directory on the kubelet root FS.
- **Single-node sample.** Bench was driven on `k7-node-01` only. r=2's
cross-node sync went to `k7-node-02` but the workload pod stayed on
the primary. A future spec could pin pods to different nodes to also
exercise the read-from-remote-replica path.
Raw per-leg logs and the aggregated CSV are under `bench/docker-perf/results/`
(gitignored — keep them in agent or local scratch space, paste into the
table above when adding a new run).
## Docker-in-VM under real Longhorn replica counts (2026-08, spec 18e/18h)
The "r=2 vs r=1 indistinguishable" result above is **invalid**: the old
bench patched a Longhorn *setting* that only affects newly-created volumes,
so both legs actually ran r=1 (CHALLENGES.md #2). `bench_docker_perf.py` now
sets real per-volume replica counts (`k7-ql-r3` leg). Host / kfd / r1 / r2
medians from the 18e HA run (5 reps); **k7-ql-r3 column re-filled in
spec 18h** after the virtiofsd wedge fix (5 reps, zero VM restarts):
| op | host | k7-fd | k7-ql-r1 | k7-ql-r2 | k7-ql-r3 |
|---|---|---|---|---|---|
| build no-cache | 76.4s | 52.5s | 108.2s | 270.9s | 292.5s |
| run io (2k files + 512MB fsync) | 1.43s | 1.02s | 41.4s | 68.4s | 88.3s |
| run read (venv tree cat) | — | — | — | — | 47.7s |
| run cpu (10s budget) | 10.5s | 10.4s | 41.5s | 47.8s | 56.4s |
Two takeaways (spec 18f issue 8 / 18h):
- **The r1→r2 jump dominates the redundancy cost** (build 108→271s; r2→r3
adds only ~8% on build): the second replica forces synchronous
cross-node writes, the third mostly parallelizes with them. Post-wedge
r3 `run io` (88.3s) is higher than the old single-rep 64.5s sample —
that sample was the lucky survivor of a ~50% kill rate, not a median.
- **kql-r3 IO wedge — root-caused and FIXED (spec 18g):** the "VM exec
path dies after a run_io rep" wedge was not guest memory, not dockerd,
and not Longhorn faulting — the guest was healthy (load 0.4, 1.5 GB
free, zero dirty pages, clean dmesg) at the moment of death. The killer
was the **kata shim**: kata's default virtiofsd runs with
`--thread-pool-size=1`, so all virtio-fs IO (container rootfs + the
Longhorn-PVC-backed `/var/lib/docker`) serializes through one thread.
A `docker run` of the ~790 MB bench image makes vfs copy the whole
rootfs and then fsync 512 MB through that single thread against an
r=3 volume (~60–80 s saturated); any agent RPC touching virtio-fs
blocks behind it, the shim's agent health ping (`CheckRequest`) times
out, and the shim declares "Dead agent" and kills the healthy VM
(`sandbox stopped unexpectedly`, pod sandbox recreated). Repro rate was
~50% per run_io rep. Fix: `k7 install` now sets
`virtio_fs_extra_args = ["--thread-pool-size=16", ...]` in the
kata-qemu config — 18h re-ran 5/5 `run_io` + 5/5 `run_read` with zero
VM restarts on the same r=3 volume (`run_read` median 47.7s).
Exec-probe pressure was reduced too (kubelet's default 1 s exec-probe
timeout sprayed cancelled ttrpc execs — `docker info` legitimately
takes >1 s while dockerd copies vfs layers), which cuts the
`ttrpc: received message on inactive stream` noise but was NOT
sufficient on its own.
+145 -91
View File
@@ -1,12 +1,7 @@
<h1 align="center">k7</h1>
<p align="center">
<span style="font-family: 'Georgia', sans-serif; font-weight: bold; font-size: 48px; font-style: italic; color: #ef672b; vertical-align: middle; margin-left: 10px;">
KATAKATE
</span>
</p>
<p align="center" style="font-weight: bold; font-size: 20px; ">
Self-hosted secure VM sandboxes for AI compute at scale
<b>Self-hosted secure VM sandboxes for AI compute at scale</b>
</p>
@@ -21,16 +16,13 @@
<p align="center">
<a href="https://news.ycombinator.com/item?id=45656952">
<img src="https://img.shields.io/badge/Show%20HN-%231%20🔥-FF6600" alt="Show HN #1">
<img src="https://img.shields.io/badge/Show%20HN-%231%20🔥-orange" alt="Show HN #1">
</a>
<a href="assets/show-hn_nb1_post-id-45656952.png" title="Screenshot proof">📸</a>
<a href="https://console.dev">
<img src="https://img.shields.io/badge/Featured-Console.dev-4F39F5" alt="Featured on Console.dev">
<img src="https://img.shields.io/badge/Featured%20on-Console.dev-blue" alt="Featured on Console.dev">
</a>
<a href="assets/k7-console-dev.png" title="Screenshot proof">📸</a>
<a href="https://changelog.com/news/169">
<img src="https://img.shields.io/badge/Featured-Changelog-59B287" alt="Featured on Changelog">
</a>
<a href="https://www.youtube.com/watch?v=2tgqzZvmbak">
<img src="https://img.shields.io/badge/GitHub%20Trending-Oct%2023%2C%202025-black?logo=github" alt="GitHub Trending (Oct 23, 2025)">
</a>
@@ -48,7 +40,7 @@
</a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg"></a>
<img src="https://img.shields.io/badge/install%20with-apt-blue?logo=debian">
<img src="https://img.shields.io/pypi/v/katakate">
<img src="https://img.shields.io/pypi/v/k7-sdk">
</p>
@@ -61,12 +53,12 @@
<i><b>Katakate</b></i> aims to make it easy to create, manage and orchestrate lightweight safe VM sandboxes for executing untrusted code, at scale. It is built on battle-tested VM isolation with Kata, Firecracker and Kubernetes. It is orignally motivated by AI agents that need to run arbitrary code at scale but it is also great for:
<i><b>Katakate</b></i> aims to make it easy to create, manage and orchestrate lightweight safe VM sandboxes for executing untrusted code, at scale. It is built on battle-tested VM isolation with Kata, Firecracker, QEMU, Longhorn, and Kubernetes — plus Katakate's own <i><b>k7d</b></i> runtime. It is orignally motivated by AI agents that need to run arbitrary code at scale but it is also great for:
- Custom serverless (like AWS Fargate, but yours)
- Hardened CI/CD runners (no Docker-in-Docker risks)
- Blockchain execution layers for AI dApps
> <b>100% open‑source</b> (Apache‑2.0). For technical support, write us at: hi@katakate.org</b>
> <b>100% open‑source</b> (Apache‑2.0). For technical support, write us at: hi@katakate.org
<h3 align="left">
The Tech Stack
@@ -75,21 +67,47 @@ The Tech Stack
<i><b>Katakate</b></i> is built on:
- <i><b>Kubernetes</b></i> for orchestration, with K3s which is prod-ready and a great choice for edge nodes,
- <i><b>Kata</b></i> to encapsulate containers into light-weight virtual-machines,
- <i><b>Firecracker</b></i> as the chosen VM, for super-fast boots, light footprints and minimal attack surface,
- <i><b>Devmapper Snapshotter</b></i> with <i><b>thin-pool provisioning of logical volumes</b></i> for efficient use of disk space shared by dozens of VMs per node.
- <i><b>Firecracker</b></i> (`kfd`) for super-fast boots, light footprints and minimal attack surface (with the jailer),
- <i><b>Devmapper Snapshotter</b></i> with <i><b>thin-pool provisioning of logical volumes</b></i> for efficient disk use across many Firecracker VMs per node,
- <i><b>QEMU</b></i> (`kql`) via Kata when you want a fuller VMM and durable sandbox disks,
- <i><b>Longhorn</b></i> for replicated PVC-backed root disks on the QEMU path — named snapshots, restore, disk-only fork, and cross-node mobility,
- <i><b>k7d</b></i> — Katakate's own microVM runtime daemon (<a href="https://github.com/Katakate/k7d">katakate/k7d</a>) with VM-level warm fork (CoW disk+memory) and in-place pause/resume.
<h3 align="left">
Coming Soon
Sandbox backends
</h3>
`k7 install --backend <kfd|kql|k7d>` provisions one or more backends per node; `k7 create --backend …` picks one per sandbox. See [docs/BACKENDS.md](docs/BACKENDS.md) for the architecture and [PERFORMANCE.md](PERFORMANCE.md) for the full measurements (Hetzner AX41 node, medians).
- 🛠️ Docker <code>build</code> / <code>run</code> / <code>compose</code> support <b><i>inside the VM sandbox</i></b>
- 🌐 Multi-node cluster capabilities for distributed workloads
- 🔍 Cilium FQDN-based DNS resolution to safely whitelist domains, not just IP blocks
- ⚙️ Support other VMM such as Qemu for GPU workloads
| | `kfd` (kata-firecracker-devmapper) | `kql` (kata-qemu-longhorn) | `k7d` |
|---|---|---|---|
| VMM | Firecracker (Kata) | QEMU (Kata) | k7d (custom KVM VMM) |
| RuntimeClass | `kata` | `kata-qemu` | `k7` |
| Sandbox storage | devmapper thin-pool (needs a spare raw disk) | Longhorn PVC (replicated, persistent) | erofs images + reflink XFS + guest tmpfs |
| Create → Ready* | not re-measured† | 17.1s | **2.1s** |
| Named snapshot* | — | 6.5s (Longhorn, disk-only) | — (VM snapshot trees via the k7d API) |
| Fork → usable* | — | 46.7s (disk clone + cold boot) | **~5 ms VM CoW fork**; **~2.4 s** end-to-end via k7/k8s (pod Ready + exec) |
| Pause / resume* | scale to 0 / 1 | 1.3s / 4.1s (disk survives) | **0.2s / 0.3s (VM frozen in place, memory survives)** |
| Docker-in-VM sidecar | ✅ (ephemeral docker data) | ✅ (persistent docker data; fastest `docker pull`) | ✅ (VM-lifetime docker data) |
| Cross-pod persistence | ✗ | ✅ snapshots/restore | ✗ (fork carries state instead) |
📋 **See [ROADMAP.md](ROADMAP.md) for the complete feature roadmap and project priorities.**
\* medians of 3 on one Hetzner AX41 node — methodology, ranges, and the docker-sidecar
numbers are in [PERFORMANCE.md](PERFORMANCE.md).
† kfd needs a spare raw disk the benchmark node didn't have; its docker-workload numbers
are in the [PERFORMANCE.md](PERFORMANCE.md) spec-10b section.
<h3 align="left">
Also available today
</h3>
- 🛠️ Docker <code>build</code> / <code>run</code> inside VM sandboxes (docker sidecar on <b>kfd</b>, <b>kql</b>, and <b>k7d</b>; see [PERFORMANCE.md](PERFORMANCE.md))
- ⚡ <b>Warm VM fork</b> on the k7d backend: <code>k7 fork</code> CoW-clones a running sandbox's disk <i>and memory</i> in ~5&nbsp;ms at the VMM; end-to-end through k7/Kubernetes is ~2&nbsp;s to a Ready pod
- 🌐 Multi-node clusters (Ansible + Longhorn)
- 🔍 Cilium CNI with FQDN egress policies
- 📸 Pause / resume / fork / restore and <code>k7 snapshot</code> lifecycle
- 🐍 Python SDK: <code>pip install k7-sdk</code> (<code>katakate</code> package deprecated)
📋 **See [ROADMAP.md](ROADMAP.md) for upcoming work (GPU passthrough, …).**
<p align="left" style="margin-top: 40px; font-size: 14px;">
@@ -106,14 +124,17 @@ For usage you need:
We provide a:
- **CLI**: to use on the node(s) directly --> `apt install k7`
- **API**: deployed on the (master) node(s) --> `k7 start-api`
- **Python SDK**: Python client sync/async talking to API --> `pip install katakate`
- **API**: deployed automatically by `k7 install` (toggle with `k7 api enable` / `k7 api disable`)
- **Python SDK**: HTTP client sync/async --> `pip install k7-sdk`
## Current requirements
### For the node(s)
- Ubuntu (amd64 or arm64) host.
- **`k7d` backend is amd64 / x86_64 only** (same ISA; Debian calls it `amd64`,
the release tarball is `*-x86_64-linux.tar.gz`). `kfd` and `kql` support
amd64 and arm64.
- Hardware virtualization (KVM) available and accessible
- Check: `ls /dev/kvm` should exist.
- This is typically available on your own Linux machine.
@@ -138,11 +159,22 @@ We provide a:
```
Already tested setups:
- Hetzner Robot instance with Ubuntu 24.04, x86_64 or ARM64 arch, booked with 1 extra empty disk `nvme2n1` for the thin-pool provisioning. See the setup guide (PDF): [tutorials/k7_hetzner_node_setup.pdf](tutorials/k7_hetzner_node_setup.pdf).
- Hetzner Robot dedicated with Ubuntu 24.04 and a **spare raw NVMe** for the `kfd` thin-pool. Dual-NVMe boxes (no third drive): install the OS on one disk only — see [tutorials/k7_hetzner_node_setup.md](tutorials/k7_hetzner_node_setup.md). (Older PDF that assumed an add-on third NVMe: [tutorials/k7_hetzner_node_setup.pdf](tutorials/k7_hetzner_node_setup.pdf).)
### For the client
Just recent Python.
Recent Python, or the **`k7`** CLI / **`k7-sdk`** from a Linux node or your laptop (API URL + key).
#### Development on macOS
The **`.deb` / PPA package is Linux-only** (amd64/arm64). On a MacBook:
- **CLI from source:** `./src/k7/cli/dev.sh` (same commands as `k7`; uses `uv` + `PYTHONPATH=src`)
- **API client from laptop:** set `K7_API_URL` and `K7_API_KEY`, then `dev.sh create` / `dev.sh list` (no `--core`)
- **`k7 install`** targets Linux servers with KVM — run on the node or via SSH, not on macOS locally
- **`pip install k7-sdk`** for Python scripts only
Do not install the Ubuntu `.deb` on macOS.
## Quick Start
@@ -168,20 +200,20 @@ Current task: Reminder about logging out and back in for group changes
Optionally pass `-v` for a verbose output.
> It will also tell you which raw disk was auto-selected for the LVM thin-pool. If you prefer, specify the disk explicitly:
> It will also tell you which raw disk was auto-selected for the LVM thin-pool. If you prefer, specify the disk explicitly (on a dual-NVMe Hetzner box this is usually the spare, e.g. `/dev/nvme1n1`):
> ```bash
> k7 install --disk /dev/nvme2n1
> k7 install --disk /dev/nvme1n1
> ```
This will install and most importantly connect together the following components:
This will install and most importantly connect together the following components (depending on `--backend`):
- Kubernetes (K3s prod-ready distribution)
- Kata (for container virtualization)
- Firecracker (as Virtual Machine Manager)
- Jailer (to secure Firecracker VMs further into a chroot)
- devmapper snapshotter with thin-pool provisioning of logical volumes for VM efficient disk memory usage
- Firecracker + Jailer + devmapper thin-pool (`kfd`)
- QEMU via Kata + Longhorn PVC-backed roots (`kql`)
- k7d daemon + `containerd-shim-k7-v1` + RuntimeClass `k7` (`k7d`)
Careful design: config updates will not touch your existing Docker or containerd setups. We chose to use K3s' own containerd for minimal disruption. Installation may however overwrite existing installations of K3s, Kata, Firecracker, Jailer.
Careful design: config updates will not touch your existing Docker or containerd setups. We chose to use K3s' own containerd for minimal disruption. Installation may however overwrite existing installations of K3s, Kata, Firecracker, Jailer, QEMU/Kata config, or Longhorn.
### CLI Usage
@@ -220,6 +252,9 @@ env_file: path/to/your/secrets/.env
# Create a sandbox (uses k7.yaml in the current directory by default, but you can also pass: -f myfile.yaml)
k7 create
# Or pick a backend explicitly (kfd | kql | k7d — aliases for the full names)
k7 create -f k7.yaml --backend k7d
# List sandboxes
k7 list
@@ -230,21 +265,59 @@ k7 delete my-sandbox-123
k7 delete-all
```
#### Fork / pause / snapshot
```bash
# Warm CoW fork (disk + memory) — source must be a k7d sandbox
k7 create -f k7.yaml --backend k7d # name from yaml, e.g. my-sandbox-123
k7 exec my-sandbox-123 sh -c 'echo hi > /tmp/state.txt'
k7 fork my-sandbox-123 branch-a
k7 exec branch-a cat /tmp/state.txt # inherited memory + disk
# Disk-only fork (cold boot from cloned PVC) — kql / kata-qemu-longhorn
k7 create -f k7.yaml --backend kql
k7 fork my-sandbox-123 branch-b
# optional: pin the Longhorn VolumeSnapshot name used for the clone
k7 fork my-sandbox-123 branch-c --snapshot my-snap
# Parallel branches from one base
for i in $(seq 0 7); do k7 fork my-sandbox-123 exp-$i & done; wait
# Pause / resume (kql keeps the PVC; k7d freezes the live VM)
k7 pause my-sandbox-123
k7 resume my-sandbox-123
# Named disk snapshot without pausing (kql)
k7 snapshot create my-sandbox-123 my-named-snap
```
On **k7d**, the VMM fork itself is ~5&nbsp;ms; end-to-end through Kubernetes
to a Ready pod is ~2&nbsp;s. On **kql**, fork is a Longhorn snapshot + PVC
clone + cold boot (~45&nbsp;s). See [PERFORMANCE.md](PERFORMANCE.md) and
[docs/BACKENDS.md](docs/BACKENDS.md).
### API usage
If you'd like to manage workloads remotely, just use the API:
The K7 API is deployed automatically by `k7 install` as the `k7-api`
Deployment in `kube-system`. K3s keeps it running on its own; there's no
separate "start" step.
```shell
# Start API server (containerized and SSL support with Cloudflared)
k7 start-api
# Check status + endpoint
k7 api status
k7 api endpoint
# Generate API key
k7 generate-api-key my-key1
# Temporarily disable / re-enable
k7 api disable
k7 api enable
```
Make sure your user is in the `Docker` group to be allowed to start or stop the API.
Generating / listing / revoking keys talks to `/etc/k7/api_keys.json`, so
those subcommands need to run on the node (typically `sudo` or `root`).
As for generating / listing / revoking keys, you might need `sudo` or `root`.
### Python SDK Usage
@@ -252,43 +325,51 @@ After your k7 API is up, usage is very simple.
Install the Python SDK via:
```shell
pip install katakate
pip install k7-sdk
```
Or if you want async support:
```shell
pip install "katakate[async-sdk]"
pip install "k7-sdk[async]"
```
The legacy `katakate` PyPI name remains as a one-release shim that re-exports `k7_sdk` with a deprecation warning.
Then use with:
```python
from katakate import Client
from k7_sdk import Client
k7 = Client(
endpoint='https://<your-endpoint>',
api_key='your-key')
# Create sandbox
# Create sandbox (pick backend: kata-firecracker-devmapper | kata-qemu-longhorn | k7d)
sb = k7.create({
"name": "my-sandbox",
"image": "alpine:latest"
"name": "base",
"image": "alpine:latest",
"backend": "k7d",
})
# Execute code
result = sb.exec('echo "Hello World"')
result = sb.exec('echo "Hello World" > /tmp/hi.txt && cat /tmp/hi.txt')
print(result['stdout'])
# List all sandboxes
sandboxes = k7.list()
# Fork: k7d = warm CoW (disk + memory); kql = disk clone + cold boot
branch = sb.fork("branch-a")
print(branch.exec("cat /tmp/hi.txt")["stdout"]) # still there on k7d
# Delete sandbox
# Parallel exploration
forks = [sb.fork(f"exp-{i}") for i in range(4)]
# List / delete
sandboxes = k7.list()
sb.delete()
```
#### Async variant
```python
import asyncio
from katakate import AsyncClient
from k7_sdk import AsyncClient
async def main():
k7 = AsyncClient(
@@ -338,26 +419,6 @@ sudo make uninstall
Note: we recommend running `make uninstall` before reinstalling if it is not your first install, to avoid stale copies of cached files in the .deb package.
### Fast development workflow
For faster development iterations when working on the CLI, you can use `dev.sh` to run `k7` commands directly without rebuilding the binary:
```shell
# Basic commands
./src/k7/cli/dev.sh install
./src/k7/cli/dev.sh list
./src/k7/cli/dev.sh create
# Install with options
./src/k7/cli/dev.sh install -v
./src/k7/cli/dev.sh install --disk /dev/nvme2n1
# Create sandbox with options
./src/k7/cli/dev.sh create --name test --image alpine:latest
```
This script uses `uv run` to execute the CLI with all dependencies, so you can test changes immediately without running `make build` every time. This is especially useful when iterating on CLI code changes.
### Build and run the API container
@@ -371,7 +432,7 @@ make api-run-local
```
### Build the katakate Python SDK from source
### Build the k7-sdk Python SDK from source
Preferred (uv):
@@ -389,9 +450,10 @@ uv pip install -e .
K7 sandboxes are hardened by default with multiple layers of security:
- **VM isolation**: Kata Containers provide hardware-level isolation via lightweight VMs with Firecracker
- VMs are further restricted into a chroot using Jailer
- Kata's Seccomp restrictions are enabled
- **VM isolation**: Kata Containers (Firecracker or QEMU) or the k7d RuntimeClass provide hardware-level isolation via lightweight VMs
- On `kfd`, Firecracker processes are further restricted into a chroot using the Jailer
- Kata's Seccomp restrictions are enabled on the Kata backends
- `kql` uses QEMU + Longhorn for durable, cross-node-mobile disks; `k7d` has its own CoW-fork isolation trade-offs (see k7d `SECURITY.md`)
- **Linux capabilities**: All capabilities are dropped by default (`drop: ALL`) for defense-in-depth
- Only explicitly add back capabilities you need via `cap_add` parameter
@@ -409,26 +471,18 @@ K7 sandboxes are hardened by default with multiple layers of security:
- **Network policies**: Complete network isolation for VM sandboxes
- **Ingress isolation**: All inter-VM communication is blocked by default to prevent sandbox-to-sandbox access
- **Egress lockdown**: Control outbound traffic with CIDR-based restrictions using Kubernetes NetworkPolicies
- **DNS is blocked** when egress is locked down; only IPs/CIDRs in `egress_whitelist` are reachable
- **Egress lockdown**: per-sandbox allowlists — CIDRs via Kubernetes NetworkPolicy, or **FQDN / domain** allowlists via Cilium (`CiliumNetworkPolicy`; default CNI)
- **DNS is blocked** when egress is locked down; only entries in `egress_whitelist` (CIDR or domain) are reachable
- Administrative access via `kubectl exec` and `k7 shell` is preserved (uses Kubernetes API, not pod networking)
- Soon to come: Cilium integration for domain name whitelisting
More security features are currently on the roadmap, including integrating AppArmor.
More security features are on the roadmap (e.g. AppArmor).
## Packaging & Releases
- Layout uses `src/`:
- CLI, API, core live under `src/k7/`
- SDK under `src/katakate/`
- Root packaging targets the `katakate` SDK only; assets under `src/k7/` are not part of the PyPI distribution.
- `MANIFEST.in` (for the `katakate` SDK) should include essentials like `LICENSE` and `README.md` only; deploy assets from `src/k7/deploy/*` belong to the Debian/CLI packaging flow, not to the PyPI package.
- `setup.py` for `katakate` lives at repo root; packages from `src/`.
- SDK under `src/k7_sdk/` (PyPI package `k7-sdk`; `src/katakate/` is a deprecation shim)
- Root `setup.py` publishes the SDK; assets under `src/k7/` belong to the Debian CLI / API image, not the PyPI wheel.
- User docs: `~/docs/k7/` (Mintlify). See `docs/README.md` in this repo.
- The CLI Debian package is built via `src/k7/cli/build.sh` and produces `dist/k7_<version>_amd64.deb` and `dist/k7_<version>_arm64.deb`.
- CI (tags `v*`) can publish the PyPI SDK and upload the `.deb` artifact.
## Known issues
- Jailer seems to be currently ignored by Kata despite being passed correctly into its configuration, and despite the Jailer process being started. The use of Kubernetes secrets could be a reason of incompatibility. This is under investigation.
- CI (tags `v*`) can publish the PyPI SDK and upload the `.deb` artifact.
+33 -40
View File
@@ -1,64 +1,57 @@
# 🗺️ Project Roadmap
# Project Roadmap
This document outlines the upcoming milestones, goals, and long-term vision for **K7**.
It helps contributors and users understand where the project is heading.
Where **K7** is headed — for contributors and operators.
---
## 🚀 Current Focus
Core stability and foundational runtime improvements.
- [x] Add `--disk` argument to `k7 install` to specify external disk path explicitly for thin pool provisioning, and test it (merged PR #5)
- [x] Test if removing DNS resolution completely doesn't break functionality (to protect against DNS exfiltration) (merged PR #6)
- [ ] Add pause/resume/fork/clone support for sandboxes
- [ ] Fix jailer functionality (known issue)
- [ ] Add multi-node support (currently single K3s node supported)
## Current focus
Release engineering: apt/PPA, GHCR `k7-api`, and PyPI `k7-sdk`, after the
sibling [`Katakate/k7d`](https://github.com/Katakate/k7d) v0.1.0 artifact
exists (default install URL depends on it).
---
## 🧩 Next Goals
Broader compatibility and container integration.
- [x] Add ARM support for Linux Debian (merged PR #4, big thanks to @spullara)
- [ ] Add Docker build / run / compose capabilities in VM sandboxes (major feature!)
- [ ] Integrate Cilium networking
- [ ] Implement Docker pull deny/whitelist
## Recently shipped
- [x] Multi-node Ansible, Longhorn topology, HA / cross-node tests
- [x] Cilium CNI + FQDN egress
- [x] API + SDK parity: pause / resume / fork
- [x] Snapshot lifecycle + GC; restore from VolumeSnapshot
- [x] CLI → API by default; `k7 api` / `k7 dev api rebuild`
- [x] Docker-in-VM sidecar + bench harness
- [x] Firecracker jailer
- [x] Python SDK as **`k7-sdk`** / `k7_sdk` (`katakate` deprecated)
- [x] `k7d` backend install path (artifact URL / local override)
---
## ⚙️ Future Work
## Next goals
Cross-platform support and continuous delivery.
- [ ] Add QEMU support (macOS ARM, GPU support)
- [ ] Cross-node mobility of snapshots (dependent on multi-node + sandbox snapshot/resume/fork features)
- [ ] Add AppArmor integration
- [ ] Add CI/CD and deployment tests
- [ ] PPA (`apt install k7`), GHCR `k7-api`, PyPI `k7-sdk`
- [ ] Default `k7d` install from public `Katakate/k7d` GitHub Releases
- [ ] Optional macOS CLI artifacts (tarball / Homebrew) — after the above
---
## 🔐 Advanced Features
## Future work
Security, customization, and extended runtime capabilities.
- [ ] Add TEE (Trusted Execution Environment) support
- [ ] Add custom rootfs support (lighter, alternative images)
- [ ] GPU passthrough support
- [ ] Cross-node mobility of snapshots / forks for the `k7d` backend
(`kql` already moves state across nodes via Longhorn)
- [ ] AppArmor integration
- [ ] CI/CD deployment tests on every public tag
- [ ] TEE support; custom rootfs; persistent in-API interpreter
---
## 💬 How to Contribute
## How to contribute
We welcome ideas and feedback!
If you'd like to suggest a feature or help with one listed above:
1. Open a [Discussion](https://github.com/katakate/k7/discussions) or [Issue](https://github.com/katakate/k7/issues)
2. Reference the relevant roadmap item
3. Let's collaborate on the design or implementation
1. Open a [Discussion](https://github.com/Katakate/k7/discussions) or
[Issue](https://github.com/Katakate/k7/issues)
2. Reference the roadmap item
3. See [`CONTRIBUTING.md`](CONTRIBUTING.md)
---
📅 *Last updated: October 2025*
*Last updated: August 2026*
+49 -19
View File
@@ -2,39 +2,69 @@
## Supported Versions
This project is pre-1.0 (currently 0.0.1) and under active development and security hardening. Breaking changes may occur between minor versions until 1.0.0.
This project is pre-1.0 (targeting **0.1.0** for the public cut; working
tree may still show `0.0.4-dev`) and under active development. Breaking
changes may occur until 1.0.0. Security fixes land on the latest release
line only.
## Reporting a Vulnerability
If you believe you have found a security vulnerability, please email:
- security@katakate.org (preferred)
- Or open a private security advisory via GitHub (Security → Advisories → Report a vulnerability)
- **security@katakate.org** (preferred)
- Or open a private security advisory via GitHub
(Security → Advisories → Report a vulnerability)
Please include:
- A detailed description of the issue and potential impact
- Steps to reproduce or proof-of-concept
- Affected versions/commit SHAs and environment details
- Affected versions / commit SHAs and environment details
We aim to acknowledge reports within 72 hours and provide a remediation plan or mitigation timeline when applicable.
We aim to acknowledge reports within 72 hours and provide a remediation
plan or mitigation timeline when applicable.
## Scope and Current Model
Do **not** open a public issue for security-sensitive reports.
- Nodes run K3s + Kata + Firecracker; containers run as non-root with restricted capabilities.
- API uses API keys with hashed storage and expiry; file-backed by default.
- Egress network restrictions via Kubernetes NetworkPolicies (IP-based whitelists). When egress lockdown is enabled, DNS resolution is blocked by default.
- All ingress network blocked by default to avoid default K8s pod to pod communications; this doesn't affect kubectl exec / k7 shell into sandboxes which are based on the k8s API.
## Scope and current model
Known limitations (pre-0.1.0):
- Nodes run **K3s**. A cluster (or a single node) can install **multiple
sandbox backends**; each sandbox picks one:
- **`kfd`** (`kata-firecracker-devmapper`) — Firecracker via Kata. The
Firecracker process runs inside the **jailer** (chroot + dropped
capabilities + seccomp). An integration test asserts the jailer is
active after install.
- **`kql`** (`kata-qemu-longhorn`) — **QEMU** via Kata with a Longhorn
PVC root (durable disk, named snapshots / restore / disk-only fork).
- **`k7d`** — [Katakate/k7d](https://github.com/Katakate/k7d)
(`runtimeClassName: k7`); CoW sibling-fork isolation differs — see
k7d's `SECURITY.md`.
- Sandbox containers run as non-root with restricted capabilities on top
of the VM boundary.
- The control plane API uses API keys with hashed storage and expiry
(file-backed by default at `/etc/k7/api_keys.json` — rotate and protect
that file).
- **Ingress** to sandboxes is denied by default (NetworkPolicy).
**Egress** is per-sandbox: open, blocked, CIDR allowlist, or **FQDN**
allowlist when Cilium is the CNI (default). DNS is blocked by default
when egress is locked down.
- **Multi-node** clusters are supported (Ansible inventory; Longhorn for
the QEMU/`kql` path). Cilium FQDN egress applies cluster-wide.
- No rate limiting or abuse protection at API layer yet.
- API key storage is local file; rotate and protect `/etc/k7/api_keys.json`.
- No domain-based egress control (planned via Cilium/FQDN policies).
- Jailer currently ignored by Kata
- Only single-node supported right now, multi-node support high on the roadmap
- We might want to get rid of the compose setup for the API and instead directly deploy the API on the K3s cluster by writing a few manifests.
- If keeping API out-of-cluster we should rather pass to the API a dedicated RBAC restricted Kube config instead of the admin config.
See also the docs: security model, networking, and backends comparison.
### Known limitations (pre-1.0)
- No rate limiting or abuse protection at the API layer yet.
- API key storage is local file-backed; treat the API host as trusted.
- Young project; no independent security audit yet.
- The `k7d` backend has a different isolation trade-off for CoW sibling
forks — see k7d's `SECURITY.md`.
- Prefer a dedicated RBAC-restricted kubeconfig for the API rather than
cluster-admin credentials in production.
## Responsible Disclosure
Do not publicly disclose vulnerabilities before we have had a reasonable time to investigate and release fixes. We appreciate coordinated disclosure and will credit reporters unless anonymity is requested.
Do not publicly disclose vulnerabilities before we have had a reasonable
time to investigate and release fixes. We appreciate coordinated
disclosure and will credit reporters unless anonymity is requested.
+145
View File
@@ -0,0 +1,145 @@
# Docker workload benchmark — Spec 10b
Quantifies the **storage tax** of running Docker inside a k7 sandbox vs
natively on the host. Output lands in [`PERFORMANCE.md`](../../PERFORMANCE.md)
under a dated section, formatted so the median + ratio numbers can be
cited verbatim from any blog post.
## What gets benched
| Label | What it is |
|-------------|------------|
| `host` | Native Docker on the Hetzner node (no k7 involved) |
| `k7-fd` | k7 sandbox, `kata-firecracker-devmapper` (kfd) backend, `--sidecar docker` (docker daemon's `/var/lib/docker` is an emptyDir) |
| `k7-ql-r1` | k7 sandbox, `kata-qemu-longhorn` (kql) backend, `--sidecar docker`, Longhorn `replicas=1` |
| `k7-ql-r2` | Same as `k7-ql-r1` but Longhorn `replicas=2` (requires ≥ 2-node cluster) |
The four environments stack the storage path cleanly: `host` → no VM,
no Longhorn. `k7-fd` → VM but no Longhorn. `k7-ql-r1` → VM + one local
Longhorn replica. `k7-ql-r2` → VM + one local + one cross-node Longhorn
replica. Subtracting `k7-fd / host` from `k7-ql-r1 / host` isolates the
**Longhorn tax** from the **kata-qemu tax**.
## Workloads (5 runs each, after a discarded warm-up)
| Operation | Isolates |
|-----------|----------|
| `pull debian:12-slim` | Network + extract + write of an external image |
| `build (no-cache)` | The headline number: apt + pip + git + 256 MB fsync |
| `build (cached)` | Sanity — should be sub-second; non-zero on `k7-ql-*` would mean the bind mount of `/var/lib/docker` is broken |
| `run cpu (10s budget)` | Kata-qemu CPU overhead (Python busy-loop) |
| `run io (2k small + 512 MB fsync)` | Write path through the storage stack |
| `run read (venv tree cat)` | Read path (mostly page-cache hits after the first call) |
The Dockerfile that drives `build (no-cache)` is pinned at
[`bench/docker-perf/bench.Dockerfile`](bench.Dockerfile) — apt + pip
generate thousands of small files (metadata pressure), git clone is
inode-heavy, and `dd … conv=fsync` measures sync-write throughput.
## How to run it
The bench is a **pytest module** (not a shell script) — it reuses the
exact same `k7_core` / `test_namespace` fixtures that
`tests/integration/test_sidecar_docker.py` already uses to spin up a
`docker:27.5-cli` sandbox with `--sidecar docker`. The harness times
`k7_core.exec_command(...)` calls instead of doing anything new.
Run all three envs:
```bash
# On the cluster node (or any host that already runs the integration suite):
K7_BENCH_ENVS=host,k7-ql-r1,k7-ql-r2 \
uv run pytest -m bench tests/integration/bench_docker_perf.py -v -s
```
Pick a subset (useful for iterating on tooling):
```bash
K7_BENCH_ENVS=host uv run pytest -m bench -v -s tests/integration/bench_docker_perf.py
K7_BENCH_ENVS=k7-ql-r1 uv run pytest -m bench -v -s tests/integration/bench_docker_perf.py
```
Switching `r1 → r2` is done **in-cluster** by patching Longhorn's
`default-replica-count` setting via `kubectl` — no `k7 install` reinstall,
no cluster-wide downtime, and the value is restored at session teardown.
### Environment variables
| Var | Default | Purpose |
|-----|---------|---------|
| `K7_BENCH_ENVS` | `host,k7-ql-r1,k7-ql-r2` | Comma-separated subset of envs to run |
| `K7_BENCH_REPS` | `5` | Reps per cell |
| `K7_BENCH_WARMUP` | `1` | 0 to disable the warm-up |
| `K7_BENCH_OUT` | `/tmp` | Where logs + the aggregated CSV land |
### Output
Each leg writes a `bench-<label>-<ts>.log` of the same shape `render.py`
already parses:
```
# label=k7-ql-r1
# timestamp=20260529T120000Z
# kernel=...
# docker_version=...
# longhorn_replicas=1
# columns: OPERATION RUN SECONDS
pull 1 3.142
pull 2 3.098
build_nocache 1 60.42
...
```
When the pytest session ends, a session-scoped fixture aggregates all
logs from `bench_logs` into a single CSV at
`$K7_BENCH_OUT/bench-results-<ts>.csv`. Feed that to `render.py`:
```bash
uv run python bench/docker-perf/render.py \
--input /tmp/bench-results-<ts>.csv \
--title "Docker workloads inside sandboxes — 2026-05-29 (k7 <sha>)" \
--hardware "Hardware: 2x Hetzner AX42, Ubuntu 24.04, kernel 6.8, Docker 27.5, Longhorn 1.7.x." \
--output - >> PERFORMANCE.md
```
## Known confounds (acknowledge these honestly in any post)
1. **Page cache** — the harness drops `/proc/sys/vm/drop_caches` only on
the host (where it has the privilege). Inside the sandbox we can't,
so the `run read` leg is mostly a page-cache measurement, not a
storage-stack one. Don't over-interpret that row.
2. **Network egress for `pull`** — Cilium FQDN egress + the sandbox's
network path aren't the same as the host's. `pull` is here for
context, not as a storage-tax signal.
3. **Kata-qemu CPU overhead** — the `run cpu` row reflects both CPU
virtualization overhead and any scheduler differences. Reporting it
explicitly lets readers do their own subtraction.
4. **Workload immutability** — the Dockerfile is committed and the base
image (`debian:12-slim`) is captured by digest in each log header.
The only real variable across re-runs is the k7 / Longhorn version.
## Statistical handling
- 5 reps per cell, first warm-up run is discarded.
- Reported as **median** + **min/max** (mean is too sensitive to outliers
on storage benchmarks).
- Cells with `(max − min) / median > 0.30` are flagged with `⚠`. Treat
flagged cells as "rerun with more reps before publishing" — don't
quote them.
## Re-rendering without re-running
Already have a CSV (manually edited, or salvaged from a partial run)?
Re-render with:
```bash
python3 bench/docker-perf/render.py \
--input /tmp/bench-results-<ts>.csv \
--title "Docker workloads inside sandboxes — 2026-05-29 (k7 <sha>)" \
--hardware "Hardware: ..." \
--output /tmp/section.md
```
That same output is what `bench_docker_perf.py`'s teardown prints
instructions for — re-rendering is just the path for fixing typos in
the title / hardware line without re-running the bench.
+22
View File
@@ -0,0 +1,22 @@
# Spec 10b workload: small but realistic. apt + pip generate thousands of
# small files (metadata pressure), git clone is inode/dir-creation heavy,
# ``dd ... conv=fsync`` measures large-block sync write throughput. Each
# layer is bounded so the whole no-cache build completes in a few minutes
# even on the slowest path (k7-ql-r2).
#
# Pinned to ``debian:12-slim`` (no floating tag) so the benchmark is
# reproducible across runs. Image digest is captured in the run log.
FROM debian:12-slim
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential python3 python3-pip python3-venv git ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir numpy pandas requests pytest httpx pydantic \
&& find /opt/venv -name '__pycache__' -prune -exec rm -rf {} + \
&& /opt/venv/bin/python -c "import numpy, pandas; print('ok')"
RUN git clone --depth=1 https://github.com/python/cpython.git /tmp/cpython \
&& find /tmp/cpython -type f | wc -l
RUN dd if=/dev/zero of=/tmp/big bs=1M count=256 conv=fsync \
&& rm /tmp/big
CMD ["/bin/true"]
+208
View File
@@ -0,0 +1,208 @@
"""CSV → Markdown renderer for the Spec 10b Docker benchmark.
Input CSV (produced by ``run_all.sh`` from ``/tmp/bench-<label>-*.log``):
label,operation,run,seconds
host,pull,1,3.142
host,pull,2,3.118
...
k7-ql-r2,run_read,5,7.214
Output: two Markdown tables.
1) Raw timings — one row per operation, one column per environment.
Cell format: ``<median> s (<min>–<max>)``.
2) Ratio table — for the three "interesting" rows (``build_nocache``,
``run_io``, ``run_cpu``), each sandbox env's median divided by host
median. The number that lands on Hacker News.
The renderer gates publishability on the spec-10b rule "(max - min) /
median > 0.30 → investigate". Failing cells are tagged with ``⚠``.
Deterministic: ``--input`` and stable column ordering mean the same CSV
always produces byte-identical Markdown (see test_render_perf.py).
"""
from __future__ import annotations
import argparse
import csv
import math
import sys
from collections import defaultdict
from collections.abc import Iterable
from pathlib import Path
from statistics import median
# Stable column ordering for the rendered tables.
ENVS: list[str] = ["host", "k7-fd", "k7-ql-r1", "k7-ql-r2", "k7-ql-r3"]
# Stable row ordering, plus the human-readable column label for each op.
OP_DISPLAY: list[tuple[str, str]] = [
("pull", "pull debian:12-slim"),
("build_nocache", "build (no-cache)"),
("build_cached", "build (cached)"),
("run_cpu", "run cpu (10s budget)"),
("run_io", "run io (2k small + 512 MB)"),
("run_read", "run read (venv tree cat)"),
]
# Operations included in the ratio-vs-host table — the ones that genuinely
# isolate the storage/VM tax. ``pull`` is network-dominated, ``build_cached``
# is sub-second everywhere, ``run_read`` is page-cache-dominated; reporting
# ratios on those would just amplify measurement noise.
RATIO_OPS: list[tuple[str, str]] = [
("build_nocache", "build (no-cache) ratio"),
("run_io", "run io ratio"),
("run_cpu", "run cpu ratio"),
]
RANGE_GATE = 0.30 # spec 10b: (max - min) / median > 0.30 → ⚠
def _load_rows(csv_path: Path) -> list[dict[str, str]]:
with csv_path.open() as f:
return list(csv.DictReader(f))
def _aggregate(rows: Iterable[dict[str, str]]) -> dict[tuple[str, str], dict[str, float]]:
"""Group seconds by (label, operation) and compute median / min / max.
Returns a ``{(label, op): {"median": …, "min": …, "max": …, "n": …}}`` dict.
Cells with fewer than 1 sample are omitted (caller renders ``—``).
"""
by_cell: dict[tuple[str, str], list[float]] = defaultdict(list)
for row in rows:
try:
value = float(row["seconds"])
except (KeyError, ValueError):
continue
# Skip NaN sentinels — these are recorded by the bench when an op
# (typically docker daemon wedging on vfs after a long no-cache
# build) fails; aggregating them would poison median/min/max.
if math.isnan(value):
continue
by_cell[(row["label"], row["operation"])].append(value)
out: dict[tuple[str, str], dict[str, float]] = {}
for key, values in by_cell.items():
if not values:
continue
m = median(values)
out[key] = {
"median": m,
"min": min(values),
"max": max(values),
"n": float(len(values)),
"flag": 1.0 if m > 0 and (max(values) - min(values)) / m > RANGE_GATE else 0.0,
}
return out
def _fmt_secs(value: float) -> str:
"""Friendly seconds display — ms when sub-second, s otherwise."""
if value < 1.0:
return f"{value * 1000:.0f} ms"
if value < 10.0:
return f"{value:.2f} s"
return f"{value:.1f} s"
def _fmt_cell(stats: dict[str, float] | None) -> str:
if stats is None:
return "—"
flag = "⚠ " if stats["flag"] >= 0.5 else ""
return f"{flag}{_fmt_secs(stats['median'])} ({_fmt_secs(stats['min'])}–{_fmt_secs(stats['max'])})"
def _fmt_ratio(stats: dict[str, float] | None, host_stats: dict[str, float] | None) -> str:
if stats is None or host_stats is None or host_stats["median"] == 0:
return "—"
ratio = stats["median"] / host_stats["median"]
if ratio < 10:
return f"{ratio:.2f}×"
return f"{ratio:.1f}×"
def _envs_present(agg: dict[tuple[str, str], dict[str, float]]) -> list[str]:
"""Return the envs from ``ENVS`` that have ≥1 measurement in the CSV.
Lets the renderer skip the ``k7-ql-r2`` column cleanly when only a
1-node cluster was available.
"""
seen = {label for label, _ in agg}
return [e for e in ENVS if e in seen]
def render(
csv_path: Path,
*,
title: str,
hardware_note: str = "",
) -> str:
rows = _load_rows(csv_path)
agg = _aggregate(rows)
envs = _envs_present(agg) or ENVS
lines: list[str] = []
lines.append(f"## {title}")
lines.append("")
if hardware_note:
lines.append(hardware_note)
lines.append("")
lines.append("5 runs per cell, median (range in parens). ⚠ marks cells whose (max−min)/median > 0.30.")
lines.append("")
# Raw table.
header_envs = "|".join(f" {e} " for e in envs)
sep = "|".join("-" * (len(e) + 2) for e in envs)
lines.append(f"| Operation |{header_envs}|")
lines.append(f"|---------------------------|{sep}|")
for op_key, op_display in OP_DISPLAY:
cells = "|".join(f" {_fmt_cell(agg.get((env, op_key)))} " for env in envs)
lines.append(f"| {op_display:<26}|{cells}|")
lines.append("")
# Ratio table — only the sandbox envs (i.e. drop the host column itself).
sandbox_envs = [e for e in envs if e != "host"]
if sandbox_envs and ("host" in envs):
ratio_labels = [f"{e} / host" for e in sandbox_envs]
header_envs = "|".join(f" {label} " for label in ratio_labels)
sep = "|".join("-" * (len(label) + 2) for label in ratio_labels)
lines.append(f"| Cell |{header_envs}|")
lines.append(f"|---------------------------|{sep}|")
for op_key, op_display in RATIO_OPS:
host_stats = agg.get(("host", op_key))
cells = "|".join(f" {_fmt_ratio(agg.get((env, op_key)), host_stats)} " for env in sandbox_envs)
lines.append(f"| {op_display:<26}|{cells}|")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Render bench CSV to Markdown")
parser.add_argument("--input", "-i", type=Path, required=True, help="Path to the aggregated CSV")
parser.add_argument(
"--title",
"-t",
required=True,
help="Markdown section heading text (e.g. 'Docker workloads inside sandboxes — 2026-05-29 (k7 v<sha>)')",
)
parser.add_argument(
"--hardware",
default="",
help="One-line hardware / kernel / Docker / Longhorn / FS note",
)
parser.add_argument("--output", "-o", type=Path, default=None, help="Write to this file instead of stdout")
args = parser.parse_args(argv)
md = render(args.input, title=args.title, hardware_note=args.hardware)
if args.output is not None:
args.output.write_text(md)
else:
sys.stdout.write(md)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -0,0 +1,7 @@
# Spec 10b: keep the directory but ignore generated artifacts. Logs / CSVs /
# rendered sections live here; the published numbers go in PERFORMANCE.md.
*.log
*.csv
section-*.md
**/
!.gitignore
+6
View File
@@ -1,3 +1,9 @@
k7 (0.2.0) noble; urgency=medium
* Release 0.2.0.
-- Katakate <hi@katakate.org> Tue, 11 Aug 2026 23:29:02 +0200
k7 (0.0.3) noble; urgency=medium
* Fix Docker image name to use lowercase (ghcr.io/katakate/k7-api)
+238
View File
@@ -0,0 +1,238 @@
# Sandbox backends: kfd, kql, k7d
k7 turns every sandbox into a Kubernetes Deployment whose pod runs inside a
hardware-isolated microVM. *How* that VM is built, stored, snapshotted, and
forked is the backend's job. Three backends exist today; a node can install
any combination (`k7 install --backend kfd,kql,k7d`) and each sandbox picks
one (`k7 create --backend …`, annotation `k7.katakate.org/backend`).
| | `kfd` — kata-firecracker-devmapper | `kql` — kata-qemu-longhorn | `k7d` |
|---|---|---|---|
| VMM | Firecracker via Kata Containers | QEMU via Kata Containers | [k7d](https://github.com/katakate/k7d), Katakate's purpose-built KVM VMM |
| containerd RuntimeClass | `kata` | `kata-qemu` | `k7` |
| Rootfs / storage | devmapper LVM thin-pool (needs a spare raw disk) | overlayfs + Longhorn PVC mounted at `/mnt/state` | cached erofs images on virtio-blk, guest tmpfs upper, reflink-XFS volume images |
| Named snapshots (`k7 snapshot`) | ✗ | ✅ Longhorn VolumeSnapshot (disk-only, crash-consistent) | ✗ by design — see below |
| `k7 fork` | ✗ | disk clone: snapshot → new PVC → cold boot | **warm fork: CoW disk *and* memory of the live VM** |
| `k7 pause` / `resume` | scale to 0/1 (state lost) | scale to 0/1, disk persists on the PVC | **VM frozen in place: vCPUs stop, memory stays** |
| `k7 restore` | ✗ | ✅ boot a new sandbox from any named snapshot | ✗ (fork the live sandbox instead) |
| Docker-in-VM sidecar | ✅ (emptyDir data) | ✅ (PVC-persistent data) | ✅ (VM-lifetime data) |
Measured numbers for all of this live in [PERFORMANCE.md](../PERFORMANCE.md):
on the same node, k7d creates in ~2.1s vs kql's ~17.1s, forks to a usable
sandbox in ~2.4s vs ~46.7s (and the k7d fork inherits the source's memory),
and pauses/resumes in ~0.2s/~0.3s vs ~1.3s/~4.1s — while kql keeps named
persistent snapshots and the faster in-sidecar `docker pull`.
## Which backend when?
- **kfd** — smallest attack surface and fast boots, when you don't need any
snapshot/fork lifecycle. Requires a dedicated raw disk for the thin-pool.
- **kql** — durable sandboxes. The root disk is a replicated Longhorn volume:
it survives pod restarts and node reboots, can be snapshotted by name,
restored into brand-new sandboxes, and forked (at the cost of a full VM
boot on the cloned disk).
- **k7d** — ephemeral-but-forkable compute, e.g. agent/RL rollouts. The whole
VM (filesystem *and* RAM: running processes, page cache, sockets) can be
forked in seconds, and pause/resume is instantaneous because nothing is
torn down. State does not survive pod deletion — persistence is "fork it
again", not "write it to a disk".
## The k7d backend
**Architecture:** Linux **amd64 / x86_64** only (Debian `amd64` ≡ tarball
`x86_64`). Unlike `kfd` / `kql`, there is no arm64 build yet.
### What k7 installs (`k7 install --backend k7d`)
The Ansible playbook:
1. checks `/dev/kvm` exists (loud failure otherwise) and loads
`vhost_vsock` + `tun`;
2. installs `erofs-utils`, `xfsprogs`, `virtiofsd`, and provisions a sparse
reflink-capable XFS image loop-mounted at `/var/lib/k7d/disks` (warm forks
clone writable volume images with `FICLONE` reflinks);
3. downloads the k7d release tarball
(`k7d_artifact_url`, default the `katakate/k7d` GitHub release for
`k7d_version`; override with `k7d_artifact_local_path` or
`k7 install --k7d-artifact <path>`) and runs the bundled `install.sh`,
which installs `k7d` + `containerd-shim-k7-v1` into `/usr/local/bin`,
guest kernel/initramfs into `/usr/local/share/k7d`, and starts
`k7d.service` (control socket `/run/k7d/k7d.sock`).
The default URL resolves to the `katakate/k7d` GitHub release for
`k7d_version`; use `--k7d-artifact` (or `k7d_artifact_local_path`)
to install from a locally built tarball instead.
4. registers the `k7` runtime in the k3s containerd template **with**
`pod_annotations = ["k7d.katakate.org/*"]` and **without** a `BinaryName`
option (containerd resolves the shim from
`runtime_type = "io.containerd.k7.v1"`), restarts k3s, and creates
RuntimeClass `k7`;
5. labels the node `k7.katakate.org/backend-k7d=true`.
### How a sandbox maps to a VM
One pod = one k7d microVM. The pod's containers (sandbox + optional docker
sidecar) all run as runc containers *inside* that single VM — multi-container
pods share the guest kernel, network namespace, and tmpfs. VM size follows the
pod's CPU/memory limits (the shim reads the CRI sandbox annotations, so
`k7 create --cpu 2 --memory 1Gi` gives a 2-vCPU/1 GiB VM with a host-side CFS
cap).
### The fork story
`k7 fork src dst` on a k7d sandbox does **not** copy a disk. Instead the new
Deployment's pod carries two annotations:
```yaml
k7d.katakate.org/fork-source-cluster: <source CRI sandbox id>
k7d.katakate.org/fork-source-vm: <source CRI sandbox id>
```
The pod's containerd shim resolves the source VM through the k7d daemon and
issues a `fork_vm`: the daemon briefly pauses the source, captures dirty
pages, and builds the child from a `MAP_PRIVATE` CoW mapping of the source's
memory plus reflink clones of its disk overlays. The child inherits
*everything* — files, tmpfs, running processes, page cache — and diverges
independently from that point. The source keeps running (its vsock identity
is preserved). The fork pod then *adopts* the workload container already
running inside the forked guest, so `kubectl exec` / `k7 exec` transparently
target the inherited state.
Properties and limits (fail loudly, never silently):
- Warm fork works for **single-workload sandboxes**; forking a sandbox with a
sidecar is rejected (no reliable container mapping — use kql for that).
- The fork is pinned to the source's node (the k7d daemon is node-local;
cross-node fork is k7d's M12 roadmap item).
- If the fork pod is ever restarted by Kubernetes it re-forks from the (then
current) source — a fork is a live branch, not a stored artifact.
- `k7 snapshot`/`k7 restore` are rejected on k7d: named, storable snapshots
are a Longhorn/kql feature. k7d has its own richer VM snapshot **trees**
(fork/rollback/suspend of whole VM states, including multi-VM clusters)
driven through the k7d daemon API — see the
[k7d project](https://github.com/katakate/k7d). Whole-cluster fork
(forking an inner k8s cluster of VMs as one unit) is deliberately a
k7d-level feature, not a k7 verb.
### k7d VM ops work on any node (per-node k7-agent)
k7d pause/resume/fork need three things that only exist **on the node
hosting the sandbox**: the k7d daemon socket (`/run/k7d/k7d.sock`), the k3s
containerd socket, and `crictl`. Since spec 18g this is handled by the
`k7-agent` DaemonSet (kube-system, same `k7-api:local` image running
`k7.api.agent:app`), so VM ops work **through the API for a sandbox on any
node**:
- A VM op on a sandbox co-located with the k7-api pod runs directly (the
deployment mounts both sockets and ships `crictl`).
- A VM op on a sandbox on any OTHER node is forwarded to the k7-agent pod
on that node (`POST /agent/v1/vm/{pause,resume,fork,lookup}` on the pod
IP). Forwarding authenticates with the shared token the install playbook
writes to `/etc/k7/agent_token` (root, 0600) on every node; a
CiliumNetworkPolicy limits pod-originated agent ingress to the k7-api
pod. No Ready agent on the node / missing token → loud error, never a
silent no-op.
- **CLI on a node** also works for any sandbox: local sandboxes talk to the
local daemon, remote ones are forwarded the same way (root can read the
token).
- A fork still **lands on the source's node** — the agent proxies to the
node-local daemon; the cross-node fork *data path* is k7d spec 9a M12
(daemon side, not built yet). When it lands, only the forwarding target
changes.
kql pause/resume/fork have none of these constraints (they are pure
Kubernetes/Longhorn operations) and work through the API for any node.
### Pause / resume
`k7 pause` on k7d asks the daemon (over `/run/k7d/k7d.sock`) to stop the VM's
vCPU threads and park its device workers: RAM, devices, and the vsock CID all
stay. The pod object remains scheduled (annotated
`k7.katakate.org/k7d-paused=true`), so `k7 resume` is just "restart the vCPU
loop" — sub-second, and every in-memory byte survives. Compare kql, where
pause scales the Deployment to zero (the VM is destroyed; only the Longhorn
disk survives) and resume pays a full VM boot.
## Egress modes
A sandbox has exactly one of three egress modes (spec 18f issue 4):
| Mode | CLI | YAML / API | Result |
|---|---|---|---|
| **block-all** (CLI default) | `k7 create …` (no egress flag) | `egress_whitelist: []` | deny-all egress NetworkPolicy |
| **whitelist** | `--egress <entry>` (repeatable) | `egress_whitelist: [<entries>]` | only listed CIDRs/domains |
| **open** | `--egress-open` | omit `egress_whitelist` (or `null`) | no egress policy at all |
`--egress-open` and `--egress` are mutually exclusive. Note the asymmetric
defaults, kept for backward compatibility: the CLI without flags is
**block-all**, while an API/YAML request that omits `egress_whitelist`
entirely is **open**.
Wildcard semantics: `--egress '*.docker.com'` covers subdomains at **any
depth** (`registry.docker.com` *and* `production.cloudfront.docker.com`) but
not the apex `docker.com` itself — add it as its own entry. Under the hood
k7 translates a leading `*.` into Cilium's multi-label `**.` matchPattern; a
bare Cilium `*` never crosses label boundaries, which used to silently break
CDN-backed registries (spec 18f issue 3). The install also sets Cilium
`dnsProxy.minTtl=3600` so clients that cache a resolved IP longer than the
CDN's 30–60s DNS TTL (dockerd's blob downloader does) keep their learned
FQDN→IP allowance for an hour.
## Disk pool sizing (kfd + k7d)
Both node-local storage pools have fixed sizes chosen at install time
(spec 18f issue 5) — set them per node in the inventory:
| Pool | Backend | Default | Inventory knob | Utilization |
|---|---|---|---|---|
| `kata-vg/thin-pool` (LVM, on the spare disk) | kfd | 100G PV | `kata_thinpool_pv_size` | `lvs kata-vg` (`Data%`/`Meta%`) |
| `/var/lib/k7d/disks` (sparse XFS loopback) | k7d | 32G image | `k7d_disks_image_size` | `df -h /var/lib/k7d/disks` |
Failure modes when a pool fills — both are **invisible to kubelet** (no
disk-pressure eviction, the pools are not part of the root filesystem):
- **kfd thin-pool full**: LVM autoextend (`thin_pool_autoextend_threshold=80`)
grows the pool within the PV; once the PV itself is exhausted writes inside
sandboxes start failing with I/O errors and new kfd pods fail to create
their devmapper snapshots (`CreateContainerError`). Only the first
`kata_thinpool_pv_size` of the spare disk is used — size it generously.
- **k7d pool full**: writable volume images and fork reflink clones fail;
the k7d daemon rejects new sandboxes/forks loudly (`no space left on
device`). The image is sparse, so `ls -l` shows the virtual size —
use `du`/`df` for actual usage.
Utilization for both pools on every node is surfaced through the API
(spec 18g) and CLI/SDK (spec 18h): `GET /api/v1/nodes/storage`,
`k7 nodes storage` (`--json` for raw), and `Client.nodes_storage()`
return a per-node map of `kata_thinpool` (`lvs` size/data%/metadata%)
and `k7d_disks` (`df` size/used/avail), collected from the k7-agent
DaemonSet. A node whose agent is unreachable gets a loud
`{"error": ...}` entry.
## Memory limits (`--memory`)
All three backends honour `k7 create --memory <qty>` (Kubernetes quantity
like `2Gi` / `4096Mi`):
| Backend | Mechanism |
|---|---|
| **kfd** | Kata stamps `io.katacontainers.config.hypervisor.default_memory` (MiB). containerd forwards `io.katacontainers.*` (`pod_annotations` on `runtimes.kata`) and `configuration-fc.toml` allowlists `default_memory` (spec 18h — before that the annotation was silently ignored and the VM stayed at the 2048 MiB default). |
| **kql** | Same annotation path via `runtimes.kata-qemu` + `configuration-qemu.toml` (spec 18g). |
| **k7d** | No Kata annotation — the k7d shim sizes the VM straight from the pod's CRI CPU/memory limits. |
## Known issues
- **kata-fc VMM leak under churn** (spec 18f issue 1b): kata 3.24.0 with the
jailer records the `--daemonize`d jailer's PID, so its SIGTERM fallback at
pod deletion signals a dead PID. When the graceful in-guest shutdown fails
(dead agent under parallel churn), the firecracker process is orphaned and
spins at 100% CPU. `k7 install` deploys a per-node systemd timer
(`k7-vmm-reaper.timer`, 1-minute cadence) that kills VMM processes whose
kata shim is gone, and the integration suite asserts zero orphans cluster
wide after teardown (`tests/integration/test_zz_leaks.py`).
- **kql docker-in-VM wedge under heavy fsync + replicas ≥ 2 — FIXED**
(spec 18f issue 8 / spec 18g): kata's default single-threaded virtiofsd
serialized all virtio-fs IO; under a sustained fsync burst against an
r≥2 Longhorn volume the kata-agent's health ping starved behind the IO
convoy and the shim killed the healthy VM. `k7 install` now widens the
virtiofsd thread pool (`--thread-pool-size=16`); see PERFORMANCE.md for
the root-cause narrative and post-fix numbers.
+4 -39
View File
@@ -1,41 +1,6 @@
# Katakate Docs
# Docs moved
This directory contains the Mintlify site for the Katakate project (k7 CLI, API, and Python SDK).
User-facing documentation lives at https://docs.katakate.org
(source: https://github.com/Katakate/docs).
Run locally:
```
npm i -g mint
mint dev
```
## Development
Install the [Mintlify CLI](https://www.npmjs.com/package/mint) to preview your documentation changes locally. To install, use the following command:
```
npm i -g mint
```
Run the following command at the root of your documentation, where your `docs.json` is located:
```
mint dev
```
View your local preview at `http://localhost:3000`.
## Publishing changes
Install our GitHub app from your [dashboard](https://dashboard.mintlify.com/settings/organization/github-app) to propagate changes from your repo to your deployment. Changes are deployed to production automatically after pushing to the default branch.
## Need help?
### Troubleshooting
- If your dev environment isn't running: Run `mint update` to ensure you have the most recent version of the CLI.
- If a page loads as a 404: Make sure you are running in a folder with a valid `docs.json`.
### Resources
- [Mintlify documentation](https://mintlify.com/docs)
- [Mintlify community](https://mintlify.com/community)
This directory keeps only in-repo technical notes (e.g. BACKENDS.md).
-76
View File
@@ -1,76 +0,0 @@
---
title: "Claude Code setup"
description: "Configure Claude Code for your documentation workflow"
icon: "asterisk"
---
Claude Code is Anthropic's official CLI tool. This guide will help you set up Claude Code to help you write and maintain your documentation.
## Prerequisites
- Active Claude subscription (Pro, Max, or API access)
## Setup
1. Install Claude Code globally:
```bash
npm install -g @anthropic-ai/claude-code
```
2. Navigate to your docs directory.
3. (Optional) Add the `CLAUDE.md` file below to your project.
4. Run `claude` to start.
## Create `CLAUDE.md`
Create a `CLAUDE.md` file at the root of your documentation repository to train Claude Code on your specific documentation standards:
````markdown
# Mintlify documentation
## Working relationship
- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so
- ALWAYS ask for clarification rather than making assumptions
- NEVER lie, guess, or make up information
## Project context
- Format: MDX files with YAML frontmatter
- Config: docs.json for navigation, theme, settings
- Components: Mintlify components
## Content strategy
- Document just enough for user success - not too much, not too little
- Prioritize accuracy and usability of information
- Make content evergreen when possible
- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason
- Check existing patterns for consistency
- Start by making the smallest reasonable changes
## Frontmatter requirements for pages
- title: Clear, descriptive page title
- description: Concise summary for SEO/navigation
## Writing standards
- Second-person voice ("you")
- Prerequisites at start of procedural content
- Test all code examples before publishing
- Match style and formatting of existing pages
- Include both basic and advanced use cases
- Language tags on all code blocks
- Alt text on all images
- Relative paths for internal links
## Git workflow
- NEVER use --no-verify when committing
- Ask how to handle uncommitted changes before starting
- Create a new branch when no clear branch exists for changes
- Commit frequently throughout development
- NEVER skip or disable pre-commit hooks
## Do not
- Skip frontmatter on any MDX file
- Use absolute URLs for internal links
- Include untested code examples
- Make assumptions - always ask for clarification
````
-420
View File
@@ -1,420 +0,0 @@
---
title: "Cursor setup"
description: "Configure Cursor for your documentation workflow"
icon: "arrow-pointer"
---
Use Cursor to help write and maintain your documentation for Katakate. This guide shows how to configure Cursor for better results on technical writing tasks and using Mintlify components.
## Prerequisites
- Cursor editor installed
- Access to your documentation repository
## Project rules
Create project rules that all team members can use. In your documentation repository root:
```bash
mkdir -p .cursor
```
Create `.cursor/rules.md`:
````markdown
# Mintlify technical writing rule
You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices.
## Core writing principles
### Language and style requirements
- Use clear, direct language appropriate for technical audiences
- Write in second person ("you") for instructions and procedures
- Use active voice over passive voice
- Employ present tense for current states, future tense for outcomes
- Avoid jargon unless necessary and define terms when first used
- Maintain consistent terminology throughout all documentation
- Keep sentences concise while providing necessary context
- Use parallel structure in lists, headings, and procedures
### Content organization standards
- Lead with the most important information (inverted pyramid structure)
- Use progressive disclosure: basic concepts before advanced ones
- Break complex procedures into numbered steps
- Include prerequisites and context before instructions
- Provide expected outcomes for each major step
- Use descriptive, keyword-rich headings for navigation and SEO
- Group related information logically with clear section breaks
### User-centered approach
- Focus on user goals and outcomes rather than system features
- Anticipate common questions and address them proactively
- Include troubleshooting for likely failure points
- Write for scannability with clear headings, lists, and white space
- Include verification steps to confirm success
## Mintlify component reference
### Callout components
#### Note - Additional helpful information
<Note>
Supplementary information that supports the main content without interrupting flow
</Note>
#### Tip - Best practices and pro tips
<Tip>
Expert advice, shortcuts, or best practices that enhance user success
</Tip>
#### Warning - Important cautions
<Warning>
Critical information about potential issues, breaking changes, or destructive actions
</Warning>
#### Info - Neutral contextual information
<Info>
Background information, context, or neutral announcements
</Info>
#### Check - Success confirmations
<Check>
Positive confirmations, successful completions, or achievement indicators
</Check>
### Code components
#### Single code block
Example of a single code block:
```javascript config.js
const apiConfig = {
baseURL: 'https://api.example.com',
timeout: 5000,
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
};
```
#### Code group with multiple languages
Example of a code group:
<CodeGroup>
```javascript Node.js
const response = await fetch('/api/endpoint', {
headers: { Authorization: `Bearer ${apiKey}` }
});
```
```python Python
import requests
response = requests.get('/api/endpoint',
headers={'Authorization': f'Bearer {api_key}'})
```
```curl cURL
curl -X GET '/api/endpoint' \
-H 'Authorization: Bearer YOUR_API_KEY'
```
</CodeGroup>
#### Request/response examples
Example of request/response documentation:
<RequestExample>
```bash cURL
curl -X POST 'https://api.example.com/users' \
-H 'Content-Type: application/json' \
-d '{"name": "John Doe", "email": "john@example.com"}'
```
</RequestExample>
<ResponseExample>
```json Success
{
"id": "user_123",
"name": "John Doe",
"email": "john@example.com",
"created_at": "2024-01-15T10:30:00Z"
}
```
</ResponseExample>
### Structural components
#### Steps for procedures
Example of step-by-step instructions:
<Steps>
<Step title="Install dependencies">
Run `npm install` to install required packages.
<Check>
Verify installation by running `npm list`.
</Check>
</Step>
<Step title="Configure environment">
Create a `.env` file with your API credentials.
```bash
API_KEY=your_api_key_here
```
<Warning>
Never commit API keys to version control.
</Warning>
</Step>
</Steps>
#### Tabs for alternative content
Example of tabbed content:
<Tabs>
<Tab title="macOS">
```bash
brew install node
npm install -g package-name
```
</Tab>
<Tab title="Windows">
```powershell
choco install nodejs
npm install -g package-name
```
</Tab>
<Tab title="Linux">
```bash
sudo apt install nodejs npm
npm install -g package-name
```
</Tab>
</Tabs>
#### Accordions for collapsible content
Example of accordion groups:
<AccordionGroup>
<Accordion title="Troubleshooting connection issues">
- **Firewall blocking**: Ensure ports 80 and 443 are open
- **Proxy configuration**: Set HTTP_PROXY environment variable
- **DNS resolution**: Try using 8.8.8.8 as DNS server
</Accordion>
<Accordion title="Advanced configuration">
```javascript
const config = {
performance: { cache: true, timeout: 30000 },
security: { encryption: 'AES-256' }
};
```
</Accordion>
</AccordionGroup>
### Cards and columns for emphasizing information
Example of cards and card groups:
<Card title="Getting started guide" icon="rocket" href="/quickstart">
Complete walkthrough from installation to your first API call in under 10 minutes.
</Card>
<CardGroup cols={2}>
<Card title="Authentication" icon="key" href="/auth">
Learn how to authenticate requests using API keys or JWT tokens.
</Card>
<Card title="Rate limiting" icon="clock" href="/rate-limits">
Understand rate limits and best practices for high-volume usage.
</Card>
</CardGroup>
### API documentation components
#### Parameter fields
Example of parameter documentation:
<ParamField path="user_id" type="string" required>
Unique identifier for the user. Must be a valid UUID v4 format.
</ParamField>
<ParamField body="email" type="string" required>
User's email address. Must be valid and unique within the system.
</ParamField>
<ParamField query="limit" type="integer" default="10">
Maximum number of results to return. Range: 1-100.
</ParamField>
<ParamField header="Authorization" type="string" required>
Bearer token for API authentication. Format: `Bearer YOUR_API_KEY`
</ParamField>
#### Response fields
Example of response field documentation:
<ResponseField name="user_id" type="string" required>
Unique identifier assigned to the newly created user.
</ResponseField>
<ResponseField name="created_at" type="timestamp">
ISO 8601 formatted timestamp of when the user was created.
</ResponseField>
<ResponseField name="permissions" type="array">
List of permission strings assigned to this user.
</ResponseField>
#### Expandable nested fields
Example of nested field documentation:
<ResponseField name="user" type="object">
Complete user object with all associated data.
<Expandable title="User properties">
<ResponseField name="profile" type="object">
User profile information including personal details.
<Expandable title="Profile details">
<ResponseField name="first_name" type="string">
User's first name as entered during registration.
</ResponseField>
<ResponseField name="avatar_url" type="string | null">
URL to user's profile picture. Returns null if no avatar is set.
</ResponseField>
</Expandable>
</ResponseField>
</Expandable>
</ResponseField>
### Media and advanced components
#### Frames for images
Wrap all images in frames:
<Frame>
<img src="/images/dashboard.png" alt="Main dashboard showing analytics overview" />
</Frame>
<Frame caption="The analytics dashboard provides real-time insights">
<img src="/images/analytics.png" alt="Analytics dashboard with charts" />
</Frame>
#### Videos
Use the HTML video element for self-hosted video content:
<video
controls
className="w-full aspect-video rounded-xl"
src="link-to-your-video.com"
></video>
Embed YouTube videos using iframe elements:
<iframe
className="w-full aspect-video rounded-xl"
src="https://www.youtube.com/embed/4KzFe50RQkQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
#### Tooltips
Example of tooltip usage:
<Tooltip tip="Application Programming Interface - protocols for building software">
API
</Tooltip>
#### Updates
Use updates for changelogs:
<Update label="Version 2.1.0" description="Released March 15, 2024">
## New features
- Added bulk user import functionality
- Improved error messages with actionable suggestions
## Bug fixes
- Fixed pagination issue with large datasets
- Resolved authentication timeout problems
</Update>
## Required page structure
Every documentation page must begin with YAML frontmatter:
```yaml
---
title: "Clear, specific, keyword-rich title"
description: "Concise description explaining page purpose and value"
---
```
## Content quality standards
### Code examples requirements
- Always include complete, runnable examples that users can copy and execute
- Show proper error handling and edge case management
- Use realistic data instead of placeholder values
- Include expected outputs and results for verification
- Test all code examples thoroughly before publishing
- Specify language and include filename when relevant
- Add explanatory comments for complex logic
- Never include real API keys or secrets in code examples
### API documentation requirements
- Document all parameters including optional ones with clear descriptions
- Show both success and error response examples with realistic data
- Include rate limiting information with specific limits
- Provide authentication examples showing proper format
- Explain all HTTP status codes and error handling
- Cover complete request/response cycles
### Accessibility requirements
- Include descriptive alt text for all images and diagrams
- Use specific, actionable link text instead of "click here"
- Ensure proper heading hierarchy starting with H2
- Provide keyboard navigation considerations
- Use sufficient color contrast in examples and visuals
- Structure content for easy scanning with headers and lists
## Component selection logic
- Use **Steps** for procedures and sequential instructions
- Use **Tabs** for platform-specific content or alternative approaches
- Use **CodeGroup** when showing the same concept in multiple programming languages
- Use **Accordions** for progressive disclosure of information
- Use **RequestExample/ResponseExample** specifically for API endpoint documentation
- Use **ParamField** for API parameters, **ResponseField** for API responses
- Use **Expandable** for nested object properties or hierarchical information
````
-96
View File
@@ -1,96 +0,0 @@
---
title: "Windsurf setup"
description: "Configure Windsurf for your documentation workflow"
icon: "water"
---
Configure Windsurf's Cascade AI assistant to help you write and maintain documentation. This guide shows how to set up Windsurf specifically for your Mintlify documentation workflow.
## Prerequisites
- Windsurf editor installed
- Access to your documentation repository
## Workspace rules
Create workspace rules that provide Windsurf with context about your documentation project and standards.
Create `.windsurf/rules.md` in your project root:
````markdown
# Mintlify technical writing rule
## Project context
- This is a documentation project on the Mintlify platform
- We use MDX files with YAML frontmatter
- Navigation is configured in `docs.json`
- We follow technical writing best practices
## Writing standards
- Use second person ("you") for instructions
- Write in active voice and present tense
- Start procedures with prerequisites
- Include expected outcomes for major steps
- Use descriptive, keyword-rich headings
- Keep sentences concise but informative
## Required page structure
Every page must start with frontmatter:
```yaml
---
title: "Clear, specific title"
description: "Concise description for SEO and navigation"
---
```
## Mintlify components
### Callouts
- `<Note>` for helpful supplementary information
- `<Warning>` for important cautions and breaking changes
- `<Tip>` for best practices and expert advice
- `<Info>` for neutral contextual information
- `<Check>` for success confirmations
### Code examples
- When appropriate, include complete, runnable examples
- Use `<CodeGroup>` for multiple language examples
- Specify language tags on all code blocks
- Include realistic data, not placeholders
- Use `<RequestExample>` and `<ResponseExample>` for API docs
### Procedures
- Use `<Steps>` component for sequential instructions
- Include verification steps with `<Check>` components when relevant
- Break complex procedures into smaller steps
### Content organization
- Use `<Tabs>` for platform-specific content
- Use `<Accordion>` for progressive disclosure
- Use `<Card>` and `<CardGroup>` for highlighting content
- Wrap images in `<Frame>` components with descriptive alt text
## API documentation requirements
- Document all parameters with `<ParamField>`
- Show response structure with `<ResponseField>`
- Include both success and error examples
- Use `<Expandable>` for nested object properties
- Always include authentication examples
## Quality standards
- Test all code examples before publishing
- Use relative paths for internal links
- Include alt text for all images
- Ensure proper heading hierarchy (start with h2)
- Check existing patterns for consistency
````
-62
View File
@@ -1,62 +0,0 @@
---
title: "Execute command"
description: "Run a command inside a sandbox"
---
Endpoint: `POST /api/v1/sandboxes/{name}/exec`
<RequestExample>
```bash cURL
curl -X POST "$BASE/api/v1/sandboxes/my-sandbox/exec?namespace=default" \
-H "X-API-Key: $K7_API_KEY" \
-H "Content-Type: application/json" \
-d '{"command": "echo Hello"}'
```
</RequestExample>
<ResponseExample>
```json Success
{
"data": {
"exit_code": 0,
"stdout": "Hello\n",
"stderr": "",
"duration_ms": 12
}
}
```
</ResponseExample>
<ParamField path="name" type="string" required>Sandbox name</ParamField>
<ParamField query="namespace" type="string" default="default">Namespace</ParamField>
<ParamField body="command" type="string" required>Shell command to execute</ParamField>
### Semantics
- `exit_code`: `0` on success, non-zero when the command fails.
- `stdout`/`stderr`: Raw streams captured from the process; may include newlines.
- `duration_ms`: Client-observed duration including stream lifecycle.
### Examples
```bash
curl -X POST "$BASE/api/v1/sandboxes/my-sandbox/exec?namespace=default" \
-H "Authorization: Bearer $K7_API_KEY" \
-H "Content-Type: application/json" \
-d '{"command": "apk add --no-cache curl && curl -I https://example.com"}'
```
Error example (non-zero exit):
```json
{
"data": {
"exit_code": 2,
"stdout": "",
"stderr": "some error...",
"duration_ms": 37
}
}
```
-34
View File
@@ -1,34 +0,0 @@
---
title: "Health"
description: "Root and /health endpoints"
---
## Root
Endpoint: `GET /`
```bash
curl "$BASE/"
```
Example:
```json
{ "message": "K7 Sandbox API", "version": "x.y.z" }
```
## Health
Endpoint: `GET /health`
```bash
curl "$BASE/health"
```
Example:
```json
{ "status": "healthy" }
```
-31
View File
@@ -1,31 +0,0 @@
---
title: "Metrics"
description: "Get CPU and memory usage for sandboxes"
---
Endpoint: `GET /api/v1/sandboxes/metrics`
<ParamField query="namespace" type="string" default="default">Namespace</ParamField>
<RequestExample>
```bash cURL
curl -H "X-API-Key: $K7_API_KEY" "$BASE/api/v1/sandboxes/metrics?namespace=default"
```
</RequestExample>
Example response:
```json
{
"data": [
{ "name": "my-sandbox", "namespace": "default", "cpu_usage": "10m", "memory_usage": "64Mi" }
]
}
```
### Units
- `cpu_usage` uses Kubernetes format (e.g., `10m` = 10 millicores).
- `memory_usage` uses Kubernetes format (e.g., `64Mi`).
-201
View File
@@ -1,201 +0,0 @@
---
title: "Sandboxes"
description: "Create, list, get, and delete sandboxes"
---
## Create sandbox
Endpoint: `POST /api/v1/sandboxes`
<RequestExample>
```bash cURL
curl -X POST "$BASE/api/v1/sandboxes" \
-H "X-API-Key: $K7_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "my-sandbox",
"image": "alpine:latest",
"namespace": "default",
"limits": {"cpu": "500m", "memory": "512Mi"}
}'
```
</RequestExample>
Body example:
```json
{
"name": "my-sandbox",
"image": "alpine:latest",
"namespace": "default",
"limits": {"cpu": "500m", "memory": "512Mi"}
}
```
Body example with egress whitelist (safe pattern: proxy IP only):
```json
{
"name": "my-restricted-sandbox",
"image": "alpine:latest",
"namespace": "default",
"egress_whitelist": ["10.0.0.5/32"],
"limits": {"cpu": "500m", "memory": "512Mi"}
}
```
<ResponseExample>
```json Success
{
"data": {
"name": "my-sandbox",
"namespace": "default",
"image": "alpine:latest"
}
}
```
</ResponseExample>
### Request body schema
Fields accepted in the JSON body when creating a sandbox:
- `name` (string, required): Unique sandbox name in the namespace
- `image` (string, required): Container image, e.g. `alpine:latest`
- `namespace` (string, default `default`): Kubernetes namespace
- `env_file` (string | null): Path (on API host) to `.env` file to inject as Secret
- `before_script` (string, default empty): Shell commands to run before the container is marked Ready
- `limits` (object): Resource limits/requests; keys supported: `cpu`, `memory`, `ephemeral-storage`
- `egress_whitelist` (string[] | [] | null): See Egress section below
- `pod_non_root` (boolean, default false): Run pod as non-root (UID/GID/FSGroup 65532)
- `container_non_root` (boolean, default false): Run container as non-root (UID 65532)
- `cap_drop` (string[] | null): List of capabilities to drop; default policy is `ALL`
- `cap_add` (string[] | null): List of capabilities to add back
### Responses
- `201 Created` with Location header to the created resource:
```json
{ "data": { "name": "my-sandbox", "namespace": "default", "image": "alpine:latest" } }
```
- `400 BadRequest` when validation fails (invalid limits, bad env file, already exists, etc.)
## List sandboxes
Endpoint: `GET /api/v1/sandboxes`
<ParamField query="namespace" type="string" default="default">Namespace</ParamField>
<RequestExample>
```bash cURL
curl -H "X-API-Key: $K7_API_KEY" "$BASE/api/v1/sandboxes?namespace=default"
```
</RequestExample>
Returns list of sandbox objects with fields: name, namespace, status, ready, restarts, age, image, error_message.
<ResponseExample>
```json Success
{
"data": [
{
"name": "my-sandbox",
"namespace": "default",
"status": "Running",
"ready": "True",
"restarts": 0,
"age": "0:05:42",
"image": "alpine:latest",
"error_message": ""
}
]
}
```
</ResponseExample>
## Get sandbox
Endpoint: `GET /api/v1/sandboxes/{name}`
<ParamField path="name" type="string" required>Sandbox name</ParamField>
<ParamField query="namespace" type="string" default="default">Namespace</ParamField>
<RequestExample>
```bash cURL
curl -H "X-API-Key: $K7_API_KEY" "$BASE/api/v1/sandboxes/my-sandbox?namespace=default"
```
</RequestExample>
<ResponseExample>
```json Success
{
"data": {
"name": "my-sandbox",
"namespace": "default",
"status": "Running",
"ready": "True",
"restarts": 0,
"age": "0:05:42",
"image": "alpine:latest",
"error_message": ""
}
}
```
</ResponseExample>
## Delete sandbox
Endpoint: `DELETE /api/v1/sandboxes/{name}`
<ParamField path="name" type="string" required>Sandbox name</ParamField>
<ParamField query="namespace" type="string" default="default">Namespace</ParamField>
<RequestExample>
```bash cURL
curl -X DELETE -H "X-API-Key: $K7_API_KEY" \
"$BASE/api/v1/sandboxes/my-sandbox?namespace=default"
```
</RequestExample>
<ResponseExample>
```json Success
{ "data": { "message": "Sandbox my-sandbox deleted successfully" } }
```
</ResponseExample>
## Delete all sandboxes
Endpoint: `DELETE /api/v1/sandboxes`
<ParamField query="namespace" type="string" default="default">Namespace</ParamField>
<RequestExample>
```bash cURL
curl -X DELETE -H "X-API-Key: $K7_API_KEY" \
"$BASE/api/v1/sandboxes?namespace=default"
```
</RequestExample>
<ResponseExample>
```json Success
{
"data": {
"message": "Deleted 1 sandboxes",
"results": [ { "name": "my-sandbox", "success": true, "error": null } ]
}
}
```
</ResponseExample>
<Warning>
Deleting sandboxes is irreversible.
</Warning>
## See also
- API Security & networking: `/api/security`
-72
View File
@@ -1,72 +0,0 @@
---
title: "Auth & responses"
description: "Authentication, base URL, and response format"
---
Base URL: `https://<your-endpoint>` (see `k7 api-status` / `k7 get-api-endpoint`).
## Authentication
Send your API key via either header:
```http
X-API-Key: <your-key>
# or
Authorization: Bearer <your-key>
```
All endpoints return standard HTTP codes. `401 Unauthorized` if the key is missing/invalid/expired.
## Response envelope
Successful responses:
```json
{ "data": ... }
```
Errors:
```json
{ "error": { "code": "BadRequest", "message": "..." } }
```
Common error codes:
- `BadRequest` (400): Invalid input or missing parameters
- `Unauthorized` (401): Missing or invalid API key
- `NotFound` (404): Resource was not found
- `Conflict` (409): Resource already exists
- `UnprocessableEntity` (422): Validation failed
- `InternalServerError` (500): Unhandled error
## Headers
Required headers for requests with body:
```http
Content-Type: application/json
```
Authentication headers (either):
```http
X-API-Key: <your-key>
# or
Authorization: Bearer <your-key>
```
## Resources
- Sandboxes: create, list, get, delete, delete-all
- Exec: run a command in a sandbox and get stdout/stderr/exit_code
- Metrics: CPU and memory usage per sandbox
<Info>
Health endpoints:
- `GET /` → basic API info
- `GET /health` → health check
</Info>
-187
View File
@@ -1,187 +0,0 @@
---
title: "Security & networking"
description: "Non-root execution, Linux capabilities, and egress control"
---
This page covers sandbox security posture and how to configure it via the API.
## Non-root execution
- `pod_non_root` (boolean, default false): Run the Pod as non-root (UID/GID/FSGroup 65532). Applies pod-wide filesystem ownership.
- `container_non_root` (boolean, default false): Run the main container as non-root (UID 65532) and disallow privilege escalation.
Guidance:
- Enable both flags for consistent non-root behavior and fewer permission surprises when writing to volumes.
- Some package managers (e.g., Alpine `apk add`) require root. To run `apk add` inside the container, you have options:
- Use `before_script` with a base image that already includes needed tools, or
- Temporarily run the main container with root by leaving `container_non_root` disabled for setup, or
- Build a custom image with dependencies pre-installed (recommended for production reproducibility).
Example (non-root):
```json
{
"name": "nr-example",
"image": "alpine:latest",
"pod_non_root": true,
"container_non_root": true
}
```
Example (install packages first as root, then lock down egress):
```json
{
"name": "setup-then-lock",
"image": "alpine:latest",
"before_script": "apk add --no-cache curl git",
"egress_whitelist": ["203.0.113.0/24"]
}
```
## Linux capabilities
Default policy: drop ALL capabilities. Add back only what you need. If you specify `cap_drop` explicitly, you override the default; to keep `drop ALL` and add back minimal caps, leave `cap_drop` unset and only use `cap_add`.
- `cap_drop` (string[]): Capabilities to drop. If omitted, `ALL` is dropped by default.
- `cap_add` (string[]): Capabilities to add back.
- `allow_privilege_escalation`: always set to `false`.
- Seccomp profile: `RuntimeDefault`.
Examples:
Minimal add-back while still dropping ALL by default:
```json
{
"name": "caps-minimal",
"image": "alpine:latest",
"cap_add": ["CHOWN"],
"cap_drop": null
}
```
Override drop policy (not recommended unless you know why):
```json
{
"name": "caps-custom",
"image": "alpine:latest",
"cap_drop": ["NET_RAW"],
"cap_add": []
}
```
### Common capability requirements
When you encounter permission errors in `before_script` or during container execution, you may need to add specific capabilities. Here are common use cases:
**Package managers (apt-get, yum, dnf):**
```json
{
"name": "install-packages",
"image": "ubuntu:22.04",
"cap_add": ["SETUID", "SETGID", "CHOWN", "DAC_OVERRIDE"],
"before_script": "apt-get update && apt-get install -y git curl"
}
```
- `SETUID`/`SETGID`: Required for package managers to drop privileges during installation
- `CHOWN`: Needed for changing file ownership during package installation
- `DAC_OVERRIDE`: Allows bypassing file permission checks (needed for installing packages)
**Alpine package manager (apk):**
```json
{
"name": "alpine-packages",
"image": "alpine:latest",
"cap_add": ["SETUID", "SETGID", "CHOWN"],
"before_script": "apk add --no-cache git curl"
}
```
**File operations requiring ownership changes:**
```json
{
"name": "file-ops",
"image": "alpine:latest",
"cap_add": ["CHOWN", "FOWNER"],
"before_script": "chown -R user:group /some/path"
}
```
**Network operations (raw sockets, packet capture):**
```json
{
"name": "network-tools",
"image": "alpine:latest",
"cap_add": ["NET_RAW", "NET_ADMIN"]
}
```
<Warning>
Adding `NET_RAW` or `NET_ADMIN` significantly reduces isolation. Only use when absolutely necessary for network debugging or specialized tools.
</Warning>
<Info>
**Best practice**: Pre-build custom images with dependencies installed rather than installing packages at runtime. This improves security, reproducibility, and startup time.
</Info>
## Network isolation and egress lockdown
### Ingress isolation (Default: Enabled)
**All inter-VM communication is blocked by default** to prevent sandbox-to-sandbox access. This provides strong isolation between different sandboxes running in the same cluster.
**Key points:**
- **Ingress blocking**: VM sandboxes cannot communicate with each other by default
- **Administrative access preserved**: `kubectl exec` and `k7 shell` still work normally (they use the Kubernetes API, not pod networking)
- **System services allowed**: Traffic from `kube-system` namespace is permitted for cluster functionality
- **No configuration needed**: This security feature is enabled by default for all sandboxes
### Egress lockdown and whitelisting
Use `egress_whitelist` to control outbound traffic. The policy is applied after the container becomes Ready so `before_script` runs with open egress.
Behavior:
- Omit `egress_whitelist`: egress open (external internet allowed).
- `[]`: full egress block (no DNS resolution; no outbound IPs).
- `["CIDR", ...]`: allow only listed CIDR blocks; DNS is blocked.
Examples:
Full isolation (no inter-VM communication, no external access):
```json
{ "name": "fully-isolated", "image": "alpine:latest", "egress_whitelist": [] }
```
Partial isolation (no inter-VM communication, but external internet allowed):
```json
{ "name": "partial-isolation", "image": "alpine:latest" }
```
Whitelist specific external services (avoid public DNS resolvers):
```json
{
"name": "egress-restricted",
"image": "alpine:latest",
"egress_whitelist": ["10.0.0.5/32"]
}
```
<Info>
**Network Policy Details:**
- **Ingress**: Blocked by default (inter-VM isolation) - system services and kubectl exec still work
- **DNS**: When egress is locked down, DNS resolution is blocked by default (no CoreDNS exception)
- **Administrative access**: `kubectl exec`, `k7 shell`, and API operations bypass network policies
</Info>
<Warning>
Do not whitelist public DNS resolver IPs (e.g., 1.1.1.1, 8.8.8.8). Because K7's `egress_whitelist` is CIDR-only (no L7/port rules), allowing those IPs enables outbound DNS (UDP/TCP 53) and DNS-over-HTTPS (443), which can be used for exfiltration. If you want an egress deny with whitelisting, prefer whitelisting only your own egress proxy/gateway IP and enforce DNS/DoH policy at that proxy. Later, whenever we integrate Cilium (a roadmap feature), it will be much simpler as you'll be able to whitelist domain names directly.
</Warning>
### Mitigations when DNS is blocked
- Use IP/CIDR whitelisting only (no domains post-lockdown)
- Pre-resolve/fetch in `before_script` (runs before lockdown with open egress)
- If you must allow DNS temporarily, consider an operational override at cluster level (not provided by K7 config)
-94
View File
@@ -1,94 +0,0 @@
---
title: 'Development'
description: 'Preview changes locally to update your docs'
---
<Info>
**Prerequisites**:
- Node.js version 19 or higher
- A docs repository with a `docs.json` file
</Info>
Follow these steps to install and run Mintlify on your operating system.
<Steps>
<Step title="Install the Mintlify CLI">
```bash
npm i -g mint
```
</Step>
<Step title="Preview locally">
Navigate to your docs directory where your `docs.json` file is located, and run the following command:
```bash
mint dev
```
A local preview of your documentation will be available at `http://localhost:3000`.
</Step>
</Steps>
## Custom ports
By default, Mintlify uses port 3000. You can customize the port Mintlify runs on by using the `--port` flag. For example, to run Mintlify on port 3333, use this command:
```bash
mint dev --port 3333
```
If you attempt to run Mintlify on a port that's already in use, it will use the next available port:
```md
Port 3000 is already in use. Trying 3001 instead.
```
## Mintlify versions
Please note that each CLI release is associated with a specific version of Mintlify. If your local preview does not align with the production version, please update the CLI:
```bash
npm mint update
```
## Validating links
The CLI can assist with validating links in your documentation. To identify any broken links, use the following command:
```bash
mint broken-links
```
## Deployment
If the deployment is successful, you should see the following:
<Frame>
<img src="/images/checks-passed.png" alt="Screenshot of a deployment confirmation message that says All checks have passed." style={{ borderRadius: '0.5rem' }} />
</Frame>
## Code formatting
We suggest using extensions on your IDE to recognize and format MDX. If you're a VSCode user, consider the [MDX VSCode extension](https://marketplace.visualstudio.com/items?itemName=unifiedjs.vscode-mdx) for syntax highlighting, and [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) for code formatting.
## Troubleshooting
<AccordionGroup>
<Accordion title='Error: Could not load the "sharp" module using the darwin-arm64 runtime'>
This may be due to an outdated version of node. Try the following:
1. Remove the currently-installed version of the CLI: `npm remove -g mint`
2. Upgrade to Node v19 or higher.
3. Reinstall the CLI: `npm i -g mint`
</Accordion>
<Accordion title="Issue: Encountering an unknown error">
Solution: Go to the root of your device and delete the `~/.mintlify` folder. Then run `mint dev` again.
</Accordion>
</AccordionGroup>
Curious about what changed in the latest CLI version? Check out the [CLI changelog](https://www.npmjs.com/package/mintlify?activeTab=versions).
-87
View File
@@ -1,87 +0,0 @@
{
"$schema": "https://mintlify.com/docs.json",
"theme": "linden",
"name": "Docs",
"colors": {
"primary": "#ef672b",
"light": "#ef672b",
"dark": "#ef672b"
},
"appearance": {
"default": "dark",
"background": {
"color": { "dark": "#000000" }
}
},
"favicon": "/favicon.png",
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Getting started",
"pages": [
"index",
"getting-started/installation"
]
},
{
"group": "Using Katakate",
"pages": [
"guides/cli",
"guides/python-sdk",
"guides/langchain-agent",
"guides/utilities"
]
},
{
"group": "API overview",
"pages": [
"api/introduction",
"api/security"
]
},
{
"group": "API endpoints",
"pages": [
"api/endpoints/sandboxes",
"api/endpoints/exec",
"api/endpoints/metrics",
"api/endpoints/health"
]
}
]
}
]
},
"logo": {
"light": "/images/k7-logo.png",
"dark": "/images/k7-logo.png"
},
"navbar": {
"links": [
{
"label": "GitHub",
"href": "https://github.com/Katakate/k7"
}
]
},
"contextual": {
"options": [
"copy",
"view",
"chatgpt",
"claude",
"perplexity",
"mcp",
"cursor",
"vscode"
]
},
"footer": {
"socials": {
"github": "https://github.com/Katakate"
}
}
}
-35
View File
@@ -1,35 +0,0 @@
---
title: 'Code blocks'
description: 'Display inline code and code blocks'
icon: 'code'
---
## Inline code
To denote a `word` or `phrase` as code, enclose it in backticks (`).
```
To denote a `word` or `phrase` as code, enclose it in backticks (`).
```
## Code blocks
Use [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) by enclosing code in three backticks and follow the leading ticks with the programming language of your snippet to get syntax highlighting. Optionally, you can also write the name of your code after the programming language.
```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
````md
```java HelloWorld.java
class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
```
````
-59
View File
@@ -1,59 +0,0 @@
---
title: 'Images and embeds'
description: 'Add image, video, and other HTML elements'
icon: 'image'
---
<img
style={{ borderRadius: '0.5rem' }}
src="https://mintlify-assets.b-cdn.net/bigbend.jpg"
/>
## Image
### Using Markdown
The [markdown syntax](https://www.markdownguide.org/basic-syntax/#images) lets you add images using the following code
```md
![title](/path/image.jpg)
```
Note that the image file size must be less than 5MB. Otherwise, we recommend hosting on a service like [Cloudinary](https://cloudinary.com/) or [S3](https://aws.amazon.com/s3/). You can then use that URL and embed.
### Using embeds
To get more customizability with images, you can also use [embeds](/writing-content/embed) to add images
```html
<img height="200" src="/path/image.jpg" />
```
## Embeds and HTML elements
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/4KzFe50RQkQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ width: '100%', borderRadius: '0.5rem' }}
></iframe>
<br />
<Tip>
Mintlify supports [HTML tags in Markdown](https://www.markdownguide.org/basic-syntax/#html). This is helpful if you prefer HTML tags to Markdown syntax, and lets you create documentation with infinite flexibility.
</Tip>
### iFrames
Loads another HTML page within the document. Most commonly used for embedding videos.
```html
<iframe src="https://www.youtube.com/embed/4KzFe50RQkQ"> </iframe>
```
-88
View File
@@ -1,88 +0,0 @@
---
title: 'Markdown syntax'
description: 'Text, title, and styling in standard markdown'
icon: 'text-size'
---
## Titles
Best used for section headers.
```md
## Titles
```
### Subtitles
Best used for subsection headers.
```md
### Subtitles
```
<Tip>
Each **title** and **subtitle** creates an anchor and also shows up on the table of contents on the right.
</Tip>
## Text formatting
We support most markdown formatting. Simply add `**`, `_`, or `~` around text to format it.
| Style | How to write it | Result |
| ------------- | ----------------- | --------------- |
| Bold | `**bold**` | **bold** |
| Italic | `_italic_` | _italic_ |
| Strikethrough | `~strikethrough~` | ~strikethrough~ |
You can combine these. For example, write `**_bold and italic_**` to get **_bold and italic_** text.
You need to use HTML to write superscript and subscript text. That is, add `<sup>` or `<sub>` around your text.
| Text Size | How to write it | Result |
| ----------- | ------------------------ | ---------------------- |
| Superscript | `<sup>superscript</sup>` | <sup>superscript</sup> |
| Subscript | `<sub>subscript</sub>` | <sub>subscript</sub> |
## Linking to pages
You can add a link by wrapping text in `[]()`. You would write `[link to google](https://google.com)` to [link to google](https://google.com).
Links to pages in your docs need to be root-relative. Basically, you should include the entire folder path. For example, `[link to text](/writing-content/text)` links to the page "Text" in our components section.
Relative links like `[link to text](../text)` will open slower because we cannot optimize them as easily.
## Blockquotes
### Singleline
To create a blockquote, add a `>` in front of a paragraph.
> Dorothy followed her through many of the beautiful rooms in her castle.
```md
> Dorothy followed her through many of the beautiful rooms in her castle.
```
### Multiline
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
```md
> Dorothy followed her through many of the beautiful rooms in her castle.
>
> The Witch bade her clean the pots and kettles and sweep the floor and keep the fire fed with wood.
```
### LaTeX
Mintlify supports [LaTeX](https://www.latex-project.org) through the Latex component.
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
```md
<Latex>8 x (vk x H1 - H2) = (0,1)</Latex>
```
-87
View File
@@ -1,87 +0,0 @@
---
title: 'Navigation'
description: 'The navigation field in docs.json defines the pages that go in the navigation menu'
icon: 'map'
---
The navigation menu is the list of links on every website.
You will likely update `docs.json` every time you add a new page. Pages do not show up automatically.
## Navigation syntax
Our navigation syntax is recursive which means you can make nested navigation groups. You don't need to include `.mdx` in page names.
<CodeGroup>
```json Regular Navigation
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Getting Started",
"pages": ["quickstart"]
}
]
}
]
}
```
```json Nested Navigation
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Getting Started",
"pages": [
"quickstart",
{
"group": "Nested Reference Pages",
"pages": ["nested-reference-page"]
}
]
}
]
}
]
}
```
</CodeGroup>
## Folders
Simply put your MDX files in folders and update the paths in `docs.json`.
For example, to have a page at `https://yoursite.com/your-folder/your-page` you would make a folder called `your-folder` containing an MDX file called `your-page.mdx`.
<Warning>
You cannot use `api` for the name of a folder unless you nest it inside another folder. Mintlify uses Next.js which reserves the top-level `api` folder for internal server calls. A folder name such as `api-reference` would be accepted.
</Warning>
```json Navigation With Folder
"navigation": {
"tabs": [
{
"tab": "Docs",
"groups": [
{
"group": "Group Name",
"pages": ["your-folder/your-page"]
}
]
}
]
}
```
## Hidden pages
MDX files not included in `docs.json` will not show up in the sidebar but are accessible through the search bar and by linking directly to them.
-110
View File
@@ -1,110 +0,0 @@
---
title: "Reusable snippets"
description: "Reusable, custom snippets to keep content in sync"
icon: "recycle"
---
import SnippetIntro from '/snippets/snippet-intro.mdx';
<SnippetIntro />
## Creating a custom snippet
**Pre-condition**: You must create your snippet file in the `snippets` directory.
<Note>
Any page in the `snippets` directory will be treated as a snippet and will not
be rendered into a standalone page. If you want to create a standalone page
from the snippet, import the snippet into another file and call it as a
component.
</Note>
### Default export
1. Add content to your snippet file that you want to re-use across multiple
locations. Optionally, you can add variables that can be filled in via props
when you import the snippet.
```mdx snippets/my-snippet.mdx
Hello world! This is my content I want to reuse across pages. My keyword of the
day is {word}.
```
<Warning>
The content that you want to reuse must be inside the `snippets` directory in
order for the import to work.
</Warning>
2. Import the snippet into your destination file.
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import MySnippet from '/snippets/path/to/my-snippet.mdx';
## Header
Lorem impsum dolor sit amet.
<MySnippet word="bananas" />
```
### Reusable variables
1. Export a variable from your snippet file:
```mdx snippets/path/to/custom-variables.mdx
export const myName = 'my name';
export const myObject = { fruit: 'strawberries' };
```
2. Import the snippet from your destination file and use the variable:
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import { myName, myObject } from '/snippets/path/to/custom-variables.mdx';
Hello, my name is {myName} and I like {myObject.fruit}.
```
### Reusable components
1. Inside your snippet file, create a component that takes in props by exporting
your component in the form of an arrow function.
```mdx snippets/custom-component.mdx
export const MyComponent = ({ title }) => (
<div>
<h1>{title}</h1>
<p>... snippet content ...</p>
</div>
);
```
<Warning>
MDX does not compile inside the body of an arrow function. Stick to HTML
syntax when you can or use a default export if you need to use MDX.
</Warning>
2. Import the snippet into your destination file and pass in the props
```mdx destination-file.mdx
---
title: My title
description: My Description
---
import { MyComponent } from '/snippets/custom-component.mdx';
Lorem ipsum dolor sit amet.
<MyComponent title={'Custom title'} />
```
-318
View File
@@ -1,318 +0,0 @@
---
title: 'Global Settings'
description: 'Mintlify gives you complete control over the look and feel of your documentation using the docs.json file'
icon: 'gear'
---
Every Mintlify site needs a `docs.json` file with the core configuration settings. Learn more about the [properties](#properties) below.
## Properties
<ResponseField name="name" type="string" required>
Name of your project. Used for the global title.
Example: `mintlify`
</ResponseField>
<ResponseField name="navigation" type="Navigation[]" required>
An array of groups with all the pages within that group
<Expandable title="Navigation">
<ResponseField name="group" type="string">
The name of the group.
Example: `Settings`
</ResponseField>
<ResponseField name="pages" type="string[]">
The relative paths to the markdown files that will serve as pages.
Example: `["customization", "page"]`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="logo" type="string or object">
Path to logo image or object with path to "light" and "dark" mode logo images
<Expandable title="Logo">
<ResponseField name="light" type="string">
Path to the logo in light mode
</ResponseField>
<ResponseField name="dark" type="string">
Path to the logo in dark mode
</ResponseField>
<ResponseField name="href" type="string" default="/">
Where clicking on the logo links you to
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="favicon" type="string">
Path to the favicon image
</ResponseField>
<ResponseField name="colors" type="Colors">
Hex color codes for your global theme
<Expandable title="Colors">
<ResponseField name="primary" type="string" required>
The primary color. Used for most often for highlighted content, section
headers, accents, in light mode
</ResponseField>
<ResponseField name="light" type="string">
The primary color for dark mode. Used for most often for highlighted
content, section headers, accents, in dark mode
</ResponseField>
<ResponseField name="dark" type="string">
The primary color for important buttons
</ResponseField>
<ResponseField name="background" type="object">
The color of the background in both light and dark mode
<Expandable title="Object">
<ResponseField name="light" type="string" required>
The hex color code of the background in light mode
</ResponseField>
<ResponseField name="dark" type="string" required>
The hex color code of the background in dark mode
</ResponseField>
</Expandable>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topbarLinks" type="TopbarLink[]">
Array of `name`s and `url`s of links you want to include in the topbar
<Expandable title="TopbarLink">
<ResponseField name="name" type="string">
The name of the button.
Example: `Contact us`
</ResponseField>
<ResponseField name="url" type="string">
The url once you click on the button. Example: `https://mintlify.com/docs`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topbarCtaButton" type="Call to Action">
<Expandable title="Topbar Call to Action">
<ResponseField name="type" type={'"link" or "github"'} default="link">
Link shows a button. GitHub shows the repo information at the url provided including the number of GitHub stars.
</ResponseField>
<ResponseField name="url" type="string">
If `link`: What the button links to.
If `github`: Link to the repository to load GitHub information from.
</ResponseField>
<ResponseField name="name" type="string">
Text inside the button. Only required if `type` is a `link`.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="versions" type="string[]">
Array of version names. Only use this if you want to show different versions
of docs with a dropdown in the navigation bar.
</ResponseField>
<ResponseField name="anchors" type="Anchor[]">
An array of the anchors, includes the `icon`, `color`, and `url`.
<Expandable title="Anchor">
<ResponseField name="icon" type="string">
The [Font Awesome](https://fontawesome.com/search?q=heart) icon used to feature the anchor.
Example: `comments`
</ResponseField>
<ResponseField name="name" type="string">
The name of the anchor label.
Example: `Community`
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the anchor. Generally, this is the name of the folder you put your pages in.
</ResponseField>
<ResponseField name="color" type="string">
The hex color of the anchor icon background. Can also be a gradient if you pass an object with the properties `from` and `to` that are each a hex color.
</ResponseField>
<ResponseField name="version" type="string">
Used if you want to hide an anchor until the correct docs version is selected.
</ResponseField>
<ResponseField name="isDefaultHidden" type="boolean" default="false">
Pass `true` if you want to hide the anchor until you directly link someone to docs inside it.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="topAnchor" type="Object">
Override the default configurations for the top-most anchor.
<Expandable title="Object">
<ResponseField name="name" default="Documentation" type="string">
The name of the top-most anchor
</ResponseField>
<ResponseField name="icon" default="book-open" type="string">
Font Awesome icon.
</ResponseField>
<ResponseField name="iconType" default="duotone" type="string">
One of: "brands", "duotone", "light", "sharp-solid", "solid", or "thin"
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="tabs" type="Tabs[]">
An array of navigational tabs.
<Expandable title="Tabs">
<ResponseField name="name" type="string">
The name of the tab label.
</ResponseField>
<ResponseField name="url" type="string">
The start of the URL that marks what pages go in the tab. Generally, this
is the name of the folder you put your pages in.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="api" type="API">
Configuration for API settings. Learn more about API pages at [API Components](/api-playground/demo).
<Expandable title="API">
<ResponseField name="baseUrl" type="string">
The base url for all API endpoints. If `baseUrl` is an array, it will enable for multiple base url
options that the user can toggle.
</ResponseField>
<ResponseField name="auth" type="Auth">
<Expandable title="Auth">
<ResponseField name="method" type='"bearer" | "basic" | "key"'>
The authentication strategy used for all API endpoints.
</ResponseField>
<ResponseField name="name" type="string">
The name of the authentication parameter used in the API playground.
If method is `basic`, the format should be `[usernameName]:[passwordName]`
</ResponseField>
<ResponseField name="inputPrefix" type="string">
The default value that's designed to be a prefix for the authentication input field.
E.g. If an `inputPrefix` of `AuthKey` would inherit the default input result of the authentication field as `AuthKey`.
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="playground" type="Playground">
Configurations for the API playground
<Expandable title="Playground">
<ResponseField name="mode" default="show" type='"show" | "simple" | "hide"'>
Whether the playground is showing, hidden, or only displaying the endpoint with no added user interactivity `simple`
Learn more at the [playground guides](/api-playground/demo)
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="maintainOrder" type="boolean">
Enabling this flag ensures that key ordering in OpenAPI pages matches the key ordering defined in the OpenAPI file.
<Warning>This behavior will soon be enabled by default, at which point this field will be deprecated.</Warning>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="openapi" type="string | string[]">
A string or an array of strings of URL(s) or relative path(s) pointing to your
OpenAPI file.
Examples:
<CodeGroup>
```json Absolute
"openapi": "https://example.com/openapi.json"
```
```json Relative
"openapi": "/openapi.json"
```
```json Multiple
"openapi": ["https://example.com/openapi1.json", "/openapi2.json", "/openapi3.json"]
```
</CodeGroup>
</ResponseField>
<ResponseField name="footerSocials" type="FooterSocials">
An object of social media accounts where the key:property pair represents the social media platform and the account url.
Example:
```json
{
"x": "https://x.com/mintlify",
"website": "https://mintlify.com"
}
```
<Expandable title="FooterSocials">
<ResponseField name="[key]" type="string">
One of the following values `website`, `facebook`, `x`, `discord`, `slack`, `github`, `linkedin`, `instagram`, `hacker-news`
Example: `x`
</ResponseField>
<ResponseField name="property" type="string">
The URL to the social platform.
Example: `https://x.com/mintlify`
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="feedback" type="Feedback">
Configurations to enable feedback buttons
<Expandable title="Feedback">
<ResponseField name="suggestEdit" type="boolean" default="false">
Enables a button to allow users to suggest edits via pull requests
</ResponseField>
<ResponseField name="raiseIssue" type="boolean" default="false">
Enables a button to allow users to raise an issue about the documentation
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="modeToggle" type="ModeToggle">
Customize the dark mode toggle.
<Expandable title="ModeToggle">
<ResponseField name="default" type={'"light" or "dark"'}>
Set if you always want to show light or dark mode for new users. When not
set, we default to the same mode as the user's operating system.
</ResponseField>
<ResponseField name="isHidden" type="boolean" default="false">
Set to true to hide the dark/light mode toggle. You can combine `isHidden` with `default` to force your docs to only use light or dark mode. For example:
<CodeGroup>
```json Only Dark Mode
"modeToggle": {
"default": "dark",
"isHidden": true
}
```
```json Only Light Mode
"modeToggle": {
"default": "light",
"isHidden": true
}
```
</CodeGroup>
</ResponseField>
</Expandable>
</ResponseField>
<ResponseField name="backgroundImage" type="string">
A background image to be displayed behind every page. See example with
[Infisical](https://infisical.com/docs) and [FRPC](https://frpc.io).
</ResponseField>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

-220
View File
@@ -1,220 +0,0 @@
---
title: "Quickstart"
description: "Install k7, prepare your node, start the API, and run your first sandbox"
---
Katakate (k7) lets you run secure, lightweight VM sandboxes backed by Kata Containers and Firecracker, orchestrated with Kubernetes. This Quickstart gets you from zero to a working sandbox via CLI and Python SDK.
<Note>
If you already installed k7 previously, consider running `make uninstall` before reinstalling to avoid stale cached files in a previous `.deb`.
</Note>
## Requirements
- Linux (amd64) host with hardware virtualization (KVM)
- Check: `ls /dev/kvm` should exist
- Cloud guidance: AWS `.metal`, GCP (enable nested virtualization), Azure D/Ev series; typical VPS often lack KVM
- One raw, unformatted disk for thin‑pool provisioning (recommended for many sandboxes)
- Docker with Compose plugin (for the API)
- Install Docker: `curl -fsSL https://get.docker.com | sh`
- Ansible for the installer (Ubuntu):
```bash
sudo add-apt-repository universe -y
sudo apt update
sudo apt install -y ansible
```
- Python 3.10+ on the client for the SDK
<Info>
Tested setup example: Hetzner Robot instance, Ubuntu 24.04 (x86_64), with an extra empty NVMe disk (for the thin‑pool). See the detailed setup guide (PDF): <a href="/tutorials/k7_hetzner_node_setup.pdf" target="_blank" rel="noopener noreferrer">k7_hetzner_node_setup.pdf</a>.
</Info>
## Install the CLI (APT)
Install the `k7` CLI on the node(s) that will host the VM sandboxes:
```bash
sudo add-apt-repository ppa:katakate.org/k7
sudo apt update
sudo apt install k7
```
## Install K7 on your node(s)
This installs and wires up Kubernetes (K3s), Kata, Firecracker, Jailer, and the devmapper snapshotter with thin‑pool provisioning:
```bash
k7 install
```
![Example output: k7 install](/images/ex-install.png)
<Check>
You should see "Installation completed successfully!" when done. Add `-v` for verbose output.
</Check>
## Start the API and manage keys
### Start the API
```bash
k7 start-api
```
![Example: k7 start-api](/images/ex-start-api.png)
### Check API status
```bash
k7 api-status
```
![Example: k7 api-status](/images/ex-api-status.png)
### Get the public endpoint
```bash
k7 get-api-endpoint
```
![Example: k7 get-api-endpoint](/images/ex-get-api-endpoint.png)
### Generate an API key
```bash
k7 generate-api-key mykey
```
![Example: k7 generate-api-key](/images/ex-generate-api-key.png)
### Stop the API
```bash
k7 stop-api
```
![Example: k7 stop-api](/images/ex-stop-api.png)
<Info>
- Ensure your user is in the `docker` group to manage the API containers.
- API keys are stored at `/etc/k7/api_keys.json` by default. Authentication accepts `X-API-Key` header or `Authorization: Bearer <token>`.
</Info>
## Create your first sandbox via CLI
Example `k7.yaml`:
```yaml
name: demo
image: alpine:3.20
namespace: default
env_file: /root/secrets.env
limits:
cpu: "100m"
memory: "128Mi"
before_script: |
# Installing curl. Egress open during before_script, then restricted (empty whitelist) afterwards
apk add curl
echo $ENV_VAR_1
egress_whitelist: []
```
### Create a sandbox
```bash
# Uses k7.yaml in the current directory by default
k7 create
```
![Example: k7 create](/images/ex-create.png)
### Shell into your sandbox
```bash
k7 shell demo
```
![Example: k7 shell](/images/ex-shell.png)
### List sandboxes
```bash
k7 list
```
![Example: k7 list](/images/ex-list.png)
### Delete a sandbox
```bash
k7 delete my-sandbox-123
```
### Delete all sandboxes
```bash
k7 delete-all
```
### Prerequisites for the SDK
```bash
# Ensure the API is running and you have an endpoint and API key
k7 start-api
k7 get-api-endpoint
k7 generate-api-key my-key
```
## Create your first sandbox via Python SDK
Install the SDK on your client machine:
```bash
pip install katakate
```
Use the synchronous client:
```python
from katakate import Client
k7 = Client(endpoint="https://<your-endpoint>", api_key="<your-key>")
# Create sandbox
sb = k7.create({
"name": "my-sandbox",
"image": "alpine:latest"
})
# Execute code
result = sb.exec('echo "Hello World"')
print(result["stdout"]) # or just print(sb.exec("echo hi"))
# List and cleanup
print(k7.list())
sb.delete()
```
Async variant:
```python
import asyncio
from katakate import AsyncClient
async def main():
k7 = AsyncClient(endpoint="https://<your-endpoint>", api_key="<your-key>")
print(await k7.list())
await k7.aclose()
asyncio.run(main())
```
## Next steps
- Explore the CLI guide: `/guides/cli`
- Explore the Python SDK guide: `/guides/python-sdk`
- Integrate with the REST API: `/api/introduction`
-190
View File
@@ -1,190 +0,0 @@
---
title: "CLI reference"
description: "All k7 commands with options and examples"
---
Use `k7 -h` for built-in help. Below are the primary commands.
## install
Install K7 components on host node(s).
```bash
k7 install [-v]
```
- **-v**: verbose output
## version
Check version of installed K7 .deb package
```bash
k7 -V
```
## create
Create a sandbox from a YAML file or flags.
```bash
k7 create -f k7.yaml
# or
k7 create --name my-sb --image alpine:latest \
--cpu 1 --memory 1Gi --storage 2Gi \
--env-file .env --egress 10.0.0.5/32 \
--before-script "apk add curl"
```
### YAML configuration reference
All fields map to the server-side `SandboxConfig`:
- **name** (string, required): unique sandbox name.
- **image** (string, required): container image, e.g. `alpine:latest`.
- **namespace** (string, default `default`): Kubernetes namespace.
- **env_file** (string, optional): (absolute) path to an env file on the host node.
- **egress_whitelist** (array of CIDR strings, optional): allowed egress IPs, e.g. `"1.1.1.1/32"` for single hosts or `"10.0.0.0/8"` for ranges.
- **limits** (object, optional): resource limits:
- **cpu** (string): cores or millicores, e.g. `"1"` or `"500m"`.
- **memory** (string): e.g. `"1Gi"`, `"512Mi"`.
- **ephemeral-storage** (string): e.g. `"2Gi"`.
- **before_script** (string, optional): shell script run once at container start.
- Runs with open egress; readiness waits for completion when set.
- **pod_non_root** (boolean, optional): run Pod as non-root (UID/GID/FSGroup 65532).
- **container_non_root** (boolean, optional): run container as non-root (UID 65532), no privilege escalation.
- **cap_add** (string[], optional): add back Linux capabilities (default policy drops ALL).
- **cap_drop** (string[], optional): override drop policy. If omitted, `ALL` is dropped by default.
Example `k7.yaml`:
```yaml
name: project-build
image: alpine:latest
namespace: default
egress_whitelist:
- "10.0.0.5/32" # Private egress proxy/gateway
limits:
cpu: "1"
memory: "1Gi"
ephemeral-storage: "2Gi"
before_script: |
# Non-root friendly example: create a working dir and print versions
mkdir -p "$HOME/work" && cd "$HOME/work"
echo "PATH=$PATH"
echo "whoami: $(whoami)"
pod_non_root: false
container_non_root: false
cap_add:
- CHOWN
```
<Warning>
Do not whitelist public DNS resolvers (e.g., 1.1.1.1, 8.8.8.8). Doing so re-enables DNS exfiltration (UDP/TCP 53 and DoH over 443). Prefer whitelisting only your own egress proxy IP and enforce DNS/DoH policies at the proxy.
If using package managers that require root (e.g., `apk add`, `apt-get install`) in `before_script` make sure you didn't add security policies that prevent it such as running the pod or container as non-root. Check Security & Networking section in the API reference for more.
</Warning>
## list
```bash
k7 list [-n NAMESPACE]
```
Lists sandboxes with status, readiness, restarts, age, and image.
## delete
```bash
k7 delete NAME [-n NAMESPACE]
```
Deletes one sandbox.
## delete-all
```bash
k7 delete-all [-n NAMESPACE]
```
Deletes all sandboxes in a namespace (with confirmation).
## shell
```bash
k7 shell NAME [-n NAMESPACE]
```
Opens an interactive shell in the sandbox pod.
## logs
```bash
k7 logs NAME [-n NAMESPACE] [--tail 200] [-f]
```
Shows container logs (before script and main container).
## top
```bash
k7 top [-n NAMESPACE] [--refresh-interval 1]
```
Top-like view of CPU and memory usage.
## start-api
```bash
k7 start-api
```
Starts the API and Cloudflared tunnel via Docker Compose.
## api-status
```bash
k7 api-status
```
Shows API running state and public URL.
## get-api-endpoint
```bash
k7 get-api-endpoint
```
Prints the public URL if available.
## stop-api
```bash
k7 stop-api
```
Stops API and Cloudflared containers.
## API keys
```bash
k7 generate-api-key NAME [--expires-days 365]
k7 list-api-keys
k7 revoke-api-key NAME
```
Keys are stored at `/etc/k7/api_keys.json`. Use with `X-API-Key` or `Authorization: Bearer`.
### Flag reference (create)
- **-n, --namespace**: Kubernetes namespace (default `default`).
- **-f, --file**: YAML config file (defaults to `k7.yaml` when using `k7 create`).
- **--name**: Sandbox name (when not using YAML).
- **--image**: Container image (when not using YAML).
- **--cpu**: CPU limit (e.g., `1`, `500m`).
- **--memory**: Memory limit (e.g., `1Gi`, `512Mi`).
- **--storage**: Ephemeral storage limit (e.g., `2Gi`).
- **--env-file**: Path to env file on the host node injected as a Secret.
- **--egress CIDR**: Repeatable; whitelist CIDR blocks for egress (omit to keep open; use none for full block).
- **--before-script**: Shell script to run once at start; runs with open egress before lockdown.
- **--pod-non-root / --no-pod-non-root**: Pod-level non-root defaults.
- **--container-non-root / --no-container-non-root**: Container runs as UID 65532, no privilege escalation.
- **--cap-add CAP**: Repeatable; add back Linux capabilities (default drop ALL).
- **--cap-drop CAP**: Repeatable; override default drop policy.
<Info>
Package installs like `apk add` require root inside the container. Either leave `container_non_root` disabled for setup or prebuild an image. See Security & networking: `/api/security`.
</Info>
-119
View File
@@ -1,119 +0,0 @@
---
title: "LangChain agent tutorial"
description: "Build a ReAct agent that executes inside a K7 sandbox"
---
This tutorial walks you through wiring a LangChain ReAct agent with a tool that executes shell commands in a K7 sandbox.
## Prerequisites
- K7 API running (`k7 start-api`) and reachable
- API key generated: `k7 generate-api-key <name>`
- Python 3.10+
## Setup
Create a `.env` file with your credentials and defaults:
```env
K7_ENDPOINT=https://your-k7-endpoint
K7_API_KEY=your-api-key
K7_SANDBOX_NAME=lc-agent
K7_SANDBOX_IMAGE=alpine:latest
K7_NAMESPACE=default
OPENAI_API_KEY=sk-your-openai-key
OPENAI_MODEL=gpt-4o-mini
```
Install dependencies:
```bash
pip install langchain langchain-openai python-dotenv katakate
```
## Agent code
```python
import os, time
from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
from langchain.agents import initialize_agent, AgentType
from langchain.memory import ConversationBufferMemory
from langchain.tools import Tool
from langchain_openai import ChatOpenAI
from katakate import Client, SandboxProxy
load_dotenv()
K7_ENDPOINT = os.getenv("K7_ENDPOINT")
K7_API_KEY = os.getenv("K7_API_KEY")
SANDBOX_NAME = os.getenv("K7_SANDBOX_NAME", "lc-agent")
SANDBOX_IMAGE = os.getenv("K7_SANDBOX_IMAGE", "alpine:latest")
SANDBOX_NAMESPACE = os.getenv("K7_NAMESPACE", "default")
k7 = Client(endpoint=K7_ENDPOINT, api_key=K7_API_KEY)
_sb: Optional[SandboxProxy] = None
def ensure_sandbox_ready(timeout_seconds: int = 60) -> SandboxProxy:
try:
sb = k7.create({
"name": SANDBOX_NAME,
"image": SANDBOX_IMAGE,
"namespace": SANDBOX_NAMESPACE,
})
except Exception:
sb = SandboxProxy(SANDBOX_NAME, SANDBOX_NAMESPACE, k7)
deadline = time.time() + timeout_seconds
while time.time() < deadline:
for info in k7.list(namespace=SANDBOX_NAMESPACE):
if info.get("name") == SANDBOX_NAME and info.get("status") == "Running":
return sb
time.sleep(2)
raise RuntimeError("Sandbox did not become Running in time")
def run_code_in_sandbox(code: str) -> str:
global _sb
if _sb is None:
_sb = ensure_sandbox_ready()
result = _sb.exec(code)
if result.get("exit_code", 1) != 0:
return f"[stderr]\n{result.get('stderr','')}\n[stdout]\n{result.get('stdout','')}"
return result.get("stdout", "")
tool = Tool(
name="sandbox_exec",
description="Execute a shell command inside an isolated K7 sandbox. Input should be a shell command string.",
func=run_code_in_sandbox,
)
llm = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-4o-mini"), temperature=0)
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
agent = initialize_agent(
tools=[tool],
llm=llm,
agent=AgentType.CONVERSATIONAL_REACT_DESCRIPTION,
memory=memory,
verbose=True,
handle_parsing_errors=True,
)
print("Ask me to run a command in a sandbox, e.g.: 'List files in /'\n")
while True:
try:
user = input("You: ")
except (EOFError, KeyboardInterrupt):
break
if not user.strip():
continue
resp = agent.invoke({"input": user})
print("Agent:", resp.get("output", str(resp)))
```
<Tip>
You can shell into the same sandbox in parallel: `k7 shell lc-agent`.
</Tip>
-225
View File
@@ -1,225 +0,0 @@
---
title: "Python SDK"
description: "Use the katakate client to manage sandboxes"
---
Install the SDK:
```bash
pip install katakate
```
## Synchronous client
```python
from katakate import Client
k7 = Client(endpoint="https://<your-endpoint>", api_key="<your-key>")
# Create a sandbox
sb = k7.create({
"name": "my-sandbox",
"image": "alpine:latest",
# optional: "namespace": "default",
# optional: "env_file": ".env",
# optional: "egress_whitelist": ["10.0.0.5/32"], # private egress proxy
# optional: "limits": {"cpu": "1", "memory": "1Gi", "ephemeral-storage": "2Gi"},
# optional: "before_script": "apk add curl"
})
# Execute a command
result = sb.exec('echo "Hello World"')
print(result["stdout"]) # Also includes stderr and exit_code
# List sandboxes
print(k7.list())
# Delete sandbox
sb.delete()
```
### Client configuration
- `endpoint`: Base URL of your API, e.g. `https://<your-endpoint>`.
- `api_key`: Your API key. The SDK sends it via `X-API-Key` automatically.
<Info>
Get your endpoint and API key using the CLI: `k7 api-status`, `k7 get-api-endpoint`, `k7 generate-api-key <name>`. See the CLI guide: `/guides/cli`.
</Info>
### Create with non-root, capabilities, egress controls, limits
By default, all Linux capabilities are dropped. You can add back minimal ones if needed.
```python
sb = k7.create({
"name": "secure-sb",
"image": "alpine:latest",
"namespace": "default",
# Non-root execution
"pod_non_root": True, # Pod UID/GID/FSGroup 65532
"container_non_root": True, # Container UID 65532, no privilege escalation
# Capabilities: drop ALL by default, add minimal ones back
"cap_add": ["CHOWN"],
"cap_drop": ["NET_RAW"],
# Network egress control
# - Omit key to keep egress open
# - [] blocks all egress (DNS blocked)
# - [CIDRs] allows only those CIDRs (DNS still blocked)
"egress_whitelist": [
"10.0.0.5/32", # private egress proxy
"203.0.113.0/24"
],
# Resource limits/requests (same values used for both)
"limits": {"cpu": "500m", "memory": "512Mi", "ephemeral-storage": "2Gi"},
# Optional setup commands run before Ready (executed with open egress)
"before_script": "apk add --no-cache curl git"
})
```
<Info>
`env_file` points to a file on the API host filesystem (server-side), not the client machine. If you need environment variables and you’re calling a remote API, pass values directly for now.
</Info>
### Wait until sandbox is Ready
```python
import time
def wait_until_ready(name: str, namespace: str = "default", timeout_seconds: int = 120) -> None:
deadline = time.time() + timeout_seconds
while time.time() < deadline:
for info in k7.list(namespace=namespace):
if info.get("name") == name and info.get("status") == "Running" and info.get("ready") == "True":
return
time.sleep(2)
raise TimeoutError("Sandbox did not become Ready in time")
wait_until_ready("secure-sb")
```
### Execute commands and handle errors
```python
res = sb.exec("echo hello && uname -a")
print(res["stdout"]) # command output
print(res["stderr"]) # error stream (if any)
print(res["exit_code"]) # 0 on success
# Example of a failing command
bad = sb.exec("sh -lc 'exit 2'")
if bad["exit_code"] != 0:
print("Command failed:")
print("stderr:", bad.get("stderr", ""))
```
### List, filter by namespace
```python
print(k7.list()) # all namespaces
print(k7.list(namespace="dev")) # only dev
```
### Delete and delete all
```python
k7.delete("secure-sb")
k7.delete_all(namespace="default")
```
## Async client
```python
import asyncio
from katakate import AsyncClient
async def main():
k7 = AsyncClient(endpoint="https://<your-endpoint>", api_key="<your-key>")
sandboxes = await k7.list()
print(sandboxes)
await k7.aclose()
asyncio.run(main())
```
### Async examples
Create, wait, exec, delete:
```python
import os
import asyncio
from katakate import AsyncClient
K7_ENDPOINT = os.getenv("K7_ENDPOINT")
K7_API_KEY = os.getenv("K7_API_KEY")
async def main():
try:
k7 = AsyncClient(endpoint=K7_ENDPOINT, api_key=K7_API_KEY)
cfg = {
"name": "async-sb",
"image": "alpine:latest",
"pod_non_root": True,
"container_non_root": True,
"cap_add": ["CHOWN"],
# "before_script": "apk add --no-cache curl" # This is commented out here as it would fail, because 'apk add' needs root access, which we removed with pod_non_root and container_non_root set to True
"egress_whitelist": [], # full network lockdown after the before_script
}
print("Creating sandbox...")
await k7.create(cfg)
print("Sandbox created.")
# (Optional) Simple readiness wait (poll list). This can be removed, it is just here to illustrate.
for _ in range(60):
sbs = await k7.list()
if any(s.get("name") == "async-sb" and s.get("status") == "Running" and s.get("ready") == "True" for s in sbs):
break
await asyncio.sleep(2)
out = await k7.exec("async-sb", "echo from async")
print("Output of execution:", out)
except Exception as e:
raise e
# Include a finally block to clean resources even if code fails
finally:
print("Deleting sandbox 'async-sb'...")
try:
await k7.delete("async-sb")
print("Sandbox 'async-sb' deleted.")
except:
raise Exception("Failed to delete async-sb, you might need to clean resources manually.")
print("Closing the client's httpx connection...")
try:
await k7.aclose()
print("Connection closed.)
except:
raise Exception("Failed to close the K7 client's httpx connection, you might need to clean resources manually.)
asyncio.run(main())
```
## Errors and responses
- Successful responses are wrapped as `{ "data": ... }` by the API; the SDK unwraps them.
- Errors are returned as `{ "error": { "code": string, "message": string } }` with appropriate HTTP status codes.
## Tips
- Provide a `namespace` explicitly if you use non-default namespaces.
- Keep API keys secret; rotate via `k7 revoke-api-key` and `k7 generate-api-key`.
-82
View File
@@ -1,82 +0,0 @@
---
title: Releasing (internal)
hidden: true
noindex: true
---
## Releasing (Deb/PPA + PyPI)
### Prereqs
- Docker on your host (for Docker-based deb builds)
- Ubuntu 24.04 shell/container for Debian tooling:
- apt-get install -y build-essential devscripts debhelper dh-python fakeroot lintian docker.io dput gnupg
### Build CLI .deb locally (no host pollution)
1) Start a clean builder that talks to host Docker:
```bash
docker run --rm -it \
-v "$PWD":/src -w /src \
-v /var/run/docker.sock:/var/run/docker.sock \
ubuntu:24.04 bash
```
2) Inside the container:
```bash
apt-get update
apt-get install -y build-essential devscripts debhelper dh-python fakeroot lintian docker.io
dpkg-buildpackage -b -d
ls -la ../k7_*_amd64.deb
```
3) Test install:
```bash
dpkg -i ../k7_*_amd64.deb || apt-get -y -f install
k7 -V
```
Notes:
- debian/rules uses Docker to compile the Nuitka onefile and packages only /usr/bin/k7.
- We disable strip/dwz so the onefile payload remains intact.
### Prepare and upload source to Launchpad PPA
You can smoke-test locally without signing:
```bash
dpkg-buildpackage -S -sa -d
lintian -i ../k7_*_source.changes
```
Signed upload (requires your GPG key registered on Launchpad):
```bash
gpg --batch --import /path/to/your-private-key.asc
KEYID=$(gpg --list-keys --with-colons | awk -F: '/^pub/ {print $5; exit}')
dpkg-buildpackage -S -sa -k"$KEYID"
dput ppa:katakate.org/k7 ../k7_*_source.changes
```
Helper script:
```bash
scripts/test-launchpad-build.sh # unsigned
scripts/test-launchpad-build.sh -s KEYID # signed
```
Versioning:
- Update `src/k7/__init__.py` before tagging.
- For native format (3.0 native), `debian/changelog` versions like `0.0.1` (no `-1`).
### GitHub CI (tags vX.Y.Z)
- PyPI publish: builds sdist/wheel and uploads with `PYPI_API_TOKEN`.
- Deb artifact: builds .deb via Docker (make build), uploads artifact.
- Launchpad upload: builds signed source with `dpkg-buildpackage -S -sa -d` and `PPA_GPG_PRIVATE_KEY`.
### Publish katakate (PyPI SDK) locally
1) Bump version in `src/katakate/__init__.py`.
2) Build and upload:
```bash
python -m pip install --upgrade pip build twine
python -m build
twine upload dist/*
```
Notes:
- Only `src/katakate` is packaged for PyPI; assets in `src/k7/*` are not part of the SDK.
- Ensure `~/.pypirc` or `TWINE_USERNAME=__token__` and `TWINE_PASSWORD=<pypi-token>` are set.
-86
View File
@@ -1,86 +0,0 @@
---
title: "Utilities"
description: "Helper scripts: disk wipe for thin‑pool prep and high‑density stress testing"
---
This guide explains how to use the helper scripts under `utils/`.
## Wipe a disk for thin‑pool provisioning
Script: `utils/wipe-disk.sh`
<Warning>
Destructive operation. This irreversibly erases all partitions, RAID metadata, filesystems, and attempts discards on the target device. Double‑check the device path.
</Warning>
### Usage
```bash
sudo ./utils/wipe-disk.sh /dev/nvme2n1
```
You will be prompted to type `YES` to proceed. The script will:
- Remove filesystem signatures (`wipefs -a`)
- Zap the partition table (`sgdisk --zap-all`)
- Zero the beginning and end of the disk (`dd`)
- Attempt block discard (`blkdiscard`) if supported
List disks to find the correct device:
```bash
lsblk -o NAME,SIZE,TYPE,MOUNTPOINT
```
Requirements: Linux with `wipefs`, `sgdisk`, and `blkdiscard`; run as root or via `sudo`.
## High‑density CPU/memory stress test
Script: `utils/stress_test.sh`
This script launches many sandboxes to validate CPU limit enforcement and observe resource behavior.
### What it does
- Creates namespace `stress-test`
- Generates `k7-stress-*.yaml` files, each with:
- `before_script` that installs `stress-ng` and `htop` via `apk`
- CPU and memory limits per sandbox
- Launches sandboxes in batches (default 50 total, batches of 10)
- Sets up a cleanup trap on Ctrl+C to delete resources and namespace
Default parameters (edit inside the script if desired):
- `COUNT=50`
- `NAMESPACE="stress-test"`
- `CPU_LIMIT="300m"`
- `MEM_LIMIT="2Gi"`
- `STRESS_MEM="1500M"`
### Run
```bash
bash utils/stress_test.sh
```
Monitor during the test:
```bash
k7 top -n stress-test
watch 'k3s kubectl top pods -n stress-test --sort-by=cpu'
```
Cleanup when done (also done automatically on Ctrl+C):
```bash
k7 delete-all -n stress-test -y
rm k7-stress-*.yaml
k3s kubectl delete namespace stress-test
```
Notes:
- The generated YAML uses Alpine and installs packages in `before_script`. Ensure the container can run `apk` (i.e., not forced non‑root during setup). If you enforce strict non‑root, consider prebuilding an image with dependencies.
- Ensure your node(s) have sufficient CPU/RAM to handle the configured load.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

-28
View File
@@ -1,28 +0,0 @@
---
title: "Katakate"
description: "Secure sandboxed compute for AI agents and workloads"
---
<Frame>
<img src="/images/k7-cover-upgrade.png" alt="Katakate logo" />
</Frame>
Katakate (k7) gives you production-grade, isolated sandboxes backed by Kata Containers and Firecracker, orchestrated with Kubernetes. Use the `k7` CLI to provision sandboxes, the REST API to manage them remotely, and the Python SDK to integrate into apps and agents.
<CardGroup cols={2}>
<Card title="Quickstart" icon="rocket" href="/getting-started/installation">
Get running in minutes: install, start API, create your first sandbox.
</Card>
<Card title="CLI" icon="terminal" href="/guides/cli">
All `k7` commands with examples.
</Card>
<Card title="Python SDK" icon="python" href="/guides/python-sdk">
Sync and async clients with complete examples.
</Card>
<Card title="REST API" icon="code" href="/api/introduction">
Endpoint overview, request/response formats, and errors.
</Card>
<Card title="LangChain agent" icon="robot" href="/guides/langchain-agent">
Build a ReAct agent that executes inside a sandbox.
</Card>
</CardGroup>
-4
View File
@@ -1,4 +0,0 @@
One of the core principles of software development is DRY (Don't Repeat
Yourself). This is a principle that applies to documentation as
well. If you find yourself repeating the same content in multiple places, you
should consider creating a custom snippet to keep your content in sync.
Binary file not shown.
+85
View File
@@ -0,0 +1,85 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "k7"
version = "0.2.0"
description = "Self-hosted VM sandboxes for untrusted and AI code (CLI, API, SDK)"
readme = "README.md"
requires-python = ">=3.10.11"
dependencies = [
"fastapi>=0.135.3",
"httpx>=0.28.1",
"kubernetes-asyncio>=31.1.0",
"pydantic>=2.12.5",
"python-dotenv>=1.2.2",
"python-multipart>=0.0.26",
"pyyaml>=6.0.3",
"requests>=2.32.3",
"rich>=14.3.3",
"typer>=0.24.1",
"uvicorn[standard]>=0.44.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/k7", "src/k7_sdk", "src/katakate"]
[dependency-groups]
dev = [
"ansible-lint>=26.4.0",
"pytest>=9.0.3",
"pytest-asyncio>=0.25.0",
"pytest-cov>=7.1.0",
"ruff>=0.15.10",
"ty>=0.0.29",
]
# ---------- ruff ----------
[tool.ruff]
src = ["src", "tests"]
target-version = "py310"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
ignore = [
"E501", # line length handled by formatter
"B008", # function call in defaults — standard typer/fastapi pattern
"B904", # raise-without-from in except (many existing patterns)
"SIM105", # contextlib.suppress — existing try/except/pass patterns are intentional
"SIM108", # ternary — readability preference
]
[tool.ruff.lint.isort]
known-first-party = ["k7", "k7_sdk", "katakate"]
# ---------- pytest ----------
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: tests requiring a live k7 node",
"firecracker: tests specific to kata-firecracker-devmapper backend",
"qemu: tests specific to kata-qemu-longhorn backend",
"k7d: tests specific to the k7d backend",
"multinode: tests requiring a multi-node k7 cluster (>=2 Ready nodes)",
"bench: spec-10b benchmark module; opt-in only (-m bench), not run by default",
]
addopts = [
"--strict-markers",
"-m", "not integration and not bench",
"--cov=src/k7",
"--cov-report=term-missing",
]
asyncio_mode = "auto"
# ---------- coverage ----------
[tool.coverage.run]
source = ["src/k7"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if __name__ == .__main__.",
"pass",
]
+6
View File
@@ -0,0 +1,6 @@
ruff
ty
pytest
pytest-cov
ansible-lint
httpx
+16 -6
View File
@@ -1,17 +1,27 @@
from setuptools import setup, find_packages
from pathlib import Path
from setuptools import find_packages, setup
setup(
name="katakate",
version="0.0.4-dev",
description="Katakate Sandbox Management Python SDK",
packages=find_packages(where="src", include=["katakate", "katakate.*"]),
name="k7-sdk",
version="0.2.0",
description="K7 sandbox management Python SDK (HTTP client for the k7 API)",
long_description=Path(__file__).with_name("README.md").read_text(),
long_description_content_type="text/markdown",
url="https://github.com/Katakate/k7",
license="Apache-2.0",
packages=find_packages(
where="src",
include=["k7_sdk", "k7_sdk.*", "katakate", "katakate.*"],
),
package_dir={"": "src"},
include_package_data=True,
install_requires=[
"requests>=2.31.0",
],
extras_require={
"sdk-async": ["httpx>=0.27.0"],
"async": ["httpx>=0.27.0"],
"sdk-async": ["httpx>=0.27.0"], # back-compat extra name
},
python_requires=">=3.8",
)
+2 -2
View File
@@ -1,2 +1,2 @@
- `k7` is the .deb package containing the CLI and API, aimed to be used on the node(s), installable with `apt get`.
- `katakate` is the PyPI package containing the Python SDK, aimed to be used remotely e.g. from local, installable with `pip install katakate`. The SDK client talks to the API deployed on the node.
- `k7` is the Debian package containing the CLI (and embedded installer playbook), used on Linux nodes: `apt install k7`.
- `k7-sdk` is the PyPI package (`import k7_sdk`) for the HTTP API client from your laptop or apps. The legacy `katakate` name is a one-release deprecation shim.
+1 -1
View File
@@ -1,3 +1,3 @@
"""K7 Sandbox Management System"""
__version__ = "0.0.4-dev"
__version__ = "0.2.0"
+17
View File
@@ -9,18 +9,35 @@ RUN uv venv /app/.venv && \
. /app/.venv/bin/activate && \
uv pip install --no-cache -r requirements.txt
# crictl (pinned): k7d VM ops (pause/resume/fork) resolve a pod to its CRI
# sandbox id via `crictl pods` against the node's containerd socket, which
# the k7-api deployment hostPath-mounts (spec 18f issue 2). The node's own
# /usr/local/bin/crictl is a k3s symlink and can't be mounted usefully.
ARG CRICTL_VERSION=v1.31.1
ARG CRICTL_SHA256=0a03ba6b1e4c253d63627f8d210b2ea07675a8712587e697657b236d06d7d231
RUN curl -fsSL -o /tmp/crictl.tar.gz \
"https://github.com/kubernetes-sigs/cri-tools/releases/download/${CRICTL_VERSION}/crictl-${CRICTL_VERSION}-linux-amd64.tar.gz" && \
echo "${CRICTL_SHA256} /tmp/crictl.tar.gz" | sha256sum -c - && \
tar -C /usr/local/bin -xzf /tmp/crictl.tar.gz crictl && \
rm /tmp/crictl.tar.gz
FROM python:3.12-slim AS runtime
ENV VIRTUAL_ENV=/app/.venv
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONPATH="/app"
WORKDIR /app
# lvm2: the k7-agent DaemonSet (same image, `k7.api.agent:app`) reports the
# kfd thin-pool utilization via `lvs` (spec 18g) — needs the privileged
# agent container with /dev hostPath-mounted.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
lvm2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/.venv /app/.venv
COPY --from=build /app/k7 /app/k7
COPY --from=build /usr/local/bin/crictl /usr/local/bin/crictl
RUN useradd -m -u 1000 k7user && chown -R k7user:k7user /app
USER k7user
+173
View File
@@ -0,0 +1,173 @@
"""Per-node k7 agent (spec 18g).
Runs as a DaemonSet (``k7-agent``, kube-system) on every node, reusing the
``k7-api:local`` image with an overridden command
(``uvicorn k7.api.agent:app``). It exposes ONLY the node-local operations
that the centralized k7-api pod cannot perform for sandboxes on other
nodes:
- ``POST /agent/v1/vm/{pause,resume,fork,lookup}`` — thin wrappers over the
``K7Core`` k7d helpers (the k7d daemon socket, containerd socket, and
crictl are all node-local).
- ``GET /agent/v1/storage`` — node storage-pool utilization (kfd LVM
thin-pool via ``lvs``, k7d disks pool via ``df``).
Auth: every request must carry the shared agent token (header
``X-K7-Agent-Token``) generated by the install playbook and distributed to
``/etc/k7/agent_token`` on every node. A CiliumNetworkPolicy additionally
restricts pod-originated ingress to the k7-api pod. Requests without a
valid token are rejected — never served.
"""
import json
import os
import secrets
import subprocess
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from .. import __version__
from ..core.core import K7Core
app = FastAPI(title="K7 Node Agent", version=__version__)
AGENT_TOKEN_FILE = os.getenv("K7_AGENT_TOKEN_FILE", "/etc/k7/agent_token")
KATA_VG = os.getenv("K7_KATA_VG", "kata-vg")
K7D_DISKS_DIR = os.getenv("K7D_DISKS_DIR", "/var/lib/k7d/disks")
def _load_agent_token() -> str:
"""Read the shared agent token — unreadable/empty is a deployment bug
and must fail loudly (a silent 401 would be misdiagnosed as a bad
caller token)."""
try:
with open(AGENT_TOKEN_FILE) as f:
token = f.read().strip()
except OSError as e:
raise HTTPException(
status_code=500,
detail=(
f"agent token {AGENT_TOKEN_FILE} is unreadable ({e}) — the install playbook "
"provisions it on every node; re-run `k7 install`"
),
)
if not token:
raise HTTPException(status_code=500, detail=f"agent token {AGENT_TOKEN_FILE} is empty — re-run `k7 install`")
return token
async def verify_agent_token(x_k7_agent_token: str | None = Header(None)):
if not x_k7_agent_token or not secrets.compare_digest(x_k7_agent_token.strip(), _load_agent_token()):
raise HTTPException(status_code=401, detail="Invalid or missing agent token")
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception): # type: ignore[override]
# Fail loud WITH the message — the k7-api forwarder surfaces it verbatim.
return JSONResponse(
content={"error": {"code": "InternalServerError", "message": str(exc)}},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@app.get("/health")
async def health():
return {"status": "healthy", "node": os.environ.get("K7_NODE_NAME", "")}
def _required_name(body: dict | None) -> tuple[str, str]:
body = body or {}
name = body.get("name")
if not name or not isinstance(name, str):
raise HTTPException(status_code=400, detail="name is required")
return name, body.get("namespace", "default")
@app.post("/agent/v1/vm/pause", dependencies=[Depends(verify_agent_token)])
async def vm_pause(body: dict | None = None):
name, namespace = _required_name(body)
result = await K7Core().pause_sandbox(name=name, namespace=namespace, snapshot_name=(body or {}).get("snapshot"))
return result.to_dict()
@app.post("/agent/v1/vm/resume", dependencies=[Depends(verify_agent_token)])
async def vm_resume(body: dict | None = None):
name, namespace = _required_name(body)
result = await K7Core().resume_sandbox(name=name, namespace=namespace)
return result.to_dict()
@app.post("/agent/v1/vm/fork", dependencies=[Depends(verify_agent_token)])
async def vm_fork(body: dict | None = None):
name, namespace = _required_name(body)
new_name = (body or {}).get("new_name")
if not new_name or not isinstance(new_name, str):
raise HTTPException(status_code=400, detail="new_name is required")
result = await K7Core().fork_sandbox(
source_name=name,
new_name=new_name,
namespace=namespace,
snapshot_name=(body or {}).get("snapshot"),
)
return result.to_dict()
@app.post("/agent/v1/vm/lookup", dependencies=[Depends(verify_agent_token)])
async def vm_lookup(body: dict | None = None):
name, namespace = _required_name(body)
return await K7Core().lookup_k7d_vm(name, namespace)
# ---------------------------------------------------------------------------
# Node storage-pool utilization (spec 18g part 2 / 18f issue 5 leftover).
# ---------------------------------------------------------------------------
def _run(cmd: list[str]) -> str:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=False)
if result.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"{' '.join(cmd)} failed on node {os.environ.get('K7_NODE_NAME', '')}: {result.stderr.strip()}",
)
return result.stdout
def _kata_thinpool() -> dict:
"""kfd thin-pool utilization via ``lvs`` (needs a privileged container
with /dev hostPath-mounted — the DaemonSet provides both)."""
out = _run(["lvs", "--reportformat", "json", "--units", "b", "--nosuffix", KATA_VG])
lvs = json.loads(out)["report"][0]["lv"]
pools = [lv for lv in lvs if lv["lv_attr"].startswith("t")]
if not pools:
raise HTTPException(status_code=500, detail=f"no thin pool LV found in VG {KATA_VG} (lvs returned {lvs})")
lv = pools[0]
return {
"vg": KATA_VG,
"lv": lv["lv_name"],
"size_bytes": int(float(lv["lv_size"])),
"data_percent": float(lv["data_percent"]),
"metadata_percent": float(lv["metadata_percent"]),
}
def _k7d_disks() -> dict:
"""k7d disks-pool utilization via ``df`` on the hostPath-mounted
/var/lib/k7d/disks XFS loopback mount."""
if not os.path.isdir(K7D_DISKS_DIR):
raise HTTPException(status_code=500, detail=f"{K7D_DISKS_DIR} not found — is the k7d backend installed?")
out = _run(["df", "--output=size,used,avail", "--block-size=1", K7D_DISKS_DIR])
size, used, avail = out.splitlines()[1].split()
return {
"path": K7D_DISKS_DIR,
"size_bytes": int(size),
"used_bytes": int(used),
"avail_bytes": int(avail),
"used_percent": round(100.0 * int(used) / int(size), 2) if int(size) else 0.0,
}
@app.get("/agent/v1/storage", dependencies=[Depends(verify_agent_token)])
async def storage():
return {"kata_thinpool": _kata_thinpool(), "k7d_disks": _k7d_disks()}
-36
View File
@@ -1,36 +0,0 @@
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: k7-cloudflared
restart: unless-stopped
command: tunnel --no-autoupdate --url http://k7-api:8000
depends_on:
k7-api:
condition: service_healthy
networks:
- k7-network
k7-api:
image: ${K7_API_IMAGE:-ghcr.io/katakate/k7-api}:${K7_API_TAG:-latest}
pull_policy: if_not_present
container_name: k7-api
restart: unless-stopped
user: "0:0"
volumes:
- /etc/rancher/k3s/k3s.yaml:/etc/rancher/k3s/k3s.yaml:ro
- /etc/k7:/etc/k7
environment:
- KUBECONFIG=/etc/rancher/k3s/k3s.yaml
- K7_API_KEYS_FILE=/etc/k7/api_keys.json
healthcheck:
test: ["CMD-SHELL", "python -c 'import sys,urllib.request; sys.exit(0) if urllib.request.urlopen(\"http://127.0.0.1:8000/health\", timeout=2).status==200 else sys.exit(1)' "]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
networks:
- k7-network
networks:
k7-network:
driver: bridge
+307 -24
View File
@@ -1,16 +1,18 @@
from fastapi import FastAPI, HTTPException, Depends, Header, status, Request
from fastapi.responses import JSONResponse
from typing import Optional, Any, Dict
import os
import json
import hashlib
import json
import os
import secrets
import time
from datetime import timedelta
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from .. import __version__
from ..core.core import K7Core
from ..core.models import SandboxConfig
from .. import __version__
app = FastAPI(title="K7 Sandbox API", version=__version__)
@@ -18,14 +20,29 @@ API_KEYS_FILE = Path(os.getenv("K7_API_KEYS_FILE", "/etc/k7/api_keys.json"))
def load_api_keys() -> dict:
"""Load API keys from file."""
"""Load API keys from file.
A missing file means "no keys yet" — that is a normal state. An
*unreadable* file is a deployment bug (e.g. the store is not owned by
the API uid) and must fail loudly: silently returning {} would reject
every valid key with a misleading "Invalid API key".
"""
if not API_KEYS_FILE.exists():
return {}
try:
with open(API_KEYS_FILE, "r") as f:
with open(API_KEYS_FILE) as f:
data = json.load(f)
except Exception:
return {}
except (PermissionError, OSError) as e:
raise HTTPException(
status_code=500,
detail=(
f"API key store {API_KEYS_FILE} is unreadable by the API process ({e}). "
"It must be owned by the k7-api container uid — regenerate a key with "
"`k7 generate-api-key` (which fixes ownership) on the node hosting the pod."
),
)
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"API key store {API_KEYS_FILE} is corrupt: {e}")
# Purge expired keys opportunistically
now_ts = int(time.time())
changed = False
@@ -44,18 +61,21 @@ def save_api_keys(keys: dict):
API_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(API_KEYS_FILE, "w") as f:
json.dump(keys, f, indent=2)
os.chmod(API_KEYS_FILE, 0o600)
try:
os.chmod(API_KEYS_FILE, 0o600)
except OSError:
pass
async def verify_api_key(
x_api_key: Optional[str] = Header(None),
authorization: Optional[str] = Header(None),
x_api_key: str | None = Header(None),
authorization: str | None = Header(None),
):
"""Verify API key via X-API-Key or Authorization: Bearer header.
Uses timing-attack-resistant comparison and updates last_used on success.
"""
token: Optional[str] = None
token: str | None = None
if x_api_key and x_api_key.strip():
token = x_api_key.strip()
elif authorization and authorization.lower().startswith("bearer "):
@@ -90,7 +110,9 @@ async def verify_api_key(
return valid_data
def success_response(data: Any, status_code: int = status.HTTP_200_OK, headers: Dict[str, str] | None = None) -> JSONResponse:
def success_response(
data: Any, status_code: int = status.HTTP_200_OK, headers: dict[str, str] | None = None
) -> JSONResponse:
return JSONResponse(content={"data": data}, status_code=status_code, headers=headers)
@@ -139,7 +161,7 @@ async def create_sandbox(config: dict):
try:
sandbox_config = SandboxConfig.from_dict(config)
core = K7Core()
result = core.create_sandbox(sandbox_config)
result = await core.create_sandbox(sandbox_config)
if result.success:
resource = {
@@ -156,10 +178,10 @@ async def create_sandbox(config: dict):
@app.get("/api/v1/sandboxes", dependencies=[Depends(verify_api_key)])
async def list_sandboxes(namespace: Optional[str] = None):
async def list_sandboxes(namespace: str | None = None):
"""List all sandboxes."""
core = K7Core()
sandboxes = core.list_sandboxes(namespace)
sandboxes = await core.list_sandboxes(namespace)
return success_response([sandbox.to_dict() for sandbox in sandboxes])
@@ -167,7 +189,7 @@ async def list_sandboxes(namespace: Optional[str] = None):
async def get_sandbox(name: str, namespace: str = "default"):
"""Get a single sandbox by name."""
core = K7Core()
items = core.list_sandboxes(namespace)
items = await core.list_sandboxes(namespace)
for s in items:
if s.name == name:
return success_response(s.to_dict())
@@ -178,7 +200,7 @@ async def get_sandbox(name: str, namespace: str = "default"):
async def delete_sandbox(name: str, namespace: str = "default"):
"""Delete a sandbox."""
core = K7Core()
result = core.delete_sandbox(name, namespace)
result = await core.delete_sandbox(name, namespace)
if result.success:
return success_response({"message": result.message})
@@ -190,7 +212,7 @@ async def delete_sandbox(name: str, namespace: str = "default"):
async def delete_all_sandboxes(namespace: str = "default"):
"""Delete all sandboxes in a namespace."""
core = K7Core()
result = core.delete_all_sandboxes(namespace)
result = await core.delete_all_sandboxes(namespace)
if result.success:
return success_response({"message": result.message, "results": result.data})
@@ -198,6 +220,100 @@ async def delete_all_sandboxes(namespace: str = "default"):
raise HTTPException(status_code=400, detail=result.error)
@app.post("/api/v1/sandboxes/{name}/pause", dependencies=[Depends(verify_api_key)])
async def pause_sandbox(name: str, body: dict | None = None):
"""Pause a sandbox (scale to 0) and optionally take a Longhorn VolumeSnapshot.
Body keys (all optional):
``namespace`` (default ``"default"``),
``snapshot`` (when set, snapshot the sandbox's root PVC under this name).
"""
body = body or {}
namespace = body.get("namespace", "default")
core = K7Core()
result = await core.pause_sandbox(
name=name,
namespace=namespace,
snapshot_name=body.get("snapshot"),
)
if result.success:
return success_response({"message": result.message})
raise HTTPException(status_code=400, detail=result.error)
@app.post("/api/v1/sandboxes/{name}/resume", dependencies=[Depends(verify_api_key)])
async def resume_sandbox(name: str, body: dict | None = None):
"""Resume a paused sandbox (scale back to 1)."""
body = body or {}
namespace = body.get("namespace", "default")
core = K7Core()
result = await core.resume_sandbox(name=name, namespace=namespace)
if result.success:
return success_response({"message": result.message})
raise HTTPException(status_code=400, detail=result.error)
@app.post("/api/v1/sandboxes/{name}/fork", dependencies=[Depends(verify_api_key)])
async def fork_sandbox(name: str, body: dict):
"""Fork a kata-qemu-longhorn sandbox into a new name with a cloned root disk.
Required body key: new_name. Optional: namespace, snapshot.
The handler blocks until the cloned PVC is bound (matches CLI behaviour).
"""
new_name = (body or {}).get("new_name")
if not new_name or not isinstance(new_name, str):
raise HTTPException(status_code=400, detail="new_name is required")
namespace = body.get("namespace", "default")
snapshot = body.get("snapshot")
core = K7Core()
result = await core.fork_sandbox(
source_name=name,
new_name=new_name,
namespace=namespace,
snapshot_name=snapshot,
)
if result.success:
resource = {
"name": new_name,
"namespace": namespace,
"source": name,
"message": result.message,
}
location = f"/api/v1/sandboxes/{new_name}?namespace={namespace}"
return success_response(resource, status_code=status.HTTP_201_CREATED, headers={"Location": location})
err = (result.error or "").lower()
if "already exists" in err:
raise HTTPException(status_code=409, detail=result.error)
if "not found" in err:
raise HTTPException(status_code=404, detail=result.error)
raise HTTPException(status_code=400, detail=result.error)
@app.get("/api/v1/sandboxes/{name}/logs", dependencies=[Depends(verify_api_key)])
async def get_sandbox_logs(
name: str,
namespace: str = "default",
container: str = "sandbox",
tail: int = 200,
since: int = 0,
):
"""Read pod logs (snapshot; no streaming yet — see Spec 10g risks)."""
core = K7Core()
result = await core.get_logs(
sandbox_name=name,
namespace=namespace,
container=container,
tail_lines=tail if tail > 0 else None,
since_seconds=since if since > 0 else None,
)
if result.success:
return success_response(result.data or {"logs": ""})
err = (result.error or "").lower()
if "no pod found" in err or "not found" in err:
raise HTTPException(status_code=404, detail=result.error)
raise HTTPException(status_code=400, detail=result.error)
@app.post("/api/v1/sandboxes/{name}/exec", dependencies=[Depends(verify_api_key)])
async def exec_command(name: str, command_data: dict, namespace: str = "default"):
"""Execute a command in a sandbox."""
@@ -206,7 +322,7 @@ async def exec_command(name: str, command_data: dict, namespace: str = "default"
raise HTTPException(status_code=400, detail="Command is required")
core = K7Core()
result = core.exec_command(name, command, namespace)
result = await core.exec_command(name, command, namespace)
return success_response(result.to_dict())
@@ -226,9 +342,176 @@ async def install_node(install_data: dict):
raise HTTPException(status_code=400, detail=result.error)
@app.get("/api/v1/nodes/storage", dependencies=[Depends(verify_api_key)])
async def get_nodes_storage():
"""Per-node storage-pool utilization (kfd thin-pool + k7d disks pool),
aggregated from the k7-agent DaemonSet (spec 18g). A node whose agent
is unreachable gets an ``{"error": ...}`` entry — never omitted."""
core = K7Core()
return success_response(await core.nodes_storage())
@app.get("/api/v1/sandboxes/metrics", dependencies=[Depends(verify_api_key)])
async def get_sandbox_metrics(namespace: Optional[str] = None):
async def get_sandbox_metrics(namespace: str | None = None):
"""Get resource usage metrics for sandboxes."""
core = K7Core()
metrics = core.get_sandbox_metrics(namespace)
metrics = await core.get_sandbox_metrics(namespace)
return success_response(metrics)
# ---------------------------------------------------------------------------
# Spec 10e: VolumeSnapshot lifecycle endpoints.
# ---------------------------------------------------------------------------
def _parse_keep_fork_for(value: str | None) -> timedelta:
"""Accept ``10m`` / ``2h`` / ``3600`` (seconds) — fail loudly on garbage."""
if value is None or value == "":
return timedelta(minutes=10)
if value.endswith("m"):
return timedelta(minutes=int(value[:-1]))
if value.endswith("h"):
return timedelta(hours=int(value[:-1]))
if value.endswith("s"):
return timedelta(seconds=int(value[:-1]))
return timedelta(seconds=int(value))
@app.get("/api/v1/snapshots", dependencies=[Depends(verify_api_key)])
async def list_snapshots(
namespace: str | None = "default",
all_namespaces: bool = False,
sandbox: str | None = None,
kind: str | None = None,
):
"""List VolumeSnapshots, optionally filtered by namespace / sandbox / kind."""
core = K7Core()
snaps = await core.list_snapshots(
namespace=namespace,
all_namespaces=all_namespaces,
sandbox=sandbox,
kind=kind,
)
return success_response([s.to_dict() for s in snaps])
@app.get("/api/v1/snapshots/{name}", dependencies=[Depends(verify_api_key)])
async def get_snapshot(name: str, namespace: str = "default"):
"""Inspect a single VolumeSnapshot by name."""
core = K7Core()
snap = await core.get_snapshot(name, namespace=namespace)
if snap is None:
raise HTTPException(status_code=404, detail=f"Snapshot {name} not found in namespace {namespace}")
return success_response(snap.to_dict())
@app.post("/api/v1/sandboxes/{name}/snapshot", dependencies=[Depends(verify_api_key)])
async def create_snapshot(name: str, body: dict):
"""Snapshot a running sandbox's root PVC without pausing it (kind=named).
Body keys: ``snapshot_name`` (required), ``namespace`` (default ``"default"``).
"""
snapshot_name = (body or {}).get("snapshot_name")
if not snapshot_name or not isinstance(snapshot_name, str):
raise HTTPException(status_code=400, detail="snapshot_name is required")
namespace = body.get("namespace", "default")
core = K7Core()
result = await core.create_snapshot(sandbox_name=name, snapshot_name=snapshot_name, namespace=namespace)
if result.success:
resource = {"name": snapshot_name, "namespace": namespace, "source_sandbox": name}
location = f"/api/v1/snapshots/{snapshot_name}?namespace={namespace}"
return success_response(resource, status_code=status.HTTP_201_CREATED, headers={"Location": location})
err = (result.error or "").lower()
if "already exists" in err:
raise HTTPException(status_code=409, detail=result.error)
raise HTTPException(status_code=400, detail=result.error)
@app.delete("/api/v1/snapshots/{name}", dependencies=[Depends(verify_api_key)])
async def delete_snapshot(name: str, namespace: str = "default"):
"""Delete a VolumeSnapshot by name."""
core = K7Core()
result = await core.delete_snapshot(name, namespace=namespace)
if result.success:
return success_response({"message": result.message})
if "not found" in (result.error or "").lower():
raise HTTPException(status_code=404, detail=result.error)
raise HTTPException(status_code=400, detail=result.error)
@app.post("/api/v1/snapshots/{name}/restore", dependencies=[Depends(verify_api_key)])
async def restore_snapshot(name: str, body: dict):
"""Boot a brand-new sandbox from a standalone VolumeSnapshot (Spec 10f).
Body keys:
``new_sandbox_name`` (required),
``namespace`` (default ``"default"``),
``overrides`` (optional dict: image, backend, root_disk_size, sidecar,
limits, entrypoint, cmd, before_script),
``keep_snapshot`` (default ``true``).
"""
body = body or {}
new_name = body.get("new_sandbox_name")
if not new_name or not isinstance(new_name, str):
raise HTTPException(status_code=400, detail="new_sandbox_name is required")
namespace = body.get("namespace", "default")
keep_snapshot = bool(body.get("keep_snapshot", True))
overrides_dict = body.get("overrides") or {}
if not isinstance(overrides_dict, dict):
raise HTTPException(status_code=400, detail="overrides must be a JSON object")
# Filter to known SandboxConfigOverrides keys; ignore garbage.
allowed = {"image", "backend", "root_disk_size", "sidecar", "limits", "entrypoint", "cmd", "before_script"}
filtered = {k: v for k, v in overrides_dict.items() if k in allowed}
from k7.core.models import SandboxConfigOverrides
overrides = SandboxConfigOverrides(**filtered) if filtered else None
core = K7Core()
result = await core.restore_sandbox(
snapshot_name=name,
new_sandbox_name=new_name,
namespace=namespace,
overrides=overrides,
keep_snapshot=keep_snapshot,
)
if result.success:
resource = {
"name": new_name,
"namespace": namespace,
"source_snapshot": name,
"message": result.message,
}
location = f"/api/v1/sandboxes/{new_name}?namespace={namespace}"
return success_response(resource, status_code=status.HTTP_201_CREATED, headers={"Location": location})
err = (result.error or "").lower()
if "not found" in err:
raise HTTPException(status_code=404, detail=result.error)
if "already exists" in err:
raise HTTPException(status_code=409, detail=result.error)
raise HTTPException(status_code=400, detail=result.error)
@app.post("/api/v1/snapshots/gc", dependencies=[Depends(verify_api_key)])
async def gc_snapshots(body: dict | None = None):
"""Sweep stale ``kind=fork`` snapshots older than ``keep_fork_for``.
Body (all optional):
``namespace`` (default ``"default"``),
``all_namespaces`` (default ``false``),
``keep_fork_for`` (default ``"10m"``, also accepts ``2h`` / ``45s`` / plain seconds),
``dry_run`` (default ``false``).
"""
body = body or {}
keep_for = _parse_keep_fork_for(body.get("keep_fork_for"))
core = K7Core()
result = await core.gc_snapshots(
namespace=body.get("namespace", "default"),
all_namespaces=bool(body.get("all_namespaces", False)),
keep_fork_for=keep_for,
dry_run=bool(body.get("dry_run", False)),
)
if result.success:
return success_response({"message": result.message, "results": result.data})
raise HTTPException(status_code=400, detail=result.error)
+3 -3
View File
@@ -1,10 +1,10 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
kubernetes==28.1.0
kubernetes-asyncio==31.1.0
pydantic==2.5.0
python-multipart==0.0.6
requests==2.31.0
httpx==0.28.1
typer==0.9.0
rich==13.7.0
pyyaml==6.0.1
python-dotenv==1.0.0
python-dotenv==1.0.0
+67
View File
@@ -0,0 +1,67 @@
"""Snapshot garbage-collection entrypoint (Spec 10e, Option C backstop).
Run as ``python -m k7.api.snapshot_gc`` inside the ``k7-api`` container.
The accompanying CronJob (``snapshot-gc-cronjob.yaml``) invokes this
every 10 minutes to clean up stale ``kind=fork`` VolumeSnapshots that
the inline cleanup in ``fork_sandbox`` missed (e.g. due to an API pod
crash mid-fork).
Behaviour:
- Lists snapshots cluster-wide (``all_namespaces=True``).
- Honours the same ``keep_fork_for`` window as :meth:`K7Core.gc_snapshots`.
- Skips anything that isn't ``kind=fork`` — pause and named snapshots are
never touched.
- Environment overrides: ``K7_GC_KEEP_FORK_FOR_MINUTES`` (default ``10``),
``K7_GC_DRY_RUN`` (``true``/``false``, default ``false``).
"""
from __future__ import annotations
import asyncio
import os
import sys
from datetime import timedelta
from k7.core.core import K7Core
def _env_int(key: str, default: int) -> int:
raw = os.environ.get(key)
if raw is None or raw == "":
return default
try:
return int(raw)
except ValueError:
print(f"⚠️ Invalid integer for {key}={raw!r}; using default {default}", file=sys.stderr)
return default
def _env_bool(key: str, default: bool) -> bool:
raw = os.environ.get(key, "").strip().lower()
if raw == "":
return default
return raw in ("1", "true", "yes", "y", "on")
async def _main() -> int:
keep_minutes = _env_int("K7_GC_KEEP_FORK_FOR_MINUTES", 10)
dry_run = _env_bool("K7_GC_DRY_RUN", False)
core = K7Core()
result = await core.gc_snapshots(
all_namespaces=True,
keep_fork_for=timedelta(minutes=keep_minutes),
dry_run=dry_run,
)
if not result.success:
print(f"❌ snapshot-gc failed: {result.error}", file=sys.stderr)
return 1
print(result.message)
for record in result.data or []:
marker = "would-delete" if dry_run else ("deleted" if record.get("deleted") else "failed")
print(f" [{marker}] {record['namespace']}/{record['name']} (age={record['age']})")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(_main()))
+1
View File
@@ -0,0 +1 @@
+97
View File
@@ -0,0 +1,97 @@
#!/bin/sh
# shellcheck disable=SC3040 # pipefail: guarded probe, not assumed
set -eu
(set -o pipefail) 2>/dev/null && set -o pipefail || true
SCRIPT_START=$(date +%s)
STATE_MNT="/mnt/state"
SLOT="${K7_PERSIST_SLOT:?missing K7_PERSIST_SLOT}"
READY_FILE="${STATE_MNT}/.ready"
BASE="${STATE_MNT}/${SLOT}"
MIGRATION_MARK="${BASE}/.migrated_v1"
i=0
while [ ! -f "${READY_FILE}" ]; do
i=$((i+1))
if [ "$i" -gt 600 ]; then
echo "k7-persist: timeout waiting for ${READY_FILE}" >&2
exit 1
fi
sleep 0.1
done
mkdir -p "${BASE}"
is_symlink() { [ -L "$1" ]; }
is_dir() { [ -d "$1" ]; }
require_tool() {
tool="$1"
if ! command -v "$tool" >/dev/null 2>&1; then
echo "k7-persist: missing required tool '$tool' in image" >&2
exit 1
fi
}
require_tool tar
require_tool mount
if [ ! -x /bin/sh ]; then
echo "k7-persist: missing required shell /bin/sh in image" >&2
exit 1
fi
copy_if_empty() {
src="$1"
dst="$2"
mkdir -p "$dst"
if [ -z "$(ls -A "$dst" 2>/dev/null || true)" ]; then
echo "k7-persist: seeding $dst from $src (one-time)..."
(cd "$src" && tar -cpf - .) | (cd "$dst" && tar -xpf -)
fi
}
bind_mount_dir() {
target="$1"
name="$2"
if [ ! -e "$target" ]; then
return 0
fi
if is_symlink "$target"; then
echo "k7-persist: skip symlink $target"
return 0
fi
if ! is_dir "$target"; then
echo "k7-persist: skip non-dir $target"
return 0
fi
persist="${BASE}/${name}"
copy_if_empty "$target" "$persist"
mount --make-rprivate / || true
mount --bind "$persist" "$target"
echo "k7-persist: bound $target -> $persist"
}
bind_mount_dir /etc etc
bind_mount_dir /var var
bind_mount_dir /usr usr
bind_mount_dir /home home
bind_mount_dir /root root
bind_mount_dir /opt opt
bind_mount_dir /bin bin
bind_mount_dir /sbin sbin
bind_mount_dir /lib lib
bind_mount_dir /lib64 lib64
SCRIPT_END=$(date +%s)
SCRIPT_ELAPSED=$((SCRIPT_END - SCRIPT_START))
echo "k7-persist: script completed in ${SCRIPT_ELAPSED}s" >&2
date > "${MIGRATION_MARK}" 2>/dev/null || true
if [ "$#" -eq 0 ]; then
echo "k7-persist: no command provided; defaulting to keepalive sleep" >&2
exec sleep 365d
fi
exec "$@"
+9 -2
View File
@@ -4,10 +4,13 @@ RUN apt-get update && apt-get install -y python3 python3-pip python3-venv gcc pa
WORKDIR /app
RUN python3 -m venv /app/venv
ENV PATH="/app/venv/bin:$PATH"
RUN pip install --no-cache-dir nuitka typer kubernetes python-dotenv pyyaml rich
RUN pip install --no-cache-dir nuitka typer kubernetes kubernetes_asyncio httpx python-dotenv pyyaml rich requests
# Copy source package into build context (src layout)
# Copy source packages into build context (src layout). The CLI imports
# k7_sdk (its API client since the SDK rename) — without it the onefile
# binary dies at import time with ModuleNotFoundError.
COPY src/k7/ /app/k7/
COPY src/k7_sdk/ /app/k7_sdk/
# Build the binary with deploy assets embedded
RUN python3 -m nuitka \
@@ -16,6 +19,10 @@ RUN python3 -m nuitka \
--include-module=rich \
--include-module=typer \
--include-module=kubernetes \
--include-package=kubernetes_asyncio \
--include-package=httpx \
--include-module=dotenv \
--include-package=k7_sdk \
--include-package=requests \
--include-data-dir=k7=k7 \
k7/cli/k7.py
+202
View File
@@ -0,0 +1,202 @@
"""CLI ↔ API client plumbing (Spec 10g).
This module owns:
- ``_resolve_api_url`` / ``_resolve_api_key`` — the four-stage lookup
chain (flag → env → config file → ``/etc/k7/api_*``) shared by every
CLI handler that talks to the API.
- ``CliContext`` — the dataclass carried on ``typer.Context.obj``. Holds
either a configured ``katakate.Client`` (the default) or a flag
signalling the ``--core`` escape hatch (direct ``K7Core`` calls, no
HTTP, used by integration tests and node-side debugging).
- ``ApiUnreachable`` — a small exception type the CLI catches to map
``requests.ConnectionError`` / ``Timeout`` to a single helpful exit
message ("could not reach API at <URL>: ...").
- ``handle_api_call(fn)`` — decorator-style helper that runs an SDK
call, maps HTTP / connection errors to ``typer.Exit(1)``, and surfaces
the server's ``{"error": {"message": ...}}`` body when present.
"""
from __future__ import annotations
import json
import os
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TypeVar
import requests
import typer
from k7_sdk.client import Client
from ._config import get_config_value
# ---------------------------------------------------------------------------
# Endpoint + key resolution.
# ---------------------------------------------------------------------------
_ETC_API_ENDPOINT = Path("/etc/k7/api_endpoint")
_ETC_API_KEYS = Path("/etc/k7/api_keys.json")
def _resolve_api_url(flag: str | None) -> str | None:
"""Look up the API URL through the documented chain.
Precedence: ``--api-url`` flag > ``K7_API_URL`` env var > config file >
``/etc/k7/api_endpoint`` (on a cluster node). Returns ``None`` when
nothing is set; the caller decides whether that's an error.
"""
if flag:
return flag
env = os.environ.get("K7_API_URL")
if env:
return env
cfg = get_config_value("api.url")
if cfg:
return cfg
if _ETC_API_ENDPOINT.exists():
try:
v = _ETC_API_ENDPOINT.read_text().strip()
if v:
return v
except OSError:
pass
return None
def _resolve_api_key(flag: str | None) -> str | None:
"""Same chain as ``_resolve_api_url`` but for the API key.
Cluster-node fallback reads ``/etc/k7/api_keys.json`` (the same file
``generate_api_key`` writes to) and picks the **first** entry's raw
token. Note: that file stores SHA-256 hashes, not raw keys, so the
fallback only works when k7 wrote the raw key alongside (current
layout: each entry has both ``hash`` and ``token`` fields when
created by ``k7 generate-api-key``). If only hashes are present the
fallback fails gracefully and the user must pass ``--api-key`` or
set ``K7_API_KEY``.
"""
if flag:
return flag
env = os.environ.get("K7_API_KEY")
if env:
return env
cfg = get_config_value("api.key")
if cfg:
return cfg
if _ETC_API_KEYS.exists():
try:
data = json.loads(_ETC_API_KEYS.read_text())
except (OSError, json.JSONDecodeError):
return None
# File layout: {sha256_hash: {"name": ..., "token": "...", ...}, ...}.
# Newer entries store the raw token under ``token``; pick the first
# one we find (deterministic since dict preserves insertion order).
if isinstance(data, dict):
for entry in data.values():
if isinstance(entry, dict):
token = entry.get("token")
if isinstance(token, str) and token:
return token
return None
# ---------------------------------------------------------------------------
# CliContext + adapters.
# ---------------------------------------------------------------------------
@dataclass
class CliContext:
"""The ``typer.Context.obj`` populated by ``@app.callback()``.
The ``katakate.Client`` is built **lazily** by :meth:`client`, so
handlers that don't talk to the API (``install``, ``start-api``,
``config set`` …) never trigger the missing-URL / missing-key
error paths.
``use_core=True`` flips the CLI to direct ``K7Core`` calls (the
legacy in-process path) — useful for integration tests on the node
and for debugging when the API itself is misbehaving.
"""
use_core: bool = False
api_url: str | None = None
api_key: str | None = None
_client: Client | None = None
def client(self) -> Client:
"""Resolve and cache the SDK client; exits 1 when the URL / key are unset."""
if self._client is None:
self._client = resolve_client(self.api_url, self.api_key)
return self._client
class ApiUnreachable(RuntimeError):
"""Raised when the SDK can't reach the API at all (connection / timeout)."""
def resolve_client(api_url: str | None, api_key: str | None) -> Client:
"""Build a ``katakate.Client`` from the resolved URL + key.
Raises ``typer.Exit(1)`` with a pointed message when either is
missing — the CLI should never silently fall through to a broken
Client constructor.
"""
url = _resolve_api_url(api_url)
if not url:
typer.echo(
"❌ No API URL configured. Pass --api-url, set K7_API_URL, "
"or run `k7 config set api.url https://<node>:<nodeport>`.",
err=True,
)
raise typer.Exit(1)
key = _resolve_api_key(api_key)
if not key:
typer.echo(
"❌ No API key configured. Pass --api-key, set K7_API_KEY, "
"or run `k7 config set api.key <KEY>` "
"(generate one with `k7 generate-api-key <name>` on the node).",
err=True,
)
raise typer.Exit(1)
# NodePort exposes plain HTTP; off-cluster setups should put the API
# behind ingress + TLS, in which case verify_ssl=True (the default)
# does the right thing. For local/demo HTTP, the SDK skips verify.
return Client(endpoint=url, api_key=key, verify_ssl=url.startswith("https://"))
T = TypeVar("T")
def handle_api_call(fn: Callable[[], T]) -> T:
"""Run an SDK call and exit-1 on the documented failure shapes.
Translates ``requests.ConnectionError`` / ``Timeout`` into a single
"could not reach API" message and maps ``HTTPError`` to the server's
``{"error": {"message": ...}}`` body when present (otherwise the
raw ``status_code``).
"""
try:
return fn()
except requests.ConnectionError as e:
typer.echo(f"❌ Could not reach the K7 API: {e}", err=True)
raise typer.Exit(1) from None
except requests.Timeout as e:
typer.echo(f"❌ Timed out talking to the K7 API: {e}", err=True)
raise typer.Exit(1) from None
except requests.HTTPError as e:
message: str | None = None
if e.response is not None:
try:
body = e.response.json()
if isinstance(body, dict) and isinstance(body.get("error"), dict):
message = body["error"].get("message")
except ValueError:
pass
if not message:
message = f"HTTP {e.response.status_code}: {e.response.text[:300]}"
typer.echo(f"❌ {message or e}", err=True)
raise typer.Exit(1) from None
+217
View File
@@ -0,0 +1,217 @@
"""TOML config file CRUD + ``k7 config`` sub-app (Spec 10g).
Stores per-user CLI config at ``$XDG_CONFIG_HOME/k7/config.toml``
(falling back to ``~/.config/k7/config.toml``). The single supported
section today is ``[api]`` with ``url`` and ``key`` keys — enough to
power the ``_resolve_api_url`` / ``_resolve_api_key`` chain in
``_client.py`` so users don't have to pass ``--api-url`` /
``--api-key`` on every invocation.
The file is the same posture as ``~/.docker/config.json`` and
``~/.kube/config`` — plaintext, chmod 0600 on write. A future spec
can layer OS keychain integration on top.
"""
from __future__ import annotations
import builtins
import json
import os
import re
import sys
from pathlib import Path
import typer
CONFIG_DIR_ENV = "K7_CONFIG_DIR" # tests override this to a tmp path
_SUPPORTED_KEYS: set[str] = {"api.url", "api.key"}
def _config_dir() -> Path:
"""Return the directory holding ``config.toml``.
Honours ``K7_CONFIG_DIR`` (tests / explicit overrides), then
``XDG_CONFIG_HOME``, then ``~/.config/k7``.
"""
explicit = os.environ.get(CONFIG_DIR_ENV)
if explicit:
return Path(explicit)
xdg = os.environ.get("XDG_CONFIG_HOME")
base = Path(xdg) if xdg else Path.home() / ".config"
return base / "k7"
def config_file_path() -> Path:
return _config_dir() / "config.toml"
_SECTION_RE = re.compile(r"^\s*\[\s*([A-Za-z0-9_.-]+)\s*\]\s*$")
_KEY_RE = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"((?:[^"\\]|\\.)*)"\s*$')
def _toml_loads_simple(text: str) -> dict:
"""Tiny TOML reader for the section-of-string-values shape this file uses.
Python 3.10 (k7's minimum) lacks ``tomllib`` and we don't want to add
``tomli`` just for two-key parsing. Supports::
[api]
url = "https://10.0.0.1:31000"
key = "k7-..."
Unknown / malformed lines are ignored (the user's view is "k7 config
wrote the file; k7 config can read it back" — anything outside that
contract is best-effort).
"""
data: dict = {}
current: dict | None = None
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _SECTION_RE.match(line)
if m:
current = data.setdefault(m.group(1), {})
continue
m = _KEY_RE.match(line)
if not m or current is None:
continue
value = m.group(2).encode("utf-8").decode("unicode_escape")
current[m.group(1)] = value
return data
def read_config() -> dict:
"""Return the parsed config dict; ``{}`` when the file is absent / unreadable."""
p = config_file_path()
if not p.exists():
return {}
try:
return _toml_loads_simple(p.read_text())
except Exception:
# A malformed config file shouldn't crash the CLI — fall back to no config
# and let the next resolution layer (env vars / on-node fallback) take over.
return {}
def _get_dotted(d: dict, key: str) -> str | None:
"""Look up ``"api.url"`` against ``{"api": {"url": "..."}}``."""
section, _, leaf = key.partition(".")
if not leaf:
return None
sub = d.get(section)
if not isinstance(sub, dict):
return None
val = sub.get(leaf)
return str(val) if val is not None else None
def _set_dotted(d: dict, key: str, value: str) -> None:
section, _, leaf = key.partition(".")
if not leaf:
raise ValueError(f"Invalid config key {key!r}; expected 'section.field' form")
sub = d.setdefault(section, {})
if not isinstance(sub, dict): # pragma: no cover - defensive against malformed user files
sub = {}
d[section] = sub
sub[leaf] = value
def _toml_dumps(d: dict) -> str:
"""Serialise a simple ``{section: {key: str}}`` dict to TOML.
We don't pull in ``tomli_w`` for this — k7 only ever writes flat
string fields under a single ``[api]`` section. Keep the surface
tiny on purpose.
"""
lines: builtins.list[str] = []
for section in sorted(d.keys()):
body = d[section]
if not isinstance(body, dict):
continue
lines.append(f"[{section}]")
for key in sorted(body.keys()):
value = body[key]
if value is None:
continue
lines.append(f"{key} = {json.dumps(str(value))}")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def write_config(data: dict) -> Path:
"""Persist ``data`` to disk, creating the directory if needed (mode 0600)."""
p = config_file_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(_toml_dumps(data))
try:
os.chmod(p, 0o600)
except OSError:
pass
return p
def get_config_value(key: str) -> str | None:
return _get_dotted(read_config(), key)
def set_config_value(key: str, value: str) -> Path:
if key not in _SUPPORTED_KEYS:
raise ValueError(f"Unknown config key {key!r}. Supported keys: {', '.join(sorted(_SUPPORTED_KEYS))}")
data = read_config()
_set_dotted(data, key, value)
return write_config(data)
# ---------------------------------------------------------------------------
# ``k7 config`` Typer sub-app.
# ---------------------------------------------------------------------------
config_app = typer.Typer(
help="Read / write per-user CLI configuration.",
context_settings={"help_option_names": ["-h", "--help"]},
)
@config_app.command("set")
def config_set(
key: str = typer.Argument(..., help="Config key in 'section.field' form (e.g. api.url)"),
value: str = typer.Argument(..., help="Value to store"),
):
"""Set a config value (persists to ~/.config/k7/config.toml)."""
try:
path = set_config_value(key, value)
except ValueError as e:
typer.echo(f"❌ {e}", err=True)
raise typer.Exit(1) from None
redacted = "<redacted>" if key.endswith(".key") else value
typer.echo(f"Wrote {key} = {redacted} to {path}")
@config_app.command("get")
def config_get(
key: str = typer.Argument(..., help="Config key in 'section.field' form"),
):
"""Print one config value. Exits 1 when the key is unset."""
value = get_config_value(key)
if value is None:
typer.echo("", err=False)
raise typer.Exit(1)
typer.echo(value)
@config_app.command("show")
def config_show():
"""Print the full config (API keys are redacted)."""
data = read_config()
if not data:
typer.echo(f"(empty — config file: {config_file_path()})")
return
# Redact secrets for terminal display.
redacted_view: dict = {}
for section, body in data.items():
if not isinstance(body, dict):
continue
redacted_view[section] = {k: ("<redacted>" if k == "key" else v) for k, v in body.items()}
sys.stdout.write(_toml_dumps(redacted_view))
-2
View File
@@ -14,11 +14,9 @@ ARCH=$(uname -m)
case "$ARCH" in
x86_64)
DEB_ARCH="amd64"
DOCKER_PLATFORM="linux/amd64"
;;
aarch64|arm64)
DEB_ARCH="arm64"
DOCKER_PLATFORM="linux/arm64"
;;
*)
echo "Unsupported architecture: $ARCH"
+1509 -577
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
from .core import K7Core
from .models import SandboxConfig, SandboxInfo, ExecResult
from .models import ExecResult, SandboxConfig, SandboxInfo
__all__ = ["K7Core", "SandboxConfig", "SandboxInfo", "ExecResult"]
+2665 -369
View File
File diff suppressed because it is too large Load Diff
+83 -9
View File
@@ -1,24 +1,39 @@
from typing import Optional, List, Dict, Any
from dataclasses import dataclass, asdict, fields
from dataclasses import asdict, dataclass, fields
from typing import Any
import yaml
@dataclass
class SandboxConfig:
"""Data model for sandbox configuration"""
"""Data model for sandbox configuration
For kata-firecracker-devmapper: image is REQUIRED (the container to run)
For kata-qemu-longhorn: image is REQUIRED (no bare VM mode)
For k7d: image is REQUIRED (runs in a k7d microVM, runtimeClassName k7)
"""
name: str
image: str
namespace: str = "default"
env_file: Optional[str] = None
egress_whitelist: Optional[List[str]] = None
limits: Optional[Dict[str, str]] = None
runtime_class_name: str | None = None
root_disk_size: str | None = "10Gi"
backend: str | None = None # "kata-firecracker-devmapper", "kata-qemu-longhorn", or "k7d"
env_file: str | None = None
egress_whitelist: list[str] | None = None
limits: dict[str, str] | None = None
before_script: str = ""
entrypoint: list[str] | None = None
cmd: list[str] | None = None
sidecar: str | None = None # key into SIDECAR_REGISTRY, or None
# Security toggles (default off) and capabilities configuration
pod_non_root: bool = False
container_non_root: bool = False
cap_drop: Optional[List[str]] = None # default behavior handled in core: drop ALL
cap_add: Optional[List[str]] = None
cap_drop: list[str] | None = None # default behavior handled in core: drop ALL
cap_add: list[str] | None = None
# Optional explicit node placement (sets pod's node_name). Used by tests
# that need to inspect host-side state for a sandbox they just created.
node_name: str | None = None
# Note: ingress isolation is enforced by core with a hardcoded NetworkPolicy
def __post_init__(self):
@@ -27,7 +42,7 @@ class SandboxConfig:
@classmethod
def from_yaml(cls, yaml_path: str) -> "SandboxConfig":
with open(yaml_path, "r") as f:
with open(yaml_path) as f:
data = yaml.safe_load(f)
return cls(**data)
@@ -51,6 +66,11 @@ class SandboxInfo:
restarts: int
age: str
image: str
backend: str = "unknown"
# Kubernetes node hosting the sandbox pod ("" while unscheduled). Needed
# by API/SDK clients to reason about k7d VM-op node locality (spec 18f
# issue 6): k7d pause/resume/fork must run on the sandbox's node.
node: str = ""
error_message: str = ""
def to_dict(self) -> dict:
@@ -77,3 +97,57 @@ class OperationResult:
def to_dict(self) -> dict:
return asdict(self)
# Spec 10e: VolumeSnapshot kinds, used both for the ``k7.io/kind`` annotation
# we stamp at creation time and for the heuristic fallback that classifies
# pre-existing snapshots by name pattern.
SNAPSHOT_KIND_PAUSE = "pause"
SNAPSHOT_KIND_FORK = "fork"
SNAPSHOT_KIND_NAMED = "named"
@dataclass
class SandboxConfigOverrides:
"""Optional per-call overrides for ``K7Core.restore_sandbox`` (Spec 10f).
Every field is optional. When ``None`` the corresponding value is taken
from the snapshot's ``k7.io/source-*`` annotations (stamped at snapshot
creation time); the user-supplied override wins when set. ``image`` is
the only field that has no safe default — restore fails with a clear
error if neither the annotation nor an override is present.
"""
image: str | None = None
backend: str | None = None
root_disk_size: str | None = None
sidecar: str | None = None
limits: dict[str, str] | None = None
entrypoint: list[str] | None = None
cmd: list[str] | None = None
before_script: str | None = None
def to_dict(self) -> dict:
return {k: v for k, v in asdict(self).items() if v is not None}
@dataclass
class SnapshotInfo:
"""Inspectable view of a Kubernetes ``VolumeSnapshot`` managed by k7.
Fields mirror what ``k7 snapshot list/inspect`` and the HTTP API surface.
"""
name: str
namespace: str
source_pvc: str
source_sandbox: str
kind: str # one of SNAPSHOT_KIND_PAUSE / _FORK / _NAMED
ready_to_use: bool
creation_timestamp: str # RFC3339 string (kept verbatim from the API)
age: str # human-readable, computed from creation_timestamp
size_bytes: int = 0 # 0 when restoreSize is unknown / not yet set
snapshot_class: str = "longhorn"
def to_dict(self) -> dict:
return asdict(self)
+36
View File
@@ -0,0 +1,36 @@
"""Generic sidecar framework: registry + spec dataclass.
Adding a new sidecar type requires only a new entry in SIDECAR_REGISTRY.
All injection logic in core.py is driven by SidecarSpec fields — no
type-specific branching.
"""
from dataclasses import dataclass, field
@dataclass(frozen=True)
class SidecarSpec:
image: str
socket_mount: str # directory containing the socket (shared via emptyDir)
socket_name: str # socket filename within socket_mount
data_path: str # where the daemon stores persistent data
pvc_subdir: str # subdirectory on the Longhorn PVC for ql backend
readiness_cmd: list[str]
privileged: bool
env: dict[str, str] = field(default_factory=dict)
args: list[str] = field(default_factory=list)
SIDECAR_REGISTRY: dict[str, SidecarSpec] = {
"docker": SidecarSpec(
image="docker:27.5-dind",
socket_mount="/var/run",
socket_name="docker.sock",
data_path="/var/lib/docker",
pvc_subdir="docker",
readiness_cmd=["docker", "info"],
privileged=True,
env={"DOCKER_TLS_CERTDIR": ""},
args=["--tls=false"],
),
}
+71
View File
@@ -0,0 +1,71 @@
; Example multi-node inventory for `k7 install -i inventory.ini`.
;
; Each host declares which backend(s) it supports via `k7_backends` (comma-separated).
; A node may support one or both of:
; - kata-qemu-longhorn (uses Kata QEMU + overlayfs + Longhorn for sandbox storage)
; - kata-firecracker-devmapper (uses Kata Firecracker + LVM thin-pool snapshotter)
;
; Per-host vars:
; ansible_host : SSH target IP
; ansible_user : SSH user (defaults to root)
; k7_backends : comma-separated backend list
; k7_devmapper_disk : block device for FD thin-pool (e.g. /dev/nvme1n1)
; longhorn_extra_disk : extra mount path to register with Longhorn
; kata_thinpool_pv_size : LVM PV size for the kfd thin-pool (default 100G)
; k7d_disks_image_size : sparse XFS image size for k7d volume pool (default 32G)
;
; Group vars (under [k7_cluster:vars]):
; longhorn_replicas : Longhorn replica count (defaults to min(3, node count))
; longhorn_data_path : Longhorn default data path (defaults to /var/lib/longhorn)
[k7_servers]
node1 ansible_host=192.0.2.10 k7_backends=kata-qemu-longhorn,kata-firecracker-devmapper k7_devmapper_disk=/dev/nvme1n1
node2 ansible_host=5.9.18.222 k7_backends=kata-qemu-longhorn
node3 ansible_host=5.9.18.223 k7_backends=kata-qemu-longhorn
[k7_agents]
node4 ansible_host=5.9.18.224 k7_backends=kata-firecracker-devmapper k7_devmapper_disk=/dev/nvme1n1
[k7_cluster:children]
k7_servers
k7_agents
[k7_cluster:vars]
ansible_user=root
ansible_ssh_private_key_file=~/.ssh/id_ed25519
longhorn_replicas=2
; ──────────────────────────────────────────────────────────────────────
; HA note: K3s embedded etcd needs an odd number of server nodes
; (3, 5, ...) to maintain quorum. With 2 servers etcd has no fault
; tolerance — for a 2-node cluster, put the second node in [k7_agents].
; The first host listed in [k7_servers] is the cluster-init server.
;
; 3-node HA with ALL backends (kfd + kql + k7d) on every node — spec 18e.
; Run ONE command from a checkout on the first master:
;
; k7 install -i inventory.ini --ha --k7d-artifact /root/k7d-v0.1.0-x86_64-linux.tar.gz
;
; Do NOT pass --backend alongside -i: per-host `k7_backends` in the
; inventory is authoritative (an explicit --backend overrides it).
;
; On dual-NVMe boxes where the OS lives on one disk and the other is a raw
; spare, OMIT k7_devmapper_disk: NVMe enumeration (nvme0n1 vs nvme1n1) is
; NOT stable across reboots, so a hardcoded device can point at the OS disk
; after a reboot. The playbook auto-detects the empty non-root whole disk,
; which is enumeration-proof. Set k7_devmapper_disk only when a node has
; several spare disks and you must pick a specific one.
;
; [k7_servers]
; k7-node-01 ansible_host=192.0.2.11 k7_backends=kfd,kql,k7d
; k7-node-02 ansible_host=192.0.2.12 k7_backends=kfd,kql,k7d
; k7-node-03 ansible_host=192.0.2.13 k7_backends=kfd,kql,k7d
;
; [k7_cluster:children]
; k7_servers
;
; [k7_cluster:vars]
; ansible_user=root
; ansible_ssh_private_key_file=/root/.ssh/id_ed25519
; longhorn_replicas=3
; ──────────────────────────────────────────────────────────────────────
+8 -1
View File
@@ -1,2 +1,9 @@
[k7_nodes]
; Single-node localhost inventory used by `k7 install` when no inventory is given.
[k7_servers]
localhost ansible_connection=local ansible_user=root
[k7_agents]
[k7_cluster:children]
k7_servers
k7_agents
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: k7-agent
namespace: kube-system
labels:
app: k7-agent
spec:
selector:
matchLabels:
app: k7-agent
template:
metadata:
labels:
app: k7-agent
spec:
# Reuses the k7-api ServiceAccount/RBAC: the agent runs the same
# K7Core code (pod lookup, deployment annotate/create for
# pause/resume/fork).
serviceAccountName: k7-api
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: k7-agent
image: k7-api:local
imagePullPolicy: Never
# Same image as k7-api, different app: only the node-local VM ops
# and the storage endpoint (spec 18g).
command: ["uvicorn", "k7.api.agent:app", "--host", "0.0.0.0", "--port", "8000"]
# Privileged: `lvs` (kfd thin-pool utilization) issues device-mapper
# ioctls and reads raw block devices under the hostPath-mounted
# /dev — the default device cgroup denies that to unprivileged
# containers. Root (the image defaults to uid 1000) is required for
# crictl, the k7d socket, and the 0600 root-owned agent token.
securityContext:
privileged: true
runAsUser: 0
runAsGroup: 0
ports:
- containerPort: 8000
protocol: TCP
env:
- name: K7_AGENT
value: "1"
- name: K7_AGENT_TOKEN_FILE
value: /etc/k7/agent_token
- name: K7_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
# Shared agent token (playbook writes it on every node).
- name: k7-config
mountPath: /etc/k7
readOnly: true
# k7d daemon control socket (VM pause/resume/fork/lookup).
- name: k7d-run
mountPath: /run/k7d
# k3s containerd socket for crictl pod → CRI sandbox id lookup.
- name: k3s-containerd
mountPath: /run/k3s/containerd
# k7d disks pool (XFS loopback mount) for `df` utilization.
- name: k7d-lib
mountPath: /var/lib/k7d
readOnly: true
# Raw devices for `lvs` (kfd thin-pool utilization).
- name: dev
mountPath: /dev
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: k7-config
hostPath:
path: /etc/k7
type: DirectoryOrCreate
- name: k7d-run
hostPath:
path: /run/k7d
type: DirectoryOrCreate
- name: k3s-containerd
hostPath:
path: /run/k3s/containerd
type: Directory
- name: k7d-lib
hostPath:
path: /var/lib/k7d
type: DirectoryOrCreate
- name: dev
hostPath:
path: /dev
type: Directory
@@ -0,0 +1,22 @@
# Spec 18g: restrict pod-originated ingress to the k7-agent DaemonSet to
# the k7-api pod only. `host` / `remote-node` entities stay allowed so
# kubelet health probes and root CLI usage on cluster nodes keep working —
# those callers still need the shared token (/etc/k7/agent_token, root
# 0600), which is the actual authentication. Sandbox pods and any other
# workload pods are denied at the network layer.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: k7-agent-ingress
namespace: kube-system
spec:
endpointSelector:
matchLabels:
app: k7-agent
ingress:
- fromEndpoints:
- matchLabels:
app: k7-api
- fromEntities:
- host
- remote-node
@@ -0,0 +1,39 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: k7-api
labels:
app: k7-api
rules:
- apiGroups: [""]
resources: ["pods", "pods/exec", "pods/log", "secrets", "configmaps", "persistentvolumeclaims", "namespaces", "nodes"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "deployments/scale"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["networking.k8s.io"]
resources: ["networkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# FQDN egress (Spec 4a) is enforced via CiliumNetworkPolicy, so the API
# must be able to manage them to apply/delete sandbox egress rules.
- apiGroups: ["cilium.io"]
resources: ["ciliumnetworkpolicies"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots", "volumesnapshotcontents", "volumesnapshotclasses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["metrics.k8s.io"]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: ["node.k8s.io"]
resources: ["runtimeclasses"]
verbs: ["get", "list", "watch"]
# FQDN egress support probes for the CiliumNetworkPolicy CRD
# (core._cilium_available) before applying a CNP — without this, sandbox
# creation with domain egress fails only when driven through the API pod.
- apiGroups: ["apiextensions.k8s.io"]
resources: ["customresourcedefinitions"]
verbs: ["get"]
@@ -0,0 +1,14 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: k7-api
labels:
app: k7-api
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: k7-api
subjects:
- kind: ServiceAccount
name: k7-api
namespace: kube-system
@@ -0,0 +1,105 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: k7-api
namespace: kube-system
labels:
app: k7-api
spec:
replicas: 1
selector:
matchLabels:
app: k7-api
template:
metadata:
labels:
app: k7-api
spec:
serviceAccountName: k7-api
# /etc/k7 is hostPath-mounted (api_keys.json lives there). Pin the API
# pod to the first master so single-node and multi-node clusters both
# have a predictable, stable location for the host-side key store.
# The first master is labelled `k7.katakate.org/first-master=true` by
# the install playbook.
nodeSelector:
k7.katakate.org/first-master: "true"
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: k7-api
image: k7-api:local
imagePullPolicy: Never
# Root is required for the co-located k7d VM-op fallback (spec 18f
# issue 2): `crictl` must reach the k3s containerd socket (0660
# root:root) to resolve a pod's CRI sandbox id before talking to
# the k7d daemon socket. No added capabilities / not privileged.
# k7d VM ops only work for sandboxes on THIS node (the first
# master) — core fails loudly on a node mismatch; see
# docs/BACKENDS.md.
securityContext:
runAsUser: 0
runAsGroup: 0
allowPrivilegeEscalation: false
ports:
- containerPort: 8000
protocol: TCP
env:
- name: K7_API_KEYS_FILE
value: /etc/k7/api_keys.json
# k7d VM operations (pause/fork) are node-local; core compares
# the sandbox pod's node against this (os.uname() inside a
# container returns the pod name, which never matches).
- name: K7_NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
volumeMounts:
- name: k7-config
mountPath: /etc/k7
# k7d daemon control socket (VM pause/resume/fork). Present only
# on nodes provisioned with the k7d backend; DirectoryOrCreate
# keeps the pod schedulable on clusters without it (core then
# fails loudly with "socket not found").
- name: k7d-run
mountPath: /run/k7d
# k3s containerd socket for crictl pod → CRI sandbox id lookup.
- name: k3s-containerd
mountPath: /run/k3s/containerd
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
volumes:
- name: k7-config
hostPath:
path: /etc/k7
type: DirectoryOrCreate
- name: k7d-run
hostPath:
path: /run/k7d
type: DirectoryOrCreate
- name: k3s-containerd
hostPath:
path: /run/k3s/containerd
type: Directory
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: k7-api
namespace: kube-system
labels:
app: k7-api
spec:
type: NodePort
selector:
app: k7-api
ports:
- port: 8000
targetPort: 8000
protocol: TCP
nodePort: 31007
@@ -0,0 +1,7 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: k7-api
namespace: kube-system
labels:
app: k7-api
@@ -0,0 +1,47 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: k7-snapshot-gc
namespace: kube-system
labels:
app: k7-snapshot-gc
spec:
# Sweep every 10 minutes — matches K7Core.gc_snapshots' default keep window.
schedule: "*/10 * * * *"
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 600
template:
metadata:
labels:
app: k7-snapshot-gc
spec:
serviceAccountName: k7-api
restartPolicy: OnFailure
nodeSelector:
k7.katakate.org/first-master: "true"
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: gc
image: k7-api:local
imagePullPolicy: Never
command: ["python", "-m", "k7.api.snapshot_gc"]
env:
- name: K7_GC_KEEP_FORK_FOR_MINUTES
value: "10"
- name: K7_GC_DRY_RUN
value: "false"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: "500m"
memory: 256Mi
+11
View File
@@ -0,0 +1,11 @@
"""K7 Python SDK — HTTP client for the k7 API."""
from .client import AsyncClient, Client, SandboxProxy
__all__ = [
"Client",
"AsyncClient",
"SandboxProxy",
]
__version__ = "0.2.0"
+550
View File
@@ -0,0 +1,550 @@
from __future__ import annotations
import requests
try:
import httpx # optional dependency for async client
except Exception: # pragma: no cover
httpx = None
class SandboxProxy:
"""Proxy object for sandbox operations."""
def __init__(self, name: str, namespace: str, client: Client):
self.name = name
self.namespace = namespace
self._client = client
def exec(self, code: str) -> dict:
"""Execute code in the sandbox."""
return self._client._exec_command(self.name, code, self.namespace)
def delete(self) -> dict:
"""Delete this sandbox."""
return self._client.delete(self.name, self.namespace)
def pause(self, snapshot: str | None = None) -> dict:
"""Pause this sandbox (scale to 0), optionally with a VolumeSnapshot.
Pass ``snapshot="my-snap"`` to create a crash-consistent
``VolumeSnapshot`` of the sandbox's root PVC before scaling to 0.
"""
return self._client.pause(self.name, namespace=self.namespace, snapshot=snapshot)
def resume(self) -> dict:
"""Resume this sandbox (scale back to 1)."""
return self._client.resume(self.name, namespace=self.namespace)
def fork(self, new_name: str, snapshot: str | None = None) -> SandboxProxy:
"""Fork this sandbox into ``new_name``; returns a proxy for the new sandbox.
Blocks until the cloned PVC is bound (the server-side `fork_sandbox`
waits for the new pod to schedule before returning).
"""
return self._client.fork(self.name, new_name, namespace=self.namespace, snapshot=snapshot)
def snapshot(self, snapshot_name: str) -> dict:
"""Snapshot this sandbox's root PVC without pausing it (kind=named)."""
return self._client.create_snapshot(self.name, snapshot_name, namespace=self.namespace)
def logs(self, tail: int = 200, container: str = "sandbox", since: int = 0) -> str:
"""Return a snapshot of this sandbox's pod logs."""
return self._client.logs(self.name, namespace=self.namespace, container=container, tail=tail, since=since)
class Client:
"""K7 Python SDK Client."""
def __init__(self, endpoint: str, api_key: str, verify_ssl: bool = True):
self.base_url = endpoint.rstrip("/")
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({"X-API-Key": api_key})
self.session.verify = verify_ssl
def _unwrap(self, response) -> dict:
data = response.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
def create(self, sandbox_config: dict) -> SandboxProxy:
"""Create a new sandbox and return a proxy object."""
response = self.session.post(f"{self.base_url}/api/v1/sandboxes", json=sandbox_config)
response.raise_for_status()
name = sandbox_config.get("name")
namespace = sandbox_config.get("namespace", "default")
return SandboxProxy(name, namespace, self)
def list(self, namespace: str | None = None) -> list[dict]:
"""List all sandboxes."""
params = {"namespace": namespace} if namespace else {}
response = self.session.get(f"{self.base_url}/api/v1/sandboxes", params=params)
response.raise_for_status()
return self._unwrap(response)
def delete(self, name: str, namespace: str = "default") -> dict:
"""Delete a sandbox."""
response = self.session.delete(f"{self.base_url}/api/v1/sandboxes/{name}", params={"namespace": namespace})
response.raise_for_status()
return self._unwrap(response)
def delete_all(self, namespace: str = "default") -> dict:
"""Delete all sandboxes in a namespace."""
response = self.session.delete(f"{self.base_url}/api/v1/sandboxes", params={"namespace": namespace})
response.raise_for_status()
return self._unwrap(response)
def install(
self,
playbook: str | None = None,
inventory: str | None = None,
verbose: bool = False,
) -> dict:
"""Install K7 on target hosts."""
response = self.session.post(
f"{self.base_url}/api/v1/install",
json={"playbook": playbook, "inventory": inventory, "verbose": verbose},
)
response.raise_for_status()
return self._unwrap(response)
def get_metrics(self, namespace: str | None = None) -> dict:
"""Get resource usage metrics for sandboxes."""
params = {"namespace": namespace} if namespace else {}
response = self.session.get(f"{self.base_url}/api/v1/sandboxes/metrics", params=params)
response.raise_for_status()
return self._unwrap(response)
def nodes_storage(self) -> dict:
"""Per-node storage-pool utilization (kfd thin-pool + k7d disks).
Returns a map of node name → ``{kata_thinpool, k7d_disks}`` (or
``{error: ...}`` when that node's agent is unreachable).
"""
response = self.session.get(f"{self.base_url}/api/v1/nodes/storage", timeout=120)
response.raise_for_status()
return self._unwrap(response)
def pause(
self,
name: str,
namespace: str = "default",
snapshot: str | None = None,
) -> dict:
"""Pause a sandbox (scale to 0), optionally taking a Longhorn VolumeSnapshot.
``snapshot``, when set, names a crash-consistent VolumeSnapshot taken
of the sandbox's root PVC. The PVC name and ``VolumeSnapshotClass``
are derived server-side (kata-qemu-longhorn convention + the playbook's
``longhorn`` class).
"""
body: dict = {"namespace": namespace}
if snapshot is not None:
body["snapshot"] = snapshot
response = self.session.post(f"{self.base_url}/api/v1/sandboxes/{name}/pause", json=body, timeout=120)
response.raise_for_status()
return self._unwrap(response)
def resume(self, name: str, namespace: str = "default") -> dict:
"""Resume a paused sandbox (scale back to 1)."""
response = self.session.post(
f"{self.base_url}/api/v1/sandboxes/{name}/resume",
json={"namespace": namespace},
timeout=30,
)
response.raise_for_status()
return self._unwrap(response)
def fork(
self,
source: str,
new_name: str,
namespace: str = "default",
snapshot: str | None = None,
) -> SandboxProxy:
"""Fork ``source`` into a new sandbox ``new_name``; returns a proxy for it.
Blocks until the cloned PVC is bound. Today this takes ~45s for
kata-qemu-longhorn; the HTTP request stays open for the duration.
"""
body: dict = {"new_name": new_name, "namespace": namespace}
if snapshot is not None:
body["snapshot"] = snapshot
response = self.session.post(f"{self.base_url}/api/v1/sandboxes/{source}/fork", json=body, timeout=600)
response.raise_for_status()
self._unwrap(response)
return SandboxProxy(new_name, namespace, self)
# ------------------------------------------------------------------
# Spec 10e: VolumeSnapshot CRUD + GC.
# ------------------------------------------------------------------
def list_snapshots(
self,
namespace: str = "default",
all_namespaces: bool = False,
sandbox: str | None = None,
kind: str | None = None,
) -> list[dict]:
params: dict = {"namespace": namespace, "all_namespaces": str(all_namespaces).lower()}
if sandbox is not None:
params["sandbox"] = sandbox
if kind is not None:
params["kind"] = kind
response = self.session.get(f"{self.base_url}/api/v1/snapshots", params=params, timeout=30)
response.raise_for_status()
return self._unwrap(response)
def get_snapshot(self, name: str, namespace: str = "default") -> dict | None:
response = self.session.get(
f"{self.base_url}/api/v1/snapshots/{name}", params={"namespace": namespace}, timeout=15
)
if response.status_code == 404:
return None
response.raise_for_status()
return self._unwrap(response)
def create_snapshot(self, sandbox: str, snapshot_name: str, namespace: str = "default") -> dict:
response = self.session.post(
f"{self.base_url}/api/v1/sandboxes/{sandbox}/snapshot",
json={"snapshot_name": snapshot_name, "namespace": namespace},
timeout=120,
)
response.raise_for_status()
return self._unwrap(response)
def delete_snapshot(self, name: str, namespace: str = "default") -> dict:
response = self.session.delete(
f"{self.base_url}/api/v1/snapshots/{name}", params={"namespace": namespace}, timeout=60
)
response.raise_for_status()
return self._unwrap(response)
def gc_snapshots(
self,
namespace: str = "default",
all_namespaces: bool = False,
keep_fork_for: str = "10m",
dry_run: bool = False,
) -> dict:
body: dict = {
"namespace": namespace,
"all_namespaces": all_namespaces,
"keep_fork_for": keep_fork_for,
"dry_run": dry_run,
}
response = self.session.post(f"{self.base_url}/api/v1/snapshots/gc", json=body, timeout=120)
response.raise_for_status()
return self._unwrap(response)
def restore(
self,
snapshot_name: str,
new_sandbox_name: str,
namespace: str = "default",
overrides: dict | None = None,
keep_snapshot: bool = True,
) -> SandboxProxy:
"""Restore a brand-new sandbox from a standalone VolumeSnapshot (Spec 10f).
``overrides`` is a JSON-serialisable dict matching ``SandboxConfigOverrides``
on the server (keys: ``image``, ``backend``, ``root_disk_size``, ``sidecar``,
``limits``, ``entrypoint``, ``cmd``, ``before_script``). Pass at minimum
``{"image": "..."}`` if the snapshot was created before Spec 10f and
therefore lacks the ``k7.io/source-image`` annotation.
Returns a ``SandboxProxy`` for the new sandbox. The server-side restore
waits for the cloned PVC to be Bound and the Deployment to be Ready
before responding, so the proxy is safe to ``exec`` against immediately.
"""
body: dict = {
"new_sandbox_name": new_sandbox_name,
"namespace": namespace,
"keep_snapshot": keep_snapshot,
}
if overrides:
body["overrides"] = overrides
response = self.session.post(
f"{self.base_url}/api/v1/snapshots/{snapshot_name}/restore",
json=body,
timeout=600,
)
response.raise_for_status()
self._unwrap(response)
return SandboxProxy(new_sandbox_name, namespace, self)
def exec(self, name: str, command: str, namespace: str = "default") -> dict:
"""Execute a shell command in a sandbox; returns ``{stdout, stderr, exit_code, duration_ms}``."""
return self._exec_command(name, command, namespace)
def logs(
self,
name: str,
namespace: str = "default",
container: str = "sandbox",
tail: int = 200,
since: int = 0,
) -> str:
"""Return a snapshot of the sandbox pod's logs (no streaming yet).
For interactive follow today, use ``k7 --core logs --follow`` on
the node. Streaming support is a separate spec.
"""
params: dict = {"namespace": namespace, "container": container, "tail": tail}
if since > 0:
params["since"] = since
response = self.session.get(
f"{self.base_url}/api/v1/sandboxes/{name}/logs",
params=params,
timeout=60,
)
response.raise_for_status()
data = self._unwrap(response)
if isinstance(data, dict):
return str(data.get("logs", ""))
return str(data)
def _exec_command(self, name: str, command: str, namespace: str) -> dict:
"""Internal method to execute command in sandbox."""
response = self.session.post(
f"{self.base_url}/api/v1/sandboxes/{name}/exec",
json={"command": command},
params={"namespace": namespace},
)
response.raise_for_status()
return self._unwrap(response)
class AsyncClient:
"""K7 Python SDK Async Client."""
def __init__(
self,
endpoint: str,
api_key: str,
verify_ssl: bool = True,
timeout: float = 30.0,
):
if httpx is None:
raise RuntimeError("httpx is required for AsyncClient. Install with `pip install httpx`.")
self.base_url = endpoint.rstrip("/")
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers={"X-API-Key": api_key},
verify=verify_ssl,
timeout=timeout,
)
async def create(self, sandbox_config: dict) -> dict:
r = await self._client.post("/api/v1/sandboxes", json=sandbox_config)
r.raise_for_status()
return r.json()
async def list(self, namespace: str | None = None) -> list[dict]:
params = {"namespace": namespace} if namespace else {}
r = await self._client.get("/api/v1/sandboxes", params=params)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def delete(self, name: str, namespace: str = "default") -> dict:
r = await self._client.delete(f"/api/v1/sandboxes/{name}", params={"namespace": namespace})
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def delete_all(self, namespace: str = "default") -> dict:
r = await self._client.delete("/api/v1/sandboxes", params={"namespace": namespace})
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def logs(
self,
name: str,
namespace: str = "default",
container: str = "sandbox",
tail: int = 200,
since: int = 0,
) -> str:
params: dict = {"namespace": namespace, "container": container, "tail": tail}
if since > 0:
params["since"] = since
r = await self._client.get(f"/api/v1/sandboxes/{name}/logs", params=params, timeout=60)
r.raise_for_status()
data = r.json()
unwrapped = data["data"] if isinstance(data, dict) and "data" in data else data
if isinstance(unwrapped, dict):
return str(unwrapped.get("logs", ""))
return str(unwrapped)
async def exec(self, name: str, command: str, namespace: str = "default") -> dict:
r = await self._client.post(
f"/api/v1/sandboxes/{name}/exec",
json={"command": command},
params={"namespace": namespace},
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def get_metrics(self, namespace: str | None = None) -> dict:
params = {"namespace": namespace} if namespace else {}
r = await self._client.get("/api/v1/sandboxes/metrics", params=params)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def nodes_storage(self) -> dict:
"""Per-node storage-pool utilization (kfd thin-pool + k7d disks)."""
r = await self._client.get("/api/v1/nodes/storage", timeout=120)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def pause(
self,
name: str,
namespace: str = "default",
snapshot: str | None = None,
) -> dict:
body: dict = {"namespace": namespace}
if snapshot is not None:
body["snapshot"] = snapshot
r = await self._client.post(f"/api/v1/sandboxes/{name}/pause", json=body, timeout=120)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def resume(self, name: str, namespace: str = "default") -> dict:
r = await self._client.post(
f"/api/v1/sandboxes/{name}/resume",
json={"namespace": namespace},
timeout=30,
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def fork(
self,
source: str,
new_name: str,
namespace: str = "default",
snapshot: str | None = None,
) -> dict:
body: dict = {"new_name": new_name, "namespace": namespace}
if snapshot is not None:
body["snapshot"] = snapshot
r = await self._client.post(f"/api/v1/sandboxes/{source}/fork", json=body, timeout=600)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def list_snapshots(
self,
namespace: str = "default",
all_namespaces: bool = False,
sandbox: str | None = None,
kind: str | None = None,
) -> list[dict]:
params: dict = {"namespace": namespace, "all_namespaces": str(all_namespaces).lower()}
if sandbox is not None:
params["sandbox"] = sandbox
if kind is not None:
params["kind"] = kind
r = await self._client.get("/api/v1/snapshots", params=params, timeout=30)
r.raise_for_status()
data = r.json()
return data["data"] if isinstance(data, dict) and "data" in data else data
async def get_snapshot(self, name: str, namespace: str = "default") -> dict | None:
r = await self._client.get(f"/api/v1/snapshots/{name}", params={"namespace": namespace}, timeout=15)
if r.status_code == 404:
return None
r.raise_for_status()
data = r.json()
return data["data"] if isinstance(data, dict) and "data" in data else data
async def create_snapshot(self, sandbox: str, snapshot_name: str, namespace: str = "default") -> dict:
r = await self._client.post(
f"/api/v1/sandboxes/{sandbox}/snapshot",
json={"snapshot_name": snapshot_name, "namespace": namespace},
timeout=120,
)
r.raise_for_status()
data = r.json()
return data["data"] if isinstance(data, dict) and "data" in data else data
async def delete_snapshot(self, name: str, namespace: str = "default") -> dict:
r = await self._client.delete(f"/api/v1/snapshots/{name}", params={"namespace": namespace}, timeout=60)
r.raise_for_status()
data = r.json()
return data["data"] if isinstance(data, dict) and "data" in data else data
async def gc_snapshots(
self,
namespace: str = "default",
all_namespaces: bool = False,
keep_fork_for: str = "10m",
dry_run: bool = False,
) -> dict:
body: dict = {
"namespace": namespace,
"all_namespaces": all_namespaces,
"keep_fork_for": keep_fork_for,
"dry_run": dry_run,
}
r = await self._client.post("/api/v1/snapshots/gc", json=body, timeout=120)
r.raise_for_status()
data = r.json()
return data["data"] if isinstance(data, dict) and "data" in data else data
async def restore(
self,
snapshot_name: str,
new_sandbox_name: str,
namespace: str = "default",
overrides: dict | None = None,
keep_snapshot: bool = True,
) -> dict:
body: dict = {
"new_sandbox_name": new_sandbox_name,
"namespace": namespace,
"keep_snapshot": keep_snapshot,
}
if overrides:
body["overrides"] = overrides
r = await self._client.post(
f"/api/v1/snapshots/{snapshot_name}/restore",
json=body,
timeout=600,
)
r.raise_for_status()
data = r.json()
return data["data"] if isinstance(data, dict) and "data" in data else data
async def aclose(self):
await self._client.aclose()
+12 -10
View File
@@ -1,13 +1,15 @@
"""
Top-level K7 AI SDK package.
"""
"""Deprecated compatibility shim — use ``k7_sdk`` (``pip install k7-sdk``)."""
from .client import Client, AsyncClient, SandboxProxy
from __future__ import annotations
__all__ = [
"Client",
"AsyncClient",
"SandboxProxy",
]
import warnings
__version__ = "0.0.4-dev"
from k7_sdk import AsyncClient, Client, SandboxProxy
warnings.warn(
"The 'katakate' package is deprecated; pip install k7-sdk and use: from k7_sdk import Client",
DeprecationWarning,
stacklevel=2,
)
__all__ = ["Client", "AsyncClient", "SandboxProxy"]
-190
View File
@@ -1,190 +0,0 @@
import requests
from typing import Optional, List
try:
import httpx # optional dependency for async client
except Exception: # pragma: no cover
httpx = None
class SandboxProxy:
"""Proxy object for sandbox operations."""
def __init__(self, name: str, namespace: str, client: "Client"):
self.name = name
self.namespace = namespace
self._client = client
def exec(self, code: str) -> dict:
"""Execute code in the sandbox."""
return self._client._exec_command(self.name, code, self.namespace)
def delete(self) -> dict:
"""Delete this sandbox."""
return self._client.delete(self.name, self.namespace)
class Client:
"""K7 Python SDK Client."""
def __init__(self, endpoint: str, api_key: str, verify_ssl: bool = True):
self.base_url = endpoint.rstrip("/")
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({"X-API-Key": api_key})
self.session.verify = verify_ssl
def _unwrap(self, response) -> dict:
data = response.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
def create(self, sandbox_config: dict) -> SandboxProxy:
"""Create a new sandbox and return a proxy object."""
response = self.session.post(
f"{self.base_url}/api/v1/sandboxes", json=sandbox_config
)
response.raise_for_status()
name = sandbox_config.get("name")
namespace = sandbox_config.get("namespace", "default")
return SandboxProxy(name, namespace, self)
def list(self, namespace: Optional[str] = None) -> List[dict]:
"""List all sandboxes."""
params = {"namespace": namespace} if namespace else {}
response = self.session.get(f"{self.base_url}/api/v1/sandboxes", params=params)
response.raise_for_status()
return self._unwrap(response)
def delete(self, name: str, namespace: str = "default") -> dict:
"""Delete a sandbox."""
response = self.session.delete(
f"{self.base_url}/api/v1/sandboxes/{name}", params={"namespace": namespace}
)
response.raise_for_status()
return self._unwrap(response)
def delete_all(self, namespace: str = "default") -> dict:
"""Delete all sandboxes in a namespace."""
response = self.session.delete(
f"{self.base_url}/api/v1/sandboxes", params={"namespace": namespace}
)
response.raise_for_status()
return self._unwrap(response)
def install(
self,
playbook: Optional[str] = None,
inventory: Optional[str] = None,
verbose: bool = False,
) -> dict:
"""Install K7 on target hosts."""
response = self.session.post(
f"{self.base_url}/api/v1/install",
json={"playbook": playbook, "inventory": inventory, "verbose": verbose},
)
response.raise_for_status()
return self._unwrap(response)
def get_metrics(self, namespace: Optional[str] = None) -> dict:
"""Get resource usage metrics for sandboxes."""
params = {"namespace": namespace} if namespace else {}
response = self.session.get(
f"{self.base_url}/api/v1/sandboxes/metrics", params=params
)
response.raise_for_status()
return self._unwrap(response)
def _exec_command(self, name: str, command: str, namespace: str) -> dict:
"""Internal method to execute command in sandbox."""
response = self.session.post(
f"{self.base_url}/api/v1/sandboxes/{name}/exec",
json={"command": command},
params={"namespace": namespace},
)
response.raise_for_status()
return self._unwrap(response)
class AsyncClient:
"""K7 Python SDK Async Client."""
def __init__(
self,
endpoint: str,
api_key: str,
verify_ssl: bool = True,
timeout: float = 30.0,
):
if httpx is None:
raise RuntimeError(
"httpx is required for AsyncClient. Install with `pip install httpx`."
)
self.base_url = endpoint.rstrip("/")
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers={"X-API-Key": api_key},
verify=verify_ssl,
timeout=timeout,
)
async def create(self, sandbox_config: dict) -> dict:
r = await self._client.post("/api/v1/sandboxes", json=sandbox_config)
r.raise_for_status()
return r.json()
async def list(self, namespace: Optional[str] = None) -> List[dict]:
params = {"namespace": namespace} if namespace else {}
r = await self._client.get("/api/v1/sandboxes", params=params)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def delete(self, name: str, namespace: str = "default") -> dict:
r = await self._client.delete(
f"/api/v1/sandboxes/{name}", params={"namespace": namespace}
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def delete_all(self, namespace: str = "default") -> dict:
r = await self._client.delete(
"/api/v1/sandboxes", params={"namespace": namespace}
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def exec(self, name: str, command: str, namespace: str = "default") -> dict:
r = await self._client.post(
f"/api/v1/sandboxes/{name}/exec",
json={"command": command},
params={"namespace": namespace},
)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def get_metrics(self, namespace: Optional[str] = None) -> dict:
params = {"namespace": namespace} if namespace else {}
r = await self._client.get("/api/v1/sandboxes/metrics", params=params)
r.raise_for_status()
data = r.json()
if isinstance(data, dict) and "data" in data:
return data["data"]
return data
async def aclose(self):
await self._client.aclose()
View File
+110
View File
@@ -0,0 +1,110 @@
import shutil
import subprocess
import pytest
def _detect_backends() -> set[str]:
"""Return the set of backends supported by the live cluster.
Sources, in order of preference:
1. `k7.katakate.org/backend-<name>=true` labels on the cluster's nodes.
2. `/etc/k7/backend` (legacy, single primary backend) — fallback for
hosts where k3s isn't reachable.
"""
if shutil.which("k3s"):
try:
result = subprocess.run(
[
"k3s",
"kubectl",
"get",
"nodes",
"-o",
'jsonpath={range .items[*]}{.metadata.labels}{"\\n"}{end}',
],
capture_output=True,
text=True,
check=False,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
result = None
if result is not None and result.returncode == 0:
legacy = {
"firecracker-devmapper": "kata-firecracker-devmapper",
"qemu-longhorn": "kata-qemu-longhorn",
}
backends: set[str] = set()
for line in result.stdout.splitlines():
for token in (
"kata-firecracker-devmapper",
"kata-qemu-longhorn",
"k7d",
"firecracker-devmapper",
"qemu-longhorn",
):
if f'"k7.katakate.org/backend-{token}":"true"' in line:
backends.add(legacy.get(token, token))
if backends:
return backends
try:
with open("/etc/k7/backend") as f:
backend = f.read().strip()
legacy = {
"firecracker-devmapper": "kata-firecracker-devmapper",
"qemu-longhorn": "kata-qemu-longhorn",
}
backend = legacy.get(backend, backend)
if backend in ("kata-firecracker-devmapper", "kata-qemu-longhorn", "k7d"):
return {backend}
except FileNotFoundError:
pass
return {"kata-firecracker-devmapper"}
def _detect_node_count() -> int:
"""Count Ready k3s nodes; 0 when k3s is unavailable (e.g. local Mac)."""
if not shutil.which("k3s"):
return 0
try:
result = subprocess.run(
["k3s", "kubectl", "get", "nodes", "--no-headers"],
capture_output=True,
text=True,
check=False,
timeout=10,
)
except (OSError, subprocess.TimeoutExpired):
return 0
if result.returncode != 0:
return 0
return sum(1 for line in result.stdout.splitlines() if " Ready " in f" {line} ")
BACKENDS = _detect_backends()
NODE_COUNT = _detect_node_count()
@pytest.fixture()
def node_count() -> int:
"""Number of Ready nodes in the live k3s cluster."""
return NODE_COUNT
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
skip_firecracker = pytest.mark.skip(reason="cluster has no kata-firecracker-devmapper-capable node")
skip_qemu = pytest.mark.skip(reason="cluster has no kata-qemu-longhorn-capable node")
skip_k7d = pytest.mark.skip(reason="cluster has no k7d-capable node")
skip_multinode = pytest.mark.skip(reason=f"requires >=2 Ready nodes (have {NODE_COUNT})")
for item in items:
if "firecracker" in item.keywords and "kata-firecracker-devmapper" not in BACKENDS:
item.add_marker(skip_firecracker)
if "qemu" in item.keywords and "kata-qemu-longhorn" not in BACKENDS:
item.add_marker(skip_qemu)
if "k7d" in item.keywords and "k7d" not in BACKENDS:
item.add_marker(skip_k7d)
if "multinode" in item.keywords and NODE_COUNT < 2:
item.add_marker(skip_multinode)
View File

Some files were not shown because too many files have changed in this diff Show More