Skip to main content

WorkloadSpec

Struct WorkloadSpec 

Source
pub struct WorkloadSpec {
Show 23 fields pub schema_version: SchemaVersion, pub name: String, pub image: ImageRef, pub tier: TierTag, pub tenant: TenantId, pub namespace: NamespaceId, pub replicas: u32, pub command: Option<Vec<String>>, pub entrypoint: Option<Vec<String>>, pub workdir: Option<PathBuf>, pub user: Option<String>, pub env: Vec<EnvVar>, pub secrets: Vec<SecretMount>, pub volumes: Vec<VolumeMount>, pub resources: ResourceLimits, pub depends_on: Vec<MeshIdent>, pub healthcheck: Option<Healthcheck>, pub restart_policy: RestartPolicy, pub archetype: Option<LifecycleArchetype>, pub stop_policy: StopPolicy, pub expose: ExposeSpec, pub labels: HashMap<String, String>, pub annotations: HashMap<String, String>,
}
Expand description

Complete typed description of a containerd workload handed to yubaba over RPC. This is also the payload of the kind = "container" variant of Workload on disk.

Yubaba never accepts compose YAML on its RPC surface — agents, the desktop, and operator CLIs all hand yubaba WorkloadSpec values. See the arch doc for the validation layers and evolution rules.

Fields§

§schema_version: SchemaVersion

Wire-format version; always V1 today. Present at the top level so rolling clusters can detect and migrate across schema generations.

§name: String

DNS-friendly workload name, e.g. "noisetable-api". Regex: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$, length ≤ 63.

§image: ImageRef

Container image to pull.

§tier: TierTag

Tier tag controlling admission control and mesh filtering.

§tenant: TenantId

Tenant isolation axis (W206). Separates operators’ workloads at the network / DB / mesh-identity level. Defaults to TenantId::singleton for specs that predate the axis, so single-tenant clusters keep every isolation primitive a no-op. Orthogonal to Self::tier (class) and Self::namespace (routing).

§namespace: NamespaceId

Namespace routing/naming axis (W206). A pure naming key — never affects isolation; disambiguates DNS names and selects config root / provider zone within a tenant. Defaults to NamespaceId::singleton.

§replicas: u32

Target replica count. 0 registers the workload without deploying it. Range: 0–100 (cluster-wide cap; operator can raise it).

§command: Option<Vec<String>>

Override the image’s CMD. None leaves the image default.

§entrypoint: Option<Vec<String>>

Override the image’s ENTRYPOINT. None leaves the image default.

§workdir: Option<PathBuf>

Working directory inside the container.

§user: Option<String>

User to run as, e.g. "1000:1000" or "appuser".

§env: Vec<EnvVar>

Environment variables. Values may be literals, secret refs, or mesh-address references resolved by yubaba at deploy time.

§secrets: Vec<SecretMount>

Secret mounts. Values never appear in the spec JSON — only references.

§volumes: Vec<VolumeMount>

Volume mounts.

§resources: ResourceLimits

Hard resource caps enforced by containerd/cgroups.

§depends_on: Vec<MeshIdent>

Mesh idents that must reach Ready before this workload starts.

§healthcheck: Option<Healthcheck>

Container liveness/readiness probe.

§restart_policy: RestartPolicy

What yubaba does when the container exits.

§archetype: Option<LifecycleArchetype>

Explicit lifecycle archetype (R572-F1 / W244): server, appliance, or job. None means the spec predates this field (or the author didn’t set it) — callers MUST NOT read this directly to decide drainability; use WorkloadSpec::effective_archetype, which falls back to the pre-R572 volumes/restart_policy inference so no existing spec’s effective meaning changes.

Additive: this field did not exist before R572-F1. Reconciler (F4) and scheduler (F5) branching on the resolved archetype are separate, later tickets — this field alone changes no runtime behavior.

§stop_policy: StopPolicy

Graceful shutdown configuration.

§expose: ExposeSpec

Network exposure configuration — mesh, public, and operator channels are independent and can be set in any combination.

§labels: HashMap<String, String>

OCI-style labels, passed through to the container. Opaque to yubaba.

§annotations: HashMap<String, String>

Yah-specific metadata, conventionally prefixed yah.*. Opaque to yubaba beyond yah.forge=true which suppresses the Never-restart guard.

Implementations§

Source§

impl WorkloadSpec

Source

pub fn for_forge( forge_id: &str, image: ImageRef, tier: TierTag, ports: Vec<u16>, ) -> Self

Build a WorkloadSpec for a forge run.

Sets the conventional forge fields in one place so callers cannot forget any of them:

  • restart_policy = Never
  • archetype = Some(LifecycleArchetype::Job) — a forge run is exactly the container-kind instance of the job archetype (W244); set explicitly rather than left to infer since this constructor knows its own shape
  • expose.public = None, expose.operator = None
  • expose.mesh.identity = "forge.<forge_id>"
  • annotations["yah.forge"] = "true" (suppresses the shape warning)
  • tier and image come from the caller; ports becomes the mesh port list (empty is valid — forge jobs often don’t expose ports)

All other fields are set to safe defaults. Callers can mutate the returned value to fill in command, env, resources, etc.

Source

pub fn wants_host_network(&self) -> bool

Whether this workload requests the host network namespace rather than an isolated one.

Opt-in via annotations["yah.network"] == "host" (see HOST_NETWORK_ANNOTATION / HOST_NETWORK_VALUE). Default is the isolated netns every other workload gets — host networking is a privileged escape hatch for the few infra workloads that must bind a host port so an on-host ingress (e.g. a Cloudflare tunnel reaching 127.0.0.1:<port>) can route to them without CNI/bridge plumbing.

The backend (kamaji) is responsible for guarding this: host networking is only honoured for tier == "infra" workloads; a non-infra workload that sets the annotation is rejected at deploy. See validate_spec_for_constable.

Source

pub fn effective_archetype(&self) -> LifecycleArchetype

Resolve the lifecycle archetype (R572-F1 / W244): the explicit Self::archetype if set, otherwise the pre-R572 inference from volumes/restart_policy this field replaces.

This is the one seam callers should use to ask “can I kill and reschedule this?” — it is intentionally the only place that implements the fallback, so behavior for pre-existing specs (no archetype on disk) is identical to what it was before this field existed. Consumers (reconciler R572-F4, scheduler R572-F5) branch on the return value; this crate does not itself change any reconciler or scheduler behavior.

Source

pub fn fq_mesh_identity(&self) -> String

Fully-qualified mesh identity <tenant>/<namespace>/<name> (W206 / R558-F3), where <name> is this workload’s MeshExpose::identity.

Within a tenant, workloads still address each other by the short identity (namespace disambiguates only on collision); the FQN is what makes the identity unambiguous across tenants and is exactly what a MeshPeer::CrossTenant grant names.

Source

pub fn requires_taint(&self) -> Option<&str>

The taint this workload requires its node to carry, if any (R594-F2 / W267 sovereign public ingress).

Opt-in via annotations["yah.placement.requires-taint"] = "<taint name>" (see REQUIRES_TAINT_ANNOTATION) — same annotation-based, zero-blast-radius shape as Self::wants_host_network, chosen so declaring this requirement does not force a struct-literal edit at every existing WorkloadSpec { .. } construction site the way a new plain field would (see R572-F1’s handoff: ~26 sites for one field).

Both halves have since landed: MachineConfig.taints (R572-F3) and the scheduler’s affinity check in cloud::config::RequiredSpec::matches (R572-F5), which requires the key in the node’s taints or mesh_tags.

A key named here is one of only two ways a node taint can influence placement — the other is the no-<archetype> repulsion form. W305/ R742-T4 makes yah cloud validate reject any node taint that is neither, so a new affinity key must be added to cloud::config::AFFINITY_TAINT_KEYS alongside the workload that requires it.

The public-ingress appliance (W267) is the first user: a kind = "container" workload with archetype = Some(LifecycleArchetype::Appliance) and requires_taint() == Some(PUBLIC_IP_TAINT), so yubaba may one day place it only on machines carrying the "public-ip" taint and kamaji supervises it like any other container (no new Workload variant — see Workload::Container’s doc comment).

Source

pub fn memory_request_mb(&self) -> u32

The memory (MiB) a scheduler must find on a node before placing this workload — its request, as distinct from ResourceLimits::memory_mb, which is a ceiling the backend turns into a cgroup memory.max.

Opt-in via annotations["yah.placement.memory-request-mb"] (see MEMORY_REQUEST_ANNOTATION); absent or unparseable falls back to resources.memory_mb, so every spec that does not set it is admitted exactly as it was before this accessor existed.

§Why the two numbers must not be the same one

A limit answers “kill it past here”; a request answers “don’t start it somewhere smaller than here”. Generous is the safe direction for the first and the unschedulable direction for the second, so one field serving both makes a deliberately-roomy ceiling into an admission floor.

That is not hypothetical: WorkloadSpec::for_forge sets a 32 GiB ceiling explicitly reasoned as “above physical RAM on smaller build-workers ⇒ effectively unlimited there” (R590-B10), and CloudConfig::admit_workload fed that same 32768 in as the R572-F5 capacity floor. Every build-worker under 32 GiB — the three 8 GiB Pi-5s and the 16 GiB us-west-003 — became structurally unadmittable for any offloaded qed step, leaving one 47 GiB node as the fleet’s only legal target for remote CI. This is R590-B10’s own recorded follow-up (“thread a per-step memory request … instead of a blanket forge default”), reduced to the seam that closes the bug.

An annotation rather than a new ResourceLimits field on purpose: WorkloadSpec crosses a postcard wire that is positional and carries no field names (R590-B3), so adding a field would break decode on every fleet node still running an older kamaji. annotations is an existing map — an extra key rides it safely, and admission already reads placement inputs from exactly there (Self::requires_taint, the R594 node-selector).

Source

pub fn wants_native_exec(&self) -> bool

Whether this workload must be run by kamaji’s native (fork+exec) backend on the node’s own userland, rather than by a container backend (R577-T1 / W254).

Opt-in via annotations["yah.exec"] == "native" (see NATIVE_EXEC_ANNOTATION / NATIVE_EXEC_VALUE) — the same annotation-shaped, zero-blast-radius marker as Self::wants_host_network and Self::requires_taint, chosen over a new plain field for the reason R572-F1 recorded: a field forces a struct-literal edit at every existing construction site and an exhaustive-match update in kamaji-proto’s codec, and this marker needs neither.

§Why an annotation and not a runtime enum on the wire

The remote-execution wire already carries exactly one workload shape — Workload::Container(WorkloadSpec) — and every layer between the dispatcher and the node (yubaba admission, mesh assignment, log ingest, produced-file retrieval, teardown) is written against it. A Darwin build differs from a Linux build in one respect: there is no container that can host it, because you cannot containerize the Darwin kernel. Marking that one difference keeps the rest of the path shared instead of growing a parallel exec_native RPC that would have to re-implement all of it.

image stays populated for a native workload and is identity metadata only — nothing is pulled; the native backend resolves argv from entrypoint + command (container semantics) and execs it on the host.

Source

pub fn wants_microvm(&self) -> bool

Whether this workload must be run by kamaji’s microVM backend — booted in its own KVM guest with its own kernel, rather than sharing the host kernel with every other workload on the node (R605-F8 / W325 §5).

Opt-in via annotations["yah.exec"] == "microvm" (see NATIVE_EXEC_ANNOTATION / MICROVM_EXEC_VALUE).

§Why the same key as native exec, not a new one

W325’s Shape A calls this “a sibling branch on a new annotation value”, and the value — not the key — is the whole point. yah.exec names the execution substrate, and a workload has exactly one:

yah.execsubstratekernelisolation
(absent)container backendhost’snamespaces + cgroup
nativefork+exec on the hosthost’snone
microvmKVM guestits ownhardware

A second key (yah.isolation = microvm, say) would make yah.exec = native + yah.isolation = microvm expressible, and therefore something a dispatcher could emit and a backend would have to refuse — exactly the refusal validate_native_exec_spec already has to carry for the yah.sandbox pair, and for the same avoidable reason. A map key holds one value, so on this key the three substrates are mutually exclusive by construction: there is no spec on which both this and Self::wants_native_exec return true, and exec_substrate_markers_are_mutually_exclusive_by_construction pins that.

§What the marker does and does not promise

Like every marker on this struct it is inert metadata — it declares intent and nothing more. Whether a node can honour it is a node capability question (/dev/kvm, a guest kernel, a rootfs; see W325 §4), and a node whose kamaji has no microVM backend configured refuses the deploy rather than falling back to a container. That refusal is deliberate and mirrors R577-T1’s: a caller asking for microVM isolation is asking for the one property a container cannot provide, so silently downgrading it would return success while delivering the thing the caller specifically declined.

image is identity metadata only, as it is for native exec — nothing is pulled. The guest’s root filesystem comes from the node’s configured rootfs image, and argv is resolved from entrypoint + command with container semantics, so one spec shape drives all three substrates.

Source

pub fn wants_nested_sandbox(&self) -> bool

Whether this workload builds its own unprivileged container sandbox inside the one the backend gives it, and therefore needs the two capabilities plus the no_new_privs relaxation that setting up a user namespace requires (R636-B2).

Opt-in via annotations["yah.sandbox"] == "nested" (see NESTED_SANDBOX_ANNOTATION / NESTED_SANDBOX_VALUE) — the same annotation-shaped, zero-blast-radius marker as Self::wants_host_network and Self::wants_native_exec.

§What it actually grants, and why exactly that

Rootless BuildKit (the only user today: remote build-image steps dispatch moby/buildkit:*-rootless) boots through rootlesskit, which must map a range of sub-uids into a fresh user namespace. It does that by exec’ing the setuid-root helpers newuidmap / newgidmap, so it needs CAP_SETUID + CAP_SETGID in the bounding set and noNewPrivileges = false (with no_new_privs on, the kernel silently strips the setuid bit and the helper fails with “Could not set caps”).

Each of those three was measured on us-west-002 to be individually necessary — dropping any one of them puts rootlesskit back to failing before the first layer:

grantrootlesskit result
baseline (CAP_NET_BIND_SERVICE only, nnp on)fork/exec /usr/bin/newuidmap: operation not permitted
+CAP_SETUID only, nnp offfork/exec /usr/bin/newgidmap: operation not permitted
+CAP_SETUID +CAP_SETGID, nnp onnewuidmap: Could not set caps
+CAP_SETUID +CAP_SETGID, nnp offstarts; build runs to completion

It is deliberately not CAP_SYS_ADMIN: a non-rootless buildkitd would need that instead, which is a far wider grant. Emptying /etc/subuid to force rootlesskit’s single-mapping path does not avoid the helpers either — it just fails earlier with “No subuid ranges found”.

The backend guards this. Like host networking, it is honoured only for tier == "infra" workloads; a non-infra workload that sets the annotation is rejected at deploy. Every other workload keeps the CAP_NET_BIND_SERVICE-only, no_new_privs baseline.

§Mutually exclusive with Self::wants_native_exec

This grant is defined in terms of an OCI process spec — a capability set and a noNewPrivileges bit. A native (fork+exec) workload has no OCI spec, so there is nothing to apply it to; kamaji refuses a spec carrying both markers rather than accepting a request for widened privileges and silently dropping it (R577-T1 owns that refusal). The two are independent annotations — neither implies the other, which is what nested_sandbox_marker_is_independent_of_the_other_markers pins — but they are not a legal pair.

If a future runtime does have a sandbox worth widening (a MacVM under W254, say), give it its own annotation rather than relaxing that refusal. The grant this marker names is CAP_SETUID + CAP_SETGID + no_new_privs off and nothing else; letting it mean a different privilege set per backend would make “what does yah.sandbox=nested grant?” unanswerable without knowing which backend received it, which is precisely what a security-relevant marker must not be.

Trait Implementations§

Source§

impl Clone for WorkloadSpec

Source§

fn clone(&self) -> WorkloadSpec

Returns a duplicate of the value. Read more
1.0.0 (const: unstable) · Source§

fn clone_from(&mut self, source: &Self)

Performs copy-assignment from source. Read more
Source§

impl Debug for WorkloadSpec

Source§

fn fmt(&self, f: &mut Formatter<'_>) -> Result

Formats the value using the given formatter. Read more
Source§

impl<'de> Deserialize<'de> for WorkloadSpec

Source§

fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>
where __D: Deserializer<'de>,

Deserialize this value from the given Serde deserializer. Read more
Source§

impl PartialEq for WorkloadSpec

Source§

fn eq(&self, other: &WorkloadSpec) -> bool

Equality operator ==. Read more
1.0.0 (const: unstable) · Source§

fn ne(&self, other: &Rhs) -> bool

Inequality operator !=. Read more
Source§

impl Serialize for WorkloadSpec

Source§

fn serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>
where __S: Serializer,

Serialize this value into the given Serde serializer. Read more
Source§

impl StructuralPartialEq for WorkloadSpec

Source§

impl TS for WorkloadSpec

Source§

type WithoutGenerics = WorkloadSpec

If this type does not have generic parameters, then WithoutGenerics should just be Self. If the type does have generic parameters, then all generic parameters must be replaced with a dummy type, e.g ts_rs::Dummy or ().
The only requirement for these dummy types is that EXPORT_TO must be None. Read more
Source§

type OptionInnerType = WorkloadSpec

If the implementing type is std::option::Option<T>, then this associated type is set to T. All other implementations of TS should set this type to Self instead.
Source§

fn ident(cfg: &Config) -> String

Identifier of this type, excluding generic parameters.
Source§

fn docs() -> Option<String>

JSDoc comment to describe this type in TypeScript - when TS is derived, docs are automatically read from your doc comments or #[doc = ".."] attributes
Source§

fn name(cfg: &Config) -> String

Name of this type in TypeScript, including generic parameters
Source§

fn decl_concrete(cfg: &Config) -> String

Declaration of this type using the supplied generic arguments. The resulting TypeScript definition will not be generic. For that, see TS::decl(). If this type is not generic, then this function is equivalent to TS::decl().
Source§

fn decl(cfg: &Config) -> String

Declaration of this type, e.g. type User = { user_id: number, ... }. This function will panic if the type has no declaration. Read more
Source§

fn inline(cfg: &Config) -> String

Formats this types definition in TypeScript, e.g { user_id: number }. This function will panic if the type cannot be inlined.
Source§

fn inline_flattened(cfg: &Config) -> String

Flatten a type declaration. This function will panic if the type cannot be flattened.
Source§

fn visit_generics(v: &mut impl TypeVisitor)
where Self: 'static,

Iterates over all type parameters of this type.
Source§

fn output_path() -> Option<PathBuf>

Returns the output path to where T should be exported, relative to the output directory. The returned path does not include any base directory. Read more
Source§

fn visit_dependencies(v: &mut impl TypeVisitor)
where Self: 'static,

Iterates over all dependency of this type.
Source§

fn dependencies(cfg: &Config) -> Vec<Dependency>
where Self: 'static,

Resolves all dependencies of this type recursively.
Source§

fn export(cfg: &Config) -> Result<(), ExportError>
where Self: 'static,

Manually export this type to the filesystem. To export this type together with all of its dependencies, use TS::export_all. Read more
Source§

fn export_all(cfg: &Config) -> Result<(), ExportError>
where Self: 'static,

Manually export this type to the filesystem, together with all of its dependencies. To export only this type, without its dependencies, use TS::export. Read more
Source§

fn export_to_string(cfg: &Config) -> Result<String, ExportError>
where Self: 'static,

Manually generate bindings for this type, returning a String. This function does not format the output, even if the format feature is enabled. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> CloneToUninit for T
where T: Clone,

Source§

unsafe fn clone_to_uninit(&self, dest: *mut u8)

🔬This is a nightly-only experimental API. (clone_to_uninit)
Performs copy-assignment from self to dest. Read more
Source§

impl<T> DeserializeOwned for T
where T: for<'de> Deserialize<'de>,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> ToOwned for T
where T: Clone,

Source§

type Owned = T

The resulting type after obtaining ownership.
Source§

fn to_owned(&self) -> T

Creates owned data from borrowed data, usually by cloning. Read more
Source§

fn clone_into(&self, target: &mut T)

Uses borrowed data to replace owned data, usually by cloning. Read more
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = !

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, !>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.