197 lines
6.5 KiB
Go
197 lines
6.5 KiB
Go
// Package protocol defines the messages agents and the master exchange over
|
|
// their WebSocket connection. Every message is an Envelope whose Payload is
|
|
// one of the payload types below, selected by Type.
|
|
//
|
|
// Both sides close the connection on a message they cannot parse (bad JSON,
|
|
// or a known Type whose payload does not decode). A message with an unknown
|
|
// Type is logged and ignored, so a newer peer can add message types without
|
|
// breaking an older one.
|
|
package protocol
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Version is the protocol version this build speaks. Agents send it in
|
|
// Hello. Phase-1 agents do not send the field, which reads as 0.
|
|
const Version = 2
|
|
|
|
// MinJobVersion is the lowest agent protocol version the master sends jobs to.
|
|
const MinJobVersion = 2
|
|
|
|
// MaxJobOutput caps the job output an agent keeps and the master stores, in
|
|
// bytes. Only the tail is kept. With JSON escaping (worst case 6 bytes per
|
|
// byte) a JobResult carrying this much output still fits the 1 MiB read limit.
|
|
const MaxJobOutput = 128 << 10
|
|
|
|
// Message types sent from agent to master.
|
|
const (
|
|
TypeHello = "hello"
|
|
TypeSnapshot = "snapshot"
|
|
TypeUpsert = "upsert"
|
|
TypeRemove = "remove"
|
|
TypeJobProgress = "job.progress"
|
|
TypeJobResult = "job.result"
|
|
)
|
|
|
|
// Message types sent from master to agent.
|
|
const (
|
|
TypeJobStart = "job.start"
|
|
TypeJobAck = "job.ack"
|
|
)
|
|
|
|
// Envelope wraps every message on the wire. ID is the job ID (decimal) on
|
|
// job.* messages and empty on inventory messages.
|
|
type Envelope struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id,omitempty"`
|
|
Payload json.RawMessage `json:"payload,omitempty"`
|
|
}
|
|
|
|
// Hello is the first message an agent sends on every connection.
|
|
type Hello struct {
|
|
AgentVersion string `json:"agent_version"`
|
|
ProtocolVersion int `json:"protocol_version,omitempty"`
|
|
Hostname string `json:"hostname"`
|
|
SwarmManager bool `json:"swarm_manager"`
|
|
SwarmClusterID string `json:"swarm_cluster_id,omitempty"`
|
|
// KnownJobs lists every job ID the agent holds: queued, running, or
|
|
// finished but not yet acknowledged. The master fails its queued and
|
|
// running jobs for this agent that are not listed ("lost").
|
|
KnownJobs []string `json:"known_jobs,omitempty"`
|
|
}
|
|
|
|
// Kind says how a service is deployed.
|
|
type Kind string
|
|
|
|
// Service kinds.
|
|
const (
|
|
KindCompose Kind = "compose"
|
|
KindSwarm Kind = "swarm"
|
|
KindContainer Kind = "container"
|
|
)
|
|
|
|
// Service is one deployable unit as an agent sees it. Which location fields
|
|
// are set depends on Kind.
|
|
type Service struct {
|
|
Kind Kind `json:"kind"`
|
|
ComposeProject string `json:"compose_project,omitempty"`
|
|
ComposeService string `json:"compose_service,omitempty"`
|
|
WorkingDir string `json:"working_dir,omitempty"`
|
|
// ConfigFiles is the raw com.docker.compose.project.config_files label:
|
|
// absolute host paths joined by ",". Kept as a string so Service stays
|
|
// comparable with ==.
|
|
ConfigFiles string `json:"config_files,omitempty"`
|
|
SwarmService string `json:"swarm_service,omitempty"`
|
|
ContainerName string `json:"container_name,omitempty"`
|
|
Image string `json:"image"`
|
|
Digest string `json:"digest,omitempty"`
|
|
Status string `json:"status"`
|
|
Self bool `json:"self,omitempty"`
|
|
// Built says the Compose service has a build: section; it is never
|
|
// updated by jobs.
|
|
Built bool `json:"built,omitempty"`
|
|
}
|
|
|
|
// Key identifies the service within one agent's report. It is stable across
|
|
// restarts and is what Remove refers to.
|
|
func (s Service) Key() string {
|
|
switch s.Kind {
|
|
case KindCompose:
|
|
return "compose|" + s.ComposeProject + "|" + s.ComposeService
|
|
case KindSwarm:
|
|
return "swarm|" + s.SwarmService
|
|
default:
|
|
return "container|" + s.ContainerName
|
|
}
|
|
}
|
|
|
|
// Snapshot is the agent's complete inventory. Sent after every Hello.
|
|
type Snapshot struct {
|
|
Services []Service `json:"services"`
|
|
}
|
|
|
|
// Upsert reports a service that appeared or changed.
|
|
type Upsert struct {
|
|
Service Service `json:"service"`
|
|
}
|
|
|
|
// Remove reports that the service with Key is gone.
|
|
type Remove struct {
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
// JobStart asks the agent to deploy Ref to one service. Ref is "repo:tag";
|
|
// the agent resolves the tag's current digest and deploys "repo:tag@digest".
|
|
// The agent reads the Compose working directory and config files from its
|
|
// own Docker daemon, not from this message.
|
|
type JobStart struct {
|
|
Kind Kind `json:"kind"`
|
|
ComposeProject string `json:"compose_project,omitempty"`
|
|
ComposeService string `json:"compose_service,omitempty"`
|
|
SwarmClusterID string `json:"swarm_cluster_id,omitempty"`
|
|
SwarmService string `json:"swarm_service,omitempty"`
|
|
Ref string `json:"ref"`
|
|
}
|
|
|
|
// JobProgress carries output produced since the previous JobProgress for the
|
|
// same job. The first JobProgress of a job (Output may be empty) tells the
|
|
// master the job is running.
|
|
type JobProgress struct {
|
|
Output string `json:"output"`
|
|
}
|
|
|
|
// Job result statuses.
|
|
const (
|
|
ResultSucceeded = "succeeded"
|
|
ResultFailed = "failed"
|
|
)
|
|
|
|
// JobResult is a job's outcome. The agent keeps it until the master sends
|
|
// JobAck for the same ID, and resends it after every reconnect until then.
|
|
type JobResult struct {
|
|
Status string `json:"status"` // ResultSucceeded or ResultFailed
|
|
Ref string `json:"ref,omitempty"` // deployed "repo:tag@digest", set on success
|
|
Error string `json:"error,omitempty"`
|
|
// Output is the job's complete output, tail-capped at MaxJobOutput. It
|
|
// replaces whatever the master assembled from JobProgress messages.
|
|
Output string `json:"output"`
|
|
OutputTruncated bool `json:"output_truncated,omitempty"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
FinishedAt time.Time `json:"finished_at"`
|
|
}
|
|
|
|
// JobAck tells the agent the master has stored the JobResult with the same
|
|
// ID. It has no fields.
|
|
type JobAck struct{}
|
|
|
|
// Encode wraps payload in an Envelope of the given type.
|
|
func Encode(typ string, payload any) (Envelope, error) {
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return Envelope{}, fmt.Errorf("encode %s: %w", typ, err)
|
|
}
|
|
return Envelope{Type: typ, Payload: raw}, nil
|
|
}
|
|
|
|
// EncodeJob wraps a job message's payload in an Envelope carrying the job ID.
|
|
func EncodeJob(typ, jobID string, payload any) (Envelope, error) {
|
|
env, err := Encode(typ, payload)
|
|
if err != nil {
|
|
return Envelope{}, err
|
|
}
|
|
env.ID = jobID
|
|
return env, nil
|
|
}
|
|
|
|
// Decode unpacks env's payload into T.
|
|
func Decode[T any](env Envelope) (T, error) {
|
|
var v T
|
|
if err := json.Unmarshal(env.Payload, &v); err != nil {
|
|
return v, fmt.Errorf("decode %s: %w", env.Type, err)
|
|
}
|
|
return v, nil
|
|
}
|