// 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"` } // 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 }