Request
The payload is the tag byte 0 followed by the request as JSON: the image, the limits and the mounts, and the agent value.
The payload of the request is the tag byte followed by the request as
JSON, read as Notation provides. The request
is one object: the members of the container, flattened, and agent.
[0][request JSON …]
-
image. Exactly one of three objects:{"client":{"name":…,"digest":…}}, an image the client holds, which the server pulls from its own registry and fetches from the client by digest;{"server":{"name":…,"digest":…}}, an image the server obtains from a source of its own;{"registry":{"reference":…}}, a reference the server pulls.nameis a repository path without a host;digestis<algorithm>:<hex>;referenceis what the server’s runtime accepts. -
memory,disk. Limits in bytes: the memory the container may use, and the bytes the container may write to its own filesystem. Neither governs a mount. -
volume_mounts. Ordered; the server applies them in order.host_nameis anamein the client’s listing;host_relative_pathandcontainer_pathare components. An emptycontainer_pathnames the root, which the server refuses. -
identity_file_mounts,identity_directory_mounts. Each names content byidentity,<size>:<base64url sha256>with the hash of the bytes for a file and of the manifest for a directory, as the volumes::stat response defines the manifest. Every one is mounted read-only before the container starts. -
fuse_file_mounts,fuse_directory_mounts. Each names a path, anidthe client chose, andreadonly, absent meaningfalse. Every one is mounted before the id is sent. -
Paths. No component of any mount’s path is empty,
.or... No two mounts have one path. No mount lies inside a FUSE directory mount. A FUSE file mount may lie inside a directory another mount provides. No mount’s path is the root. -
Ids. No two FUSE mounts, on either list, have one
id. -
agent. Any JSON value. The server relays it verbatim to the proxy’s/agent/registerand does not read it. What the value may be is what the container’s agent-schema channel answers. -
Malformed. A payload with no byte, a payload whose first byte is not
0, and a payload whose bytes after the tag do not parse as the request are malformed. Endpoints states how a server answers a request it cannot read.
The request is defined by diverge-provider-sdk/src/endpoints/containers/agents/run/client/request/frame.rs:
//! What a client's request frame carries for an agent container run.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::decode::Decode;
use crate::encode::{Encode, Writer};
use crate::shared::containers::request::Container;
/// Ask a provider to create an agent container, and say what agent
/// it is.
///
/// A [`Container`] — the image, the limits, the mounts — and the
/// agent. The agent is on the request rather than on the channel
/// that starts a loop because it is FIXED: a container is one agent
/// for its whole life, registered with it once, and every loop it
/// runs is that agent. What a loop is asked is each loop's own, and
/// rides the [`RunLoop`](super::super::channel_request::Frame::RunLoop)
/// channel as its prompt.
///
/// The agent is a JSON value, because this crate does not know what
/// an agent is — a model, a set of tools, a personality, a harness's
/// own knobs — and a wire that typed it would have to be revised for
/// every agent that ever ran. What the value MAY be is what
/// [`agent_schema`](crate::shared::containers::agent_schema) answers.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Frame {
/// The container: image, limits, mounts. Flattened, so the wire
/// is one object rather than a container inside a request.
#[serde(flatten)]
pub container: Container,
/// The agent, as the image defines it, for the container's life.
/// Each image owns its agent type beside the loop that reads it,
/// and states it through
/// [`agent_schema`](crate::shared::containers::agent_schema).
pub agent: Value,
}
/// This frame's tag among the scope-opening requests.
///
/// One byte at the front of the payload, which is what tells a reader
/// which request it holds. The frame layer does not discriminate them
/// — [`ClientFrame::Request`](crate::frame::client::ClientFrame::Request)
/// is one type carrying bytes — so the distinction has to be in the
/// bytes, and each request owns the value that names it.
///
/// See the table in [`endpoints`](crate::endpoints) for the whole
/// allocation. The values are chosen across modules that do not know
/// about each other, so the table is the only place they can be seen
/// at once.
const TAG: u8 = 0;
/// JSON, matching [`images::check`](crate::endpoints::images::check)
/// rather than the postcard [`volumes`](crate::endpoints::volumes)
/// uses. One of these is sent per container rather than per
/// filesystem event, so there is no throughput to optimize for — and
/// it names an image the same way a check does, which is reason
/// enough for the two to look alike on the wire. The agent being a
/// [`Value`] settles it besides: a value cannot come back out of
/// postcard at all.
impl Encode for Frame {
/// The ordinary JSON failure. The tag cannot fail.
type Error = serde_json::Error;
fn encode(&self, out: &mut Writer<'_>) -> Result<(), Self::Error> {
out.extend_from_slice(&[TAG]);
serde_json::to_writer(out, self)
}
}
impl Decode<'_> for Frame {
/// Three ways to fail, and only one of them is JSON.
type Error = FrameError;
fn decode(bytes: &[u8]) -> Result<Self, Self::Error> {
let (tag, rest) = bytes.split_first().ok_or(FrameError::Empty)?;
if *tag != TAG {
return Err(FrameError::UnexpectedTag(*tag));
}
serde_json::from_slice(rest).map_err(FrameError::Body)
}
}
/// An agent container run request that could not be read.
#[derive(Debug)]
pub enum FrameError {
/// No bytes at all, so not even a tag.
Empty,
/// A tag naming some other request.
///
/// A reader that dispatched on the tag will not see this. One that
/// assumed which request it held, and was wrong, will — which is
/// the point of checking a tag rather than skipping it.
UnexpectedTag(u8),
/// The request did not parse.
Body(serde_json::Error),
}
impl std::fmt::Display for FrameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FrameError::Empty => {
f.write_str("agents run request frame is empty")
}
FrameError::UnexpectedTag(tag) => {
write!(f, "expected agents run request tag {TAG}, found {tag}")
}
FrameError::Body(error) => {
write!(f, "agents run request did not parse: {error}")
}
}
}
}
impl std::error::Error for FrameError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
FrameError::Body(error) => Some(error),
FrameError::Empty | FrameError::UnexpectedTag(_) => None,
}
}
}
The container is defined by diverge-provider-sdk/src/shared/containers/request/container.rs:
//! Asking for a container.
use serde::{Deserialize, Serialize};
use super::{FuseMount, IdentityMount, Image, VolumeMount};
/// Ask a provider to create a container.
///
/// Everything here is what a caller may CHOOSE, and it is the same for
/// every kind of container: what differs between an agent and a tool
/// server is asked once the container runs, on a channel, not here.
/// What a caller may not choose is not here at all rather than here
/// and ignored — the container's name, its published ports, its
/// entrypoint and its environment are the provider's, because they
/// are how the provider reaches the container and how the container
/// reaches back. There is no environment: the mounts are the caller's
/// only provisioning channel, and a field that is accepted and
/// ignored is a field callers will believe in.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Container {
/// The image, and who supplies it.
///
/// See [`Image`]. Which variant it is decides how the image is
/// named, which is why the source and the name are one field
/// rather than two that only make sense together.
pub image: Image,
/// How much memory the container may have, in BYTES.
///
/// A ceiling, not a hint. A process that exceeds what the
/// container is allowed is killed by the kernel rather than told —
/// no failed allocation to catch, no warning first — and the
/// container will not see this number in its own
/// `/proc/meminfo`, which reports the host's. An image that sizes
/// itself off what it thinks it has will size itself wrong.
///
/// Bytes rather than megabytes because a unit that has to be
/// spelled out in prose is a unit half of everyone gets wrong.
pub memory: u64,
/// How much the container may WRITE, in BYTES.
///
/// Its own filesystem only — what it adds to or changes over the
/// image it came from. The image's layers are read-only and are
/// not counted, so a container starts at nothing however large the
/// image is.
///
/// # It does not govern the mounts
///
/// A volume is storage that already existed, with a size of its
/// own that a [`volume`](crate::endpoints::volumes) stated when it
/// was made, and hash-mounted content is read-only. A number here
/// that silently applied to either would be this request deciding
/// how much of somebody else's storage a container may fill.
///
/// Bytes rather than megabytes, for the reason
/// [`memory`](Self::memory) gives.
pub disk: u64,
/// Volumes the provider offers, made visible inside the container.
///
/// Ordered, and a provider applies them in order. See
/// [`VolumeMount`] for how one is named without a host path.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub volume_mounts: Vec<VolumeMount>,
/// Files the caller holds, by content, mounted read-only.
///
/// Each names a file by its identity — see [`IdentityMount`].
/// The provider MUST mount every one before the container starts,
/// fetching what it does not hold from the caller by that
/// identity.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub identity_file_mounts: Vec<IdentityMount>,
/// Directories the caller holds, by content, mounted read-only.
///
/// Each names a directory by its identity — see
/// [`IdentityMount`]. No mount's path, in any of the three lists, is
/// a prefix of another's: mounting INTO a directory the image owns
/// is the point, and mounts stacking on each other is not.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub identity_directory_mounts: Vec<IdentityMount>,
/// Files the caller serves LIVE, mounted one each over FUSE.
///
/// Each names a file by a path, an id of the caller's, and
/// whether the container may write it — see [`FuseMount`]. The
/// provider MUST mount every one before the container starts, and
/// every open and every changed close inside the container is one
/// ask back to the caller, by that id. The file is overwritten in
/// place only; a program that replaces its file by rename needs a
/// directory mount. A path may lie inside a directory another
/// mount provides; it may not equal another mount's path.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fuse_file_mounts: Vec<FuseMount>,
/// Directories the caller serves LIVE, mounted one each over FUSE.
///
/// Each names a directory by a path, an id of the caller's, and
/// whether the container may change it — see [`FuseMount`]. The
/// whole tree under the path is the caller's: every listing,
/// read, write, creation, removal and rename inside the container
/// is one ask back to the caller, by that id. No other mount may
/// lie inside it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fuse_directory_mounts: Vec<FuseMount>,
}
and by diverge-provider-sdk/src/shared/containers/request/image.rs:
//! What image a container is made from, and who supplies it.
use serde::{Deserialize, Serialize};
/// The image, named the way whoever supplies it can be asked for it.
///
/// One question — can the provider get these bytes, and if not, who
/// can — and the answer decides how the image is named, which is why
/// this is one field rather than a source beside a string that means
/// something different depending on it.
///
/// # Two of the three are pinned, and the third is a choice
///
/// [`Client`](Self::Client) and [`Server`](Self::Server) carry a
/// repository name and a manifest digest, the same pair
/// [`images::check`](crate::endpoints::images::check::client::request::Frame)
/// asks about — so a check that came back available names an image a
/// run can ask for, with nothing to translate between them.
///
/// A digest cannot be repointed at different content, so those two
/// mean the same bytes every time. [`Registry`](Self::Registry) need
/// not, and that is the point: a caller writing `ubuntu:22.04` is
/// asking to track it, exactly as a loose version constraint on a
/// Python requirement is. One that wants the guarantee writes a digest
/// into the reference and gets it.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Image {
/// The caller holds it.
///
/// For images that exist nowhere a provider can reach — built
/// locally, never pushed, carrying a digest no registry has heard
/// of.
///
/// The provider runs a registry of its own and its runtime pulls
/// from that; what the registry does not hold, the provider asks
/// the caller for by digest — a manifest, a blob — over
/// [`oci`](crate::shared::containers::oci). The caller needs no
/// registry and no HTTP: it needs the image's manifest and blobs
/// in a store keyed by digest, which is what an image is once it
/// has been saved anywhere, and it answers two kinds of fetch.
///
/// The runtime never learns the registry is a proxy, and every
/// header it relies on — `Content-Type`, `Content-Length`,
/// `Range`, `Docker-Content-Digest` — is the provider's registry
/// answering from its store. A runtime already indexes layers by
/// digest and skips the ones it holds, so letting it pull means
/// that logic is USED rather than reimplemented beside it.
Client {
/// The repository path — `library/nginx`, `myorg/myimage`.
///
/// # It lands in a URL path
///
/// The provider builds its pull reference by concatenation —
/// its registry's address, a repository segment, then this —
/// with no parsing. Which makes it a path fragment wearing the costume
/// of a name: a `..` in it walks out of the scope segment and
/// into another caller's namespace, so a provider normalizes
/// or refuses before concatenating. The field cannot enforce
/// that and does not pretend to.
name: String,
/// The manifest digest, `<algorithm>:<hex>`.
///
/// What actually identifies the image, and the reason nothing
/// here has to trust the name: the provider hashes what the
/// caller sent before storing it, and the runtime hashes again
/// on pull, so a caller holding the wrong bytes under the
/// right digest fails before anything runs.
digest: String,
},
/// The provider produces it, however it likes.
///
/// Its own mirror, a pull-through cache, a private registry it
/// holds credentials for, or something already on disk. A caller
/// does not know and is not told.
///
/// Which is what makes proprietary images expressible: a provider
/// serves an image no public registry carries, and a caller asks
/// for it, without the caller ever being able to fetch it itself.
///
/// Ask [`images::check`](crate::endpoints::images::check) first if
/// the answer matters before the container does. It takes this
/// same pair, so what a check said yes to is what a run names.
Server {
/// The repository path — `library/nginx`, `myorg/myimage`.
///
/// Kept alongside the digest because a digest alone is not
/// resolvable: every registry API is repository-scoped, and
/// there is no lookup from a digest to wherever it lives.
///
/// No host. Where a provider gets the image is the provider's
/// business, and a caller naming a source it cannot reach
/// would be asserting something it has no standing to assert.
name: String,
/// The manifest digest, `<algorithm>:<hex>`.
///
/// What actually identifies the image. Any registry serving
/// these bytes serves the same image, which is what lets the
/// provider choose where to get them.
digest: String,
},
/// The provider pulls from where the caller says.
///
/// The one case where the CALLER chooses the source, for public
/// images where it knows what it wants and the provider has no
/// opinion.
///
/// Which makes the reference a host a caller picked, and the
/// provider connects there and runs what it finds. Which
/// registries are reachable is a provider's policy to set and
/// enforce, and nothing here can express that policy — a caller
/// learns it by being refused.
Registry {
/// Whatever a container runtime accepts —
/// `ghcr.io/org/image@sha256:…`, `docker.io/library/ubuntu:22.04`.
///
/// One string rather than a name and a digest, because the
/// other two variants split them for a reason that does not
/// apply here. There the pair exists so a provider can go to a
/// source the caller did not name; here the caller named the
/// source, and a reference is how a source is named — host,
/// repository, and tag or digest, in the form the runtime
/// already parses.
reference: String,
},
}
and by diverge-provider-sdk/src/shared/containers/request/volume_mount.rs:
//! One volume made visible inside a container.
use serde::{Deserialize, Serialize};
/// A volume from a listing, mounted into the container.
///
/// Host side first, then the container side — source before
/// destination, the order a mount reads in everywhere else.
///
/// # Why the host side is a name and an offset
///
/// Because a host path is not something a caller is allowed to state.
/// [`host_name`](Self::host_name) is a
/// [`Volume::name`](crate::endpoints::volumes::list::server::response::Volume::name)
/// the provider published, and
/// [`host_relative_path`](Self::host_relative_path) descends from
/// wherever that maps to — so a caller reaches a subdirectory of
/// something it was offered, and nothing else.
///
/// That is the same access model as
/// [`watch`](crate::endpoints::volumes::watch), and it holds for the same
/// reason: a provider never validates a path, it resolves a name it
/// chose and then descends. A caller cannot escape upward, because
/// there is no component it can write that means "up" — the offset is
/// components, and `..` is a name, not an instruction.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct VolumeMount {
/// Which offered volume, by the name a listing gave it.
///
/// Names come from
/// [`Volume::name`](crate::endpoints::volumes::list::server::response::Volume::name)
/// and mean nothing outside the provider that published them.
pub host_name: String,
/// How far into that volume to start, as path components
/// relative to it.
///
/// Empty mounts the volume itself, which is the common case;
/// anything else mounts a subdirectory of it.
pub host_relative_path: Vec<String>,
/// Where it appears inside the container, as path components from
/// the container's root.
///
/// Empty means the root itself, which a provider will almost
/// certainly refuse — the image's own filesystem is there.
pub container_path: Vec<String>,
}
and by diverge-provider-sdk/src/shared/containers/request/identity_mount.rs:
//! One thing the caller mounts into the container.
use serde::{Deserialize, Serialize};
/// Content the caller wants present in the container's filesystem:
/// where, and what.
///
/// One shape for a file and for a directory — which it is, the field
/// it sits in says ([`identity_file_mounts`](super::Container::identity_file_mounts) or
/// [`identity_directory_mounts`](super::Container::identity_directory_mounts)), and the
/// [`identity`](Self::identity) grammar agrees. The server MUST mount every
/// one — read-only — before the container starts: the request
/// naming it IS the requirement. What the server does not hold it
/// MAY fetch from the client, by the hash, over the fetch exchanges.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct IdentityMount {
/// Where it appears inside the container, as path components
/// from the container's root — the shape every path in this
/// crate takes, as
/// [`container_path`](super::VolumeMount::container_path)
/// does for a volume.
///
/// No component is empty, `.` or `..` — an offset is components,
/// and `..` is a name, not an instruction — and no mount's path
/// is a prefix of another mount's, file or directory. Mounting
/// INTO a directory the image owns is the point; mounts stacking
/// on each other is not. Empty would name the root, which a
/// provider refuses: the image's own filesystem is there.
pub container_path: Vec<String>,
/// The content's size-bearing identity:
/// `<size>:<base64url sha256>` — the size in bytes, then the
/// hash. For a file the hash is of its bytes; for a directory it
/// is of its manifest — one `<hash> <size> <path>` line per file,
/// paths relative and `/`-separated, sorted bytewise — and the
/// size is the total. Which of the two it is, the field it sits in
/// says, as does the fetch that asks for it; the value does not
/// need to.
///
/// Because the size rides the identity, a server can refuse an
/// oversized request up front, as a request error, with nothing
/// fetched.
pub identity: String,
}
and by diverge-provider-sdk/src/shared/containers/request/fuse_mount.rs:
//! One file, or one directory, the caller serves live into the
//! container.
use serde::{Deserialize, Serialize};
/// A file or a directory the caller keeps, mounted into the container
/// over FUSE: where, under what id, and whether the container may
/// change it. Which of the two it is, is which list of the
/// [`Container`](super::Container) it is on.
///
/// The provider MUST mount every one before it answers the run's id
/// — one request to the proxy inside the container for each, on
/// [`/fuse/mount`](crate::container_proxy::fuse::mount), each
/// complete before the agent is registered and before any filetree
/// is opened — as a filesystem the caller serves: the mount point is the file or the
/// directory itself, made if absent with every missing parent
/// directory made too, and the directory around it stays whatever
/// the image or another mount made it. Nothing in the container or
/// the provider copies the contents in or reads them back: every
/// read, write, listing, removal, rename and new directory is one
/// exchange in [`fuse`](super::super::fuse), carrying the id, and the
/// caller serves it from wherever it keeps the thing. It is for the
/// credential files vendor CLIs rewrite when they refresh a login.
///
/// A FILE mount is one regular file that can be read and, unless
/// [`readonly`](Self::readonly), overwritten in place — opened,
/// truncated, written, closed — but never deleted or moved, and
/// never replaced by a rename: the mount point is the file itself,
/// and the kernel refuses to unlink or rename a mount point, so a
/// program that saves by writing a temporary beside the file and
/// renaming it over the file fails at the rename. A DIRECTORY mount
/// is for that program: a whole tree the caller serves, whose every
/// entry can be created, overwritten by either method, renamed and
/// deleted, and whose root alone is fixed.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FuseMount {
/// Where the mount appears inside the container, as path
/// components from the container's root — the shape every path in
/// this crate takes, as
/// [`container_path`](super::IdentityMount::container_path) does
/// for content.
///
/// No component is empty, `.` or `..`. A path inside a directory
/// another mount provides is allowed — a file over a directory a
/// volume brought is the ordinary case — but a path equal to
/// another mount's, or inside another FUSE directory mount, is
/// not, and neither is the root.
pub container_path: Vec<String>,
/// The caller's own id for the mount: opaque, minted by the caller
/// when it named the mount, and echoed back on every ask the
/// provider sends for it. Two mounts on one request, on either
/// list, may not share an id.
pub id: String,
/// Whether the container may change it. `true`: every open for
/// writing, truncate, write, create, rename, removal and new
/// directory inside the container fails, and the provider never
/// sends a mutation for this id. `false`: the file is overwritable
/// in place, or the tree fully editable, every change stored with
/// the caller as it lands.
#[serde(default)]
pub readonly: bool,
}