agent

package
v0.0.6 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 44 Imported by: 0

Documentation

Overview

Package agent is the device side of cloop's remote executor: the process that runs on an edge device, dials out to a control plane, and executes the workloads it is sent.

It exists because the interesting devices are unreachable. A build box in an office, a Jetson on a factory floor, a laptop on hotel wifi — all can make outbound HTTPS and none can accept an inbound connection. So the agent inverts the usual direction: it dials the control plane, holds one multiplexed WebSocket open, and receives work over it.

Three properties shape the implementation:

  • Reconnection is normal. Networks on edge devices drop constantly, so the agent reconnects with capped exponential backoff plus jitter and resumes log streaming from the offset the control plane actually received.
  • Memory is finite. A device with 512 MiB of RAM cannot buffer a chatty harness's output while the link is down, so retention is bounded and overflow is reported rather than silently absorbed.
  • The control plane is not trusted with the filesystem. Every workdir is confined beneath a configured root, enforced on the device, because the device is the only party that knows its own filesystem.

Index

Constants

View Source
const CodeDeviceUnavailable = "device_unavailable"

CodeDeviceUnavailable is the error code an agent answers device_open with when it cannot attach the device.

View Source
const CredentialFileMode os.FileMode = 0o600

CredentialFileMode is the required permission for the credential file.

View Source
const DefaultRetainBytes = 1 << 20

DefaultRetainBytes is how much unacknowledged output one workload may hold. 1 MiB comfortably covers a multi-second reconnect for a chatty harness while staying negligible against even a small device's memory.

View Source
const EdgeOptInCommand = "sudo cloop executor agent install --upgrade --channel edge"

EdgeOptInCommand is what an operator runs on a device to put it on the edge channel.

View Source
const KVMDevice = "/dev/kvm"

KVMDevice is the device QEMU opens to use hardware virtualization. Its absence is what makes a Kata sandbox impossible rather than merely slow: Kata passes accel=kvm unconditionally and the QEMU it bundles is built --disable-tcg, so there is no software-emulation fallback to degrade to.

View Source
const RemoteUpgradeCommand = "sudo cloop executor agent install --upgrade --remote-upgrade"

RemoteUpgradeCommand is what an operator runs on a device to let the hub upgrade it.

Variables

View Source
var AgentVersion = version.String()

AgentVersion identifies this agent build to the control plane, so version skew across a fleet is visible from the Executors panel rather than being inferred from behaviour.

It was a hardcoded "1" for as long as this file existed, and was never once incremented — so the field meant to diagnose version skew was itself the reason skew could not be diagnosed. Every device in every fleet reported the same value, whatever it was actually running.

The real build version lives in pkg/version, below both this package and the CLI, which is what makes it askable from here at all. A var rather than a const so a test can stage a specific build without rebuilding, and because its value is no longer a compile-time constant.

View Source
var ErrNoOfficialInstaller = errors.New("agent: no official installer for this harness")

ErrNoOfficialInstaller reports that a harness has no entry in the table above, and so cannot be installed on request.

View Source
var ErrNotATarget = errors.New("agent: not something this device installs")

ErrNotATarget reports a target that is neither a release, "latest" nor an edge build.

View Source
var ErrNotOnEdgeChannel = errors.New("agent: this device does not follow the edge channel")

ErrNotOnEdgeChannel reports an edge target on a device that does not follow the edge channel.

Functions

func ApplyUpgradeRequest added in v0.0.5

ApplyUpgradeRequest carries out the upgrade the agent filed, as the root helper does (`cloop executor agent install --upgrade --apply-request`).

The request is taken — deleted — before anything else, so a request that is refused is not retried by the path unit. The channel it is checked against is the device's, from systemd's view of the agent's unit; a request cannot name one. It returns install.ErrNoRequest when nothing is waiting.

func CheckCredentialPermissions

func CheckCredentialPermissions(path string) string

CheckCredentialPermissions reports a warning when the credential file is readable by anyone but its owner. Returns "" when the permissions are fine.

func CheckTarget added in v0.0.5

func CheckTarget(target string, channel provenance.Channel) error

CheckTarget refuses a target this device does not install, before anything is downloaded. channel is the device's own.

func DefaultCredentialPath

func DefaultCredentialPath() (string, error)

DefaultCredentialPath returns ~/.cloop/agent.json.

It is under the user's home rather than the project's .cloop directory because the agent's identity belongs to the *device*, not to any project: a device runs workloads for many projects, and several of them may not exist on it yet.

func Detect

Detect gathers this device's capabilities.

func EnsureHarnessPath added in v0.0.3

func EnsureHarnessPath() []string

EnsureHarnessPath prepends this device's per-user harness directories to the agent process's own PATH, and reports the directories it added.

Idempotent: a directory already on PATH is not added again, so calling this at startup and again after every install cannot grow the variable without bound over a long-lived agent.

Mutating the process environment is a blunt instrument and worth justifying. The alternative is to thread a resolved absolute path from detection through to every exec, and that fails for the case that matters most — the harness is not run by this process, it is run by `cloop run` *inside* the payload, which resolves "claude" by name through whatever PATH it inherited. The only place to intervene for that is the environment the payload starts with, and the agent's own environment is its parent.

func HasContainerRuntime

func HasContainerRuntime(caps remote.AgentCapabilities) bool

HasContainerRuntime reports whether caps advertises any container runtime.

func HasHarness

func HasHarness(caps remote.AgentCapabilities, name string) bool

HasHarness reports whether caps advertises a given harness, for schedulers matching a project's provider against a device.

func NativeHarnessDirs added in v0.0.3

func NativeHarnessDirs() []string

NativeHarnessDirs returns the absolute per-user harness directories for this device, skipping any that do not exist.

Non-existent directories are dropped rather than returned hopefully: these values are prepended to PATH, and a PATH full of entries that resolve to nothing costs every exec on the device a failed stat for the life of the process.

func SaveCredential

func SaveCredential(path string, c Credential) error

SaveCredential writes the credential atomically with 0600 permissions.

Atomic because a device that loses power mid-write must not come back with a half-written identity it can neither use nor diagnose — the rename either happened or it did not.

The temp file is created with 0600 from the outset rather than chmod'ed afterwards: a create-then-chmod sequence leaves a window in which the secret exists on disk world-readable, and on a shared device that window is enough.

func UpgradeTo added in v0.0.5

UpgradeTo stages t.Target, verifies it, and installs it over spec's binary.

Types

type Agent

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

Agent is the device-side executor client.

func New

func New(cfg Config) (*Agent, error)

New builds an agent. It does not connect; call Run.

func (*Agent) AgentID

func (a *Agent) AgentID() string

AgentID returns the enrolled identity, or "" before enrollment.

func (*Agent) Capabilities

func (a *Agent) Capabilities() remote.AgentCapabilities

Capabilities reports what this device advertises. MaxConcurrent is normalised in New, so this is exactly the ceiling admission control applies.

func (*Agent) Run

func (a *Agent) Run(ctx context.Context) error

Run connects and serves until ctx is cancelled or the control plane refuses this agent permanently.

func (*Agent) TransportSummary

func (a *Agent) TransportSummary() string

TransportSummary describes the agent's transport for startup output.

func (*Agent) WorkDirRoot

func (a *Agent) WorkDirRoot() string

WorkDirRoot returns the confinement root.

type ApplyOptions added in v0.0.5

type ApplyOptions struct {
	// Installer applies the upgrade, and answers which channel the device
	// follows; nil uses the real one.
	Installer *install.Installer
	// Fetch is the staging options. Tests set the verifier and the mirror.
	Fetch upgrade.Options
	// Now is the clock, for the request's age; nil uses time.Now.
	Now func() time.Time
	// Progress receives one line per step; nil discards them.
	Progress func(string)
}

ApplyOptions parameterise ApplyUpgradeRequest.

type AttachedDevice added in v0.0.6

type AttachedDevice interface {
	// Done is closed once the attachment has ended.
	Done() <-chan struct{}
	// Close detaches it. Idempotent.
	Close() error
	// Opened describes it for the device_opened answer.
	Opened() remote.DeviceOpenedPayload
	// Give is what a sandbox is given for it: device nodes for USB, an
	// interface for L2. name is the sandbox-facing handle; iface is the
	// segment's sandbox-side shape.
	Give(name string, iface executor.HostInterface) ([]executor.HostDevice, *executor.HostInterface)
}

AttachedDevice is one device attached on this host.

type Config

type Config struct {
	// Server is the control-plane WebSocket URL,
	// e.g. wss://cloop.example.com/api/executors/connect.
	Server string
	// Token is an enrollment token, used only until a credential is issued.
	Token string
	// TokenFile is the file Token was read from, when it came from one.
	//
	// The agent deletes it once the token has been redeemed. An enrollment
	// token is single-use, so after redemption the file holds a dead secret
	// that still looks live: it would be carried into every backup and disk
	// image of the device, and an operator finding it later cannot tell
	// whether it is spent. Removing it makes the credential's lifetime match
	// its usefulness. Set by `cloop executor agent --token-file`, which is
	// how the installed service receives its token without it appearing in
	// ExecStart.
	TokenFile string
	// CredentialPath is where the long-lived credential is persisted.
	// Empty uses DefaultCredentialPath().
	CredentialPath string
	// WorkDirRoot confines every workload. Empty uses ~/.cloop/work.
	WorkDirRoot string
	// MaxConcurrent bounds simultaneous workloads. Zero means NumCPU.
	MaxConcurrent int
	// Labels are operator-supplied scheduler selectors.
	Labels map[string]string
	// RetainBytes overrides DefaultRetainBytes per workload.
	RetainBytes int
	// Pin is the control plane's expected SPKI fingerprint,
	// "sha256:<base64>". Several may be given comma-separated so a key
	// rotation can be staged. Empty means ordinary PKI verification only.
	// Taken from the stored credential when the flag is not passed.
	Pin string
	// RootCAFile adds a PEM bundle to the trusted roots, for a control plane
	// whose certificate the system store does not know — a private CA, or a
	// `cloop hub tls-init` development certificate. This is the supported way
	// to reach such a server; there is deliberately no way to disable
	// verification instead.
	RootCAFile string
	// InsecureTransport permits plaintext ws:// to a non-loopback host.
	// It exists for links already protected some other way (an established
	// mTLS tunnel, a lab with no network). Every connection attempt logs a
	// warning while it is on.
	InsecureTransport bool
	// Logf receives operational messages. Nil discards them.
	Logf func(format string, args ...any)
	// Now overrides the clock for tests.
	Now func() time.Time
	// Dial overrides connection establishment, so tests can drive the agent
	// over an in-memory pipe instead of a real socket.
	Dial func(ctx context.Context, server, token string) (remote.Conn, error)
	// HostProbes turns on the capability probes that run programs — nft(8)
	// for the packet filter, the container engine for its OCI runtimes (Task
	// 20345). The CLI sets it; tests leave it off, so building a hello does
	// not shell out to whatever tooling the test machine happens to have.
	HostProbes bool
	// SysfsRoot and DevRoot override where USB hardware is read from and
	// where device nodes are stat'ed. Tests point them at fixture trees; empty
	// means /sys and /dev.
	SysfsRoot string
	DevRoot   string
	// InstallTarget overrides where this device's managed install is looked
	// for — the unit, the helper and the binary an upgrade replaces. Tests
	// point it at a staged tree; nil is the real install under /etc and
	// /usr/local/bin.
	InstallTarget func() (install.Spec, install.Output, error)
	// UnitActive overrides how the agent asks systemd whether a unit is
	// active, and whether it could tell. Tests set it so that nothing they run
	// queries the machine's systemd; nil asks `systemctl is-active`.
	UnitActive func(unit string) (active, known bool)
	// Devices attaches forwarded devices on this host (Task 20430). Nil is
	// the host's own kernel — vhci_hcd for USB, a TAP for L2 — which is what
	// every caller but a test wants.
	Devices DeviceAttacher
	// ContainerDriver builds the driver for a container-mode payload outside
	// a virtual executor. Nil is the container driver; tests substitute one
	// that needs no engine, to exercise what the agent hands a sandbox.
	ContainerDriver func(executor.SandboxSettings) (executor.Executor, error)
	// Channel is the update channel this device follows (Task 20376), from
	// the unit's channel drop-in by way of CLOOP_UPDATE_CHANNEL. Empty is
	// stable. It is reported in the hello, so the hub knows what to offer,
	// and an edge target is refused without it. Nothing the hub sends sets
	// it.
	Channel provenance.Channel
}

Config parameterises an agent.

type Credential

type Credential struct {
	// Server is the control-plane WebSocket URL this credential is valid for.
	// Stored alongside the secret so `cloop executor agent` can be re-run with
	// no flags at all once enrolled.
	Server string `json:"server"`
	// AgentID is the control plane's identifier for this device. It is also
	// the executor ID projects bind to.
	AgentID string `json:"agent_id"`
	// Name is the operator-facing label chosen at enrollment.
	Name string `json:"name,omitempty"`
	// Credential is the long-lived secret. The control plane stores only its
	// hash, so this file is the only copy in existence.
	Credential string `json:"credential"`
	// WorkDirRoot is the filesystem root every workload is confined beneath.
	WorkDirRoot string `json:"workdir_root,omitempty"`
	// Pin is the control plane's SPKI fingerprint ("sha256:<base64>"), or
	// "" when the device was enrolled without one.
	//
	// It is persisted alongside the secret because pinning that only applied
	// to the enrollment connection would protect the least valuable moment
	// and leave every subsequent one — thousands of reconnects carrying a
	// long-lived credential, over years, from a device nobody visits —
	// trusting whatever answers DNS. Storing it makes the pin a property of
	// the device's identity rather than of one command line.
	Pin string `json:"pin,omitempty"`
	// EnrolledAt records when the credential was issued.
	EnrolledAt time.Time `json:"enrolled_at"`
}

Credential is the durable identity the agent authenticates with.

func LoadCredential

func LoadCredential(path string) (Credential, error)

LoadCredential reads the credential file.

A missing file is reported as os.ErrNotExist so callers can distinguish "not enrolled yet" (prompt for a token) from "enrolled but broken" (a real error the operator must fix).

func (Credential) Valid

func (c Credential) Valid() bool

Valid reports whether the credential has the fields needed to connect.

type DetectOptions

type DetectOptions struct {
	// WorkDirRoot is the confinement root to advertise.
	WorkDirRoot string
	// MaxConcurrent is the agent's workload ceiling.
	MaxConcurrent int
	// Labels are operator-supplied scheduler selectors.
	Labels map[string]string
	// LookPath overrides exec.LookPath for tests.
	LookPath func(string) (string, error)
	// MemoryMB overrides memory detection for tests; -1 forces "unknown".
	MemoryMB int
	// KVMDevice overrides the virtualization device probed by
	// detectVirtualization. Tests point it at a temp file; empty means
	// KVMDevice.
	KVMDevice string
	// SysfsRoot and DevRoot override where the USB inventory is read from
	// (Task 20345). Tests point them at fixture trees; empty means /sys and
	// /dev.
	SysfsRoot string
	DevRoot   string
	// ProbePacketFilter reports whether a host-side packet filter can be
	// installed, and why not. Nil skips the probe and reports none, which is
	// what every caller but the agent itself wants: the probe runs nft(8).
	ProbePacketFilter func() (bool, string)
	// ProbeOCIRuntimes lists the OCI runtimes a container engine has
	// registered. Nil skips it, for the same reason: the probe runs the
	// engine.
	ProbeOCIRuntimes func(engine string) []string
}

DetectOptions tunes capability detection.

type DeviceAttacher added in v0.0.6

type DeviceAttacher interface {
	// Support reports whether this host can attach each kind, and why not.
	Support() (usb bool, usbIssue string, l2 bool, l2Issue string)
	// AttachUSB imports the USB device behind stream, whose forwarder exports
	// it as busid. The attachment owns the stream from then on.
	AttachUSB(ctx context.Context, stream io.ReadWriteCloser, busid string) (AttachedDevice, error)
	// AttachL2 bridges stream to a new TAP named tap.
	AttachL2(stream io.ReadWriteCloser, tap string) (AttachedDevice, error)
}

DeviceAttacher attaches a forwarded device's stream on this host. The real one uses vhci_hcd and TAP (hostDeviceAttacher); tests substitute one that needs no kernel.

type UpgradeTarget added in v0.0.5

type UpgradeTarget struct {
	// Target is a release tag, "latest", or "edge:<commit>".
	Target string
	// Channel is the device's update channel, from root-owned configuration.
	Channel provenance.Channel
	// Install carries everything else install.Upgrade takes — Force, DryRun,
	// SettleTimeout, and any unit changes an operator asked for in the same
	// command. Source, the provenance fields and the Expect* fields are filled
	// in here from what was staged. AllowRollback is passed through as the
	// caller set it, and only an operator's own command may set it: a
	// request the agent filed, or a frame the hub sent, never does.
	Install install.UpgradeOptions
	// Fetch is the staging options: verification against the release
	// identity can be skipped for a release (an operator's --insecure-skip-
	// verify on the device), never for an edge build. Tests set the verifier
	// and the edge mirror.
	Fetch upgrade.Options
	// Installer applies the upgrade; nil uses the real one.
	Installer *install.Installer
	// Progress receives one line per step; nil discards them.
	Progress func(string)
}

UpgradeTarget is one device-side upgrade.

Jump to

Keyboard shortcuts

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