vknode

package
v0.14.7-dev Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2026 License: MIT Imports: 47 Imported by: 0

Documentation

Overview

Package vknode is the per-outpost half of the cloudbox cluster: it joins a cloud-side Kubernetes API server as a virtual node and runs scheduled Pods as podman containers on the host.

The package is split into three layers:

  • Layer 1 (this file's siblings client.go / containers.go / images.go / exec.go) — a thin HTTP-over-unix client for the local libpod REST API. Deliberately does not depend on github.com/containers/podman/v5/pkg/bindings because that pulls in containers/storage (cgo), which would break outpost's cross-compile story. We only need ~10 endpoints; a hand-rolled client is smaller and cheaper to bump than the full SDK.

  • Layer 2 (translate.go) — convert a corev1.Pod to a libpod SpecGenerator. Stamps every container with the outpost.io/managed=true label and the pod's namespace / name / uid so reconnect reconciliation can find what we already own and so the host operator can identify "who is running what" with a plain `podman ps`.

  • Layer 3 (provider.go, node.go) — implement virtual-kubelet's PodLifecycleHandler and NodeProvider interfaces on top of layers 1 and 2. The PodController and NodeController from virtual-kubelet take care of the apiserver watch/list and status-update plumbing.

The cluster-side details (k3s in cloudbox, RBAC, sharing) live in the plan at ~/.claude/plans/pooling-podman-containers-registered-wit-steady-reef.md and in the separate cloudbox repo.

Index

Constants

View Source
const (
	// BuildSourceAnnotation is git+https://... or git+ssh://... ,
	// optionally suffixed with @<ref>. Examples:
	//   git+https://github.com/user/repo@v1.2.3
	//   git+ssh://git@github.com/user/private@deadbeefcafe
	//   git+https://github.com/user/repo  (defaults to HEAD)
	BuildSourceAnnotation = "outpost.dhnt.io/build-source"

	// BuildDockerfileAnnotation is the path to the Dockerfile
	// relative to the build context. Defaults to "Dockerfile".
	BuildDockerfileAnnotation = "outpost.dhnt.io/build-dockerfile"

	// BuildContextAnnotation is the build context directory
	// relative to the git checkout root. Defaults to ".". Used when
	// the Dockerfile is in a subdirectory and shouldn't see the
	// rest of the repo as build context.
	BuildContextAnnotation = "outpost.dhnt.io/build-context"
)

Build-source annotation namespace. When BuildSourceAnnotation is present on a Pod, vknode.CreatePod treats the spec image as a *target* tag (the image to produce) rather than a registry reference to pull. If that tag isn't already in the local podman image store, vknode clones the source, tars the build context, and POSTs it to the libpod build endpoint. No cloudbox-side state is involved — the source URL is the source of truth.

Reproducibility across outposts is the operator's responsibility: pin BuildSourceAnnotation to an immutable git ref (tag or SHA), not a moving branch. Two outposts pulling at different times will produce identical images only when the ref is immutable.

Privacy: the outpost runs `git clone <url>` with its own credentials (SSH keys for git+ssh URLs, HTTPS auth helpers for git+https URLs). Cloudbox never sees the source.

View Source
const (
	ManagedLabel      = "outpost.io/managed"
	PodUIDLabel       = "outpost.io/pod-uid"
	PodNameLabel      = "outpost.io/pod-name"
	PodNamespaceLabel = "outpost.io/pod-namespace"

	// ContainerNameLabel records the K8s container-spec name inside a
	// (potentially multi-container) Pod. v1 only supports
	// single-container Pods so this is always pod.Spec.Containers[0].Name,
	// but recording it now makes the multi-container expansion in a
	// future version a pure-additive change.
	ContainerNameLabel = "outpost.io/container-name"
)

Well-known container labels vknode stamps onto every container it creates. Reconcile uses ManagedLabel as the boundary between outpost-cluster-owned containers and everything else the user runs locally with podman — we never touch a container that lacks it.

PodUIDLabel / PodNamespaceLabel / PodNameLabel let the host operator answer "who is running what on my machine" with a plain `podman ps --format '{{.Labels}}'`; they also let reconcile look up an owning pod when our BoltDB mapping is stale (e.g. after a vk crash that missed a write).

View Source
const (
	NodeHostLabel          = "outpost.dhnt.io/host"
	NodeLocalityLANLabel   = "outpost.dhnt.io/lan-group"
	NodeLocalityTierLabel  = "outpost.dhnt.io/tier"
	NodeLocalityTierTP     = "tp"
	NodeLocalityTierLAN    = "lan"
	NodeLocalityTierWAN    = "wan"
	NodeLocalityTierRemote = "remote"
)

Well-known Node labels vknode stamps or accepts through RunOptions. These are Kubernetes scheduling surface, so keep names centralized instead of sprinkling string literals through callers.

View Source
const AccessEndpointPath = "/api/v1/cluster/access"

AccessEndpointPath is the cloudbox endpoint that lists the namespaces permitted to schedule on a given outpost. Owner + per-(host, "podman") sharees, in the same hash format the outpost's NamespaceForEmail uses.

View Source
const DefaultNativeProcessImage = "dhnt.io/native-process"

DefaultNativeProcessImage is the placeholder image recorded when a native-process Pod omits spec.containers[].image.

View Source
const DefaultOllamaImage = "dhnt.io/ollama"

DefaultOllamaImage is retained for manifests/tests that use the original ollama marker image.

View Source
const FetchEndpointPath = "/api/cluster/kubeconfig"

FetchEndpointPath is the cloudbox endpoint that mints per-host kubeconfigs. Kept here as a constant so the bootstrap call site and any future url-builder agree without duplicating the literal.

View Source
const HostPortLabelPrefix = "outpost.io/host-port-"

HostPortLabelPrefix is the libpod-container label namespace vknode stamps to record auto-allocated host ports. One label per containerPort: `outpost.io/host-port-<containerPort>` → host port the container is actually published on. Used on daemon restart (Reconcile path) to re-derive the same in-memory pod.Spec.HostPort the original CreatePod allocated — so the transient AppRegistry entry survives without operators having to specify hostPort up-front in the Pod manifest.

Variables

View Source
var ErrNotFound = errNotFound{}

ErrNotFound returns the not-found marker for tests and external callers that want to recognize it via errors.As / errors.Is.

Functions

func AllocateMissingHostPorts

func AllocateMissingHostPorts(pod *corev1.Pod) (int, error)

AllocateMissingHostPorts walks pod's containers and, for any containerPort with HostPort==0, grabs a free TCP port from the kernel (bind :0, read the assigned port, close), writes it back into the in-memory pod.Spec. The brief gap between close and podman's later bind is the standard "ephemeral allocation race" — acceptable here because the only thing competing for that port in practice is the operator's own apps, and a TCP-bind collision would surface immediately as a podman create error rather than silent corruption.

Idempotent — pods that already have explicit hostPorts are left alone. Returns the count of newly-allocated ports for logging.

func BuildNode

func BuildNode(nodeName string, extraLabels map[string]string) *corev1.Node

BuildNode constructs the initial *corev1.Node the NodeController registers with the apiserver. nodeName is what `kubectl get nodes` will show; labels merge with the well-known kubernetes.io/* platform labels.

Capacity/Allocatable come from the local sysinfo probe.

func BuildNodeFromInfo

func BuildNodeFromInfo(nodeName string, extraLabels map[string]string, info sysinfo.Info) *corev1.Node

BuildNodeFromInfo constructs the initial Node object from already-collected host capability info. It is kept separate from BuildNode so tests and future callers can feed peer-provided sysinfo without probing the local machine.

func ConfigFromCluster

func ConfigFromCluster(apiURL, tokenFile string, caPEM []byte) (*rest.Config, error)

ConfigFromCluster builds a kube REST config that reads the bearer token from tokenFile rather than baking it into the config. This is the load-bearing detail that makes token rotation work without rebuilding the entire client-go stack: the transport re-reads tokenFile on its own schedule, so a Refresher writing a fresh token to the same file picks up without the controllers noticing.

The cmd/outpost-vk PoC uses clientcmd.BuildConfigFromFlags directly because its kubeconfig already inlines a static token (no rotation); only the main agent path goes through this builder.

func ContainerName

func ContainerName(pod *corev1.Pod) string

ContainerName is the deterministic libpod container name we use for (pod, container). It's derived from the pod UID rather than the pod's namespace/name pair so the deleted-and-recreated-with-same-name case gets a fresh container instead of colliding with the old one.

Format: "outpost-<first-8-chars-of-uid>-<container-name>". Short enough to read in `podman ps`, unique enough that two pods with the same container name on the same host don't collide.

func DefaultTokenFilePath

func DefaultTokenFilePath() (string, error)

DefaultTokenFilePath returns the canonical path for the persisted SA bearer token: conf.DefaultCacheDir()/cluster-token (i.e. ~/.cache/outpost/cluster-token on Linux+macOS, %USERPROFILE%\.cache\ outpost\cluster-token on Windows). Sharing the outpost cache dir with the rest of the agent's runtime state (pidfile, logs) keeps related state in one place — and the file mode 0600 stops it leaking even when the user's cache dir is world-readable.

func EncodeCA

func EncodeCA(ca []byte) string

EncodeCA renders a CA bundle as a base64 string. Useful for places that need a stringy form (e.g. surfacing the persisted CA length in the admin UI without sending the actual bytes).

func EnsureVolumesForPod

func EnsureVolumesForPod(ctx context.Context, c *Client, pod *corev1.Pod) error

EnsureVolumesForPod pre-creates every named libpod volume the translator will reference for this Pod. Required because libpod's /containers/create endpoint does NOT auto-materialize named volumes — only `podman run -v name:/path` does, and that's a CLI-side convenience. Without this, the container starts then immediately fails with crun's "No such device" mount error.

Idempotent: CreateVolume returns 409 for already-exists, which the client treats as success. Skips the Memory medium (tmpfs needs no volume) and any non-supported volume type (translator will reject those at BuildSpec time anyway).

Labels stamped on each volume so an operator using `podman volume inspect <name>` / `podman volume ls --filter label=...` can recover which K8s namespace and HostPath / EmptyDir name is behind a given opaque outpost-* identifier.

func HydratePodPortsFromLabels

func HydratePodPortsFromLabels(pod *corev1.Pod, labels map[string]string)

HydratePodPortsFromLabels fills pod.Spec.Containers[].Ports[].HostPort from the labels stamped at container-create time. Used by the adopt + reconcile paths: at that point the in-memory pod skeleton has the containerPort (from the apiserver-side spec) but lost the auto-allocated HostPort that the original CreatePod chose. Reading the labels back is what makes the transient app registration survive daemon restart for pods that didn't have an explicit hostPort in their Pod manifest.

Pre-existing HostPort values (explicit hostPort in the Pod spec) are left alone — the labels are only authoritative for ports that vknode allocated.

func IsClusterDisabled

func IsClusterDisabled(err error) bool

IsClusterDisabled reports whether err is a 503 from cloudbox, meaning the upstream cloudbox instance hasn't enabled cluster mode (CLUSTER_ENABLED=false or kubeconfig load failed at boot). Callers treat this as "not ready yet — try again later" rather than a permanent error.

func IsConflict

func IsConflict(err error) bool

IsConflict reports whether err describes a libpod 409 — typically "container already in the requested state" (e.g. start on a running container, stop on a stopped one). Useful for the same idempotent patterns as IsNotFound.

func IsNotFound

func IsNotFound(err error) bool

IsNotFound reports whether err describes a libpod 404 (container or image not found). Useful for idempotent delete/stop paths that should treat "already gone" as success.

func LabelsForHostPorts

func LabelsForHostPorts(pod *corev1.Pod) map[string]string

LabelsForHostPorts emits the libpod labels that record pod's auto-allocated (or explicitly-set) host ports. Called at container-create time so HydratePodPortsFromLabels on a later daemon restart can reconstruct the same pod.Spec.HostPort values without having to re-allocate (which would pick different ports and break running clients).

func LocalityTierForMeasured

func LocalityTierForMeasured(measured string) string

LocalityTierForMeasured maps a peer-plane MEASURED tier value (the ground-truth RTT classification — "tp"/"lan"/"wan"/"unreached") to the Node's locality-tier label value. tp/lan/wan pass through unchanged; an unreachable, empty, or unknown input collapses to NodeLocalityTierRemote so a host whose only links are relay-only still carries an explicit tier instead of a silently-missing label.

This is the seam that replaces the previously-stubbed tier: callers feed peerplane.Service.SelfTier() through here and into NodeLocalityLabels, so the Node's tier reflects what was measured rather than a hardcoded guess. Kept as a plain string map so vknode stays decoupled from the peerplane package.

func NamespaceForEmail

func NamespaceForEmail(email string) string

NamespaceForEmail computes the per-user workload namespace name for an email — matches cloudbox/internal/cluster.userNamespace exactly. Format: "user-<12-hex-chars>" where the hex is the first 6 bytes of the SHA-256 of the lowercased+trimmed email.

func NodeLocalityLabels

func NodeLocalityLabels(lanGroup, tier string) map[string]string

NodeLocalityLabels returns the non-empty locality labels for a Node. Values are normalized to Kubernetes label-value syntax; empty or fully trimmed values are omitted. Callers should only pass measured or cloudbox-issued locality data; per-host names are not a LAN group.

func OwnerEmailFromAccessToken

func OwnerEmailFromAccessToken(token string) (string, error)

OwnerEmailFromAccessToken decodes the email claim out of a cloudbox- issued access_token without verifying the signature. The token came from cloudbox over TLS and was already validated against JWTTokenSecret on the cloudbox side; here we just need to read the email payload so we can derive the owner's namespace.

Returns the empty string + an error when the token isn't a parseable JWT or doesn't carry an email claim — callers treat that as "owner unknown" and either error out or fall back to nil-Access for the dev case.

func RemoveEmptyDirsForPod

func RemoveEmptyDirsForPod(ctx context.Context, c *Client, pod *corev1.Pod) error

RemoveEmptyDirsForPod drops every libpod volume that DeletePod is responsible for cleaning up — namely the per-pod EmptyDir volumes, keyed by emptyDirVolumeName(podUID, volumeName). HostPath-derived volumes are NOT reaped here: their lifetime is "as long as the namespace wants the data", which DeletePod has no opinion on.

Best-effort — individual volume removal failures are logged-not- returned so the larger DeletePod path still succeeds. A leftover volume becomes inspectable via `podman volume ls` (outpost-ed-* prefix) and the operator can drop it manually.

func Run

func Run(ctx context.Context, opts RunOptions) error

Run blocks until ctx is canceled (or any sub-controller errors out), running:

  • the Provider (podman or native-process backend, selected via opts.Backend),
  • the virtual-kubelet NodeController (drives the Node lease / status updates),
  • the virtual-kubelet PodController (watches Pods assigned to this node and translates them to the chosen backend),
  • the SharedInformerFactories backing the controllers.

Returns nil on a clean ctx-canceled shutdown; non-nil for any setup or runtime error the caller should surface.

func TokenExpiry

func TokenExpiry(token string) time.Time

TokenExpiry parses the exp claim out of a ServiceAccount JWT without verifying the signature — we only need the timestamp, and the token came back to us through TLS from cloudbox which already checked it. Returns zero time when the token isn't a JWT at all (e.g. legacy opaque tokens) so the refresher's "rotate at T-12h" math reads it as "expires far in the future" and keeps using it.

func TransientAppName

func TransientAppName(namespace, name, uid string) string

TransientAppName returns the deterministic AppRegistry name for a given pod. Includes UID short-prefix so two pods with the same (namespace, name) at different points in time don't collide on AppRegistry slots — important since DeletePod runs after a brief async gap and a CreatePod for a freshly-recreated pod (same NS/name, new UID) could otherwise race the cleanup.

Format: vk-<namespace>-<name>-<uid8>. Periods and slashes are already disallowed in k8s namespace/name; underscores and dashes pass through unchanged.

func WriteTokenFile

func WriteTokenFile(path, token string) error

WriteTokenFile atomically writes the bearer token to path, creating parent directories as needed. mode 0600 because the file is a live SA credential — anyone with read access to it has the same kube powers as the outpost.

Atomic: write to <path>.tmp + rename. client-go's BearerTokenFile transport re-reads the file on its own schedule (every minute by default), and rename is the safe way to swap the contents without it seeing a partial write.

Types

type APIError

type APIError struct {
	Op      string
	Status  int
	Message string
}

APIError is returned when the libpod daemon answers a non-2xx status. Status carries the HTTP code so callers can distinguish 404 (NotFound) from 409 (Conflict) etc. — both are common shapes during reconnect reconciliation where "already exists" / "no such container" are expected rather than fatal.

func (*APIError) Error

func (e *APIError) Error() string

type Access

type Access struct {
	// contains filtered or unexported fields
}

Access is the security gate vknode.CreatePod consults before scheduling a pod on this outpost. It holds the set of Kubernetes namespace names that are permitted to schedule workloads here — derived from the outpost's owner (always) and the outpost's share-receivers (in the multi-user model, once that wires up against cloudbox).

The namespace naming convention is shared with cloudbox-side cluster.userNamespace: each cloudbox user has a namespace `user-<6-byte-sha256-of-email-hex>`. The outpost computes the same hash from the email it knows about, builds the allowed-name, and checks pod.Namespace against the set on every CreatePod.

A nil *Access means "no check" — pods from any namespace are accepted. Used in dev/single-tenant scenarios where the operator wants to verify the embedded cluster works before turning on access enforcement. Once the cloudbox-side access endpoint exists, startClusterRunner will always construct a non-nil Access.

func NewAccess

func NewAccess(namespaces ...string) *Access

NewAccess returns an Access containing the given namespaces. Pass at least the outpost owner's namespace; in the future this will also include each share-receiver's namespace, refreshed from cloudbox via Refresher.

func (*Access) Allowed

func (a *Access) Allowed(ns string) bool

Allowed reports whether ns may schedule pods on this outpost. nil receiver returns true so unconfigured outposts still work — the security choice is made by whoever constructs (or doesn't construct) the Access.

func (*Access) Set

func (a *Access) Set(namespaces ...string)

Set replaces the allowed-namespace set atomically. Used by the refresher when cloudbox's share data changes.

func (*Access) Snapshot

func (a *Access) Snapshot() []string

Snapshot returns the current allowed-namespace set as a slice. Used by status / debug surfaces; not on the hot path.

type AccessRefreshDeps

type AccessRefreshDeps struct {
	// CloudboxBase is the HTTPS base URL of cloudbox (no trailing
	// /api/v1/cluster/access), e.g. "https://ai.dhnt.io".
	CloudboxBase string

	// AccessToken is the outpost's matrix access_token. Used as Bearer
	// when calling cloudbox.
	AccessToken string

	// NodeName is the outpost identity to fetch the allow-list for —
	// always fc.AgentName in practice.
	NodeName string

	// Access is the live allow-set the gate consults on every CreatePod.
	// The refresher replaces its contents atomically via Access.Set on
	// each successful fetch. Must be non-nil.
	Access *Access
}

AccessRefreshDeps is the dependency bundle the AccessRefresher needs. Kept analogous to RefreshDeps so the two loops read the same way at call sites.

type AccessRefresher

type AccessRefresher struct {
	// contains filtered or unexported fields
}

AccessRefresher polls cloudbox at a fixed cadence and pushes the resulting allow-list into the live Access gate. Single instance per outpost process; constructed once and Run()-ed inside the vknode errgroup alongside the token Refresher.

func NewAccessRefresher

func NewAccessRefresher(deps AccessRefreshDeps) *AccessRefresher

NewAccessRefresher captures deps and returns an AccessRefresher ready to Run.

func (*AccessRefresher) Run

func (r *AccessRefresher) Run(ctx context.Context) error

Run blocks until ctx is canceled. Loop:

  1. FetchAccess from cloudbox.
  2. On success: deps.Access.Set(allowed...); log the diff from prior. Wait accessRefreshInterval.
  3. On error: log + wait accessRefreshFailureBackoff. The existing allow-set stays in place — a transient cloudbox blip never empties the gate (which would reject all sharee pods until the next successful fetch).

type AccessResponse

type AccessResponse struct {
	NodeName          string   `json:"node_name"`
	OwnerNamespace    string   `json:"owner_namespace"`
	AllowedNamespaces []string `json:"allowed_namespaces"`
}

AccessResponse mirrors hub/internal/handlers/v1_cluster.go's response shape. AllowedNamespaces is the union of (owner_namespace, every sharee with HostShare(app="podman")) — the outpost replaces its Access set with this slice on every successful fetch.

func FetchAccess

func FetchAccess(ctx context.Context, cloudboxBase, accessToken, nodeName string) (*AccessResponse, error)

FetchAccess does GET <cloudbox>/api/v1/cluster/access?node_name=<node> with Bearer <accessToken>. Returns the allow-list. Reuses FetchError + IsClusterDisabled from bootstrap.go for status-code classification so the refresher's backoff logic can treat 503 the same as a transient network error.

type Backend

type Backend interface {
	// Ensure creates+starts (or adopts an existing) workload for pod.
	// Idempotent: a workload already running for pod.UID is (re)started
	// rather than erroring on conflict. May mutate pod to hydrate the
	// resolved host ports the workload was actually published on.
	Ensure(ctx context.Context, pod *corev1.Pod) error

	// Delete stops+removes the workload for pod. Idempotent: a missing
	// workload (already cleaned up) is not an error.
	Delete(ctx context.Context, pod *corev1.Pod) error

	// Status returns the live PodStatus for pod's workload, or
	// (nil, nil) when the workload has vanished underneath us — the
	// Provider maps that to a Pending/ContainerMissing status so the
	// reconciler recreates it.
	Status(ctx context.Context, pod *corev1.Pod) (*corev1.PodStatus, error)

	// List returns skeleton Pods reconstructed from the workloads this
	// backend already owns on the host. Called once at startup so a
	// vknode restart doesn't lose track of what it created in a prior
	// lifetime.
	List(ctx context.Context) ([]*corev1.Pod, error)

	// HydratePorts best-effort merges the workload's resolved host
	// ports back onto pod.Spec in place (used by UpdatePod, where the
	// apiserver-side Pod never saw the outpost's local port
	// allocation). A missing workload is a no-op, not an error.
	HydratePorts(ctx context.Context, pod *corev1.Pod) error
}

Backend is the substrate seam of the virtual-kubelet provider: it owns "make this Pod real on this host" while the Provider owns the virtual-kubelet contract (pod cache, namespace-access gate, host-port allocation, transient-app publishing). Splitting the two lets one vknode register a virtual Node whose Pods are realized by different mechanisms:

  • podmanBackend (today) — Pods become libpod containers. On macOS/ Windows those run inside podman's Linux VM, so the host GPU is not visible to them.
  • nativeProcessBackend — Pods become native host processes (e.g. a CLI, llama.cpp, or an ollama server), keeping direct access to the host OS and hardware. The Provider, NodeProvider, cloudbox bootstrap, access gate, and kubeconfig plumbing are all backend-agnostic and get reused unchanged.

All methods are called from the Provider's PodLifecycleHandler methods, which already serialize per-pod via the apiserver's reconcile loop. A Backend may mutate the passed Pod in place (e.g. hydrating resolved host ports back onto Spec.Containers[*].Ports) — the Provider re-caches the Pod after each call.

func NewNativeProcessBackend added in v0.14.3

func NewNativeProcessBackend(cfg NativeProcessConfig) (Backend, error)

NewNativeProcessBackend returns a Backend that realizes Pods as native host processes, persisting its process registry under cfg.DataDir.

func NewOllamaBackend

func NewOllamaBackend(cfg OllamaConfig) (Backend, error)

NewOllamaBackend returns a native-process Backend using the legacy ollama marker image by default.

type CPULimits

type CPULimits struct {
	Period uint64 `json:"period,omitempty"`
	Quota  int64  `json:"quota,omitempty"`
}

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a thin HTTP client for the local libpod REST API. It speaks the versioned `/v5.0.0/libpod/*` path tree — compatible with any podman 5.x daemon (the major version is stable across minors).

func NewClient

func NewClient(socket string) (*Client, error)

NewClient returns a Client that dials the libpod REST API at the given unix socket. The socket must already exist and be reachable; callers typically obtain its path from agent.DetectPodman().

func (*Client) BuildImage

func (c *Client) BuildImage(ctx context.Context, ctxDir, dockerfile, image string) error

BuildImage POSTs the directory at ctxDir as a tar stream to libpod /build, asking it to build using `dockerfile` relative to that context and tag the result as `image`. Returns nil on success, an error including the libpod build output on failure.

.dockerignore handling is delegated to libpod's build engine — we stream the full directory; podman applies its own ignore rules. This matches `podman build` CLI semantics.

func (*Client) CreateContainer

func (c *Client) CreateContainer(ctx context.Context, spec *SpecGenerator) (*CreateResponse, error)

CreateContainer issues POST /libpod/containers/create. Returns the new container's ID. The container is created in the "created" state and must be started separately.

func (*Client) CreateVolume

func (c *Client) CreateVolume(ctx context.Context, name string, labels map[string]string) error

PullImage issues POST /libpod/images/pull. The endpoint streams a JSON-lines progress body which we discard; the call returns once the CreateVolume issues POST /libpod/volumes/create with the given name + labels. Returns nil on success and on the "already exists" path — the latter is what we hit when a second pod from the same Deployment claims a HostPath volume that an earlier pod already created.

Libpod's status code for duplicate-name is inconsistent across versions: some return 409 (Conflict), others return 500 with "volume already exists" in the body. We accept either, falling back to a body-match on 500.

func (*Client) ImageExists

func (c *Client) ImageExists(ctx context.Context, image string) (bool, error)

ImageExists asks libpod whether the named image is in the local store. Used by EnsureImageBuilt to short-circuit a build when the tag is already present.

func (*Client) InspectContainer

func (c *Client) InspectContainer(ctx context.Context, id string) (*InspectContainer, error)

InspectContainer fetches the detailed inspect record. Returns a wrapped *APIError with Status=404 when the container does not exist, so callers can use IsNotFound to distinguish "gone" from "broken".

func (*Client) ListContainers

func (c *Client) ListContainers(ctx context.Context, all bool, labelFilter map[string]string) ([]ListContainerItem, error)

func (*Client) Ping

func (c *Client) Ping(ctx context.Context) error

Ping checks that the daemon is responsive. Used by NodeProvider.Ping as the node heartbeat. We hit /libpod/_ping rather than /info because _ping returns "OK\n" and is the cheapest endpoint libpod exposes.

func (*Client) PullImage

func (c *Client) PullImage(ctx context.Context, reference string) error

stream closes (image is fully pulled). reference is the full image ref ("docker.io/library/alpine:3.20").

func (*Client) RemoveContainer

func (c *Client) RemoveContainer(ctx context.Context, id string, force, volumes bool) error

RemoveContainer issues DELETE /libpod/containers/{id}. When force is true the container is stopped first (libpod handles the sequencing). When volumes is true, named anonymous volumes are also removed.

func (*Client) RemoveVolume

func (c *Client) RemoveVolume(ctx context.Context, name string, force bool) error

RemoveVolume issues DELETE /libpod/volumes/{name}. force=true asks libpod to detach any container still referencing the volume before removing it (we set this on DeletePod cleanup so a still-running container doesn't block the per-pod volume reap). A missing volume returns 404 which we treat as success — idempotent.

func (*Client) StartContainer

func (c *Client) StartContainer(ctx context.Context, id string) error

StartContainer issues POST /libpod/containers/{id}/start. Idempotent — starting an already-running container returns 304 (not modified) which we treat as success.

func (*Client) StopContainer

func (c *Client) StopContainer(ctx context.Context, id string, timeout time.Duration) error

StopContainer issues POST /libpod/containers/{id}/stop. timeout is the seconds-before-SIGKILL grace period; 0 == kill immediately. A 304 (already stopped) is treated as success.

type CreateResponse

type CreateResponse struct {
	ID       string   `json:"Id"`
	Warnings []string `json:"Warnings,omitempty"`
}

CreateResponse is what /libpod/containers/create returns. Warnings is preserved so callers can log them; the container is still usable when warnings are present.

type FetchError

type FetchError struct {
	Status  int
	Message string
}

FetchError wraps a non-2xx cloudbox response. Status exposes the HTTP code so the caller can branch on 503 (cluster mode disabled upstream — non-fatal, retry later) vs. 401/403 (token doesn't have the right scope — fatal until pairing is refreshed).

func (*FetchError) Error

func (e *FetchError) Error() string

type FetchRequest

type FetchRequest struct {
	NodeName string `json:"node_name"`
}

FetchRequest is the JSON body the outpost POSTs to cloudbox at FetchEndpointPath. NodeName tells cloudbox which of the owner's paired hosts the kubeconfig is for; cloudbox enforces that the (owner, node_name) pair exists in its host table before minting a ServiceAccount token.

type FetchResponse

type FetchResponse struct {
	APIURL   string `json:"api_url"`
	Token    string `json:"token"`
	CAData   string `json:"ca_data,omitempty"`
	NodeName string `json:"node_name"`
}

FetchResponse mirrors cloudbox's hub/internal/handlers/cluster.go agentKubeconfigResp shape. CAData is base64 so the JSON stays clean even when the CA bundle contains PEM newlines.

type InspectConfig

type InspectConfig struct {
	Labels map[string]string `json:"Labels"`
	Env    []string          `json:"Env,omitempty"`
}

InspectConfig carries the bits of /Config we use — namely the labels we stamp at create time, which is how reconcile distinguishes outpost-owned containers from anything else the user runs locally.

type InspectContainer

type InspectContainer struct {
	ID        string        `json:"Id"`
	Name      string        `json:"Name"`
	State     InspectState  `json:"State"`
	Config    InspectConfig `json:"Config"`
	Image     string        `json:"Image"`
	ImageName string        `json:"ImageName"`
	Created   time.Time     `json:"Created"`
}

InspectContainer issues GET /libpod/containers/{id}/json. Returns only the fields the provider's status reporter actually reads — the full inspect schema is sprawling and we'd rather track adds explicitly than carry every podman field as dead code.

type InspectState

type InspectState struct {
	Status     string    `json:"Status"`
	Running    bool      `json:"Running"`
	Paused     bool      `json:"Paused"`
	Restarting bool      `json:"Restarting"`
	OOMKilled  bool      `json:"OOMKilled"`
	Dead       bool      `json:"Dead"`
	Pid        int       `json:"Pid"`
	ExitCode   int32     `json:"ExitCode"`
	Error      string    `json:"Error,omitempty"`
	StartedAt  time.Time `json:"StartedAt"`
	FinishedAt time.Time `json:"FinishedAt"`
}

InspectState mirrors the subset of /containers/{id}/json State we read. Status is one of "created" / "running" / "paused" / "exited" / "stopped" / "removing" / "stopping" — libpod's terminology, which we translate to corev1.ContainerState in the provider.

type ListContainerItem

type ListContainerItem struct {
	ID     string            `json:"Id"`
	Names  []string          `json:"Names"`
	Image  string            `json:"Image"`
	State  string            `json:"State"`  // "running", "exited", ...
	Status string            `json:"Status"` // human-readable, e.g. "Up 3 minutes"
	Labels map[string]string `json:"Labels"`
}

ListContainers issues GET /libpod/containers/json. When all is true, stopped containers are included as well as running ones. labelFilter, when non-nil, restricts the result to containers carrying every given key=value label — used by reconcile to enumerate only the containers outpost owns (label outpost.io/managed=true).

type MemoryLimits

type MemoryLimits struct {
	Limit int64 `json:"limit,omitempty"`
}

type Mount

type Mount struct {
	Type        string   `json:"Type"`
	Source      string   `json:"Source,omitempty"`
	Destination string   `json:"Destination"`
	Options     []string `json:"Options,omitempty"`
}

Mount mirrors OCI runtime spec mounts as libpod accepts them. Type is "bind" / "tmpfs"; Options is a free-form list passed through to runc (e.g. "ro", "rprivate", "noexec").

Named volumes do NOT go here — libpod has a separate `volumes` field for those (see NamedVolume). A Mount with Type="volume" is silently treated as a bind to a path matching the Source string, which yields the misleading "No such device" at start.

type NamedVolume

type NamedVolume struct {
	Name    string   `json:"Name"`
	Dest    string   `json:"Dest"`
	Options []string `json:"Options,omitempty"`
}

NamedVolume is the libpod-side representation of a Docker-style `-v <name>:<dest>:<opts>` named volume reference. Lives on SpecGenerator.Volumes (not Mounts). The volume identified by Name must exist before container create — pre-create it with CreateVolume.

type Namespace

type Namespace struct {
	NSMode string `json:"nsmode,omitempty"`
	Value  string `json:"value,omitempty"`
}

Namespace describes one of the OCI namespaces (net/pid/ipc/uts/user). NSMode is typically "host", "private", "container:<id>", or "ns:<path>". Value is required for the container: / ns: forms; otherwise empty.

type NativeProcessConfig added in v0.14.3

type NativeProcessConfig struct {
	DataDir string // where the JSON registry + process logs live
	HostIP  string // host the process is reachable on (default 127.0.0.1)
	Image   string // image recorded when a Pod omits one
}

NativeProcessConfig configures a native-process Backend. Only DataDir is required; the rest fall back to sane defaults.

type NodeProvider

type NodeProvider struct {
	// contains filtered or unexported fields
}

NodeProvider implements virtual-kubelet's node.NodeProvider on top of the libpod client. Ping is the lightweight heartbeat used to mark the node Ready/NotReady; NotifyNodeStatus pushes the slow-path status updates (capacity, conditions) that we want the apiserver to see when they change.

func NewNodeProvider

func NewNodeProvider(c *Client, node *corev1.Node) *NodeProvider

NewNodeProvider returns a NodeProvider sharing the given libpod client. node is the initial Node object — typically built with BuildNode below; NotifyNodeStatus mutates it in place (touching the Ready condition's LastHeartbeatTime) before each push.

When c is non-nil, Ping delegates to c.Ping (the libpod /libpod/_ping endpoint). When c is nil (native backends), Ping returns nil by default — callers can override with SetPinger.

func (*NodeProvider) NotifyNodeStatus

func (np *NodeProvider) NotifyNodeStatus(ctx context.Context, cb func(*corev1.Node))

NotifyNodeStatus starts an asynchronous heartbeat loop that pushes a fresh Node status to cb every staticHeartbeat. **It must not block the caller** — virtual-kubelet's NodeController.Run calls this inline during setup and won't proceed to ensureNode (where the apiserver POST happens) until we return. The loop runs in a goroutine and exits when ctx is canceled.

func (*NodeProvider) Ping

func (np *NodeProvider) Ping(ctx context.Context) error

Ping is the lightweight liveness check virtual-kubelet calls periodically to drive the node lease. When backed by a podman client it passes through to /libpod/_ping; for native backends it calls the configured pinger (always-healthy by default).

func (*NodeProvider) SetPinger

func (np *NodeProvider) SetPinger(fn func(context.Context) error)

SetPinger replaces the health-check function used by Ping. When the pinger returns an error the node is marked NotReady. The zero value (when client is nil and SetPinger is never called) is an always- healthy pinger — callers of native backends can swap in a custom probe (e.g. "is the ollama process reachable?") via this seam.

type OllamaConfig

type OllamaConfig = NativeProcessConfig

OllamaConfig is the legacy config alias for NewOllamaBackend.

type ParsedKubeconfig

type ParsedKubeconfig struct {
	APIURL string
	Token  string
	CA     []byte
}

ParsedKubeconfig is the subset of a kubeconfig the cluster runner needs to dial: apiserver URL, bearer token, and optional CA bundle. Returned by ParseKubeconfig so the admin UI can persist it into conf.ClusterConfig and discard the original YAML.

func FetchKubeconfig

func FetchKubeconfig(ctx context.Context, cloudboxBase, accessToken, nodeName string) (*ParsedKubeconfig, error)

FetchKubeconfig POSTs to cloudbox's per-host kubeconfig endpoint using the existing outpost access_token, then decodes the response into a ParsedKubeconfig the caller can persist into FileConfig.Cluster. cloudboxBase is the HTTPS URL of cloudbox (e.g. "https://ai.dhnt.io"), matching what cmd/outpost/main.go::cloudboxHTTPBase already derives from the matrix-tunnel pairing fields.

Returns an error wrapping the HTTP status when cloudbox responds non-2xx — callers can use this to distinguish 503 (cluster mode off) from 401/403 (bad token) from 5xx (cloudbox issue). The body, when present, carries cloudbox's `{"error": "..."}` shape which we surface in the error message.

func ParseKubeconfig

func ParseKubeconfig(raw []byte) (*ParsedKubeconfig, error)

ParseKubeconfig pulls APIURL / Token / CA out of a kubeconfig (YAML bytes) — typically the file at /etc/rancher/k3s/k3s.yaml for dev / PoC, or the per-host kubeconfig cloudbox issues in production.

We look at current-context's cluster + user only. AuthProvider / exec-plugin / client-cert auth are rejected with a clear error instead of silently producing a half-working credential — these are uncommon for cluster-join tokens, and adding them later is additive.

type PortMapping

type PortMapping struct {
	HostIP        string `json:"host_ip,omitempty"`
	HostPort      uint16 `json:"host_port,omitempty"`
	ContainerPort uint16 `json:"container_port"`
	Protocol      string `json:"protocol,omitempty"` // "tcp" "udp" "sctp"
}

PortMapping is one host-to-container TCP/UDP port forward.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider implements virtual-kubelet's node.PodLifecycleHandler on top of a local podman daemon. It is the per-outpost half of the cluster: the cloud-side PodController watches the apiserver for Pods assigned to this node and calls into us; we translate to libpod and back.

Concurrency: all exported methods are safe to call from multiple goroutines. The internal pod cache is protected by an RWMutex; libpod itself serializes container-state changes per-container, so we don't have to worry about racing CreateContainer+StartContainer against a concurrent RemoveContainer for the same pod (they would have to be distinct pods to begin with, since the pod UID makes container names unique).

func NewProvider

func NewProvider(podmanSocket string) (*Provider, error)

NewProvider returns a Provider that talks to the libpod daemon reachable at podmanSocket (typically the path returned by agent.DetectPodman). Call Reconcile once after construction to repopulate the in-memory pod cache from containers podman is already running with the outpost.io/managed label — this is what makes vknode survive a crash without forgetting what it owns.

func NewProviderWithBackend

func NewProviderWithBackend(b Backend) *Provider

NewProviderWithBackend returns a Provider that realizes Pods on the given Backend. It sets requireExplicitAccess to true so the native/trusted access gate is active: nil Access rejects CreatePod, and non-nil Access requires namespace grants. Callers using a podman-like substrate that should keep the single-tenant escape hatch can call SetRequireExplicitAccess(false) after construction.

func (*Provider) Client

func (p *Provider) Client() *Client

Client returns the underlying libpod client. Exported so the NodeProvider can share the same socket connection.

func (*Provider) CreatePod

func (p *Provider) CreatePod(ctx context.Context, pod *corev1.Pod) error

CreatePod creates and starts the container for pod. Idempotent: if a container with the same PodUIDLabel already exists, we just (re)start it instead of erroring on name conflict. That makes the reconcile path — "we saw this pod before our last restart, libpod still has the container" — collapse to a no-op rather than a 409 cascade.

First gate: the namespace access check. p.access (when non-nil) holds the set of namespaces permitted to schedule here — derived from the outpost's owner + sharees. Pods from outside that set are rejected with a clear error so the apiserver event surface shows what happened. nil p.access means "no check"; used in dev/single-tenant modes where the operator hasn't wired Access yet.

func (*Provider) DeletePod

func (p *Provider) DeletePod(ctx context.Context, pod *corev1.Pod) error

DeletePod stops + removes the container and forgets the pod. Idempotent against the reconcile path: a missing container (already cleaned up by a prior delete that crashed mid-flight) is not an error.

func (*Provider) GetPod

func (p *Provider) GetPod(_ context.Context, namespace, name string) (*corev1.Pod, error)

GetPod returns the cached *corev1.Pod for (namespace, name). Reports errNotFound when we have no record — the PodController treats that as "this provider doesn't know about this pod" and falls back to its own state.

func (*Provider) GetPodStatus

func (p *Provider) GetPodStatus(ctx context.Context, namespace, name string) (*corev1.PodStatus, error)

GetPodStatus reports the live status of the pod's single container by inspecting libpod. Falls back to a Pending status when no container exists yet (e.g. between CreatePod returning and the container fully starting) so the PodController doesn't see a transient "not found" and panic.

func (*Provider) GetPods

func (p *Provider) GetPods(_ context.Context) ([]*corev1.Pod, error)

GetPods returns every Pod we currently know about. Used by the PodController on startup to discover what we already own — combined with the apiserver's view, that's how the reconcile loop computes what to create/delete to converge.

func (*Provider) Reconcile

func (p *Provider) Reconcile(ctx context.Context) error

Reconcile rebuilds the in-memory pod cache from libpod's view of the world. Called once at startup so a vk restart doesn't lose track of containers we created in a previous lifetime. Containers that lack the ManagedLabel are left alone — they belong to the user, not to the cluster.

We reconstruct skeleton *corev1.Pods from the labels we stamped at create time. The reconstruction is intentionally minimal (no env, no resource limits, etc.) because the apiserver is the source of truth for the full spec; the PodController will issue an UpdatePod with the real Pod as soon as it lists the apiserver, refreshing the cache.

func (*Provider) SetAccess

func (p *Provider) SetAccess(a *Access)

SetAccess installs the namespace-access gate. Pass nil to disable the check (dev/single-tenant mode). Called once at boot from startClusterRunner with an Access built from the outpost owner's email + any sharee emails fetched from cloudbox.

func (*Provider) SetRequireExplicitAccess

func (p *Provider) SetRequireExplicitAccess(v bool)

SetRequireExplicitAccess switches the access-gate mode. When true (native/trusted backends), nil Access rejects CreatePod. When false (podman), nil Access means "allow all namespaces".

func (*Provider) SetTransientApps

func (p *Provider) SetTransientApps(a TransientApps)

SetTransientApps installs the local app router each Running pod gets published into (one transient entry per Container port with a non-zero HostPort). The published name follows TransientAppName(...), so cloudbox's /api/cluster/svc/* handler can compose a /h/<node>/app/<name>/ URL without negotiating container-port mapping out of band. Pass nil to skip publishing — the cluster still works, just only reachable via direct hostPort on the node's LAN.

func (*Provider) UpdatePod

func (p *Provider) UpdatePod(ctx context.Context, pod *corev1.Pod) error

UpdatePod handles spec/label/annotation changes from the apiserver. Pod containers are immutable in K8s, so the only thing we need to do is refresh the cached *corev1.Pod — the running container stays put. (Label-only updates that influence which selector matches a workload are an apiserver concern; the container is unaffected.)

type RefreshDeps

type RefreshDeps struct {
	// CloudboxBase is the HTTPS base URL of cloudbox (without the
	// /api/cluster/kubeconfig suffix), e.g. "https://ai.dhnt.io".
	CloudboxBase string

	// AccessToken is the outpost's existing matrix access_token. Used
	// as the Bearer when calling cloudbox's kubeconfig endpoint.
	AccessToken string

	// NodeName is the outpost identity to mint the kubeconfig for —
	// always fc.AgentName in practice.
	NodeName string

	// TokenFilePath is where the current SA token is written; the
	// refresher overwrites it atomically before the old token expires.
	// client-go's BearerTokenFile transport picks up the new contents
	// on its next read.
	TokenFilePath string

	// OnRotation, when non-nil, is called after a successful refresh
	// with the new credential. The cmd/outpost wiring uses it to
	// persist the new APIURL/Token/CA into FileConfig — so a future
	// outpost restart starts from the refreshed state without having
	// to re-fetch.
	OnRotation func(*ParsedKubeconfig)
}

RefreshDeps is the dependency bundle the Refresher needs. Keeps the constructor signature small as we add behaviors (audit hooks, etc.) later; everything in here is captured at construction time.

type Refresher

type Refresher struct {
	// contains filtered or unexported fields
}

Refresher runs a loop that re-fetches the SA token before it expires and writes the new value to TokenFilePath. Single instance per outpost process; constructed once and Run()-ed inside the vknode errgroup.

func NewRefresher

func NewRefresher(deps RefreshDeps) *Refresher

NewRefresher captures deps and returns a Refresher ready to Run.

func (*Refresher) Run

func (r *Refresher) Run(ctx context.Context, currentToken string) error

Run blocks until ctx is canceled. The loop:

  1. Decode the current token's exp; sleep until refreshLeadTime before it (or minRefreshInterval, whichever is longer).
  2. Fetch a new kubeconfig from cloudbox.
  3. Write the new token to the file.
  4. Notify OnRotation so the caller can persist.
  5. Repeat.

On fetch error: log and sleep minRefreshInterval before retrying. On context cancel: return cleanly so the errgroup tears down without flagging an error.

type ResourceLimits

type ResourceLimits struct {
	CPU    *CPULimits    `json:"cpu,omitempty"`
	Memory *MemoryLimits `json:"memory,omitempty"`
}

ResourceLimits maps a subset of cgroup limits. CPU.Quota / Period model "milliCPU" the way Kubernetes does: period=100000, quota=N*100 for N milliCPU. Memory.Limit is bytes.

type RunOptions

type RunOptions struct {
	// NodeName is the identity to register with the apiserver — what
	// `kubectl get nodes` will show. Typically the outpost's AgentName.
	NodeName string

	// PodmanSocket is the unix socket path to the local libpod daemon.
	// Callers usually obtain it from agent.DetectPodman(). Only used
	// when Backend is nil (default podman path).
	PodmanSocket string

	// Backend, when non-nil, replaces the default podman substrate.
	// When set the PodmanSocket field is ignored — the caller is
	// responsible for constructing the right Backend (e.g.
	// NewOllamaBackend). When nil the runner creates a podmanBackend
	// from PodmanSocket.
	Backend Backend

	// Kube is the already-built kube REST config. Caller is responsible
	// for plumbing the bearer token / CA — see ConfigFromCluster or
	// clientcmd.BuildConfigFromFlags.
	Kube *rest.Config

	// ExtraNodeLabels are merged into the registered Node's Labels map.
	// Useful for nodeSelector targeting (e.g. {"outpost.dhnt.io/gpu":"true"}).
	ExtraNodeLabels map[string]string

	// Access, when non-nil, is the namespace-allow gate vknode's
	// CreatePod will consult before scheduling each pod. Pass nil to
	// disable the check (dev/single-tenant escape hatch). Production
	// agents build this from the outpost owner's email +
	// (eventually) the share-receivers list cloudbox advertises.
	Access *Access

	// AllowAnyNamespace disables the native-backend fail-closed access
	// mode. This is only for standalone dev/PoC runners that are pointed
	// at a trusted kubeconfig and have no cloudbox-derived Access set.
	AllowAnyNamespace bool

	// TransientApps, when non-nil, is the local app router each
	// Running pod gets published into so cloudbox can reach it via
	// the existing /h/<node>/app/<name>/ proxy. nil = don't publish
	// (cluster works at the apiserver layer but no cloudbox-fronted
	// pod URL).
	TransientApps TransientApps
}

RunOptions configures one vknode cluster-join lifetime. Callers build it from either a kubeconfig file (the cmd/outpost-vk PoC) or from the persisted conf.ClusterConfig (the main outpost startCmd path); the runner doesn't care which.

type SpecGenerator

type SpecGenerator struct {
	Name           string            `json:"name,omitempty"`
	Image          string            `json:"image"`
	Command        []string          `json:"command,omitempty"`
	Entrypoint     []string          `json:"entrypoint,omitempty"`
	Env            map[string]string `json:"env,omitempty"`
	Labels         map[string]string `json:"labels,omitempty"`
	WorkDir        string            `json:"work_dir,omitempty"`
	Hostname       string            `json:"hostname,omitempty"`
	Terminal       bool              `json:"terminal,omitempty"`
	Stdin          bool              `json:"stdin,omitempty"`
	Remove         bool              `json:"remove,omitempty"`
	RestartPolicy  string            `json:"restart_policy,omitempty"`
	Mounts         []Mount           `json:"mounts,omitempty"`
	Volumes        []NamedVolume     `json:"volumes,omitempty"`
	NetNS          *Namespace        `json:"netns,omitempty"`
	PortMappings   []PortMapping     `json:"portmappings,omitempty"`
	ResourceLimits *ResourceLimits   `json:"resource_limits,omitempty"`
}

SpecGenerator is the libpod create-container payload. We mirror only the fields the v1.Pod translator emits — adding more is a matter of extending the struct on demand. Field names match libpod's JSON schema; keep them in sync if you bump podman.

func BuildSpec

func BuildSpec(pod *corev1.Pod) (*SpecGenerator, error)

BuildSpec converts a corev1.Pod into a libpod SpecGenerator suitable for the /libpod/containers/create endpoint. Returns an error when the Pod uses a feature outside the v1 supported surface (see the file comment). The returned spec carries the outpost.io/* identity labels so reconcile and `podman ps` both stay informative.

type TransientApps

type TransientApps interface {
	// Register associates name with the loopback URL (e.g.
	// "http://127.0.0.1:31080"). Returns an error if the name is
	// already registered with a different target.
	Register(name, target string) error
	// Unregister removes the name. No-op if not present.
	Unregister(name string)
}

TransientApps is the minimal surface vknode needs to publish running pods into the outpost's local app router so cloudbox can reach them through the existing matrix tunnel + /h/<host>/app/<name> proxy.

Implemented by *internal/agent.AppRegistry (adapter in cmd/outpost/ main.go). Kept as an interface here so vknode doesn't import internal/agent (which would cycle through agent → vknode).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL