Writing pods¶
A pod is a WebAssembly module that the node runs in its wasm3 interpreter. It communicates with the node through host imports in the env module and entry point exports declared in include/runtime/k32_app_abi.h.
Two kinds of pod¶
| Kind | Exports | Execution model |
|---|---|---|
| Request pod | k32_alloc, k32_handle; optionally k32_init, k32_tick, k32_event |
HTTP workers call k32_handle once per request. A timer calls k32_tick. Channel events arrive in k32_event. |
| Entry-point pod | _start, app_main, or main |
Runs on the pod's own thread (requires a board with PSRAM). Loops on its own and exits when the function returns. |
A module can export both. The runtime looks up entry points in the order _start, app_main, main. k32_init runs once when the container starts, and a non-zero return fails the pod with StartError (k32_init returned <n>). The simulator and boards call k32_tick once per second, or every 10 ms while k32_tick returns a positive value. On boards without PSRAM, all pod calls share one interpreter stack and entry-point pods cannot start; use k32_handle, k32_tick, and k32_event pods on those boards.
Request block¶
Before each k32_handle call, the host allocates a guest buffer with k32_alloc(size) (size is the block length plus one), copies the request into it, and calls k32_handle(ptr, len). The block format is:
The host forwards Cookie, Content-Type, X-Request-Id, and X-Forwarded-For. The guest writes the response with k32_resp_begin, optional k32_resp_header calls, zero or more k32_resp_write calls, and k32_resp_end. Headers set with k32_resp_header before k32_resp_begin or k32_resp_asset are kept and sent with the response; headers that repeat (such as Set-Cookie) accumulate, and passing an empty value before the response starts withdraws pending headers of that name (case-insensitive). The host streams the body with HTTP chunked transfer encoding. If k32_handle returns without starting a response, the client receives 500 {"error":"pod_failure","message":"workload produced no response"}.
k32_event(topic_ptr, topic_len, ptr, len) receives its topic and payload through k32_alloc in the same way. It receives messages published on topics listed in the pod's kube32.io/event-topics annotation.
Host ABI¶
All imports belong to the env module (src/runtime/app_pod.cpp, src/runtime/host_functions.cpp). kube32-cli check rejects unknown imports or signatures.
| Group | Imports | Signatures | Needs | Details |
|---|---|---|---|---|
| HTTP response | k32_resp_begin, k32_resp_header, k32_resp_write, k32_resp_end, k32_resp_asset |
v(iii), v(iiii), i(ii), v(), i(iii) |
nothing | Networking |
| HTTP calls | k32_http_call, k32_http_status |
i(iiiiiiiiiiii), i() |
securityContext.capabilities.add: ["SERVICE_CALL"] |
Networking |
| Events and channels | k32_event_emit, kube32_channel_open/send/recv/close |
i(iiii), i(ii), i(iii), i(iiii), i(i) |
hardware.kube32.io/ipc (CHANNEL_IPC) |
Networking |
| Node services | k32_log, k32_time_ms, k32_unix_time_ms, k32_random, k32_env_get, k32_mem_stats, esp32_delay_ms, esp32_get_time_us |
v(ii), I(), I(), i(), i(iiii), i(ii), v(i), I() |
nothing | include/runtime/k32_app_abi.h |
| Storage | k32_fs_open/read/write/close/size/list/remove |
i(iii), i(iii), i(iii), i(i), i(i), i(iiii), i(ii) |
hardware.kube32.io/sd (SD) and a volume claim |
Storage, storage |
| Cardputer UI | k32_display_text, k32_display_rect, k32_display_bar, k32_key_read, k32_tone |
i(iiiiii), i(iiiii), i(iiiiiii), i(), i(iii) |
hardware.kube32.io/tft-display (DISPLAY), keyboard (KEYBOARD), speaker (SPEAKER) |
Peripherals, Capability firewall |
| ESP32 peripherals | esp32_gpio_set_mode/write/read, esp32_adc_read, esp32_i2c_write/read, esp32_uart_write/read |
i(ii), i(ii), i(i), i(i), i(iiii), i(iiii), i(iii), i(iii) |
esp32.io/* resources |
Peripherals, GPIO and other peripherals |
| Infrared (RMT) | esp32_rmt_send, esp32_rmt_receive |
i(ii), i(ii) |
esp32.io/rmt-tx, esp32.io/rmt-rx |
Peripherals |
Calls without the required resource or capability return K32_ERR_PERM (-1) and log a firewall violation.
k32_resp_asset(path, len, status) streams a file from the pod's image bundle (images/web/<name>/ for embedded images, kube32/images/<name>/ on the card for wasm://sd/<name> images) without copying it into linear memory (status 0 means 200; HEAD requests are supported). It serves the .gz variant when the client accepts gzip, or replies 406 if only the .gz file exists and the client refuses gzip. When the file does not exist it returns K32_ERR_NOENT (-2) without sending anything and leaves earlier headers pending.
k32_http_call(method, method_len, url, url_len, headers, headers_len, body, body_len, resp, resp_cap, *resp_len, timeout_ms) sends a synchronous HTTP request to http://<host>[:<port>]/path or https://<host>[:<port>]/path (timeout_ms 0 defaults to 5000 ms, maximum 60 s). The host of an http:// URL resolves as a Kubernetes DNS name does in a pod: <service>, <service>.<ns>, <service>.<ns>.svc and <service>.<ns>.svc.cluster.local name Services; IPv4 addresses, other DNS names and a <name>.<name> that is no Service name hosts outside the cluster. An https:// host is always outside the cluster: the control plane makes the TLS connection and verifies the certificate against the common public CAs, for pods on workers through its egress gateway. A Service target on the same node is called in-process; one on another node is called over HTTP/1.1. A Service target receives Content-Type, Cookie, X-Request-Id, and Accept-Encoding from the caller's headers; a host outside the cluster receives every caller header except the hop-by-hop ones. NetworkPolicy egress rules apply to all of them. It copies up to resp_cap response bytes into resp, stores the byte count in *resp_len, and returns the HTTP status or a negative error: K32_ERR_PERM (-1, no capability, denied by NetworkPolicy, or refused by the egress gateway), K32_ERR_NOENT (-2, also a host name that does not resolve), K32_ERR_IO (-5), K32_ERR_TOOBIG (-7, after copying the first resp_cap bytes; k32_http_status() returns the response's status), K32_ERR_BUSY (-16, every TLS session in use), K32_ERR_LOOP (-40, self-call or cycle across pods detected via X-Kube32-Call-Chain), K32_ERR_TLS (-71, TLS handshake failed), K32_ERR_NOTSUP (-95, https:// in a cluster without TLS), K32_ERR_TIMEOUT (-110), K32_ERR_CONNREFUSED (-111), K32_ERR_HOSTUNREACH (-113), or K32_ERR_CERT (-129, certificate not accepted). See Networking and HTTPS.
k32_unix_time_ms() returns the node's wall clock in Unix milliseconds, or 0 while the clock is not set. The control plane sets its clock over SNTP when it has an uplink. k32_time_ms() counts milliseconds since the node booted.
esp32_rmt_send(durations_us, count) sends one IR frame (mark and space durations in microseconds, mark first) on the pod's RMT TX channel and returns count once the frame is out, or K32_ERR_PERM (-1), K32_ERR_INVAL (-22, an empty, overlong, or zero-duration frame), or K32_ERR_FAULT (-14). esp32_rmt_receive(out_us, cap) never waits: it copies the oldest frame the pod's RMT RX channel captured and returns its duration count, or K32_ERR_AGAIN (-11) when none is queued, K32_ERR_TOOBIG (-7, the first cap durations copied and the frame dropped), or K32_ERR_PERM (-1). Poll it from k32_tick; see Peripherals for pins, carrier, and the receive queue.
Runtime limits and lifecycle¶
Limits¶
| Limit | Value | Behaviour when exceeded |
|---|---|---|
| Request block | 16 KiB (K32_MAX_REQUEST_BYTES) |
HTTP 413 |
| Event payload | 200 bytes (K32_MAX_EVENT_BYTES), topic 1..64 bytes |
k32_event_emit returns K32_ERR_INVAL (-22) |
| Response header | name 1..64 bytes, value up to 256 bytes (K32_MAX_HEADER_BYTES), up to 8 pending headers |
Header is dropped |
Content type in k32_resp_begin |
under 128 bytes | Defaults to application/json |
| Log line | 512 bytes | Line is dropped |
k32_display_text |
64 bytes | Returns K32_ERR_FAULT (-14) |
k32_tone |
2000 ms, 100% volume | Clamped |
| IR frame | 512 durations (K32_RMT_MAX_DURATIONS); 500 ms sent (K32_RMT_MAX_FRAME_US) |
esp32_rmt_send returns K32_ERR_INVAL (-22); a longer frame received is dropped and logged |
| IR receive queue | 8 frames (K32_RMT_RX_QUEUE_FRAMES), 512 durations (K32_RMT_RX_QUEUE_DURATIONS) |
Frames are dropped; the next esp32_rmt_receive logs the count |
| Files | 96-byte paths (K32_FS_MAX_PATH), 4 open per pod (K32_FS_MAX_FDS) and per node, 4096 bytes per call (K32_FS_MAX_IO) |
Returns negative K32_ERR_*; see Storage |
| Execution budget per call | 4,000,000,000 wasm3 gas units (kDefaultGasBudgetPerCall) |
Call traps with exit code 152 |
Memory limits¶
A module declares its linear memory at link time (--initial-memory and --max-memory, in multiples of 64 KiB). The runtime sets the pod's linear memory limit to limits.memory (or requests.memory), raised to esp32.io/psram if larger.
A memory limit below 64Ki (such as 2Ki, 4Ki, or 16Ki) caps the backing allocation of the module's first linear-memory page to that byte limit. The module declares 1 page (64 KiB), only the limit is allocated, accesses past the limit trap, and memory.grow past the cap fails.
At boot, boards with PSRAM reserve one 1284 KiB block for one large pod and one shared 64 KiB interpreter stack in PSRAM. Boards without PSRAM reserve an 80 KiB pool block for pods and one shared 28 KiB interpreter stack at boot. app_main (entry-point) pods require a board with PSRAM. On both board types, all pod calls run one at a time on the shared stack. Every pod allocation (wasm3 runtime, compiled code, linear memory) comes from the pool block first and falls back to the general heap when the block is full, leaving 24 KiB of heap reserved for the system. While a pod whose linear memory needs >= 48 KiB loads, its runtime goes to the general heap. On boards with PSRAM, pods also leave a system PSRAM reserve of 128 KiB on a control plane and 64 KiB on a worker. The kubelet rejects any pod whose estimate does not fit alongside this reserve and names both reserves in the message.
The wasm3 value stack holds the frames of the calls in flight. By default, the runtime sizes it from the compiled module's heaviest call chain, with indirect calls reaching every function of their type. It is at most 4 KiB for pods with up to 64 KiB of linear memory and 16 KiB for larger pods. A module that recurses gets that maximum. Set metadata.annotations["kube32.io/wasm-stack"] (512 to 64Ki) to set the per-pod wasm3 value stack size instead. Exhausting the stack traps with exit code 139 and the message the module's stack of <N> bytes is exhausted (raise it with the annotation kube32.io/wasm-stack). The shadow stack for C and Rust locals lives inside linear memory (-z stack-size, 16 KiB by default in kube32-cli build).
Execution budget¶
Every host-initiated call (k32_init, k32_alloc, k32_handle, k32_tick, k32_event) gets a fresh wasm3 gas budget, defaulting to 4,000,000,000 units. Override it with the pod annotation kube32.io/execution-budget: "<units>" or disable metering with "off" (or "0"). Unmetered pods compile without gas checks and cannot be interrupted mid-call when stopped.
Restart policy and exit codes¶
Pods support restartPolicy: Always, OnFailure, and Never. Failed or restarting containers back off starting at 10 s, doubling on each crash up to 5 minutes (CrashLoopBackOff), and reset the back-off after 10 minutes of running. Container status records lastState, restartCount, and BackOff events. Blocking host calls (sleep, channel receive, tone, HTTP call) abort early when the kubelet stops a container, and deleting a pod reclaims its memory.
| Exit code | Reason | Cause |
|---|---|---|
Return value of app_main / main (0 for _start) |
Completed (when 0) or Error |
Entry point returned |
128 |
StartError |
Container failed to deploy or start (bad Wasm, k32_init non-zero, no thread stack) |
132 |
Error |
Unsupported instruction (SIGILL) |
134 |
Error |
unreachable or abort (SIGABRT) |
135 |
Error |
Unaligned atomic operation (SIGBUS) |
136 |
Error |
Integer division by zero, integer overflow, or invalid conversion (SIGFPE) |
137 |
OOMKilled |
Runtime allocation failed, memory limit exceeded, or abort after memory.grow was refused at the pod limit |
137 |
Error |
Stopped by the kubelet (SIGKILL) |
139 |
Error |
Out-of-bounds memory or table access, stack overflow (the module's stack of <N> bytes is exhausted (raise it with the annotation kube32.io/wasm-stack)), null reference, or indirect call type mismatch (SIGSEGV) |
152 |
Error |
Call exhausted its kube32.io/execution-budget (SIGXCPU) |
Rust¶
sdk/rust is a Cargo workspace containing the #![no_std] crate kube32-sdk and two example pods (sdk/rust/examples/counter and sdk/rust/examples/notes).
Feature of kube32-sdk |
Provides |
|---|---|
panic-handler (default) |
#[panic_handler] that logs through k32_log and traps |
hardware |
hardware::{display_text, display_rect, display_bar, key_read, tone, rgb565, rmt_send, rmt_receive} |
fs |
fs::{File, list, remove} over k32_fs_* |
Request pod¶
kube32_app! exports the request entry points (alloc, init, handle, tick, event, in that order) and reserves the static k32_alloc buffer (default 16 KiB + 512). From sdk/rust/examples/counter/src/lib.rs:
#![no_std]
use core::sync::atomic::{AtomicU64, Ordering::Relaxed};
use kube32_sdk::{emit, kube32_app, parse_u32, Buf, Error, Request, Response};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn handle(req: &Request) -> i32 {
match (req.method, req.path) {
("POST", "/inc") => {
let by = req.query_param("by").and_then(parse_u32).unwrap_or(1);
let value = COUNTER.load(Relaxed) + by as u64;
COUNTER.store(value, Relaxed);
let mut ev = Buf::<64>::new(); // event payloads are at most 200 bytes
ev.push_str("{\"type\":\"counter\",\"value\":").push_u64(value).push_str("}");
let outcome = match emit("counter.events", ev.as_bytes()) {
Ok(_) => "sent",
Err(Error::Permission) => "denied", // no hardware.kube32.io/ipc grant
Err(_) => "failed",
};
let mut resp = Response::json(200);
resp.write_str("{\"counter\":").write_u64(value)
.write_str(",\"event\":\"").write_str(outcome).write_str("\"}");
resp.end()
}
_ => {
let mut resp = Response::json(404);
resp.write_str("{\"error\":\"not_found\"}");
resp.end()
}
}
}
kube32_app! {
handle: handle,
}
Entry-point pod¶
kube32_main! exports app_main:
#![no_std]
use kube32_sdk::hardware::{display_rect, display_text, key_read, rgb565, tone};
use kube32_sdk::{delay_ms, kube32_main, log, Buf, Error};
fn main_loop() -> i32 {
let white = rgb565(255, 255, 255);
let black = rgb565(0, 0, 0);
if let Err(Error::Permission) = display_rect(0, 0, 240, 135, black) {
log("blink: no display grant");
return 1; // returning from app_main ends the container
}
let _ = tone(880, 50, 30);
for i in 0..5u64 {
let mut line = Buf::<32>::new();
line.push_str("tick ").push_u64(i);
let _ = display_text(8, 8, line.as_str(), white, black);
if let Ok(Some(k)) = key_read() {
let mut msg = Buf::<16>::new();
msg.push_str("key ").push_u64(k as u64);
log(msg.as_str());
}
delay_ms(200);
}
log("blink: done");
0
}
kube32_main!(main_loop);
Building Rust pods¶
Install the target with rustup target add wasm32-unknown-unknown and build with kube32-cli build:
$ tools/kube32-cli build sdk/rust/examples/counter
building <repo>/sdk/rust/examples/counter (rust)
sdk/rust/examples/counter/build/counter.wasm: 6,400 bytes (code 5,289, data 691)
kind: request pod (k32_handle)
exports (8): k32_alloc, k32_event, k32_handle, k32_init, k32_tick, __data_end [global], __heap_base [global], memory [memory]
imports (8): k32_env_get, k32_event_emit, k32_log, k32_resp_begin, k32_resp_end, k32_resp_header, k32_resp_write, k32_time_ms
memory: 1 page = 65,536 bytes (64Ki), max 1 page
features beyond MVP: bulk-memory (memory.copy x1, memory.fill x6); sign-ext (i32.extend8_s x1)
hardware: ipc (manifest resources: hardware.kube32.io/ipc)
ok
Pass --memory 128Ki --stack 32Ki to change the linear memory and shadow stack sizes. A crate outside the workspace needs crate-type = ["cdylib"] and a dependency on kube32-sdk.
C¶
A C pod includes runtime/k32_app_abi.h and marks exports with K32_EXPORT:
/* hello-c: the smallest useful request pod in C. */
#include "runtime/k32_app_abi.h"
#define LIT(s) (s), (uint32_t)(sizeof(s) - 1)
static unsigned char request_buf[K32_MAX_REQUEST_BYTES + 512];
static uint32_t hits;
/* The host copies each request block here before calling k32_handle. */
K32_EXPORT("k32_alloc") uint32_t k32_alloc(uint32_t size) {
return size <= sizeof request_buf ? (uint32_t)(uintptr_t)request_buf : 0;
}
K32_EXPORT("k32_handle") int32_t k32_handle(uint32_t ptr, uint32_t len) {
const char* req = (const char*)(uintptr_t)ptr; /* "GET /path?query\n..." */
uint32_t line = 0;
while (line < len && req[line] != '\n') line++;
char num[10], tmp[10];
uint32_t v = ++hits, n = 0, t = 0;
do { tmp[t++] = (char)('0' + v % 10); v /= 10; } while (v);
while (t) num[n++] = tmp[--t];
k32_resp_begin(200, LIT("text/plain"));
k32_resp_write(LIT("hello from C, request "));
k32_resp_write(num, n);
k32_resp_write(LIT(": "));
k32_resp_write(req, line);
k32_resp_write(LIT("\n"));
k32_resp_end();
return 200;
}
kube32-cli build compiles .c files with clang --target=wasm32 -O2 -nostdlib -ffreestanding -fno-exceptions -mcpu=mvp -msign-ext -mbulk-memory and links them with rust-lld or wasm-ld. If the directory contains a Makefile, kube32-cli build runs make -C <dir> (pass --artifact <path> when the output .wasm cannot be inferred from make -n -B). See apps/echo, apps/sdnote, apps/i2cscan, apps/irremote, and apps/tessera for complete C pods.
AssemblyScript¶
Install assemblyscript in the project directory (npm install --save-dev assemblyscript), add asconfig.json with {"entries": ["assembly/index.ts"]}, and declare host imports with @external("env", ...):
// hello-as: a request pod in AssemblyScript. Nothing here allocates: the
// stub runtime never frees, so a pod that allocated per request would run
// out of its 64 KiB. Text goes out through one static buffer.
@external("env", "k32_resp_begin") declare function k32_resp_begin(status: i32, ctype: usize, ctypeLen: u32): void;
@external("env", "k32_resp_write") declare function k32_resp_write(data: usize, len: u32): i32;
@external("env", "k32_resp_end") declare function k32_resp_end(): void;
@external("env", "k32_log") declare function k32_log(msg: usize, len: u32): void;
const REQUEST_BUF: usize = memory.data(16 * 1024 + 512); // K32_MAX_REQUEST_BYTES plus room for an event
const OUT: usize = memory.data(256);
let hits: u32 = 0;
// ASCII literals only: at most 256 bytes per call.
function ascii(s: string): u32 {
return <u32>String.UTF8.encodeUnsafe(changetype<usize>(s), s.length, OUT);
}
function writeStr(s: string): void {
k32_resp_write(OUT, ascii(s));
}
function writeU32(v: u32): void {
let n: u32 = 0;
let tmp: u32 = v;
do { n++; tmp /= 10; } while (tmp != 0);
for (let i: u32 = n; i > 0; i--) {
store<u8>(OUT + i - 1, 48 + v % 10);
v /= 10;
}
k32_resp_write(OUT, n);
}
export function k32_init(): i32 {
k32_log(OUT, ascii("hello-as: init"));
return 0;
}
export function k32_alloc(size: u32): u32 {
return size <= 16 * 1024 + 512 ? <u32>REQUEST_BUF : 0;
}
export function k32_handle(ptr: u32, len: u32): i32 {
// The request line ends at the first '\n': "GET /path?query"
let line: u32 = 0;
while (line < len && load<u8>(ptr + line) != 10) line++;
hits++;
k32_resp_begin(200, OUT, ascii("text/plain"));
writeStr("hello from AssemblyScript, request ");
writeU32(hits);
writeStr(": ");
k32_resp_write(ptr, line);
writeStr("\n");
k32_resp_end();
return 200;
}
kube32-cli build invokes asc with --runtime stub --noAssert --use abort= -O3z so abort compiles to a trap instead of importing env.abort.
Checking a module¶
kube32-cli check validates a .wasm binary against the runtime:
- Imports must be functions from
envwith signatures the host links. - Exports (
k32_init,k32_alloc,k32_handle,k32_tick,k32_event,_start,app_main,main) must match the host's signatures, andk32_handleork32_eventrequiresk32_alloc. - Linear memory 0 must be defined by the module (not imported), 32-bit, and within
--memorywhen given. - Instructions must belong to the opcode set enabled in this wasm3 build.
$ tools/kube32-cli check images/tessera-core.wasm --memory 64Ki
images/tessera-core.wasm: 76,228 bytes (code 64,133, data 9,313)
kind: request pod (k32_handle)
exports (6): k32_alloc, k32_event, k32_handle, k32_init, k32_tick, memory [memory]
imports (10): k32_env_get, k32_event_emit, k32_log, k32_mem_stats, k32_random, k32_resp_begin, k32_resp_end, k32_resp_header, k32_resp_write, k32_time_ms
memory: 20 pages = 1,310,720 bytes (1280Ki), max 20 pages
features beyond MVP: sign-ext (i32.extend16_s x1)
hardware: ipc (manifest resources: hardware.kube32.io/ipc)
error: memory: the module declares 1280Ki (20 pages) of linear memory but the limit is 64Ki
FAILED (1 error)
| Accepted features (beyond MVP + float) | Rejected features |
|---|---|
| sign extension, saturating float-to-int, bulk memory, reference types, tail calls, multi-value, extended constant expressions, wide arithmetic, atomics, multiple memories | SIMD (0xFD, v128), exception handling (throw, throw_ref, try_table, tag sections), typed function references (call_ref, return_call_ref, br_on_null), GC types, 64-bit memory 0, imported memories and tables |
Exit codes of kube32-cli: 0 ok, 1 input or toolchain error, 2 usage error, 3 ABI check failed, 4 kubectl failed, 5 pod already exists on deploy.
Deploying pods¶
The kubelet resolves spec.containers[0].image in this order:
wasm://sd/<name>[:<tag>]reads<card>/kube32/images/<name>[-<tag>].wasm(up to 256 KiB without PSRAM, 2 MiB with PSRAM).- Any other image reference is looked up in the firmware's embedded registry by its key (the last path component before
:). - Otherwise, the kubelet decodes
metadata.annotations["kube32.io/wasm-base64"].
Inline image (kube32-cli deploy)¶
kube32-cli deploy checks the module, embeds it in kube32.io/wasm-base64, and creates the Pod with kubectl create:
$ tools/kube32-cli deploy sdk/rust/examples/counter/build/counter.wasm --node esp32-node-01 --env GREETING=hello
pod/counter created
deployed default/counter: 6,400 bytes inline, 64Ki linear memory, hardware: ipc
Key flags for kube32-cli deploy:
--replace: deletes an existing pod of the same name (kubectl delete pod <name> --wait=true) before creating the new one. The kubelet does not restart a running pod when its manifest changes in place.--memory <qty>and--psram <qty>: setrequests.memory,limits.memory, andesp32.io/psram.--hardware <list>: comma-separated list ofipc,display,keyboard,speaker,sd,service-callornone(default: inferred from the module's imports).--sd-claim <claim>: volume claim name whensdis used (default: pod name).--image <wasm://...>: references an SD or embedded image instead of attachingkube32.io/wasm-base64.--dry-run: prints the generated Pod YAML without callingkubectl.
SD card image (kube32-cli sd-push / push)¶
Copy a module onto a mounted card with sd-push, or upload it over the network through the node proxy (/api/v1/nodes/<node>/proxy/storage/<path>) with kube32-cli push:
$ tools/kube32-cli sd-push sdk/rust/examples/notes/build/notes.wasm --to $SIM/sdcard
copied $SIM/sdcard/kube32/images/notes.wasm (4,500 bytes)
image: wasm://sd/notes
$ tools/kube32-cli deploy sdk/rust/examples/notes/build/notes.wasm --image wasm://sd/notes --node esp32-node-02
See Storage and volumes for volume and image layout.
Embedded image (kube32-cli embed)¶
Copy a checked module into images/<name>.wasm for the next firmware or simulator build:
$ tools/kube32-cli embed sdk/rust/examples/counter/build/counter.wasm
embedded <repo>/images/counter.wasm (6,400 bytes)
image: wasm://embedded/counter:v1
Rebuild the firmware with pio run -e esp32s3, or reconfigure and build the host simulator with cmake -S . -B build && cmake --build build.
Manifest fields and annotations¶
Pod spec fields¶
| Field | Meaning |
|---|---|
spec.containers[0].image |
wasm://sd/<name>[:<tag>], wasm://embedded/<name>, or wasm://inline/<name>-<digest> |
spec.nodeName, spec.nodeSelector |
Pins or selects nodes (such as node-role.kubernetes.io/control-plane or kube32.io/board: cardputer) |
spec.restartPolicy |
Always (default), OnFailure, or Never |
spec.containers[0].env, envFrom |
Literal values, valueFrom (configMapKeyRef, secretKeyRef, fieldRef), envFrom with prefix, $(VAR) expansion, and <SVC>_SERVICE_HOST / _PORT links (unless enableServiceLinks: false). Missing refs hold the pod in CreateContainerConfigError. |
resources.requests.memory, limits.memory |
Scheduler heap accounting and Wasm linear memory limit (supports sub-page limits below 64Ki) |
resources.requests["esp32.io/psram"] |
Places the Wasm sandbox in PSRAM and raises the sandbox size to max(memory, psram) |
resources.requests["hardware.kube32.io/*"] |
Grants ipc, tft-display, keyboard, speaker, sd |
resources.requests["esp32.io/*"] |
Claims gpio-<N>, gpio, adc, i2c, i2c-bus-<N>, uart, uart-<N>, rmt-tx, rmt-rx |
securityContext.capabilities.add |
Grants CHANNEL_IPC, DISPLAY, KEYBOARD, SPEAKER, SD, SERVICE_CALL |
spec.volumes[].persistentVolumeClaim.claimName |
Mounts <root>/kube32/volumes/<claim> on card or internal flash (kube32.io/sd: {claim: ...} requires a card) |
ports[].containerPort |
Target port named by Services (7000 is the node's pod HTTP server port) |
Annotations¶
| Annotation | Target | Meaning |
|---|---|---|
kube32.io/wasm-base64 |
Pod | Base64-encoded .wasm module used when the image is not on SD or embedded |
kube32.io/wasm-sha256, kube32.io/wasm-size |
Pod | Module digest and byte size recorded by kube32-cli deploy |
kube32.io/execution-budget |
Pod | Per-call wasm3 gas limit (<units> or "off", default 4000000000) |
kube32.io/wasm-stack |
Pod | Per-pod wasm3 value stack size (512 to 64Ki; default: what the module's calls need, at most 4Ki for pods up to 64Ki of linear memory) |
kube32.io/event-topics |
Pod | Comma-separated channel topics delivered to the pod's k32_event export |
kube32.io/tick-interval |
Pod | Period of the pod's k32_tick: "250ms", "2s", or an integer in milliseconds, from 50 ms to 1 h (default 1 s) |
kube32.io/i2c-bus-<N> |
Pod | Bus pins and speed, e.g. "sda=8,scl=9,hz=100000" |
kube32.io/uart-<N> |
Pod | UART pins and baud rate, e.g. "tx=17,rx=18,baud=115200" |
kube32.io/rmt-tx |
Pod | IR transmit pin and carrier, e.g. "pin=2,carrier=38000,duty=33" (carrier=0: no carrier) |
kube32.io/rmt-rx |
Pod | IR receive pin, mark level, and end-of-frame gap, e.g. "pin=1,active=low,idle_us=12000" |
kube32.io/event-stream |
Ingress | Serves SSE streams of channel topics: "<path>=<topic>[,<topic>...][;<path>=...]" |
kube32.io/cluster-telemetry |
Ingress | Serves cluster telemetry JSON at <path> |
kube32.io/stats-topic |
Ingress | Publishes {"t":"stats","ingress":"<ns>/<name>","rps":N,"viewers":N} every second on <topic> |
Running on the host simulator¶
Build and start kube32-sim in standalone mode (API server, Ingress controller, and simulated nodes esp32-node-01 and esp32-node-02 in one process):
$ cmake --build build -j8
$ export SIM=/some/scratch/dir
$ KUBE32_SD_ROOT=$SIM/sdcard ./build/kube32-sim --role standalone --port 16443 --ingress-port 18080 --pod-port 17000 --ipc-port 17400 --data-dir $SIM/data
Point kubectl at http://127.0.0.1:16443 and apply a Pod, Service, and Ingress (such as examples/echo.yaml), or deploy a module with kube32-cli deploy. Reach request pods in two ways:
- Directly through the pod HTTP server (
--pod-port 17000) with theX-Kube32-Pod: <namespace>/<name>header:
$ curl -s -H 'X-Kube32-Pod: default/counter' 'http://127.0.0.1:17000/inc?by=5' -X POST
{"counter":5,"event":"sent"}
- Through the Ingress controller (
--ingress-port 18080), which routes requests according tonetworking.k8s.io/v1Ingress rules and serves SSE streams configured withkube32.io/event-stream(see Networking):