- Move completed subtasks (02+01_dispatch_state, 03+01,02_runner_actions) to archive - Refactor agent_runner.dart, oto_server_job_client.dart, registration_client.dart - Update smoke tests and server tests for Go services
703 lines
24 KiB
Go
703 lines
24 KiB
Go
package runnersocket
|
||
|
||
import (
|
||
"net"
|
||
"testing"
|
||
"time"
|
||
|
||
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"
|
||
)
|
||
|
||
func testServer(registry *runnerregistry.Registry) *Server {
|
||
return New("127.0.0.1:0", registry, cicdstate.NewStore())
|
||
}
|
||
|
||
// pipeClient creates a TcpClient backed by one end of a net.Pipe.
|
||
// heartbeat is disabled (intervalSec=0) to avoid background timer interference.
|
||
// Cleanup closes both ends.
|
||
func pipeClient(t *testing.T) (*protoSocket.TcpClient, net.Conn) {
|
||
t.Helper()
|
||
a, b := net.Pipe()
|
||
client := protoSocket.NewTcpClient(a, 0, 0, parserMap())
|
||
t.Cleanup(func() { _ = a.Close(); _ = b.Close() })
|
||
return client, b
|
||
}
|
||
|
||
// runnerParserMap returns a parser map for the runner (client) side of a
|
||
// proto-socket connection. It includes server-to-runner frames so tests can
|
||
// verify dispatch and cancel delivery.
|
||
func runnerParserMap() protoSocket.ParserMap {
|
||
return protoSocket.ParserMap{
|
||
protoSocket.TypeNameOf(&otopb.RunRequest{}): parserFor(func() *otopb.RunRequest { return &otopb.RunRequest{} }),
|
||
protoSocket.TypeNameOf(&otopb.CancelRunRequest{}): parserFor(func() *otopb.CancelRunRequest { return &otopb.CancelRunRequest{} }),
|
||
}
|
||
}
|
||
|
||
// pipeClientPair creates two TcpClients backed by the two ends of a net.Pipe.
|
||
// serverSide uses parserMap(); runnerSide uses runnerParserMap() so tests can
|
||
// capture and inspect RunRequest frames sent by DispatchQueuedJobs.
|
||
// Cleanup closes both connections.
|
||
func pipeClientPair(t *testing.T) (serverSide, runnerSide *protoSocket.TcpClient) {
|
||
t.Helper()
|
||
a, b := net.Pipe()
|
||
serverSide = protoSocket.NewTcpClient(a, 0, 0, parserMap())
|
||
runnerSide = protoSocket.NewTcpClient(b, 0, 0, runnerParserMap())
|
||
t.Cleanup(func() { _ = a.Close(); _ = b.Close() })
|
||
return serverSide, runnerSide
|
||
}
|
||
|
||
// TestServerSetClientReplacesDuplicate verifies that registering a second client
|
||
// for the same runner ID closes the old one and stores the new one.
|
||
func TestServerReplacesDuplicateRunnerClient(t *testing.T) {
|
||
s := testServer(runnerregistry.New())
|
||
|
||
client1, _ := pipeClient(t)
|
||
client2, _ := pipeClient(t)
|
||
|
||
s.setClient("runner-A", client1)
|
||
s.setClient("runner-A", client2)
|
||
|
||
// Allow Close() to propagate through connCloseOnce.
|
||
time.Sleep(20 * time.Millisecond)
|
||
|
||
if client1.IsAlive() {
|
||
t.Error("old client must be closed after replacement, but it is still alive")
|
||
}
|
||
if got := s.clientFor("runner-A"); got != client2 {
|
||
t.Errorf("clientFor must return new client after replacement; got %v", got)
|
||
}
|
||
}
|
||
|
||
// TestServerIgnoresStaleClientDisconnect verifies that removeClient ignores
|
||
// a client that is not the current entry for the given runner ID.
|
||
func TestServerIgnoresStaleClientDisconnect(t *testing.T) {
|
||
s := testServer(runnerregistry.New())
|
||
|
||
client1, _ := pipeClient(t)
|
||
client2, _ := pipeClient(t)
|
||
|
||
s.setClient("runner-B", client1)
|
||
|
||
// client2 is a different object – simulates a stale disconnect listener.
|
||
removed := s.removeClient("runner-B", client2)
|
||
if removed {
|
||
t.Error("removeClient must return false for a client that is not the current one")
|
||
}
|
||
if got := s.clientFor("runner-B"); got != client1 {
|
||
t.Errorf("current client must still be client1 after stale removeClient; got %v", got)
|
||
}
|
||
}
|
||
|
||
// TestServerStaleClientDisconnectDoesNotDisconnectRegistry verifies the complete
|
||
// stale-disconnect protection chain:
|
||
//
|
||
// 1. setClient replaces the old client.
|
||
// 2. removeClient returns false for the stale old client.
|
||
// 3. Because removeClient returns false, registry.Disconnect is NOT called,
|
||
// so the runner remains in a healthy (non-disconnected) state.
|
||
func TestServerStaleClientDisconnectDoesNotDisconnectRegistry(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s := testServer(registry)
|
||
|
||
resp := registry.Register(&otopb.RegisterRunnerRequest{
|
||
EnrollmentToken: "tok",
|
||
RunnerId: "runner-C",
|
||
ProtocolVersion: "oto.runner.v1",
|
||
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
||
})
|
||
if !resp.GetAccepted() {
|
||
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
|
||
}
|
||
registry.Heartbeat("runner-C", otopb.HeartbeatStatus_HEARTBEAT_STATUS_HEALTHY)
|
||
|
||
client1, _ := pipeClient(t)
|
||
client2, _ := pipeClient(t)
|
||
|
||
s.setClient("runner-C", client1)
|
||
// Duplicate connection: client2 replaces client1.
|
||
s.setClient("runner-C", client2)
|
||
|
||
// Simulate stale client1's disconnect listener.
|
||
removed := s.removeClient("runner-C", client1)
|
||
if removed {
|
||
t.Error("stale removeClient should return false")
|
||
}
|
||
|
||
// Because removeClient returned false the disconnect listener guard skips
|
||
// registry.Disconnect. The runner must NOT be in disconnected state.
|
||
runner, ok := registry.Snapshot("runner-C")
|
||
if !ok {
|
||
t.Fatal("runner-C not found in registry")
|
||
}
|
||
if runner.Status == runnerregistry.StatusDisconnected {
|
||
t.Errorf("runner status must not be %q after stale disconnect; got %q",
|
||
runnerregistry.StatusDisconnected, runner.Status)
|
||
}
|
||
if got := s.clientFor("runner-C"); got != client2 {
|
||
t.Errorf("current client must still be client2; got %v", got)
|
||
}
|
||
}
|
||
|
||
// TestServerDuplicateReplaceWithListenerDoesNotDeadlock verifies that replacing
|
||
// a client that carries a production-style disconnect listener does not deadlock.
|
||
//
|
||
// Before the fix, setClient held s.mu while calling old.Close(), which fired
|
||
// the listener synchronously. The listener called removeClient → tried to
|
||
// acquire s.mu again → deadlock. After the fix, old.Close() is called after
|
||
// s.mu is released, so the re-entry is safe.
|
||
func TestServerDuplicateReplaceWithListenerDoesNotDeadlock(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s := testServer(registry)
|
||
|
||
resp := registry.Register(&otopb.RegisterRunnerRequest{
|
||
EnrollmentToken: "tok",
|
||
RunnerId: "runner-E",
|
||
ProtocolVersion: "oto.runner.v1",
|
||
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
||
})
|
||
if !resp.GetAccepted() {
|
||
t.Fatalf("registry.Register rejected: %s", resp.GetRejectReason())
|
||
}
|
||
|
||
client1, _ := pipeClient(t)
|
||
client2, _ := pipeClient(t)
|
||
|
||
// Mirror the production disconnect listener from attachClientHandlers.
|
||
// This is the re-entry path: removeClient acquires s.mu, which would
|
||
// deadlock if setClient still held s.mu during old.Close().
|
||
client1.AddDisconnectListener(func(c *protoSocket.TcpClient) {
|
||
if s.removeClient("runner-E", c) {
|
||
s.registry.Disconnect("runner-E")
|
||
}
|
||
})
|
||
|
||
s.setClient("runner-E", client1)
|
||
|
||
// This must complete without hanging. If the lock-order bug is present the
|
||
// goroutine stalls here and the test times out via go test -timeout.
|
||
s.setClient("runner-E", client2)
|
||
|
||
// Allow Close() and the disconnect listener to propagate.
|
||
time.Sleep(20 * time.Millisecond)
|
||
|
||
if client1.IsAlive() {
|
||
t.Error("old client must be closed after replacement")
|
||
}
|
||
if got := s.clientFor("runner-E"); got != client2 {
|
||
t.Errorf("clientFor must return new client after replacement; got %v", got)
|
||
}
|
||
// The stale listener must not have disconnected the runner.
|
||
runner, ok := registry.Snapshot("runner-E")
|
||
if !ok {
|
||
t.Fatal("runner-E not found in registry after replacement")
|
||
}
|
||
if runner.Status == runnerregistry.StatusDisconnected {
|
||
t.Errorf("runner must not be disconnected after stale listener fires; got %q", runner.Status)
|
||
}
|
||
}
|
||
|
||
// testServerWithStore creates a Server sharing the given registry and returns
|
||
// the associated Store for state inspection in tests.
|
||
func testServerWithStore(registry *runnerregistry.Registry) (*Server, *cicdstate.Store) {
|
||
store := cicdstate.NewStore()
|
||
return New("127.0.0.1:0", registry, store), store
|
||
}
|
||
|
||
// registerRunner adds a runner to the registry with default test fields.
|
||
func registerRunner(t *testing.T, registry *runnerregistry.Registry, runnerID string) {
|
||
t.Helper()
|
||
resp := registry.Register(&otopb.RegisterRunnerRequest{
|
||
EnrollmentToken: "tok",
|
||
RunnerId: runnerID,
|
||
ProtocolVersion: "oto.runner.v1",
|
||
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
||
})
|
||
if !resp.GetAccepted() {
|
||
t.Fatalf("registerRunner %s: rejected: %s", runnerID, resp.GetRejectReason())
|
||
}
|
||
}
|
||
|
||
// createQueuedJob adds a job with a valid inline YAML to the store.
|
||
func createQueuedJob(t *testing.T, store *cicdstate.Store, jobID string) {
|
||
t.Helper()
|
||
if _, err := store.CreateJob(jobID, jobID, &cicdstate.RunInput{PipelineYAML: "pipeline: test"}); err != nil {
|
||
t.Fatalf("CreateJob %s: %v", jobID, err)
|
||
}
|
||
}
|
||
|
||
// setupRunningExecution creates a queued job, registers the runner, creates an
|
||
// execution, and transitions both job and execution to running. Returns execID.
|
||
func setupRunningExecution(t *testing.T, store *cicdstate.Store, registry *runnerregistry.Registry, jobID, runnerID string) string {
|
||
t.Helper()
|
||
createQueuedJob(t, store, jobID)
|
||
registerRunner(t, registry, runnerID)
|
||
execID := store.NextExecutionID()
|
||
if _, err := store.CreateExecution(jobID, execID); err != nil {
|
||
t.Fatalf("CreateExecution: %v", err)
|
||
}
|
||
_ = store.SetExecutionRunnerID(execID, runnerID)
|
||
if err := store.TransitionJob(jobID, cicdstate.StateRunning); err != nil {
|
||
t.Fatalf("TransitionJob running: %v", err)
|
||
}
|
||
if err := store.TransitionExecution(execID, cicdstate.StateRunning); err != nil {
|
||
t.Fatalf("TransitionExecution running: %v", err)
|
||
}
|
||
return execID
|
||
}
|
||
|
||
// TestServerDispatchQueuedJobOverSocket verifies that DispatchQueuedJobs
|
||
// transitions the queued job and its execution to running over the socket
|
||
// transport, assigns the dispatched runner ID to the execution, and sends a
|
||
// RunRequest frame whose fields match the store state.
|
||
func TestServerDispatchQueuedJobOverSocket(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
registerRunner(t, registry, "runner-dispatch")
|
||
|
||
jobID := "job-dispatch"
|
||
if _, err := store.CreateJob(jobID, jobID, &cicdstate.RunInput{
|
||
PipelineYAML: "pipeline: dispatch-test",
|
||
Variables: map[string]string{"ENV": "ci"},
|
||
CommandTypes: []string{"flutter"},
|
||
}); err != nil {
|
||
t.Fatalf("CreateJob: %v", err)
|
||
}
|
||
|
||
// Use a runner-side TcpClient so we can parse the RunRequest frame.
|
||
serverClient, runnerClient := pipeClientPair(t)
|
||
|
||
received := make(chan *otopb.RunRequest, 1)
|
||
protoSocket.AddListenerTyped[*otopb.RunRequest](&runnerClient.Communicator, func(req *otopb.RunRequest) {
|
||
select {
|
||
case received <- req:
|
||
default:
|
||
}
|
||
})
|
||
|
||
s.setClient("runner-dispatch", serverClient)
|
||
s.DispatchQueuedJobs()
|
||
|
||
var rr *otopb.RunRequest
|
||
select {
|
||
case rr = <-received:
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("timed out waiting for RunRequest")
|
||
}
|
||
|
||
job, err := store.GetJob(jobID)
|
||
if err != nil {
|
||
t.Fatalf("GetJob: %v", err)
|
||
}
|
||
if job.State != cicdstate.StateRunning {
|
||
t.Errorf("job state: want %s, got %s", cicdstate.StateRunning, job.State)
|
||
}
|
||
if job.ExecutionID == "" {
|
||
t.Fatal("no execution created for dispatched job")
|
||
}
|
||
exec, err := store.GetExecution(job.ExecutionID)
|
||
if err != nil {
|
||
t.Fatalf("GetExecution: %v", err)
|
||
}
|
||
if exec.State != cicdstate.StateRunning {
|
||
t.Errorf("execution state: want %s, got %s", cicdstate.StateRunning, exec.State)
|
||
}
|
||
if exec.RunnerID != "runner-dispatch" {
|
||
t.Errorf("execution runnerID: want runner-dispatch, got %s", exec.RunnerID)
|
||
}
|
||
|
||
// Verify RunRequest payload fields match the store state.
|
||
if rr.RunnerId != "runner-dispatch" {
|
||
t.Errorf("RunRequest.RunnerId: want runner-dispatch, got %s", rr.RunnerId)
|
||
}
|
||
if rr.JobId != jobID {
|
||
t.Errorf("RunRequest.JobId: want %s, got %s", jobID, rr.JobId)
|
||
}
|
||
if rr.ExecutionId != job.ExecutionID {
|
||
t.Errorf("RunRequest.ExecutionId: want %s, got %s", job.ExecutionID, rr.ExecutionId)
|
||
}
|
||
if rr.PipelineYaml != "pipeline: dispatch-test" {
|
||
t.Errorf("RunRequest.PipelineYaml: want %q, got %q", "pipeline: dispatch-test", rr.PipelineYaml)
|
||
}
|
||
if len(rr.Variables) == 0 || rr.Variables["ENV"] != "ci" {
|
||
t.Errorf("RunRequest.Variables: want {ENV:ci}, got %v", rr.Variables)
|
||
}
|
||
if len(rr.CommandTypes) != 1 || rr.CommandTypes[0] != "flutter" {
|
||
t.Errorf("RunRequest.CommandTypes: want [flutter], got %v", rr.CommandTypes)
|
||
}
|
||
}
|
||
|
||
// TestServerCancelExecutionSendsSocketCancelToRunner verifies the server-side
|
||
// cancel action uses the runner-facing socket as a best-effort signal channel.
|
||
func TestServerCancelExecutionSendsSocketCancelToRunner(t *testing.T) {
|
||
s := testServer(runnerregistry.New())
|
||
serverClient, runnerClient := pipeClientPair(t)
|
||
|
||
received := make(chan *otopb.CancelRunRequest, 1)
|
||
protoSocket.AddListenerTyped[*otopb.CancelRunRequest](&runnerClient.Communicator, func(req *otopb.CancelRunRequest) {
|
||
select {
|
||
case received <- req:
|
||
default:
|
||
}
|
||
})
|
||
|
||
s.setClient("runner-cancel", serverClient)
|
||
|
||
if err := s.CancelExecution("runner-cancel", "exec-cancel", "operator request"); err != nil {
|
||
t.Fatalf("CancelExecution: %v", err)
|
||
}
|
||
|
||
var req *otopb.CancelRunRequest
|
||
select {
|
||
case req = <-received:
|
||
case <-time.After(2 * time.Second):
|
||
t.Fatal("timed out waiting for CancelRunRequest")
|
||
}
|
||
if req.RunnerId != "runner-cancel" {
|
||
t.Errorf("CancelRunRequest.RunnerId: want runner-cancel, got %s", req.RunnerId)
|
||
}
|
||
if req.ExecutionId != "exec-cancel" {
|
||
t.Errorf("CancelRunRequest.ExecutionId: want exec-cancel, got %s", req.ExecutionId)
|
||
}
|
||
if req.Reason != "operator request" {
|
||
t.Errorf("CancelRunRequest.Reason: want operator request, got %s", req.Reason)
|
||
}
|
||
|
||
if err := s.CancelExecution("runner-missing", "exec-cancel", "operator request"); err == nil {
|
||
t.Fatal("expected missing connected runner to return an error")
|
||
}
|
||
}
|
||
|
||
// TestServerDispatchClaimsOnlyOneJobPerRunner verifies that a single
|
||
// DispatchQueuedJobs call dispatches at most one job per runner, leaving any
|
||
// remaining queued jobs untouched.
|
||
func TestServerDispatchClaimsOnlyOneJobPerRunner(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
registerRunner(t, registry, "runner-claim")
|
||
createQueuedJob(t, store, "job-claim-1")
|
||
createQueuedJob(t, store, "job-claim-2")
|
||
|
||
client, remote := pipeClient(t)
|
||
// Drain the remote end so Send does not block.
|
||
go func() {
|
||
buf := make([]byte, 4096)
|
||
for {
|
||
if _, err := remote.Read(buf); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
|
||
s.setClient("runner-claim", client)
|
||
s.DispatchQueuedJobs()
|
||
|
||
job1, err := store.GetJob("job-claim-1")
|
||
if err != nil {
|
||
t.Fatalf("GetJob job-claim-1: %v", err)
|
||
}
|
||
job2, err := store.GetJob("job-claim-2")
|
||
if err != nil {
|
||
t.Fatalf("GetJob job-claim-2: %v", err)
|
||
}
|
||
|
||
running, queued := 0, 0
|
||
for _, j := range []*cicdstate.Job{job1, job2} {
|
||
switch j.State {
|
||
case cicdstate.StateRunning:
|
||
running++
|
||
case cicdstate.StateQueued:
|
||
queued++
|
||
}
|
||
}
|
||
if running != 1 {
|
||
t.Errorf("want exactly 1 running job after one dispatch pass, got %d", running)
|
||
}
|
||
if queued != 1 {
|
||
t.Errorf("want exactly 1 queued job after one dispatch pass, got %d", queued)
|
||
}
|
||
}
|
||
|
||
// TestServerFailsExecutionWhenRunRequestCannotBeSent verifies that a send
|
||
// error over the socket transitions the claimed execution and job to failed
|
||
// rather than leaving them stuck in running.
|
||
func TestServerFailsExecutionWhenRunRequestCannotBeSent(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
registerRunner(t, registry, "runner-sendfail")
|
||
createQueuedJob(t, store, "job-sendfail")
|
||
|
||
client, remote := pipeClient(t)
|
||
// Close the remote end before dispatch so any write to the client fails.
|
||
_ = remote.Close()
|
||
|
||
s.setClient("runner-sendfail", client)
|
||
s.DispatchQueuedJobs()
|
||
|
||
job, err := store.GetJob("job-sendfail")
|
||
if err != nil {
|
||
t.Fatalf("GetJob: %v", err)
|
||
}
|
||
if job.State != cicdstate.StateFailed {
|
||
t.Errorf("job state: want %s, got %s", cicdstate.StateFailed, job.State)
|
||
}
|
||
if job.ExecutionID == "" {
|
||
t.Fatal("expected execution to be created before send failure")
|
||
}
|
||
exec, err := store.GetExecution(job.ExecutionID)
|
||
if err != nil {
|
||
t.Fatalf("GetExecution: %v", err)
|
||
}
|
||
if exec.State != cicdstate.StateFailed {
|
||
t.Errorf("execution state: want %s, got %s", cicdstate.StateFailed, exec.State)
|
||
}
|
||
}
|
||
|
||
// TestServerReportsRunResultOverSocket verifies that handleExecutionReport
|
||
// accepts success and failure terminal reports from the owning runner and
|
||
// transitions execution and job to the correct terminal state.
|
||
func TestServerReportsRunResultOverSocket(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
success bool
|
||
wantState string
|
||
}{
|
||
{"success", true, cicdstate.StateSucceeded},
|
||
{"failure", false, cicdstate.StateFailed},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
jobID := "job-report-" + tc.name
|
||
runnerID := "runner-report-" + tc.name
|
||
execID := setupRunningExecution(t, store, registry, jobID, runnerID)
|
||
|
||
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
|
||
RunnerId: runnerID,
|
||
JobId: jobID,
|
||
ExecutionId: execID,
|
||
Success: tc.success,
|
||
})
|
||
|
||
if !resp.GetAccepted() {
|
||
t.Fatalf("report rejected: %s", resp.GetErrorMessage())
|
||
}
|
||
exec, err := store.GetExecution(execID)
|
||
if err != nil {
|
||
t.Fatalf("GetExecution: %v", err)
|
||
}
|
||
if exec.State != tc.wantState {
|
||
t.Errorf("execution state: want %s, got %s", tc.wantState, exec.State)
|
||
}
|
||
job, err := store.GetJob(jobID)
|
||
if err != nil {
|
||
t.Fatalf("GetJob: %v", err)
|
||
}
|
||
if job.State != tc.wantState {
|
||
t.Errorf("job state: want %s, got %s", tc.wantState, job.State)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestServerRejectsTerminalReportForInactiveExecution verifies the active
|
||
// execution gate: terminal reports on queued, succeeded, or cancelled
|
||
// executions are rejected, and reports from the wrong runner are rejected.
|
||
func TestServerRejectsTerminalReportForInactiveExecution(t *testing.T) {
|
||
t.Run("queued_execution", func(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
registerRunner(t, registry, "runner-guard")
|
||
createQueuedJob(t, store, "job-guard-queued")
|
||
execID := store.NextExecutionID()
|
||
if _, err := store.CreateExecution("job-guard-queued", execID); err != nil {
|
||
t.Fatalf("CreateExecution: %v", err)
|
||
}
|
||
_ = store.SetExecutionRunnerID(execID, "runner-guard")
|
||
// Execution remains in queued state; terminal report must be rejected.
|
||
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
|
||
RunnerId: "runner-guard", JobId: "job-guard-queued", ExecutionId: execID, Success: true,
|
||
})
|
||
if resp.GetAccepted() {
|
||
t.Error("expected rejection for queued execution, got accepted")
|
||
}
|
||
})
|
||
|
||
t.Run("succeeded_execution", func(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
execID := setupRunningExecution(t, store, registry, "job-guard-succ", "runner-guard-succ")
|
||
_ = store.TransitionExecution(execID, cicdstate.StateSucceeded)
|
||
_ = store.TransitionJob("job-guard-succ", cicdstate.StateSucceeded)
|
||
|
||
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
|
||
RunnerId: "runner-guard-succ", JobId: "job-guard-succ", ExecutionId: execID, Success: true,
|
||
})
|
||
if resp.GetAccepted() {
|
||
t.Error("expected rejection for succeeded execution, got accepted")
|
||
}
|
||
})
|
||
|
||
t.Run("cancelled_execution", func(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
execID := setupRunningExecution(t, store, registry, "job-guard-cancel", "runner-guard-cancel")
|
||
if err := store.CancelJobExecution("job-guard-cancel", execID); err != nil {
|
||
t.Fatalf("CancelJobExecution: %v", err)
|
||
}
|
||
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
|
||
RunnerId: "runner-guard-cancel", JobId: "job-guard-cancel", ExecutionId: execID, Success: true,
|
||
})
|
||
if resp.GetAccepted() {
|
||
t.Error("expected rejection for cancelled execution, got accepted")
|
||
}
|
||
})
|
||
|
||
t.Run("wrong_runner", func(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
execID := setupRunningExecution(t, store, registry, "job-guard-runner", "runner-owner")
|
||
registerRunner(t, registry, "runner-other")
|
||
|
||
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
|
||
RunnerId: "runner-other", JobId: "job-guard-runner", ExecutionId: execID, Success: true,
|
||
})
|
||
if resp.GetAccepted() {
|
||
t.Error("expected rejection for wrong runner, got accepted")
|
||
}
|
||
if resp.GetErrorMessage() == "" {
|
||
t.Error("expected non-empty error message for wrong runner")
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestServerDispatchInvalidRunInputFails verifies that DispatchQueuedJobs
|
||
// transitions a job with an invalid RunInput directly to failed without creating
|
||
// an execution. Two invalid fixture variants are covered: both fields empty and
|
||
// both fields set simultaneously.
|
||
func TestServerDispatchInvalidRunInputFails(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
input *cicdstate.RunInput
|
||
}{
|
||
{
|
||
"both_empty",
|
||
&cicdstate.RunInput{PipelineYAML: "", PipelineYAMLPath: ""},
|
||
},
|
||
{
|
||
"both_set",
|
||
&cicdstate.RunInput{PipelineYAML: "pipeline: test", PipelineYAMLPath: "/some/path.yaml"},
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
runnerID := "runner-invalid-" + tc.name
|
||
jobID := "job-invalid-" + tc.name
|
||
registerRunner(t, registry, runnerID)
|
||
|
||
if _, err := store.CreateJob(jobID, jobID, tc.input); err != nil {
|
||
t.Fatalf("CreateJob: %v", err)
|
||
}
|
||
|
||
// No drain needed: validateRunInput fails before client.Send is called.
|
||
client, _ := pipeClient(t)
|
||
s.setClient(runnerID, client)
|
||
s.DispatchQueuedJobs()
|
||
|
||
job, err := store.GetJob(jobID)
|
||
if err != nil {
|
||
t.Fatalf("GetJob: %v", err)
|
||
}
|
||
if job.State != cicdstate.StateFailed {
|
||
t.Errorf("job state: want %s, got %s", cicdstate.StateFailed, job.State)
|
||
}
|
||
if job.ExecutionID != "" {
|
||
t.Errorf("no execution should be created for invalid run input; got execID %s", job.ExecutionID)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestServerReportMessageSavedToLog verifies that a non-empty Message in an
|
||
// ExecutionReportRequest is appended to the execution log after the report is
|
||
// accepted.
|
||
func TestServerReportMessageSavedToLog(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
success bool
|
||
message string
|
||
}{
|
||
{"success_with_message", true, "build succeeded: artifacts uploaded"},
|
||
{"failure_with_message", false, "build failed: compilation error"},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
registry := runnerregistry.New()
|
||
s, store := testServerWithStore(registry)
|
||
jobID := "job-log-" + tc.name
|
||
runnerID := "runner-log-" + tc.name
|
||
execID := setupRunningExecution(t, store, registry, jobID, runnerID)
|
||
|
||
resp := s.handleExecutionReport(&otopb.ExecutionReportRequest{
|
||
RunnerId: runnerID,
|
||
JobId: jobID,
|
||
ExecutionId: execID,
|
||
Success: tc.success,
|
||
Message: tc.message,
|
||
})
|
||
if !resp.GetAccepted() {
|
||
t.Fatalf("report rejected: %s", resp.GetErrorMessage())
|
||
}
|
||
|
||
logs, err := store.GetLogs(execID)
|
||
if err != nil {
|
||
t.Fatalf("GetLogs: %v", err)
|
||
}
|
||
found := false
|
||
for _, entry := range logs {
|
||
if entry.Line == tc.message {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("expected execution log to contain %q; got entries: %v", tc.message, logs)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestServerHeartbeatUsesRegisteredRunnerID verifies that connectedRunnerIDs
|
||
// reflects only currently active clients and that clientFor returns nil after
|
||
// the current client is removed.
|
||
func TestServerHeartbeatUsesRegisteredRunnerID(t *testing.T) {
|
||
s := testServer(runnerregistry.New())
|
||
|
||
client1, _ := pipeClient(t)
|
||
|
||
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
|
||
t.Errorf("expected no connected runners before any setClient; got %v", ids)
|
||
}
|
||
|
||
s.setClient("runner-D", client1)
|
||
|
||
ids := s.connectedRunnerIDs()
|
||
if len(ids) != 1 || ids[0] != "runner-D" {
|
||
t.Errorf("expected [runner-D] after setClient; got %v", ids)
|
||
}
|
||
|
||
// Current client removed → runner no longer in connected list.
|
||
s.removeClient("runner-D", client1)
|
||
|
||
if ids := s.connectedRunnerIDs(); len(ids) != 0 {
|
||
t.Errorf("expected no connected runners after removeClient; got %v", ids)
|
||
}
|
||
if got := s.clientFor("runner-D"); got != nil {
|
||
t.Errorf("expected nil from clientFor after removeClient; got %v", got)
|
||
}
|
||
}
|