230 lines
5.7 KiB
Go
230 lines
5.7 KiB
Go
package runnerregistry
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
otopb "github.com/toki/oto/services/core/oto"
|
|
)
|
|
|
|
const (
|
|
StatusAccepted = "accepted"
|
|
StatusOnline = "online"
|
|
StatusDisconnected = "disconnected"
|
|
StatusHeartbeatTimeout = "heartbeat_timeout"
|
|
)
|
|
|
|
type RunnerCapability struct {
|
|
Name string
|
|
Version string
|
|
}
|
|
|
|
type RunnerRecord struct {
|
|
RunnerID string
|
|
Alias string
|
|
ProtocolVersion string
|
|
Capability RunnerCapability
|
|
CommandTypes []string
|
|
Status string
|
|
AcceptedAt time.Time
|
|
FirstHeartbeatAt time.Time
|
|
LastHeartbeatAt time.Time
|
|
FailureReason string
|
|
}
|
|
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
now func() time.Time
|
|
runners map[string]RunnerRecord
|
|
}
|
|
|
|
func New() *Registry {
|
|
return NewWithClock(time.Now)
|
|
}
|
|
|
|
func NewWithClock(now func() time.Time) *Registry {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &Registry{
|
|
now: now,
|
|
runners: map[string]RunnerRecord{},
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Register(req *otopb.RegisterRunnerRequest) *otopb.RegisterRunnerResponse {
|
|
if req == nil {
|
|
return rejected("missing registration request")
|
|
}
|
|
|
|
token := strings.TrimSpace(req.GetEnrollmentToken())
|
|
runnerID := strings.TrimSpace(req.GetRunnerId())
|
|
protocolVersion := strings.TrimSpace(req.GetProtocolVersion())
|
|
if token == "" {
|
|
return rejected("missing enrollment token")
|
|
}
|
|
if runnerID == "" {
|
|
return rejected("missing runner id")
|
|
}
|
|
if protocolVersion == "" {
|
|
return rejected("missing protocol version")
|
|
}
|
|
|
|
if err := validateProtocolCompatibility(protocolVersion, req.GetCapability()); err != nil {
|
|
return rejectedWithCode("incompatible_runner", err.Error())
|
|
}
|
|
|
|
record := RunnerRecord{
|
|
RunnerID: runnerID,
|
|
Alias: strings.TrimSpace(req.GetAlias()),
|
|
ProtocolVersion: protocolVersion,
|
|
CommandTypes: append([]string(nil), req.GetCommandCatalog().GetCommandTypes()...),
|
|
Status: StatusAccepted,
|
|
AcceptedAt: r.now(),
|
|
}
|
|
if capability := req.GetCapability(); capability != nil {
|
|
record.Capability = RunnerCapability{
|
|
Name: strings.TrimSpace(capability.GetName()),
|
|
Version: strings.TrimSpace(capability.GetVersion()),
|
|
}
|
|
}
|
|
|
|
r.mu.Lock()
|
|
if existing, ok := r.runners[runnerID]; ok && strings.TrimSpace(existing.Alias) != "" {
|
|
record.Alias = existing.Alias
|
|
}
|
|
r.runners[runnerID] = record
|
|
r.mu.Unlock()
|
|
|
|
return &otopb.RegisterRunnerResponse{
|
|
Accepted: true,
|
|
RunnerId: runnerID,
|
|
Alias: record.Alias,
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Heartbeat(runnerID string, status otopb.HeartbeatStatus) *otopb.HeartbeatResponse {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
record, ok := r.runners[runnerID]
|
|
if !ok {
|
|
return &otopb.HeartbeatResponse{
|
|
Success: false,
|
|
ErrorMessage: "unknown runner",
|
|
}
|
|
}
|
|
|
|
if record.Status == StatusDisconnected || record.Status == StatusHeartbeatTimeout {
|
|
return &otopb.HeartbeatResponse{
|
|
Success: false,
|
|
ErrorMessage: "runner in terminal state; re-register required",
|
|
}
|
|
}
|
|
|
|
if record.Status == StatusAccepted {
|
|
record.FirstHeartbeatAt = r.now()
|
|
}
|
|
record.LastHeartbeatAt = r.now()
|
|
record.Status = StatusOnline
|
|
|
|
r.runners[runnerID] = record
|
|
|
|
return &otopb.HeartbeatResponse{
|
|
Success: true,
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Disconnect(runnerID string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
record, ok := r.runners[runnerID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
record.Status = StatusDisconnected
|
|
r.runners[runnerID] = record
|
|
return true
|
|
}
|
|
|
|
func (r *Registry) UpdateAlias(runnerID, alias string) (RunnerRecord, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
record, ok := r.runners[runnerID]
|
|
if !ok {
|
|
return RunnerRecord{}, false
|
|
}
|
|
record.Alias = strings.TrimSpace(alias)
|
|
r.runners[runnerID] = record
|
|
record.CommandTypes = append([]string(nil), record.CommandTypes...)
|
|
return record, true
|
|
}
|
|
|
|
func (r *Registry) CheckTimeouts(timeoutDuration time.Duration) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
now := r.now()
|
|
for id, record := range r.runners {
|
|
if record.Status == StatusOnline {
|
|
if now.Sub(record.LastHeartbeatAt) > timeoutDuration {
|
|
record.Status = StatusHeartbeatTimeout
|
|
record.FailureReason = "heartbeat timeout"
|
|
r.runners[id] = record
|
|
}
|
|
} else if record.Status == StatusAccepted {
|
|
if now.Sub(record.AcceptedAt) > timeoutDuration {
|
|
record.Status = StatusHeartbeatTimeout
|
|
record.FailureReason = "heartbeat timeout before first heartbeat"
|
|
r.runners[id] = record
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Registry) Snapshot(runnerID string) (RunnerRecord, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
record, ok := r.runners[runnerID]
|
|
if !ok {
|
|
return RunnerRecord{}, false
|
|
}
|
|
record.CommandTypes = append([]string(nil), record.CommandTypes...)
|
|
return record, true
|
|
}
|
|
|
|
func validateProtocolCompatibility(protocolVersion string, capability *otopb.RunnerCapability) error {
|
|
if protocolVersion != "oto.runner.v1" {
|
|
return fmt.Errorf("unsupported protocol version: %q", protocolVersion)
|
|
}
|
|
if capability == nil {
|
|
return fmt.Errorf("missing runner capability")
|
|
}
|
|
if strings.TrimSpace(capability.GetName()) != "oto-runner" {
|
|
return fmt.Errorf("unsupported capability name: %q", capability.GetName())
|
|
}
|
|
version := strings.TrimSpace(capability.GetVersion())
|
|
if version == "" {
|
|
return fmt.Errorf("missing capability version")
|
|
}
|
|
parts := strings.Split(version, ".")
|
|
if parts[0] != "1" {
|
|
return fmt.Errorf("incompatible capability major version: %q, want major version 1", version)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rejectedWithCode(code, message string) *otopb.RegisterRunnerResponse {
|
|
return &otopb.RegisterRunnerResponse{
|
|
Accepted: false,
|
|
RejectReason: message,
|
|
Error: &otopb.ProtocolError{
|
|
Code: code,
|
|
Message: message,
|
|
},
|
|
}
|
|
}
|
|
|
|
func rejected(reason string) *otopb.RegisterRunnerResponse {
|
|
return rejectedWithCode("registration_rejected", reason)
|
|
}
|