Documentation
¶
Overview ¶
Package remote implements the control-plane half of cloop's remote executor: a driver that satisfies executor.Executor by proxying frames to a cloop agent running on another machine.
The defining constraint is direction. Edge devices, laptops, and build boxes generally sit behind NAT or a firewall that permits outbound connections and nothing inbound. A control plane cannot dial them. So the agent dials *out*, holds one long-lived WebSocket to the control plane, and the control plane pushes work down that already-established connection. Everything else in the protocol follows from that inversion:
- the control plane never knows an agent's address, only its identity;
- liveness is the agent's job (heartbeats), because the control plane has no way to probe;
- reconnects are routine, not exceptional, so log streaming has to be resumable rather than restartable.
proto.go defines the wire format. It is deliberately a separate file from the transport: both the control plane (pkg/executor/remote) and the device (pkg/executor/agent) encode against these types, and a protocol change that only compiles on one side is the bug class this split is meant to prevent.
Versioning ¶
Every frame carries V. The control plane accepts any version in [MinProtocolVersion, ProtocolVersion] and tells the agent which version the session settled on in the Welcome frame. Agents older than the floor are rejected at hello with a legible error rather than failing later on a field they do not understand. Frames are JSON objects with an open payload, so adding a field is backward compatible; removing one or changing its meaning requires a version bump.
Authentication ¶
The connection is authenticated once, at the HTTP upgrade, by a bearer credential (see enroll.go). Individual frames are not separately signed: over an authenticated, ordered, TLS-protected channel a per-frame MAC keyed by the same secret proves nothing the handshake did not already prove, and pretending otherwise would be security theatre.
What frames *are* subject to is authorization. The session binds the connection to exactly one agent identity, and every inbound frame is checked against it: an agent may only report on handles the control plane dispatched to that same agent. A frame naming another agent's handle is dropped and the connection closed, so a compromised agent cannot forge status for or steal logs from its peers.
Index ¶
- Constants
- Variables
- func HeartbeatDeadline() time.Duration
- func MintBundle(store Store, opts MintOptions) (Bundle, EnrollmentRecord, error)
- func NegotiateVersion(peerVersion int) (int, error)
- func NewPipe(buffer int) (Conn, Conn)
- func Revoke(store Store, id string, now time.Time) (kind string, err error)
- func SupportsResumeTerminate(version int) bool
- func SupportsRevocation(version int) bool
- func SupportsSecretFiles(version int) bool
- func SupportsWorkspaceProvisioning(version int) bool
- func SupportsWriteBack(version int) bool
- func ValidateSecretFiles(files []SecretFile) error
- type AcceptOptions
- type AgentCapabilities
- type AgentRecord
- type Bundle
- type ByePayload
- type Conn
- type EnrollmentRecord
- type ErrorPayload
- type Executor
- func (e *Executor) AgentCapabilities() AgentCapabilities
- func (e *Executor) AgentInventory() (AgentCapabilities, bool)
- func (e *Executor) AgentVersion() string
- func (e *Executor) AttachHandleStore(store executor.HandleStore)
- func (e *Executor) Capabilities() executor.Capabilities
- func (e *Executor) ConnStatus() string
- func (e *Executor) Connected() bool
- func (e *Executor) HandleStatuses(ctx context.Context) ([]executor.Status, error)
- func (e *Executor) Handles() []string
- func (e *Executor) HealthCheck(ctx context.Context) error
- func (e *Executor) HoldsLease(leaseID string) bool
- func (e *Executor) ID() string
- func (e *Executor) Kind() string
- func (e *Executor) Leases() []string
- func (e *Executor) LogGapped(handleID string) bool
- func (e *Executor) Name() string
- func (e *Executor) ProtocolVersion() int
- func (e *Executor) Revocations() []RevokeResult
- func (e *Executor) RevokeLease(ctx context.Context, p RevokePayload) RevokeResult
- func (e *Executor) Signal(ctx context.Context, handleID string, sig executor.Signal) error
- func (e *Executor) Start(ctx context.Context, spec executor.Spec) (handle executor.Handle, err error)
- func (e *Executor) Status(ctx context.Context, handleID string) (executor.Status, error)
- func (e *Executor) Stream(ctx context.Context, handleID string) (<-chan executor.LogLine, error)
- func (e *Executor) SupportsRevocation() bool
- func (e *Executor) SupportsWorkspaceProvisioning() bool
- func (e *Executor) SupportsWriteBack() bool
- func (e *Executor) WriteBackBundle(handleID string) ([]byte, error)
- type ExpiredLease
- type ExpiredLeaseSweeper
- type Frame
- type FrameType
- type HeartbeatAckPayload
- type HeartbeatPayload
- type HelloPayload
- type Hub
- func (h *Hub) Deregister(agentID string)
- func (h *Hub) Executor(agentID string) (*Executor, bool)
- func (h *Hub) Executors() []*Executor
- func (h *Hub) LeaseHolders(leaseID string) []string
- func (h *Hub) Restore() error
- func (h *Hub) Revocations() []RevokeResult
- func (h *Hub) Revoke(agentID string) error
- func (h *Hub) RevokeExecutorLeases(ctx context.Context, executorID, reason string, action RevokeAction) []RevokeResult
- func (h *Hub) RevokeLease(ctx context.Context, p RevokePayload) []RevokeResult
- func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request)
- func (h *Hub) StartLeaseJanitor(ctx context.Context, interval time.Duration, sweep ExpiredLeaseSweeper) (stop func())
- type HubOptions
- type LogAckPayload
- type LogChunkPayload
- type MintOptions
- type Options
- type RedeemOptions
- type ResultChunkPayload
- type ResultPayload
- type ResumeAck
- type ResumeAction
- type ResumeHandle
- type RevokeAction
- type RevokePayload
- type RevokeResult
- type RevokeState
- type RevokedPayload
- type SecretFile
- type Session
- type SignalPayload
- type StartPayload
- type StartedPayload
- type StatusPayload
- type StatusReqPayload
- type Store
- type WelcomePayload
- type WorkspaceCredential
Constants ¶
const ( // ProtocolVersion is the newest version this build speaks. // // v2 added the revoke frame (see TypeRevoke): the control plane can take // one secret lease back from a task that is already running, rather than // waiting for the task to exit. // // v3 added StartPayload.WorkspaceCredential: the control plane hands the // device the short-lived material for one authenticated git fetch, so an // agent can materialise a private source tree before the harness starts // instead of running it against an empty directory. // // v4 added the result frames (see TypeResultChunk and TypeResult): the // device sends back the work product — a branch and commit SHA, and for a // device with no egress the git bundle itself — so a task's edits survive // the workload that made them. // // v5 gave ResumeAck an Action (see ResumeAction). Until then the only way // the control plane could refuse a resume offer was to leave the handle out // of Welcome.ResumeAccepted, and the agent read that as "stop reporting" — // so a hub that restarted with an empty handle map refused every offer and // left every workload running on the device forever, unreadable and // unstoppable. An explicit action lets the refusal say "terminate this", // which is the only honest answer for work nobody is left to collect. // // v6 added StartPayload.SecretFiles: the *contents* of a secret lease's // credential files, so a device that shares no filesystem with the hub can // place them itself. Until then a lease's files were written into the hub's // own /dev/shm and named in Spec.Env — a delivery that means nothing on // another machine — so a repository-scoped github_pat arrived at an edge // device as an environment variable pointing at a path that did not exist // there, with no token behind it. ProtocolVersion = 6 // MinProtocolVersion is the oldest version this build still accepts. MinProtocolVersion = 1 // MinRevocationVersion is the first version whose agents understand the // revoke frame. A v1 agent connects and runs work perfectly well; it just // cannot be trusted with material that must be revocable, so the hub // refuses to place such a workload there (see Executor.Start). // // This is a placement rule, not a connectivity rule. Refusing the *device* // would strand a fleet mid-upgrade over a capability most of its work does // not need; refusing the *workload that depends on it* fails exactly the // runs whose guarantee could not be met, and names the reason. MinRevocationVersion = 2 // MinWorkspaceVersion is the first version whose agents provision a git // workspace before launching the harness. // // It is a placement rule for the same reason MinRevocationVersion is, with // a sharper edge: an older agent does not *reject* the credential field, it // ignores it. It would answer the start frame cheerfully, run the harness // in the empty directory it created, and produce a transcript that looks // like a working run operating on no code at all — which is the precise // failure the workspace contract exists to remove. So the hub refuses to // dispatch such a workload rather than trusting the device to complain. MinWorkspaceVersion = 3 // MinWriteBackVersion is the first version whose agents return the work // product after a task finishes. // // A placement rule again, and for the sharpest version of the same reason. // An older agent ignores Spec.WriteBack exactly as it would ignore any // unknown field: it runs the harness, streams a transcript describing the // files it edited, reports success — and then the device's tree, the only // copy of that work, stays on the device. The run is indistinguishable from // one that delivered. So the hub refuses to dispatch a workload that asks // for a write-back to a device that cannot make one, rather than // discovering the loss after the work is gone. MinWriteBackVersion = 4 // MinResumeTerminateVersion is the first version whose agents understand an // explicit refusal in ResumeAck.Action. // // Unlike the three floors above this one is *not* a placement rule, because // there is nothing to refuse to place: it governs what the hub may say to an // agent that is already connected and already running work. An older agent // does not reject an unknown Action, it ignores it — and then reads the // handle's mere presence in ResumeAccepted as "keep streaming", which for a // handle the hub has forgotten means chunks the hub answers with // CodeUnknownHandle forever. So the hub omits refusals entirely below this // version, which reproduces the pre-v5 wire exactly: the agent sees the // handle missing from the list and abandons it, which is what it did before. // // The leak that behaviour causes is fixed on the *device* rather than here // (see the agent's applyResume): an upgraded agent kills an abandoned // workload whatever the hub's version, so the fix reaches a fleet through // the half that can actually stop a process. MinResumeTerminateVersion = 5 // MinSecretFilesVersion is the first version whose agents receive a secret // lease's credential files and place them where the workload will look. // // A placement rule, like MinWorkspaceVersion and for the same shape of // reason: an older agent does not reject StartPayload.SecretFiles, it // ignores it. It would answer the start cheerfully and run the harness with // an environment naming GIT_CONFIG_GLOBAL, KUBECONFIG and CLOOP_LEASE_DIR // under a directory nothing ever created — and a repository-scoped // github_pat, whose entire enforcement *is* those files, would arrive with // no token at all. git then fails to authenticate for a reason nothing in // the transcript names. // // So the hub refuses the *workload*, not the device. A v5 agent still // connects and still runs everything that needs no credential files; // stranding a fleet mid-upgrade over a capability most work does not use // would be a worse failure than the one being prevented. MinSecretFilesVersion = 6 )
Protocol version bounds. Bump ProtocolVersion for any incompatible change; raise MinProtocolVersion only when support for old agents is genuinely dropped, since doing so strands every deployed device until it is upgraded.
const ( // HeartbeatInterval is the nominal gap between agent heartbeats. The // agent applies jitter (see JitterFraction) so that a fleet reconnecting // after a control-plane restart does not resynchronise into a thundering // herd that beats in lockstep forever after. HeartbeatInterval = 15 * time.Second // MissedHeartbeatLimit is how many consecutive intervals may elapse with // no traffic before the control plane declares the agent unreachable. // Three is chosen so a single dropped packet or a brief radio outage on // an LTE edge device does not evict a working executor. MissedHeartbeatLimit = 3 // JitterFraction is the maximum proportional deviation applied to the // heartbeat interval and to reconnect backoff, matching the ±25% jitter // pkg/provider/retry.go uses for provider calls. JitterFraction = 0.25 // WriteTimeout bounds a single frame write. A wedged TCP connection that // never drains must not block the control plane's dispatch goroutine // forever; exceeding this closes the session and the agent reconnects. WriteTimeout = 20 * time.Second )
Timing constants. These are protocol-level agreements, not tunables: both sides must derive their timeouts from the same numbers or a healthy agent gets reaped as dead.
const ( // RevokeScrub invalidates the material and lets the task keep running. RevokeScrub = executor.RevokeScrub // RevokeKill scrubs and then terminates every task holding the lease. RevokeKill = executor.RevokeKill )
const ( CodeUnauthorized = "unauthorized" // CodeVersionUnsupported: the peer's protocol version is out of range. CodeVersionUnsupported = "version_unsupported" // CodeUnknownHandle: the handle is not known to the receiver. CodeUnknownHandle = "unknown_handle" // CodeStartFailed: the workload could not be started. CodeStartFailed = "start_failed" // CodeForbiddenPath: the spec's workdir escapes the agent's root. CodeForbiddenPath = "forbidden_path" // CodeProtocol: the frame was malformed or out of sequence. CodeProtocol = "protocol" // CodeBusy: the agent is at its concurrency ceiling. CodeBusy = "busy" // CodeRevokeUnsupported: the agent's protocol version predates the // revoke frame. Distinct from CodeProtocol so the hub can report // "upgrade this device" rather than "the device is broken". CodeRevokeUnsupported = "revoke_unsupported" // CodeWriteBackFailed: the work product could not be returned. Distinct // from CodeStartFailed because the workload ran — the operator's problem // is a delivery one, and the transcript they are about to read describes // edits that did not arrive. CodeWriteBackFailed = "write_back_failed" )
Error codes carried in ErrorPayload.Code. They are matched programmatically, so unlike Message they must stay stable.
const ( StatusOnline = "online" StatusOffline = "offline" )
Executor status values mirrored into statedb.
const ( // RevokeStatePending: the frame is on its way, or is queued for an agent // that is not currently connected but is expected back. RevokeStatePending = executor.RevokeStatePending // RevokeStateRevoked: the agent acked and reported what it scrubbed. RevokeStateRevoked = executor.RevokeStateRevoked // RevokeStateUnreachable: the agent is offline, so the material is still // on the device. The revocation is queued and will be replayed, but until // then this is the honest state and the UI must show it as such. RevokeStateUnreachable = executor.RevokeStateUnreachable // RevokeStateFailed: the agent answered, and the answer was an error. RevokeStateFailed = executor.RevokeStateFailed )
const DefaultEnrollTTL = 15 * time.Minute
DefaultEnrollTTL bounds an unredeemed enrollment token. Fifteen minutes is long enough to paste a command onto a device over a slow link and short enough that a token left in shell history is inert by the time anyone finds it.
const DefaultLeaseJanitorInterval = time.Minute
DefaultLeaseJanitorInterval is how often expired leases are swept off live agents. It is a fraction of DefaultMaxLeaseTTL (15 minutes in pkg/secretbroker) so a lapsed credential is taken back within a minute of lapsing rather than at the end of the following lease period.
const LogAckBytes = 32 << 10
LogAckBytes is how much output may accumulate before the control plane acknowledges it. Acking every chunk would double the frame count on a chatty run; acking too rarely would make the agent retain more unacked output than necessary. 32 KiB matches MaxLogChunkBytes, so a busy stream acks roughly every chunk while a trickle of small chunks is batched.
const MaxEnrollTTL = 24 * time.Hour
MaxEnrollTTL caps --ttl. Operators reach for "just make it a week" when provisioning is awkward; that converts a single-use token into a standing credential, which is the thing this design exists to avoid.
const MaxFrameBytes = 1 << 20 // 1 MiB
MaxFrameBytes bounds a single decoded frame. This ceiling exists so a malicious or malfunctioning agent cannot make the control plane allocate unboundedly from a single read.
Two frames are large enough to need their own bound underneath this one: a log chunk, capped at MaxLogChunkBytes, and — since v6 — a start frame carrying credential files, capped at MaxSecretFilesBytes. Everything else is small.
const MaxLogChunkBytes = 32 << 10 // 32 KiB
MaxLogChunkBytes is the largest amount of workload output an agent puts in one log_chunk frame. Output beyond this is split across frames; it is never accumulated waiting for a newline, because a harness that prints a progress bar with no trailing newline still has to reach the live log panel.
const MaxResultChunkBytes = 256 << 10 // 256 KiB
MaxResultChunkBytes bounds one bundle slice.
Larger than a log chunk because a bundle is a bulk transfer with no latency requirement — nobody is watching it scroll — and well under MaxFrameBytes so base64's 4/3 expansion plus the envelope still fits with room to spare.
const MaxSecretFilesBytes = 512 << 10 // 512 KiB
MaxSecretFilesBytes bounds the total plaintext one start frame may carry in StartPayload.SecretFiles.
Before v6 a start frame was small and bounded by the Spec alone; now it can carry file contents, so it is the one frame that could plausibly approach MaxFrameBytes. The ceiling is set well below it because JSON encodes []byte as base64: 512 KiB of plaintext is ~683 KiB on the wire, which still leaves room for the Spec, the workspace credential and the envelope.
It is checked on both sides and it is not the same check as MaxFrameBytes. Hitting the frame limit produces "payload 1103241 bytes exceeds 1048576", which tells an operator nothing about which lease is too big; hitting this one names the credential files. And enforcing it on the sending side means the hub refuses a lease it could never deliver instead of dispatching a frame the device will drop.
executor.MaxSecretFileBytes (256 KiB) bounds one file; this bounds the set, which is the quantity a frame actually has to carry.
const StatusUnreachable = "unreachable"
StatusUnreachable is the executors-table status written when an agent misses MissedHeartbeatLimit consecutive heartbeats. It is a distinct value from "offline" (a clean bye) so an operator can tell a device that shut down tidily apart from one that fell off the network.
const TokenFileMode os.FileMode = 0o600
TokenFileMode is the permission an enrollment file must carry. It is the same 0600 the agent's long-lived credential uses, for the same reason: the contents are a bearer credential for as long as they are unredeemed.
Variables ¶
var ( // ErrProtocol: a frame was malformed, out of range, or arrived in a state // where it makes no sense. ErrProtocol = errors.New("remote: protocol error") // ErrVersionUnsupported: the peer's protocol version does not overlap // with this build's supported range. ErrVersionUnsupported = errors.New("remote: unsupported protocol version") // ErrAgentUnreachable: no live session for this agent. Start returns it // immediately rather than queueing work for a device that may never come // back. ErrAgentUnreachable = errors.New("remote: agent is unreachable") // ErrAgentBusy: the agent is at its advertised concurrency ceiling. ErrAgentBusy = errors.New("remote: agent is at capacity") // ErrSessionClosed: the session ended while a request was in flight. ErrSessionClosed = errors.New("remote: session closed") // ErrTokenInvalid: an enrollment token is malformed, unknown, or its MAC // does not verify. ErrTokenInvalid = errors.New("remote: invalid enrollment token") // ErrTokenExpired: the enrollment token's TTL elapsed before redemption. ErrTokenExpired = errors.New("remote: enrollment token expired") // ErrTokenAlreadyUsed: the enrollment token was already redeemed. This is // the replay case, and it is deliberately distinct from ErrTokenInvalid // so an operator can tell "someone captured and replayed this token" // apart from "the device typo'd it". ErrTokenAlreadyUsed = errors.New("remote: enrollment token already redeemed") // ErrRevoked: the enrollment token or agent credential was revoked. ErrRevoked = errors.New("remote: credential revoked") // ErrCredentialInvalid: an agent credential is malformed or unknown. ErrCredentialInvalid = errors.New("remote: invalid agent credential") // ErrPathOutsideRoot: a spec's workdir escapes the agent's configured // root. Enforced on the device, because the control plane cannot know the // device's filesystem, and re-checked as a hard boundary rather than a // convention. ErrPathOutsideRoot = errors.New("remote: workdir escapes agent root") // ErrAgentNotFound: no agent with the requested ID is enrolled. ErrAgentNotFound = errors.New("remote: agent not enrolled") // ErrRevocationUnsupported: the agent speaks a protocol version older // than MinRevocationVersion, so material handed to it could never be // taken back mid-run. Placing a workload that carries revocable secrets // fails with this rather than proceeding without the guarantee. // // It wraps executor.ErrRevocationUnsupported so a caller that has no // reason to know which driver refused — the run panel reporting why a // task would not start — can match the one sentinel and still get this // driver's far more specific message. ErrRevocationUnsupported = fmt.Errorf("remote: agent does not support lease revocation: %w", executor.ErrRevocationUnsupported) // ErrWorkspaceUnsupported: the agent speaks a protocol version older than // MinWorkspaceVersion, so it would ignore the workspace credential and run // the harness against an empty tree. Placing a workload whose source has to // be cloned fails with this rather than producing a run that looks fine and // operated on no code. ErrWorkspaceUnsupported = errors.New("remote: agent does not support workspace provisioning") // ErrSecretFilesUnsupported: the agent speaks a protocol version older than // MinSecretFilesVersion, so it would ignore the credential files and run the // harness with an environment naming paths nothing ever created. Placing a // workload whose secret lease delivers files fails with this rather than // producing a run whose git authentication fails for a reason nothing names. ErrSecretFilesUnsupported = errors.New("remote: agent does not support secret credential files") // ErrLeaseNotHeld: the agent was asked to revoke a lease it is not // holding. It is reported, not raised — "the material is not here" is // the end state a revocation wants — so callers treat it as success // with a note rather than as something to retry. ErrLeaseNotHeld = errors.New("remote: agent is not holding this lease") )
Sentinel errors for the remote executor. Callers match with errors.Is; implementations wrap these with %w and add detail.
The distinction that matters most operationally is between ErrAgentUnreachable and everything else. A control plane that hangs waiting for a NAT'd device that went offline is indistinguishable, from the UI, from a workload that is simply slow — so Start fails fast with this specific error and the caller can say "edge-1 is offline" instead of spinning.
Functions ¶
func HeartbeatDeadline ¶
HeartbeatDeadline is how long the control plane waits for traffic from an agent before marking it unreachable. It is derived from the protocol constants rather than written as a literal so the two can never drift.
func MintBundle ¶
func MintBundle(store Store, opts MintOptions) (Bundle, EnrollmentRecord, error)
MintBundle mints an enrollment token and wraps it, with the hub's pin, into the blob the operator carries to the device.
It is a thin layer over Mint rather than a replacement so the storage-facing contract (single-use, hash-only persistence, MAC-bound tokens) stays in one place and is exercised by both paths.
func NegotiateVersion ¶
NegotiateVersion returns the version two peers should speak, or an error when their supported ranges do not overlap. Taking the lower of the two advertised maxima is what lets a newer control plane keep serving older agents through a staged fleet upgrade.
func NewPipe ¶
NewPipe returns two Conns wired to each other. Frames written to one are readable from the other. Closing either closes both, matching how a real socket behaves.
func Revoke ¶
Revoke invalidates an enrollment token or an agent credential by ID.
The same command handles both because an operator responding to a leak knows the ID they were given, not which table it lives in. An enrollment ID additionally revokes the agent it produced: a token that leaked before redemption and was redeemed by an attacker must not leave that attacker's credential live.
func SupportsResumeTerminate ¶
SupportsResumeTerminate reports whether an agent speaking this protocol version acts on an explicit ResumeAck.Action rather than reading only membership of Welcome.ResumeAccepted.
func SupportsRevocation ¶
SupportsRevocation reports whether an agent speaking this protocol version honours the revoke frame.
func SupportsSecretFiles ¶
SupportsSecretFiles reports whether an agent speaking this protocol version receives a lease's credential file contents and places them for the workload.
func SupportsWorkspaceProvisioning ¶
SupportsWorkspaceProvisioning reports whether an agent speaking this protocol version materialises a git workspace before starting the harness.
func SupportsWriteBack ¶
SupportsWriteBack reports whether an agent speaking this protocol version returns the work product after a task finishes.
func ValidateSecretFiles ¶
func ValidateSecretFiles(files []SecretFile) error
ValidateSecretFiles checks a start payload's credential files: each one individually, plus the aggregate size no single file can see.
Both sides call it. The hub calls it before dispatch, so a lease it could never deliver fails with a message naming the files instead of producing a frame the device silently drops; the agent calls it at decode, before a byte of it reaches a filesystem.
Types ¶
type AcceptOptions ¶
type AcceptOptions struct {
// Agent is the already-authenticated identity of the peer. The hello
// frame's AgentID is checked against it; a mismatch is fatal.
Agent AgentRecord
// Executor is the durable driver this session attaches to.
Executor *Executor
// IssuedCredential is non-empty when this connection redeemed an
// enrollment token, in which case the credential is handed to the agent
// in the welcome frame. A freshly enrolling agent does not yet know its
// own ID, so the hello identity check is relaxed for exactly this case.
IssuedCredential string
// Now overrides the clock for tests.
Now func() time.Time
// HeartbeatPoll overrides how often the watchdog checks for silence.
// Zero uses half the heartbeat interval, which keeps detection latency a
// fraction of the deadline. Tests set it small so a timeout transition
// can be exercised in milliseconds against a synthetic clock instead of
// waiting out a real 45-second deadline.
HeartbeatPoll time.Duration
}
AcceptOptions parameterises the server-side handshake.
type AgentCapabilities ¶
type AgentCapabilities struct {
OS string `json:"os"`
Arch string `json:"arch"`
// CPUs is the number of logical cores visible to the agent.
CPUs int `json:"cpus,omitempty"`
// MemoryMB is total system memory in megabytes; 0 when undetectable.
MemoryMB int `json:"memory_mb,omitempty"`
// ContainerRuntimes lists runtimes found on PATH ("docker", "podman"),
// so the control plane can route container workloads to a device that
// can actually honour them.
ContainerRuntimes []string `json:"container_runtimes,omitempty"`
// Harnesses lists agent CLIs found on PATH ("claude", "codex", ...). A
// device without the harness a project needs should not be sent its work.
Harnesses []string `json:"harnesses,omitempty"`
// WorkDirRoot is the directory the agent confines every workload to.
// Reported so an operator can see the sandbox boundary from the UI.
WorkDirRoot string `json:"workdir_root,omitempty"`
// WorkspaceProvisioning reports that this device can materialise a git
// workspace itself — it has git on PATH and a build that knows how to use
// it. Advertised rather than assumed because the failure it prevents is
// silent: a device without git would answer the start frame and run the
// harness against an empty tree.
WorkspaceProvisioning bool `json:"workspace_provisioning,omitempty"`
// WriteBack reports that this device can return a finished task's work
// product — it has git and a build that knows how to commit and bundle.
// Advertised for the same reason WorkspaceProvisioning is, and against the
// same silent failure at the other end of the run: a device that cannot
// write back does not say so, it just keeps the work.
WriteBack bool `json:"write_back,omitempty"`
// MaxConcurrent is the agent's ceiling on simultaneous workloads.
MaxConcurrent int `json:"max_concurrent,omitempty"`
// Labels are free-form selectors (region, site, gpu) set by the operator.
Labels map[string]string `json:"labels,omitempty"`
}
AgentCapabilities describes the device, so the control plane can decide whether a workload belongs here. It is a superset of executor.Capabilities: the extra fields (CPU count, memory, container runtimes, harnesses) are what a scheduler needs and an in-process driver never had to report.
func (AgentCapabilities) Executor ¶
func (c AgentCapabilities) Executor() executor.Capabilities
Executor projects the device's advertised capabilities onto the driver-independent executor.Capabilities the rest of cloop reasons about.
Isolation is always IsolationRemote and SharesHostFilesystem always false: whatever the device does internally, from the control plane's perspective the workload is on another machine and Spec.WorkDir is not a path it can read. Claiming otherwise would let host-side tooling silently look in the wrong place.
type AgentRecord ¶
type AgentRecord struct {
AgentID string `json:"agent_id"`
Name string `json:"name"`
// SecretHash is hex-encoded SHA-256 of the credential secret.
SecretHash string `json:"secret_hash"`
WorkDirRoot string `json:"workdir_root,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen,omitempty"`
RevokedAt time.Time `json:"revoked_at,omitempty"`
// EnrollmentID records which token minted this agent, so revoking a
// leaked token can also reach the identity it produced.
EnrollmentID string `json:"enrollment_id,omitempty"`
}
AgentRecord is the control plane's durable view of one enrolled agent and its long-lived credential.
func Authenticate ¶
Authenticate verifies a long-lived agent credential and returns the agent it belongs to. It is called on every reconnect, so revoking an agent takes effect the next time it dials rather than requiring the control plane to hunt down a live session — though the session layer also drops live connections on revoke, so both paths are covered.
func Redeem ¶
func Redeem(store Store, token string, opts RedeemOptions) (credential string, agent AgentRecord, err error)
Redeem exchanges an enrollment token for a long-lived agent credential.
It is the security-critical path, so the order of checks is deliberate: format and MAC first (cheap, no storage access), then existence, then revocation, then expiry, then the secret comparison, and only then the atomic single-use claim. Verifying the secret before claiming means a wrong secret cannot burn a legitimate token; claiming atomically after means two correct-secret racers cannot both succeed.
The returned credential string is shown once and stored only as a hash.
func (AgentRecord) Revoked ¶
func (r AgentRecord) Revoked() bool
Revoked reports whether the agent's credential was revoked.
type Bundle ¶
type Bundle struct {
// Server is the control-plane WebSocket URL the device dials.
Server string `json:"server"`
// Token is the single-use enrollment token.
Token string `json:"token"`
// Pin is the hub's SPKI fingerprint ("sha256:<base64>"), or "" when the
// control plane has no TLS certificate configured. An empty pin is not a
// silent downgrade: the device still refuses plaintext to a non-loopback
// host, so an unpinned bundle can only be used against a server with a
// publicly-trusted certificate, or over loopback.
Pin string `json:"pin,omitempty"`
// EnrollmentID identifies the minted token for revocation.
EnrollmentID string `json:"enrollment_id"`
// Name is the operator-facing label the device will carry.
Name string `json:"name"`
// WorkDirRoot is the filesystem root the device confines workloads to.
WorkDirRoot string `json:"workdir_root,omitempty"`
// ExpiresAt is when the token stops being redeemable.
ExpiresAt time.Time `json:"expires_at"`
}
Bundle is the enrollment payload handed to a device.
It is deliberately JSON with an unpadded-base64 envelope rather than a bespoke format: an operator pastes it into a terminal, a provisioning script pipes it through a file, and both need it to survive being a single opaque word with no shell-special characters in it.
func DecodeBundle ¶
DecodeBundle parses the output of Encode.
func ReadTokenFile ¶
ReadTokenFile loads enrollment material from a file, accepting either a bundle or a bare token.
This is what lets an installed service keep the token out of its command line (see pkg/executor/install): a unit file is world-readable and `systemctl show` prints ExecStart to anyone, so the credential travels as a path and is read here instead.
Accepting both forms is not laxity — an operator with a bare token from an older hub, and a provisioning system writing the bundle the dashboard displays, should both work without the operator having to know which one they hold. The prefix makes the two unambiguous.
warning is non-empty when the file is readable by anyone but its owner. It is a warning rather than a refusal for the reason set out in the agent's credential loader: bricking a fleet because a provisioning script ran with the wrong umask is a worse outcome than the exposure, and the operator can act on a warning by revoking the token.
func (Bundle) Command ¶
Command renders the copy-pasteable device command for this bundle.
The pin is included as a flag rather than left to the bundle blob because the flag form is what an operator reads, checks against what the control plane printed, and notices when it is missing.
type ByePayload ¶
type ByePayload struct {
Reason string `json:"reason,omitempty"`
// Reconnect tells the peer whether to come back. False means the
// credential was revoked or the agent deregistered, so an agent that
// obeys it stops hammering a control plane that will never accept it.
Reconnect bool `json:"reconnect"`
}
ByePayload announces an orderly shutdown.
func DecodeBye ¶
func DecodeBye(f Frame) (ByePayload, error)
type Conn ¶
type Conn interface {
// ReadFrame blocks until a frame arrives, ctx is cancelled, or the peer
// goes away (io.EOF / net.ErrClosed).
ReadFrame(ctx context.Context) (Frame, error)
// WriteFrame sends one frame.
WriteFrame(ctx context.Context, f Frame) error
// Close terminates the connection. Idempotent.
Close(reason string) error
}
Conn is a bidirectional, ordered, message-framed connection.
Ordering is load-bearing: the log-resume design assumes that a chunk sent before a terminal status frame arrives before it, which is what lets the control plane close a log stream on terminal status without worrying about output still in flight. A transport that reorders would break that, so any future implementation must preserve message order.
Implementations must be safe for one concurrent reader and one concurrent writer. They are not required to tolerate concurrent readers or concurrent writers; the session serialises both.
type EnrollmentRecord ¶
type EnrollmentRecord struct {
ID string `json:"id"`
Name string `json:"name"`
// SecretHash is hex-encoded SHA-256 of the token secret.
SecretHash string `json:"secret_hash"`
WorkDirRoot string `json:"workdir_root,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
CreatedBy string `json:"created_by,omitempty"`
// RedeemedAt is zero until redemption. Non-zero is what makes the token
// single-use: redemption is a conditional update that only succeeds while
// this is still zero, so two concurrent redemptions cannot both win.
RedeemedAt time.Time `json:"redeemed_at,omitempty"`
RedeemedAgentID string `json:"redeemed_agent_id,omitempty"`
RevokedAt time.Time `json:"revoked_at,omitempty"`
}
EnrollmentRecord is the control plane's durable view of one minted token. The secret itself is absent by construction — only SecretHash is persisted.
func Mint ¶
func Mint(store Store, opts MintOptions) (token string, rec EnrollmentRecord, err error)
Mint creates a single-use enrollment token, persists its hash, and returns the token string. The returned string is the only time the secret exists outside the operator's terminal — it is not recoverable from the database.
func (EnrollmentRecord) Expired ¶
func (r EnrollmentRecord) Expired(now time.Time) bool
Expired reports whether the token's TTL has elapsed as of now.
func (EnrollmentRecord) Redeemed ¶
func (r EnrollmentRecord) Redeemed() bool
Redeemed reports whether the token has already been used.
func (EnrollmentRecord) Revoked ¶
func (r EnrollmentRecord) Revoked() bool
Revoked reports whether the token was explicitly revoked.
type ErrorPayload ¶
ErrorPayload reports a request-scoped failure.
func DecodeError ¶
func DecodeError(f Frame) (ErrorPayload, error)
type Executor ¶
type Executor struct {
// contains filtered or unexported fields
}
Executor proxies executor.Executor calls to a remote agent.
func NewExecutor ¶
NewExecutor builds a remote executor for an enrolled agent. It starts disconnected: Start fails with ErrAgentUnreachable until the device dials in and a session attaches.
When Options.HandleStore is set it also rehydrates: every workload this executor ID dispatched before the process restarted is put back into the handle map, so the agent's reconnect finds a control plane that still recognises its work. That happens here, synchronously, because the agent may dial in the moment the hub's listener opens and a resume offer that races rehydration would be refused — which now means killed.
func (*Executor) AgentCapabilities ¶
func (e *Executor) AgentCapabilities() AgentCapabilities
AgentCapabilities reports what the device advertised, including the fields (CPU count, memory, container runtimes, harnesses) that the driver-agnostic executor.Capabilities has no room for.
func (*Executor) AgentInventory ¶
func (e *Executor) AgentInventory() (AgentCapabilities, bool)
AgentInventory returns the full capability advertisement of the connected agent, and whether there was a session to ask.
The bool matters because the zero AgentCapabilities is indistinguishable from a device that genuinely reported nothing, and rendering "0 CPUs, no harnesses" for a merely-offline device would invent a fault.
func (*Executor) AgentVersion ¶
AgentVersion reports the cloop build the connected agent advertised, or "" if the device is not currently connected.
An empty string from a *connected* agent is meaningful in itself: it is a build from before agents reported a version at all.
func (*Executor) AttachHandleStore ¶
func (e *Executor) AttachHandleStore(store executor.HandleStore)
AttachHandleStore installs the durable handle store after construction and rehydrates from it immediately.
It exists because of boot order rather than convenience: the hub builds an executor per enrolled agent from the enrollment store, which is open long before the state database that backs handle persistence. Calling this later is equivalent to having passed Options.HandleStore, because adoption is idempotent — a handle already in the map is left alone — so a caller that does both, or that re-attaches on a config reload, adopts each workload once.
A nil store is ignored rather than clearing the current one. "Forget how to recognise your device's workloads" is not something a caller should be able to ask for by passing a zero value, and the consequence of granting it would be that the next reconnect terminates every run in flight.
func (*Executor) Capabilities ¶
func (e *Executor) Capabilities() executor.Capabilities
Capabilities implements executor.Executor.
Workspace provisioning is reported as the *intersection* of what the device advertised and what the live session can actually carry. A device with git on PATH is no use if the connection cannot hand it a credential, and claiming otherwise would let placement route a private-repository run to an agent that would silently start the harness in an empty directory.
When there is no session the last advertised value stands, which is deliberate and matches Hub.Restore's reasoning: an enrolled device that is merely offline should place and then fail with the truthful ErrAgentUnreachable, not vanish from placement behind "no executor can provision a workspace".
func (*Executor) ConnStatus ¶
Status reports the executor-level connectivity status (not a handle's).
func (*Executor) HandleStatuses ¶
HandleStatuses implements executor.Lister from the control plane's last-known view of the agent, without dialling the device.
Not round-tripping is the point: this feeds the Executors panel's load column, and an offline agent behind NAT would otherwise make that column block until the request timed out. The cached view is refreshed by the status frames the agent pushes as work progresses, so it is current for a connected agent and honestly stale for a disconnected one — which is what the card's status dot is already telling the operator.
func (*Executor) HealthCheck ¶
HealthCheck implements executor.Executor. It is a liveness question, and for a NAT'd device the only honest answer is "is the agent currently connected and beating", since the control plane cannot probe it.
func (*Executor) HoldsLease ¶
HoldsLease reports whether any handle this executor tracks was started with material from leaseID.
It answers true for an executor with any *unresolved* handle too, whatever the lease — a workload rehydrated from a row that did not record its bindings might be holding this one, and nothing here can tell. That routes the revocation to RevokeLease, which reports the doubt. See executor.LeaseIndex.Holds.
func (*Executor) LogGapped ¶
LogGapped reports whether output was permanently lost for a handle, so a consumer can label a partial log instead of presenting it as complete.
func (*Executor) ProtocolVersion ¶
ProtocolVersion reports the version the live session negotiated, or 0 when the agent is not connected. The Executors panel shows it so "this device is too old for revocable secrets" is visible before a run fails.
func (*Executor) Revocations ¶
func (e *Executor) Revocations() []RevokeResult
Revocations reports this executor's revocation log for the UI.
func (*Executor) RevokeLease ¶
func (e *Executor) RevokeLease(ctx context.Context, p RevokePayload) RevokeResult
RevokeLease takes one lease's material back from this executor's agent.
It always returns a result, even on failure: the caller's next move depends on *which* failure, and an error alone cannot say whether the material is gone, in doubt, or definitely still out there. The log entry is written before the frame goes out so a revocation is never lost to a crash between the two.
Unlike the hub's own drivers this does *not* fail on an unresolved binding, and the asymmetry is deliberate. Those drivers' lease index is the only record that a workload was ever handed a credential, so doubt there is unresolvable. Here the device keeps an index of its own, in a process the hub's restart did not touch, and the frame reaches it either way — so the agent's ack is a *better* answer than anything the hub could reconstruct, and Known comes from the machine actually holding the material. What the unresolved mark still buys is the ask: HoldsLease answers true for it, so the frame is sent rather than the executor skipped.
func (*Executor) Signal ¶
Signal implements executor.Executor.
Signalling a handle the control plane has already seen terminate returns nil without a round trip: the desired end state already holds, and bothering an LTE-connected device to tell it so is pure cost.
func (*Executor) Start ¶
func (e *Executor) Start(ctx context.Context, spec executor.Spec) (handle executor.Handle, err error)
Start implements executor.Executor by dispatching the spec to the device.
It fails fast rather than queueing. A control plane that buffers work for an offline device looks identical, from the UI, to one that is merely slow — and the work may sit there until the device returns days later, by which point the run is meaningless. ErrAgentUnreachable lets the caller say "edge-1 is offline" immediately.
The named returns are what let the workspace audit's end event observe the outcome from a defer: provisioning is bracketed by two rows and the second one has to say whether the dispatch it describes worked, from whichever of this function's several exits was taken.
func (*Executor) Status ¶
Status implements executor.Executor.
When the agent is unreachable this returns the last known status with State forced to StateUnknown rather than an error. That is the documented meaning of StateUnknown, and it matters for the UI: a run whose device dropped off should render as "state unknown, last seen running" instead of vanishing behind an error banner.
func (*Executor) Stream ¶
Stream implements executor.Executor. Output arrives as log_chunk frames and is republished through the same logbus every other driver uses, so the dropped-chunk and late-subscriber semantics are identical regardless of where the workload runs.
func (*Executor) SupportsRevocation ¶
SupportsRevocation reports whether the currently attached agent honours the revoke frame. False for a disconnected executor: with no session there is nothing that could honour anything.
func (*Executor) SupportsWorkspaceProvisioning ¶
SupportsWorkspaceProvisioning reports whether the currently attached agent can materialise a git workspace: it must have advertised the capability *and* speak a protocol version that can carry the credential. False for a disconnected executor, since with no session there is nothing to ask.
func (*Executor) SupportsWriteBack ¶
SupportsWriteBack reports whether the currently attached agent can return a finished task's work product.
Both halves have to hold: the session's negotiated version has to carry the result frames, and the device has to have advertised that it can produce a bundle. A device with git but an old build, and a new build on a device without git, fail in the same silent way — the work stays on the device — so neither is allowed to look like support.
func (*Executor) WriteBackBundle ¶
WriteBackBundle implements executor.WriteBackFetcher.
The bytes are released to the caller and dropped here in the same breath. Holding them after the consumer has taken them would keep a bundle alive for as long as the handle is retained, which for a finished workload is long enough to matter across a fleet.
type ExpiredLease ¶
ExpiredLease is one lapsed lease the janitor should take back.
type ExpiredLeaseSweeper ¶
type ExpiredLeaseSweeper func(now time.Time) []ExpiredLease
ExpiredLeaseSweeper is the callback a janitor uses to find leases whose TTL has run out. It returns the lease IDs that should be taken back, along with a human-readable reason for the audit trail.
It is a callback rather than a lease store because leases are minted by whoever is spawning work (pkg/ui), not by this package: the hub knows which agent holds which lease, and the caller knows when each lease expires. Neither half can sweep alone.
type Frame ¶
type Frame struct {
// V is the protocol version of this frame.
V int `json:"v"`
// Type discriminates Payload.
Type FrameType `json:"type"`
// ID correlates a response with its request. Empty for unsolicited
// frames such as log chunks and terminal status reports.
ID string `json:"id,omitempty"`
// Handle scopes the frame to one workload. Empty for session-level
// frames (hello, welcome, heartbeat, bye).
Handle string `json:"handle,omitempty"`
// Payload is the type-specific body, decoded by the Decode* helpers.
Payload json.RawMessage `json:"payload,omitempty"`
}
Frame is the envelope for every message in both directions.
func NewFrame ¶
NewFrame builds a frame of the current protocol version with payload marshalled. A payload that cannot be marshalled is a programming error in the caller, so it is returned rather than silently dropped.
func NewFrameAt ¶
NewFrameAt builds a frame stamped with an explicit protocol version.
Once a session has negotiated a version, every frame it sends must carry that version and not this build's maximum. The receiver validates the envelope version against its own supported range, so a v2 control plane that stamped v2 onto a frame for a v1 agent would have every frame rejected as out of range — the negotiation would be decorative. Versions above the negotiated one are clamped rather than trusted, so a caller cannot accidentally widen a session past what the peer agreed to.
func (Frame) String ¶
String renders a frame for a diagnostic, without its payload.
The omission is the point. Since protocol v3 a start frame's payload carries the brokered credential for a workspace fetch (see StartPayload.WorkspaceCredential), and since v6 the plaintext of the lease's credential files as well (StartPayload.SecretFiles) — so a single `%v` on a Frame in some future log line would write live tokens into the control plane's log and, on the device, into the agent's. What identifies a frame is its type, its correlation ID, its handle and its size; none of those is a secret, and nothing else belongs in a log line anyway.
type FrameType ¶
type FrameType string
FrameType discriminates the payload. Values are strings rather than integers so that a packet capture or a debug log is readable without a decoder ring.
const ( // TypeHello (agent → control plane) opens a session: protocol version, // agent identity, advertised capabilities, and any handles the agent is // still running from a previous connection. TypeHello FrameType = "hello" // TypeWelcome (control plane → agent) accepts a session and states the // negotiated version and heartbeat interval. TypeWelcome FrameType = "welcome" // TypeHeartbeat (agent → control plane) proves liveness and reports // which handles the agent still considers running. TypeHeartbeat FrameType = "heartbeat" // TypeHeartbeatAck (control plane → agent) confirms receipt. An agent // that stops seeing acks knows the path is broken in the return // direction, which a send-only heartbeat could not detect. TypeHeartbeatAck FrameType = "heartbeat_ack" // TypeStart (control plane → agent) dispatches a workload. TypeStart FrameType = "start" // TypeStarted (agent → control plane) answers a start with the handle or // the reason it could not run. TypeStarted FrameType = "started" // TypeSignal (control plane → agent) asks the agent to signal a handle. TypeSignal FrameType = "signal" // TypeLogChunk (agent → control plane) carries workload output with the // byte offset it starts at, which is what makes resume possible. TypeLogChunk FrameType = "log_chunk" // TypeLogAck (control plane → agent) reports the highest contiguous byte // offset durably received, releasing the agent's retained buffer. TypeLogAck FrameType = "log_ack" // TypeStatusReq (control plane → agent) requests a status refresh. TypeStatusReq FrameType = "status_req" // TypeStatus (agent → control plane) reports a handle's state; sent both // on request and unsolicited when a workload terminates. TypeStatus FrameType = "status" // TypeResultChunk (agent → control plane) carries a slice of the git // bundle holding a finished task's work product, with the byte offset it // starts at. Added in protocol v4. // // It is a separate frame from TypeLogChunk rather than a stream flag on it // because the two have opposite requirements. Log output is lossy on // purpose — a subscriber that falls behind has chunks dropped so the // workload is never blocked — while a bundle with a hole in it is not a // smaller bundle, it is not a bundle. So this one is offset-checked, // size-capped and digest-verified end to end, and a gap fails the // write-back instead of being tolerated. TypeResultChunk FrameType = "result_chunk" // TypeResult (agent → control plane) closes a write-back: the branch, the // commit SHA, the bundle's length and digest, or the reason there is // nothing to send. Added in protocol v4. // // It is what the hub waits for — chunk delivery alone can never signal // completeness, since "no more chunks" and "the link dropped" look // identical from the receiving end. // // The frame order for a finished workload is fixed and load-bearing: // result chunks, then this frame, then the terminal TypeStatus. The status // frame is what closes the log stream, and executor.Run reads Status the // instant that stream closes — so a write-back arriving after it would be // recorded on a handle whose consumer had already returned. Sending it // before means the ordering is a property of the agent's control flow // rather than of a flag both sides have to agree about. TypeResult FrameType = "result" // TypeRevoke (control plane → agent) takes one secret lease back from a // running task. Added in protocol v2. TypeRevoke FrameType = "revoke" // TypeRevoked (agent → control plane) acknowledges a revoke, reporting // what was actually scrubbed. The ack is what lets the UI distinguish // "revoked" from "revoke pending" from "unreachable" — a fire-and-forget // revoke could only ever claim the first. TypeRevoked FrameType = "revoked" // TypeBye (either direction) announces an orderly shutdown, so the peer // can distinguish a planned disconnect from a dropped link. TypeBye FrameType = "bye" // TypeError (either direction) reports a request-scoped failure without // tearing down the session. TypeError FrameType = "error" )
type HeartbeatAckPayload ¶
type HeartbeatAckPayload struct {
Seq uint64 `json:"seq"`
// ServerTime lets the agent log clock skew, which is the usual cause of
// otherwise inexplicable credential-expiry failures on edge devices with
// no RTC.
ServerTime time.Time `json:"server_time"`
}
HeartbeatAckPayload confirms a heartbeat.
func DecodeHeartbeatAck ¶
func DecodeHeartbeatAck(f Frame) (HeartbeatAckPayload, error)
type HeartbeatPayload ¶
type HeartbeatPayload struct {
// Seq increments per heartbeat, letting the control plane log gaps.
Seq uint64 `json:"seq"`
// ActiveHandles is what the agent believes is still running. The control
// plane reconciles: a handle it thinks is running but the agent has
// forgotten is resolved as failed rather than left hanging forever.
ActiveHandles []string `json:"active_handles,omitempty"`
// LoadPercent is a coarse 0-100 busy signal for scheduling; -1 unknown.
LoadPercent int `json:"load_percent,omitempty"`
}
HeartbeatPayload proves liveness.
func DecodeHeartbeat ¶
func DecodeHeartbeat(f Frame) (HeartbeatPayload, error)
type HelloPayload ¶
type HelloPayload struct {
// ProtocolVersion is the newest version the agent speaks. The control
// plane replies with the version actually negotiated.
ProtocolVersion int `json:"protocol_version"`
// AgentID is the identity the credential was issued to. It is checked
// against the authenticated identity, never trusted on its own — a frame
// claiming to be another agent is a session-fatal error.
AgentID string `json:"agent_id"`
// Name is the operator-facing label chosen at enrollment.
Name string `json:"name,omitempty"`
// AgentVersion is the cloop build running on the device, for diagnosing
// version-skew problems from the control plane.
AgentVersion string `json:"agent_version,omitempty"`
// Capabilities is what this device can run; the scheduler matches on it.
Capabilities AgentCapabilities `json:"capabilities"`
// Resume lists workloads still running from a previous connection,
// with the offset each one's log stream reached. A reconnecting agent
// uses this to hand the control plane back its in-flight work instead of
// orphaning it.
Resume []ResumeHandle `json:"resume,omitempty"`
}
HelloPayload opens a session.
func DecodeHello ¶
func DecodeHello(f Frame) (HelloPayload, error)
type Hub ¶
type Hub struct {
// contains filtered or unexported fields
}
Hub accepts agent connections and maintains the executors they back.
func NewHub ¶
func NewHub(opts HubOptions) (*Hub, error)
NewHub builds a hub. Executors for previously-enrolled agents are created lazily on first connect rather than at startup: an executor with no session would advertise itself as bindable while being unable to run anything, and Restore exists for callers that do want them present-but-offline.
func (*Hub) Deregister ¶
Deregister removes an agent's executor from the registry entirely.
func (*Hub) LeaseHolders ¶
LeaseHolders reports which connected agents hold a lease, so a caller can tell "nobody has this" apart from "three devices have it and two are offline" before deciding what to promise the operator.
func (*Hub) Restore ¶
Restore registers an offline executor for every enrolled, unrevoked agent.
This is what makes a control-plane restart survivable from the operator's point of view: without it, a project bound to edge-1 fails Resolve with "executor not registered" until the device happens to reconnect, which reads as a configuration error rather than as "the device is offline". With it, the executor exists and fails Start with ErrAgentUnreachable — the truthful and actionable error.
func (*Hub) Revocations ¶
func (h *Hub) Revocations() []RevokeResult
Revocations reports the whole fleet's revocation log for the Secrets panel.
func (*Hub) Revoke ¶
Revoke revokes an agent's credential and immediately drops its live session.
Both halves are necessary. Revoking only in storage would leave an already connected device running work until it happened to reconnect — which, for a long-lived outbound WebSocket, could be never. Dropping the session without revoking would let it reconnect a second later.
func (*Hub) RevokeExecutorLeases ¶
func (h *Hub) RevokeExecutorLeases(ctx context.Context, executorID, reason string, action RevokeAction) []RevokeResult
RevokeExecutorLeases takes back every lease an executor is holding.
This is the cordon/drain trigger. Taking a device out of rotation is an operator saying "I no longer trust this to be running my work" — most often because it is being decommissioned, has misbehaved, or is suspected compromised. Leaving it holding live credentials until its in-flight tasks happen to finish would answer that with "in a few hours".
func (*Hub) RevokeLease ¶
func (h *Hub) RevokeLease(ctx context.Context, p RevokePayload) []RevokeResult
RevokeLease takes one secret lease back from every agent holding it.
The fan-out is over executors that actually hold the lease rather than over all of them: a revoke frame to a device that was never given the credential is noise on someone's LTE uplink, and its "not known here" ack would clutter the panel with rows that mean nothing.
An empty result is not an error. It means no connected agent was holding the lease — the ordinary case for a hub-local executor, whose material the caller wipes directly. The caller decides what to say about that; this method's job is to report exactly what the remote fleet did.
func (*Hub) ServeHTTP ¶
func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP upgrades an agent connection and runs its session.
Authentication is by bearer token in the Authorization header. Two token shapes are accepted and they mean different things:
- an enrollment token (clet1.…) is redeemed here, single-use: the agent receives its long-lived credential in the welcome frame;
- an agent credential (clac1.…) authenticates a reconnect.
Failures return 401 with no detail about which check failed. The agent's own error frame carries a usable message once the connection is up, but an unauthenticated caller probing this endpoint learns nothing about whether a token exists, is expired, or was already redeemed.
The Origin check runs first, ahead of any token handling. That ordering is load-bearing, not tidiness: redemption is single-use and destructive, so a cross-origin request that reached Redeem would consume the operator's enrollment token — turning a refused connection into a token that must be re-minted. Checking origin first means a rejected request changes no state.
func (*Hub) StartLeaseJanitor ¶
func (h *Hub) StartLeaseJanitor(ctx context.Context, interval time.Duration, sweep ExpiredLeaseSweeper) (stop func())
StartLeaseJanitor sweeps expired leases off live agents until ctx is done.
This is the second of the three revocation triggers, and it is the one that makes a TTL mean anything. Before it, Lease.Expired was consulted only by the caller that minted the lease: an executor handed the material simply kept it, so a fifteen-minute TTL bounded nothing for a task that ran for three hours. Sweeping *live sessions* rather than re-checking at mint time is the whole difference.
It returns a stop function so a caller that owns the hub's lifetime can shut the janitor down deterministically instead of leaking a ticker goroutine per server restart in tests.
type HubOptions ¶
type HubOptions struct {
// Store is the enrollment/credential persistence. Required.
Store Store
// Registry is where connected agents are registered as executors. Nil
// uses executor.DefaultRegistry.
Registry *executor.Registry
// OnStatusChange mirrors executor connectivity into durable storage. It
// is a callback rather than a *statedb.DB so this package stays free of
// a storage dependency, matching how the registry takes a binding lookup.
OnStatusChange func(executorID, status string, at time.Time)
// OnEnroll is called after a device successfully redeems an enrollment
// token, so the caller can write an executors-table row for it.
OnEnroll func(agent AgentRecord, caps AgentCapabilities)
// OnConnect is called after *every* successful handshake, including the
// one that enrolled the device, carrying what the agent just advertised
// about itself.
//
// It exists because OnEnroll fires once, ever. A device's build version
// and capabilities are exactly the facts that change when an operator
// upgrades it, so recording them only at enrollment meant the stored copy
// was frozen at whatever the device was running the day it joined —
// reproducing, in the storage layer, the same staleness the hardcoded
// agent version produced on the wire. An upgraded device would have gone
// on being reported as its year-old self.
//
// Distinct from OnEnroll rather than replacing it: enrollment is an
// auditable one-time event that also establishes CreatedAt and the minting
// token, and collapsing the two would either lose that or re-assert it on
// every reconnect.
OnConnect func(agent AgentRecord, caps AgentCapabilities, agentVersion string)
// OnRevokeAck receives an agent's acknowledgement of a lease revocation,
// so the caller can write the audit row. It fires for replayed
// revocations too — the ones delivered long after the operator pressed
// the button — which is the only signal that a queued revocation landed.
OnRevokeAck func(executorID, leaseID string, ack RevokedPayload)
// HandleStore persists handle identity for every executor this hub builds,
// so a restart still recognises the workloads its devices are running
// (Task 20191). Nil is the pre-Task-20191 behaviour; see
// Options.HandleStore for what that costs.
//
// One store for the whole hub rather than one per agent: rows are scoped by
// executor ID inside the store, and a per-agent factory — the shape
// WorkspaceSource needs, because a *grant* is issued to a subject — would
// invite an implementation that scoped by something else and let one
// device's executor adopt another's handles.
HandleStore executor.HandleStore
// WorkspaceSource builds the credential source for one agent's executor.
//
// It is a factory rather than a single source because a grant is issued to
// a *subject*: pkg/executor/gitcreds binds the executor ID at construction
// so that a lease taken for edge-1 can never be satisfied by a grant issued
// to edge-2. Nil leaves every executor without one, in which case a
// workload naming a grant is refused rather than dispatched to fetch a
// private repository anonymously.
WorkspaceSource func(executorID string) executor.WorkspaceCredentialSource
// ExternalURL is what this deployment calls itself, e.g.
// https://cloop.example.com. Its host is always an accepted WebSocket
// Origin, which is what makes the Executors panel work when a reverse
// proxy rewrites Host so the same-origin check cannot fire.
ExternalURL string
// AllowedOrigins lists additional browser Origins permitted to open an
// agent WebSocket, on top of loopback, same-origin, and ExternalURL.
// Entries may be full origins, host:port, or bare hosts.
AllowedOrigins []string
// Logf receives operational messages. Nil discards them.
Logf func(format string, args ...any)
// Now overrides the clock for tests.
Now func() time.Time
}
HubOptions configures a Hub.
type LogAckPayload ¶
type LogAckPayload struct {
// Offset is the highest contiguous byte offset the control plane has
// accepted. The agent may discard everything below it.
Offset int64 `json:"offset"`
}
LogAckPayload releases the agent's retained output buffer.
func DecodeLogAck ¶
func DecodeLogAck(f Frame) (LogAckPayload, error)
type LogChunkPayload ¶
type LogChunkPayload struct {
Stream executor.StreamName `json:"stream"`
// Offset is the byte position of Text's first byte within this handle's
// output. Offsets, not sequence numbers, are the unit of acknowledgement:
// after a reconnect the agent may re-chunk the same bytes differently, so
// only a byte position identifies "where we got to" unambiguously.
Offset int64 `json:"offset"`
Text string `json:"text"`
// Time is the device's clock when the output was read.
Time time.Time `json:"time"`
}
LogChunkPayload carries workload output.
func DecodeLogChunk ¶
func DecodeLogChunk(f Frame) (LogChunkPayload, error)
func (LogChunkPayload) End ¶
func (p LogChunkPayload) End() int64
End returns the offset just past this chunk, i.e. the next expected offset.
type MintOptions ¶
type MintOptions struct {
// Name is the operator-facing label the agent will carry.
Name string
// TTL bounds redemption. Zero uses DefaultEnrollTTL; values above
// MaxEnrollTTL are rejected rather than silently clamped, because an
// operator who asked for a week should learn that the answer is no.
TTL time.Duration
// WorkDirRoot is the filesystem root the enrolled agent will confine
// every workload to. Empty lets the device choose its own default.
WorkDirRoot string
// Labels are free-form scheduler selectors.
Labels map[string]string
// CreatedBy identifies the minting operator (OIDC subject, or "" local).
CreatedBy string
// Server is the control-plane WebSocket URL to record in the enrollment
// bundle. Not persisted: it is what the operator carries to the device,
// not something the control plane needs to remember.
Server string
// Pin is the hub's SPKI fingerprint ("sha256:<base64>") to record in the
// bundle, so the device can tell its control plane apart from anything
// else that answers the same hostname. Also not persisted.
Pin string
// Now overrides the clock for tests.
Now func() time.Time
}
MintOptions parameterises token creation.
type Options ¶
type Options struct {
// ID is the registry key. It is the agent ID, so a project bound to an
// agent stays bound across reconnects and control-plane restarts.
ID string
// Name is the operator-facing label from enrollment.
Name string
// Capabilities is what the device advertised at hello.
Capabilities AgentCapabilities
// OnStatusChange, when set, is called whenever the executor transitions
// between online/offline/unreachable. The hub uses it to mirror status
// into statedb without this package importing storage.
OnStatusChange func(executorID, status string, at time.Time)
// OnRevokeAck, when set, is called when an agent acknowledges a lease
// revocation. It is a callback for the same reason OnStatusChange is:
// the audit trail lives in storage and this package must not depend on
// it. Replayed revocations report through here too, which is the only
// way an operator learns that a queued revocation finally landed.
OnRevokeAck func(executorID, leaseID string, ack RevokedPayload)
// Workspace leases the credential a git workspace fetch needs, at dispatch
// time and for the length of one start.
//
// It is an interface (executor.WorkspaceCredentialSource) rather than the
// broker itself so this package stays free of the hub's secret store —
// pkg/executor/agent imports pkg/executor/remote, and an edge binary must
// not carry a secret database because the control plane happens to have
// one. Nil means no credential can be leased: a workload naming a grant is
// refused with the same typed error the broker would have raised, rather
// than dispatched to fetch a private repository anonymously.
Workspace executor.WorkspaceCredentialSource
// HandleStore persists handle identity so a hub that restarts still knows
// which workloads it dispatched to this device (Task 20191). See
// rehydrate.go.
//
// Nil is the pre-Task-20191 behaviour and remains supported: the driver
// dispatches, streams, signals and reconciles exactly as it did. What it
// loses is the one thing only a durable row can supply — recognising a
// reconnecting agent's resume offer after a restart. Without it every offer
// is refused, and a refusal now stops the workload, so a storeless hub
// trades a leaked process for a lost run.
//
// It is a field rather than a constructor argument because a remote
// executor is built by the hub from an enrollment record, early and
// synchronously, while the state database that backs the store is opened
// later; AttachHandleStore installs it once that has happened.
HandleStore executor.HandleStore
// Now overrides the clock for tests.
Now func() time.Time
}
Options configures a remote executor.
type RedeemOptions ¶
RedeemOptions parameterises redemption.
type ResultChunkPayload ¶
type ResultChunkPayload struct {
// Offset is the byte position of Data's first byte within the bundle.
// Unlike a log chunk's offset this is not merely an acknowledgement
// token: the hub refuses a chunk that does not start exactly where the
// previous one ended, because a bundle assembled out of order or with a
// hole is corrupt in a way git will report as something else entirely.
Offset int64 `json:"offset"`
// Data is the raw slice. A []byte marshals to base64 in JSON, so no
// encoding decision is made here.
Data []byte `json:"data"`
}
ResultChunkPayload carries a slice of the work product's git bundle.
func DecodeResultChunk ¶
func DecodeResultChunk(f Frame) (ResultChunkPayload, error)
DecodeResultChunk decodes a bundle slice, refusing one that could not have come from a well-behaved agent.
The bounds are enforced at decode rather than at assembly so a malformed frame is rejected before its contents are ever appended to anything. An empty chunk is refused too: it advances nothing and would let a peer hold a write-back open indefinitely at no cost to itself.
func (ResultChunkPayload) End ¶
func (p ResultChunkPayload) End() int64
End returns the offset just past this chunk, i.e. the next expected offset.
type ResultPayload ¶
type ResultPayload struct {
// Result is the metadata: mode, branch, commit, digest, or the failure.
// It never contains bundle bytes — those arrived as chunks — for the same
// reason executor.Status does not: this struct is logged and persisted.
Result executor.WriteBackResult `json:"result"`
}
ResultPayload closes a write-back.
func DecodeResult ¶
func DecodeResult(f Frame) (ResultPayload, error)
DecodeResult decodes the frame that closes a write-back.
type ResumeAck ¶
type ResumeAck struct {
HandleID string `json:"handle_id"`
// FromOffset is the first byte the control plane still needs. The agent
// resends from here; anything below it was already delivered. Meaningless
// when Action is ResumeTerminate.
FromOffset int64 `json:"from_offset"`
// Action is the verdict. Empty means ResumeContinue, which is what a v4
// hub's acks decode to and is therefore the only backward-compatible
// default. Added in protocol v5.
Action ResumeAction `json:"action,omitempty"`
// Reason is operator-facing text explaining a refusal, carried into the
// device's log so "why did my task die" has an answer on the machine it
// died on and not only in the control plane's.
Reason string `json:"reason,omitempty"`
}
ResumeAck is the control plane's answer to one resume offer.
func (ResumeAck) Effective ¶
func (a ResumeAck) Effective() ResumeAction
Effective returns the action to apply, defaulting an absent or unknown value to ResumeContinue.
The default is the non-destructive one on purpose, and for the same reason RevokePayload.Effective defaults to scrub: an agent that met a control plane speaking a dialect it does not understand must fail towards keeping the work, because the workload is hours of compute and the ack is one field.
type ResumeAction ¶
type ResumeAction string
ResumeAction is the control plane's verdict on one resume offer.
It exists because absence used to be the only way to say no, and absence cannot be distinguished from an old hub, a truncated list, or a bug — so the agent's only safe reading of it was the destructive one. Naming the verdict makes the destructive path deliberate and lets it carry a reason the device can put in its own log, which is where the operator looks when a workload dies moments after the hub came back.
const ( // ResumeContinue resends this handle's output from FromOffset and carries // on. This is the default for an absent or unrecognised action: a frame // from a possibly-newer control plane must not be read as an instruction // to destroy work. ResumeContinue ResumeAction = "continue" // ResumeTerminate stops the workload. The control plane has no record of // it — most often because it restarted and this handle was never // persisted — so nothing can read its output, collect its work product or // signal it. Leaving it running would burn the device's CPU on a result // with no destination, invisibly and until someone logs into the device. ResumeTerminate ResumeAction = "terminate" )
func (ResumeAction) Valid ¶
func (a ResumeAction) Valid() bool
Valid reports whether the action is one this build knows.
type ResumeHandle ¶
type ResumeHandle struct {
HandleID string `json:"handle_id"`
StartedAt time.Time `json:"started_at"`
// LogOffset is the total number of output bytes the agent has produced
// for this handle so far. The control plane compares it against what it
// received and asks for the gap.
LogOffset int64 `json:"log_offset"`
}
ResumeHandle re-attaches one surviving workload after a reconnect.
type RevokeAction ¶
type RevokeAction = executor.RevokeAction
RevokeAction is what the agent should do about a workload still using the material being taken away: scrub or kill.
type RevokePayload ¶
type RevokePayload = executor.RevokeRequest
RevokePayload takes one lease's material back from a running agent.
func DecodeRevoke ¶
func DecodeRevoke(f Frame) (RevokePayload, error)
DecodeRevoke decodes a revoke frame. A revoke with no lease ID is rejected rather than treated as "revoke everything": a wildcard revocation is not a thing this protocol offers, and silently inventing one from a malformed frame would let a truncated write take a whole device's credentials away.
type RevokeResult ¶
type RevokeResult = executor.RevokeOutcome
RevokeResult is one executor's outcome for one lease revocation.
type RevokeState ¶
type RevokeState = executor.RevokeState
RevokeState is how far one lease's revocation has got.
type RevokedPayload ¶
type RevokedPayload = executor.RevokeReport
RevokedPayload acknowledges a revoke and reports what was actually done. It is deliberately specific rather than a bare "ok": an operator revoking a kubeconfig needs to know whether the file is gone or whether the agent merely forgot about it, and an ack that cannot distinguish the two would let a UI claim a guarantee the system did not deliver.
func DecodeRevoked ¶
func DecodeRevoked(f Frame) (RevokedPayload, error)
type SecretFile ¶
type SecretFile struct {
// LeaseID and GrantID attribute the file, so the device can tie what it
// wrote to the binding a later revoke frame will name.
LeaseID string `json:"lease_id,omitempty"`
GrantID string `json:"grant_id,omitempty"`
// Dir is the directory the workload's environment already points at. It is
// the hub's *declaration*, not an instruction: the hub has no idea what is
// writable on an edge device, so the agent picks a real directory of its
// own and relocates the environment onto it (see the agent's
// materializeSecretFiles and Spec.RelocateSecrets).
Dir string `json:"dir"`
// Name is a bare file name. Re-validated on receipt: a name with a
// separator in it is an arbitrary-file-write primitive aimed at the device.
Name string `json:"name"`
// Mode is the permission bits, as a plain uint32 because fs.FileMode's
// JSON form is an opaque integer whose high bits mean things a wire format
// should not be able to assert. Zero means 0600.
Mode uint32 `json:"mode,omitempty"`
// Content is the plaintext, base64 in JSON. This is the credential;
// everything else in this struct is bookkeeping.
Content []byte `json:"content"`
}
SecretFile is one credential file, with the bytes, on its way to a device.
It mirrors executor.SecretFile rather than reusing it because the two have opposite serialization contracts: the executor type must never appear in JSON (see its json:"-" field on Spec), and this one exists only to be marshalled. Keeping them separate makes "the bytes are on the wire" an explicit, greppable opt-in of exactly one struct instead of a property that could be reintroduced by adding a tag somewhere else.
func NewSecretFile ¶
func NewSecretFile(f executor.SecretFile) SecretFile
NewSecretFile converts a driver-facing credential file into the wire form.
func NewSecretFiles ¶
func NewSecretFiles(files []executor.SecretFile) []SecretFile
NewSecretFiles converts a set, returning nil for an empty one so the field is omitted from the frame entirely rather than encoded as an empty array.
func (SecretFile) Executor ¶
func (f SecretFile) Executor() executor.SecretFile
Executor converts the wire form into the type a driver consumes.
func (SecretFile) GoString ¶
func (f SecretFile) GoString() string
GoString mirrors String so %#v cannot print the content either.
func (SecretFile) String ¶
func (f SecretFile) String() string
String renders the file without its content, so a `%v` on a start payload — or on a slice of these, which is how they travel — cannot write a live credential into the hub's log or the device's. Marshalling is unaffected; only fmt goes through here.
The rendering is delegated to executor.SecretFile so the two redactions cannot drift apart: a future field added to one and printed there would otherwise be printed here too, unredacted.
func (SecretFile) Validate ¶
func (f SecretFile) Validate() error
Validate re-derives executor.SecretFile.Validate on the receiving side.
Re-deriving rather than trusting is the whole point. The sender validated too, but this struct crossed a process boundary and, in this system's threat model, the control plane is a party that can be compromised (see the confinement note at the top of the agent's vault.go). A device that took the hub's word for "this is a bare file name" would hand a compromised hub a write anywhere on every enrolled machine.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session is one live connection to an enrolled agent.
func Accept ¶
Accept performs the server side of the handshake on an already-authenticated connection and returns a running Session.
Authentication happened at the HTTP upgrade; what remains here is *authorization* and version negotiation. The hello frame's AgentID is compared against the authenticated identity and a mismatch closes the connection, so a valid credential for edge-1 cannot be used to impersonate edge-2 and steal its work or forge its status.
On success the session's read loop is already running in its own goroutine.
func (*Session) AgentVersion ¶
AgentVersion returns the cloop build the device reported at hello, or "" if it reported none.
The hello field existed and carried a comment about "diagnosing version-skew problems from the control plane", but nothing on this side ever read it: the value was decoded and dropped on the floor. Surfacing it is what lets the hub persist it and the Executors panel flag a device that trails.
func (*Session) Capabilities ¶
func (s *Session) Capabilities() AgentCapabilities
Capabilities returns what the agent advertised at hello.
func (*Session) Done ¶
func (s *Session) Done() <-chan struct{}
Done returns a channel closed when the session ends.
type SignalPayload ¶
SignalPayload asks the agent to signal a handle.
func DecodeSignal ¶
func DecodeSignal(f Frame) (SignalPayload, error)
type StartPayload ¶
type StartPayload struct {
Spec executor.Spec `json:"spec"`
// HandleID is assigned by the control plane rather than the agent, so a
// start whose response is lost to a disconnect is still addressable: the
// control plane can ask about the handle it named, and the agent can
// treat a repeated start for a known handle as a no-op instead of
// launching the workload twice.
HandleID string `json:"handle_id"`
// WorkspaceCredential is the material for the single authenticated git
// fetch Spec.Workspace needs, when it needs one. Added in protocol v3; nil
// for a public repository or a workspace that needs no provisioning.
//
// It is a sibling of Spec rather than a field inside it, and that is a
// hard rule rather than a stylistic one: pkg/executorstore persists the
// dispatched Spec, the audit trail echoes it, and the reconcile loop
// re-reads it after a control-plane restart. A token placed anywhere in a
// Spec would be durable in three places within a second of being minted.
// Here it is one field of one frame — written to a socket, never to a disk
// — and the hub releases the lease as soon as the agent confirms the start.
WorkspaceCredential *WorkspaceCredential `json:"workspace_credential,omitempty"`
// SecretFiles carries the plaintext of the credential files this workload's
// secret lease produced, for the device to write where Spec.Env already
// says they are. Added in protocol v6; nil when the lease carries none,
// which is every workload that leases only environment variables.
//
// It is a sibling of Spec for exactly the reason WorkspaceCredential is,
// and here the rule is enforced by the type system rather than by
// convention: executor.Spec.SecretFiles is tagged json:"-" precisely so
// that it *cannot* travel inside a Spec. pkg/executorstore persists the
// dispatched Spec, the audit trail echoes it, and reconcile re-reads it
// after a control-plane restart — a credential that reached a Spec field
// would be durable in three places within a second of being minted. Here
// the bytes are one field of one frame, written to a socket and never to a
// disk, and the device wipes them when the workload ends.
//
// This is also why the remote protocol has to carry them explicitly: a
// driver that shares the hub's filesystem reads the files the broker
// already wrote, and a driver on another machine has nothing to read.
SecretFiles []SecretFile `json:"secret_files,omitempty"`
}
StartPayload dispatches a workload. The Spec is the same struct host and container drivers consume; it was designed to be serializable for exactly this reason.
func DecodeStart ¶
func DecodeStart(f Frame) (StartPayload, error)
DecodeStart decodes a dispatch, refusing one whose credential files could not have come from a well-behaved hub.
The check is here rather than at the point of the write because this is the boundary: everything past it is device code that has already accepted the frame. A traversal in a file name, a mode that would make a token world-readable, or a payload too large to be a lease is rejected before the agent has decided where anything goes.
func (StartPayload) CredentialFiles ¶
func (p StartPayload) CredentialFiles() []executor.SecretFile
CredentialFiles converts a start payload's files into the type the device's placement code consumes.
It is not called SecretFiles() because that name belongs to the field; the asymmetry mirrors GitCredential() above, which is the accessor for WorkspaceCredential for the same reason.
func (StartPayload) GitCredential ¶
func (p StartPayload) GitCredential() executor.GitCredential
GitCredential converts the wire form into the type the device's provisioner consumes. A payload with no credential yields the zero value, which executor.GitCredential.Empty reports as "this fetch is unauthenticated".
type StartedPayload ¶
type StartedPayload struct {
HandleID string `json:"handle_id"`
PID int `json:"pid,omitempty"`
StartedAt time.Time `json:"started_at"`
// Error is non-empty when the workload could not be started; the control
// plane surfaces it verbatim so the operator sees the device's reason
// (missing binary, workdir outside root) rather than a generic failure.
Error string `json:"error,omitempty"`
}
StartedPayload answers a start.
func DecodeStarted ¶
func DecodeStarted(f Frame) (StartedPayload, error)
type StatusPayload ¶
type StatusPayload struct {
Status executor.Status `json:"status"`
// FinalOffset is the total output byte count when the workload reached a
// terminal state. The control plane uses it to tell "the stream ended"
// apart from "the connection dropped mid-stream": if it has fewer bytes
// than FinalOffset it knows output is still outstanding.
FinalOffset int64 `json:"final_offset,omitempty"`
}
StatusPayload reports a handle's state.
func DecodeStatus ¶
func DecodeStatus(f Frame) (StatusPayload, error)
type StatusReqPayload ¶
type StatusReqPayload struct{}
StatusReqPayload requests a status refresh. It carries no fields today; the handle is in the envelope. It exists as a named type so adding one later does not change the frame's shape.
type Store ¶
type Store interface {
// PutEnrollment persists a newly minted token record.
PutEnrollment(rec EnrollmentRecord) error
// GetEnrollment loads a token by its public ID. Returns ErrTokenInvalid
// when no such row exists.
GetEnrollment(id string) (EnrollmentRecord, error)
// RedeemEnrollment atomically marks id redeemed by agentID. It must
// return ErrTokenAlreadyUsed if the row is already redeemed.
RedeemEnrollment(id, agentID string, at time.Time) error
// RevokeEnrollment marks a token revoked; unredeemed or not.
RevokeEnrollment(id string, at time.Time) error
// ListEnrollments returns every token record, newest first.
ListEnrollments() ([]EnrollmentRecord, error)
// PutAgent persists an agent credential record.
PutAgent(rec AgentRecord) error
// GetAgent loads an agent by ID. Returns ErrAgentNotFound when absent.
GetAgent(agentID string) (AgentRecord, error)
// RevokeAgent marks an agent's credential revoked.
RevokeAgent(agentID string, at time.Time) error
// TouchAgent records that the agent was seen at t.
TouchAgent(agentID string, at time.Time) error
// ListAgents returns every enrolled agent.
ListAgents() ([]AgentRecord, error)
}
Store is the persistence the enrollment flow needs.
It is an interface rather than a *statedb.DB so that enrollment logic — which is where the security-relevant decisions live — can be tested against an in-memory fake, including the concurrent-redemption race that a SQLite-backed test would make awkward to provoke deterministically.
RedeemEnrollment carries the atomicity requirement: implementations must perform the "is it still unredeemed?" check and the write to RedeemedAt in a single atomic step. A read-then-write implementation would let two racing devices both redeem one token and end up sharing an identity.
type WelcomePayload ¶
type WelcomePayload struct {
// ProtocolVersion is the version the session settled on: the lower of
// what the two sides support. Both must encode at this version.
ProtocolVersion int `json:"protocol_version"`
// ExecutorID is the control plane's ID for this agent, which is also the
// agent's own identity and the registry key projects bind to. The three
// are deliberately one value: an operator reading a project binding, an
// executors-table row, and an agent's log should not have to correlate
// three different identifiers for the same device.
ExecutorID string `json:"executor_id"`
// Credential is the long-lived per-agent credential, sent only on the
// connection that redeemed an enrollment token. Redemption happens
// inline on the connect rather than as a separate HTTP call so a device
// needs exactly one reachable URL and one round trip to enroll.
//
// It is the single time this secret crosses the wire; the control plane
// keeps only its hash. An agent that fails to persist it must re-enroll.
Credential string `json:"credential,omitempty"`
// HeartbeatSeconds is the interval the agent must beat at. Sent rather
// than assumed so the control plane can slow down a large fleet without
// redeploying every device.
HeartbeatSeconds int `json:"heartbeat_seconds"`
// ResumeAccepted answers Hello.Resume, one entry per offer the control
// plane has a verdict for. Since v5 the verdict is explicit: an entry may
// say "resume from this offset" or "terminate this workload" (see
// ResumeAck.Action). Below v5 the hub emits accepted handles only, because
// an older agent would read a refusal's presence in this list as
// permission to keep streaming.
//
// The field name is historical and no longer literal — every entry used to
// be an acceptance. It is kept because it is a wire name, and renaming it
// would strand every deployed agent for the sake of a better noun.
//
// A handle the agent offered and that is absent here still means "the
// control plane is not tracking this". An agent that receives no entry for
// a workload must stop it rather than merely stop reporting it: nothing
// will ever read its output, collect its result, or be able to signal it.
ResumeAccepted []ResumeAck `json:"resume_accepted,omitempty"`
}
WelcomePayload accepts a session.
func DecodeWelcome ¶
func DecodeWelcome(f Frame) (WelcomePayload, error)
type WorkspaceCredential ¶
type WorkspaceCredential struct {
// Username is the HTTP basic user. Empty means the broker's GitHub
// convention, "x-access-token", which executor.GitCredential applies.
Username string `json:"username,omitempty"`
// Password is the token. This is the secret; everything else in this
// struct is bookkeeping.
Password string `json:"password"`
// ExpiresAt is the lease deadline, so a device can decline to begin a
// fetch with a credential that will lapse mid-transfer rather than failing
// halfway through with an opaque 401.
ExpiresAt time.Time `json:"expires_at,omitempty"`
}
WorkspaceCredential is HTTP basic material for one git fetch.
It deliberately carries no lease or grant identifier. The device neither renews nor releases the lease — the hub does both — so an identifier here would buy nothing and cost the ability to correlate a captured frame with a lease in the hub's audit trail.
func (WorkspaceCredential) GoString ¶
func (c WorkspaceCredential) GoString() string
GoString mirrors String so %#v cannot print the token either.
func (WorkspaceCredential) String ¶
func (c WorkspaceCredential) String() string
String renders the credential without its secret, so a `%v` on a start payload — or on this struct directly — cannot write a live token into a log. Marshalling is unaffected: only fmt goes through here.