oto/services/core/internal/runnersocket/server.go
toki 88c8ff3b07 feat: runner proto socket transport hardening and related updates
- Add runner-proto-socket-transport-hardening milestone and SDD docs
- Add runnersocket package for Go service
- Update agent config, runner, and job client (Dart)
- Update Bootstrap scripts (PowerShell, shell)
- Update Go service HTTP server handlers and routes
- Add CICD state store updates
- Update agent-ops domain rules and phase roadmap
2026-06-20 18:23:30 +09:00

448 lines
14 KiB
Go

package runnersocket
import (
"context"
"fmt"
"net"
"sort"
"strconv"
"strings"
"sync"
protoSocket "git.toki-labs.com/toki/proto-socket/go"
"github.com/toki/oto/services/core/internal/cicdstate"
"github.com/toki/oto/services/core/internal/runnerregistry"
otopb "github.com/toki/oto/services/core/oto"
"google.golang.org/protobuf/proto"
)
const (
heartbeatIntervalSeconds = 30
heartbeatWaitSeconds = 45
)
// Server owns the runner-facing proto-socket listener.
type Server struct {
addr string
registry *runnerregistry.Registry
store *cicdstate.Store
mu sync.RWMutex
server *protoSocket.TcpServer
clients map[string]*protoSocket.TcpClient
}
// New creates a runner socket server sharing the core registry and CICD store.
func New(addr string, registry *runnerregistry.Registry, store *cicdstate.Store) *Server {
return &Server{
addr: addr,
registry: registry,
store: store,
clients: make(map[string]*protoSocket.TcpClient),
}
}
// Start binds the proto-socket TCP listener.
func (s *Server) Start(ctx context.Context) error {
host, port, err := splitAddr(s.addr)
if err != nil {
return err
}
server := protoSocket.NewTcpServer(host, port, func(conn net.Conn) *protoSocket.TcpClient {
client := protoSocket.NewTcpClient(conn, heartbeatIntervalSeconds, heartbeatWaitSeconds, parserMap())
s.attachClientHandlers(client)
return client
})
s.mu.Lock()
s.server = server
s.mu.Unlock()
return server.Start(ctx)
}
// Stop closes the listener and connected runner sockets.
func (s *Server) Stop() error {
s.mu.RLock()
server := s.server
s.mu.RUnlock()
if server == nil {
return nil
}
return server.Stop()
}
// DispatchQueuedJobs pushes queued jobs to currently connected idle runners.
func (s *Server) DispatchQueuedJobs() {
for _, runnerID := range s.connectedRunnerIDs() {
if s.runnerHasActiveExecution(runnerID) {
continue
}
client := s.clientFor(runnerID)
if client == nil {
continue
}
runRequest, jobID, execID, err := s.claimNextQueuedJob(runnerID)
if err != nil {
if jobID != "" {
if execID != "" {
_ = s.store.AppendLog(execID, fmt.Sprintf("failed to prepare run request over proto-socket: %v", err))
_ = s.store.TransitionExecution(execID, cicdstate.StateFailed)
}
_ = s.store.TransitionJob(jobID, cicdstate.StateFailed)
}
continue
}
if runRequest == nil {
continue
}
if err := client.Send(runRequest); err != nil {
_ = s.store.AppendLog(execID, fmt.Sprintf("failed to dispatch run request over proto-socket: %v", err))
_ = s.store.TransitionExecution(execID, cicdstate.StateFailed)
_ = s.store.TransitionJob(jobID, cicdstate.StateFailed)
continue
}
}
}
// CancelExecution asks a connected runner to cancel an execution.
func (s *Server) CancelExecution(runnerID, execID, reason string) error {
client := s.clientFor(runnerID)
if client == nil {
return fmt.Errorf("runner socket is not connected")
}
return client.Send(&otopb.CancelRunRequest{
RunnerId: runnerID,
ExecutionId: execID,
Reason: reason,
})
}
func (s *Server) attachClientHandlers(client *protoSocket.TcpClient) {
var runnerIDMu sync.RWMutex
runnerID := ""
setRunnerID := func(id string) {
runnerIDMu.Lock()
runnerID = id
runnerIDMu.Unlock()
}
getRunnerID := func() string {
runnerIDMu.RLock()
defer runnerIDMu.RUnlock()
return runnerID
}
protoSocket.AddRequestListenerTyped[*otopb.RegisterRunnerRequest, *otopb.RegisterRunnerResponse](
&client.Communicator,
func(req *otopb.RegisterRunnerRequest) (*otopb.RegisterRunnerResponse, error) {
res := s.registry.Register(req)
if res.GetAccepted() {
id := res.GetRunnerId()
setRunnerID(id)
s.setClient(id, client)
s.registry.Heartbeat(id, otopb.HeartbeatStatus_HEARTBEAT_STATUS_HEALTHY)
go s.DispatchQueuedJobs()
}
return res, nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.HeartbeatRequest, *otopb.HeartbeatResponse](
&client.Communicator,
func(req *otopb.HeartbeatRequest) (*otopb.HeartbeatResponse, error) {
runnerID := strings.TrimSpace(req.GetRunnerId())
if runnerID == "" {
runnerID = getRunnerID()
}
return s.registry.Heartbeat(runnerID, req.GetStatus()), nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.ExecutionReportRequest, *otopb.ExecutionReportResponse](
&client.Communicator,
func(req *otopb.ExecutionReportRequest) (*otopb.ExecutionReportResponse, error) {
res := s.handleExecutionReport(req)
if res.GetAccepted() {
go s.DispatchQueuedJobs()
}
return res, nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.LogAppendRequest, *otopb.LogAppendResponse](
&client.Communicator,
func(req *otopb.LogAppendRequest) (*otopb.LogAppendResponse, error) {
return s.handleLogAppend(req), nil
},
)
protoSocket.AddRequestListenerTyped[*otopb.ArtifactReportRequest, *otopb.ArtifactReportResponse](
&client.Communicator,
func(req *otopb.ArtifactReportRequest) (*otopb.ArtifactReportResponse, error) {
return s.handleArtifactReport(req), nil
},
)
client.AddDisconnectListener(func(c *protoSocket.TcpClient) {
id := getRunnerID()
if id == "" {
return
}
if s.removeClient(id, c) {
s.registry.Disconnect(id)
}
})
}
func (s *Server) handleExecutionReport(req *otopb.ExecutionReportRequest) *otopb.ExecutionReportResponse {
runnerID := strings.TrimSpace(req.GetRunnerId())
execID := strings.TrimSpace(req.GetExecutionId())
if err := s.ensureRunnerKnown(runnerID); err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, ExecutionId: execID, ErrorMessage: err.Error()}
}
exec, err := s.store.GetExecution(execID)
if err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, ExecutionId: execID, ErrorMessage: "execution not found"}
}
if exec.RunnerID != runnerID {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, ExecutionId: execID, ErrorMessage: "execution owner mismatch"}
}
jobID := strings.TrimSpace(req.GetJobId())
if jobID == "" {
jobID = exec.JobID
} else if jobID != exec.JobID {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, JobId: jobID, ExecutionId: execID, ErrorMessage: "job id mismatch for execution"}
}
targetState := cicdstate.StateFailed
if req.GetSuccess() {
targetState = cicdstate.StateSucceeded
}
if err := s.store.TransitionExecution(execID, targetState); err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, JobId: jobID, ExecutionId: execID, ErrorMessage: err.Error()}
}
if err := s.store.TransitionJob(jobID, targetState); err != nil {
return &otopb.ExecutionReportResponse{RunnerId: runnerID, JobId: jobID, ExecutionId: execID, ErrorMessage: err.Error()}
}
if strings.TrimSpace(req.GetMessage()) != "" {
_ = s.store.AppendLog(execID, req.GetMessage())
}
return &otopb.ExecutionReportResponse{
Accepted: true,
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
State: targetState,
}
}
func (s *Server) handleLogAppend(req *otopb.LogAppendRequest) *otopb.LogAppendResponse {
runnerID := strings.TrimSpace(req.GetRunnerId())
execID := strings.TrimSpace(req.GetExecutionId())
if err := s.ensureRunnerKnown(runnerID); err != nil {
return &otopb.LogAppendResponse{ErrorMessage: err.Error()}
}
if err := s.ensureExecutionOwner(runnerID, execID); err != nil {
return &otopb.LogAppendResponse{ErrorMessage: err.Error()}
}
line := strings.TrimSpace(req.GetLine())
if line == "" {
return &otopb.LogAppendResponse{ErrorMessage: "line is required"}
}
if err := s.store.AppendLog(execID, line); err != nil {
return &otopb.LogAppendResponse{ErrorMessage: "execution not found"}
}
return &otopb.LogAppendResponse{Accepted: true}
}
func (s *Server) handleArtifactReport(req *otopb.ArtifactReportRequest) *otopb.ArtifactReportResponse {
runnerID := strings.TrimSpace(req.GetRunnerId())
execID := strings.TrimSpace(req.GetExecutionId())
if err := s.ensureRunnerKnown(runnerID); err != nil {
return &otopb.ArtifactReportResponse{ErrorMessage: err.Error()}
}
if err := s.ensureExecutionOwner(runnerID, execID); err != nil {
return &otopb.ArtifactReportResponse{ErrorMessage: err.Error()}
}
name := strings.TrimSpace(req.GetName())
path := strings.TrimSpace(req.GetPath())
if name == "" || path == "" {
return &otopb.ArtifactReportResponse{ErrorMessage: "name and path are required"}
}
if err := s.store.AppendArtifact(execID, name, path); err != nil {
return &otopb.ArtifactReportResponse{ErrorMessage: "execution not found"}
}
return &otopb.ArtifactReportResponse{Accepted: true}
}
func (s *Server) claimNextQueuedJob(runnerID string) (*otopb.RunRequest, string, string, error) {
if err := s.ensureRunnerKnown(runnerID); err != nil {
return nil, "", "", err
}
var foundJob *cicdstate.Job
for _, job := range s.store.SnapshotJobs() {
if job.State != cicdstate.StateQueued {
continue
}
if foundJob == nil || job.CreatedAt.Before(foundJob.CreatedAt) {
copyJob := job
foundJob = &copyJob
}
}
if foundJob == nil {
return nil, "", "", nil
}
if foundJob.RunInput == nil {
return nil, foundJob.ID, "", fmt.Errorf("job has no run request")
}
if err := validateRunInput(foundJob.RunInput); err != nil {
return nil, foundJob.ID, "", err
}
execID := s.store.NextExecutionID()
if _, err := s.store.CreateExecution(foundJob.ID, execID); err != nil {
return nil, foundJob.ID, execID, err
}
_ = s.store.SetExecutionRunnerID(execID, runnerID)
if err := s.store.TransitionJob(foundJob.ID, cicdstate.StateRunning); err != nil {
return nil, foundJob.ID, execID, err
}
if err := s.store.TransitionExecution(execID, cicdstate.StateRunning); err != nil {
return nil, foundJob.ID, execID, err
}
return runRequestToProto(foundJob.RunInput, runnerID, foundJob.ID, execID), foundJob.ID, execID, nil
}
func (s *Server) runnerHasActiveExecution(runnerID string) bool {
for _, exec := range s.store.SnapshotExecutions() {
if exec.RunnerID == runnerID && exec.State == cicdstate.StateRunning {
return true
}
}
return false
}
func (s *Server) ensureRunnerKnown(runnerID string) error {
if strings.TrimSpace(runnerID) == "" {
return fmt.Errorf("missing runner id")
}
if s.registry == nil {
return nil
}
if _, ok := s.registry.Snapshot(runnerID); !ok {
return fmt.Errorf("runner not found")
}
return nil
}
func (s *Server) ensureExecutionOwner(runnerID, execID string) error {
exec, err := s.store.GetExecution(execID)
if err != nil {
return fmt.Errorf("execution not found")
}
if exec.RunnerID != runnerID {
return fmt.Errorf("execution owner mismatch")
}
return nil
}
func (s *Server) setClient(runnerID string, client *protoSocket.TcpClient) {
s.mu.Lock()
old := s.clients[runnerID]
s.clients[runnerID] = client
s.mu.Unlock()
// Close outside the lock: Close() fires disconnect listeners synchronously,
// and those listeners call removeClient which also acquires s.mu. Closing
// inside the lock would cause a deadlock on re-entry.
if old != nil && old != client {
_ = old.Close()
}
}
func (s *Server) removeClient(runnerID string, client *protoSocket.TcpClient) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.clients[runnerID] == client {
delete(s.clients, runnerID)
return true
}
return false
}
func (s *Server) clientFor(runnerID string) *protoSocket.TcpClient {
s.mu.RLock()
defer s.mu.RUnlock()
return s.clients[runnerID]
}
func (s *Server) connectedRunnerIDs() []string {
s.mu.RLock()
defer s.mu.RUnlock()
ids := make([]string, 0, len(s.clients))
for id := range s.clients {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func parserMap() protoSocket.ParserMap {
return protoSocket.ParserMap{
protoSocket.TypeNameOf(&otopb.RegisterRunnerRequest{}): parserFor(func() *otopb.RegisterRunnerRequest { return &otopb.RegisterRunnerRequest{} }),
protoSocket.TypeNameOf(&otopb.HeartbeatRequest{}): parserFor(func() *otopb.HeartbeatRequest { return &otopb.HeartbeatRequest{} }),
protoSocket.TypeNameOf(&otopb.ExecutionReportRequest{}): parserFor(func() *otopb.ExecutionReportRequest { return &otopb.ExecutionReportRequest{} }),
protoSocket.TypeNameOf(&otopb.LogAppendRequest{}): parserFor(func() *otopb.LogAppendRequest { return &otopb.LogAppendRequest{} }),
protoSocket.TypeNameOf(&otopb.ArtifactReportRequest{}): parserFor(func() *otopb.ArtifactReportRequest { return &otopb.ArtifactReportRequest{} }),
}
}
func parserFor[T proto.Message](newMessage func() T) func([]byte) (proto.Message, error) {
return func(b []byte) (proto.Message, error) {
m := newMessage()
return m, proto.Unmarshal(b, m)
}
}
func splitAddr(addr string) (string, int, error) {
host, portText, err := net.SplitHostPort(addr)
if err != nil {
return "", 0, err
}
port, err := strconv.Atoi(portText)
if err != nil {
return "", 0, err
}
return host, port, nil
}
func validateRunInput(in *cicdstate.RunInput) error {
hasYAML := strings.TrimSpace(in.PipelineYAML) != ""
hasPath := strings.TrimSpace(in.PipelineYAMLPath) != ""
if hasYAML == hasPath {
return fmt.Errorf("run request must set exactly one of pipeline_yaml or pipeline_yaml_path")
}
return nil
}
func runRequestToProto(in *cicdstate.RunInput, runnerID, jobID, execID string) *otopb.RunRequest {
if in == nil {
return nil
}
rr := &otopb.RunRequest{
RunnerId: runnerID,
JobId: jobID,
ExecutionId: execID,
PipelineYamlPath: in.PipelineYAMLPath,
PipelineYaml: in.PipelineYAML,
CommandTypes: append([]string(nil), in.CommandTypes...),
}
if len(in.Variables) > 0 {
rr.Variables = make(map[string]string, len(in.Variables))
for k, v := range in.Variables {
rr.Variables[k] = v
}
}
return rr
}