원격 실행 요청을 기존 runner 실행 경로와 연결하고, step/log/artifact 보고 및 cancel/status/self-update 제어면을 정리한다. 로드맵과 task 로그도 완료 상태로 동기화한다.
1370 lines
48 KiB
Go
1370 lines
48 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"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 TestHandleHealthz(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleHealthz(rr, req)
|
|
|
|
if status := rr.Code; status != http.StatusOK {
|
|
t.Errorf("handleHealthz returned wrong status code: got %v want %v", status, http.StatusOK)
|
|
}
|
|
|
|
expected := "OK"
|
|
if body := rr.Body.String(); body != expected {
|
|
t.Errorf("handleHealthz returned unexpected body: got %v want %v", body, expected)
|
|
}
|
|
}
|
|
|
|
func TestHandleHealthz_MethodNotAllowed(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/healthz", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleHealthz(rr, req)
|
|
|
|
if status := rr.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("handleHealthz for POST returned wrong status code: got %v want %v", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestHandleReadyz(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleReadyz(rr, req)
|
|
|
|
if status := rr.Code; status != http.StatusOK {
|
|
t.Errorf("handleReadyz returned wrong status code: got %v want %v", status, http.StatusOK)
|
|
}
|
|
|
|
expected := "OK"
|
|
if body := rr.Body.String(); body != expected {
|
|
t.Errorf("handleReadyz returned unexpected body: got %v want %v", body, expected)
|
|
}
|
|
}
|
|
|
|
func TestHandleReadyz_MethodNotAllowed(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/readyz", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleReadyz(rr, req)
|
|
|
|
if status := rr.Code; status != http.StatusMethodNotAllowed {
|
|
t.Errorf("handleReadyz for POST returned wrong status code: got %v want %v", status, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestServerMux(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
server := NewServerWithRegistry(":0", registry)
|
|
mux := server.httpServer.Handler.(*http.ServeMux)
|
|
|
|
// Test healthz routing
|
|
reqHealth := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
|
rrHealth := httptest.NewRecorder()
|
|
mux.ServeHTTP(rrHealth, reqHealth)
|
|
if rrHealth.Code != http.StatusOK {
|
|
t.Errorf("mux /healthz failed: got status %v", rrHealth.Code)
|
|
}
|
|
|
|
// Test readyz routing
|
|
reqReady := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
|
rrReady := httptest.NewRecorder()
|
|
mux.ServeHTTP(rrReady, reqReady)
|
|
if rrReady.Code != http.StatusOK {
|
|
t.Errorf("mux /readyz failed: got status %v", rrReady.Code)
|
|
}
|
|
|
|
// Test fallback/not found routing
|
|
reqNotFound := httptest.NewRequest(http.MethodGet, "/unknown", nil)
|
|
rrNotFound := httptest.NewRecorder()
|
|
mux.ServeHTTP(rrNotFound, reqNotFound)
|
|
if rrNotFound.Code != http.StatusNotFound {
|
|
t.Errorf("mux /unknown routing status code: got %v want %v", rrNotFound.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerRegisterAcceptsAndStoresRunner(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
body := bytes.NewBufferString(`{
|
|
"enrollment_token":"token-123",
|
|
"runner_id":"runner-123",
|
|
"alias":"linux-build",
|
|
"protocol_version":"oto.runner.v1",
|
|
"capability":{"name":"oto-runner","version":"1.0.0"},
|
|
"command_catalog":{"command_types":["Shell","Git"]}
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/register", body)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerRegister(registry)(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
var response otopb.RegisterRunnerResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !response.GetAccepted() {
|
|
t.Fatalf("accepted=false, reason=%q", response.GetRejectReason())
|
|
}
|
|
record, ok := registry.Snapshot("runner-123")
|
|
if !ok {
|
|
t.Fatal("registered runner was not stored")
|
|
}
|
|
if record.Alias != "linux-build" {
|
|
t.Fatalf("alias = %q, want linux-build", record.Alias)
|
|
}
|
|
if got := record.CommandTypes; len(got) != 2 || got[0] != "Shell" || got[1] != "Git" {
|
|
t.Fatalf("command types = %#v, want [Shell Git]", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerRegisterRejectsMissingToken(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
body := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"protocol_version":"oto.runner.v1"
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/register", body)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerRegister(registry)(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
var response otopb.RegisterRunnerResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.GetAccepted() {
|
|
t.Fatal("missing token registration was accepted")
|
|
}
|
|
if response.GetRejectReason() != "missing enrollment token" {
|
|
t.Fatalf("reject reason = %q, want missing enrollment token", response.GetRejectReason())
|
|
}
|
|
if response.GetError() == nil {
|
|
t.Fatal("missing structured error")
|
|
}
|
|
if response.GetError().GetCode() != "registration_rejected" {
|
|
t.Fatalf("error code = %q, want registration_rejected", response.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerRegisterMethodNotAllowed(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/runners/register", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerRegister(runnerregistry.New())(rr, req)
|
|
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerHeartbeat(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
|
|
// 1. Heartbeat on unregistered runner should fail (404)
|
|
body := bytes.NewBufferString(`{"runner_id":"runner-123", "status":1}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/heartbeat", body)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerHeartbeat(registry)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %v, want %v for unregistered runner", rr.Code, http.StatusNotFound)
|
|
}
|
|
|
|
// 2. Register runner first
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-123",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{
|
|
Name: "oto-runner",
|
|
Version: "1.0.0",
|
|
},
|
|
})
|
|
|
|
// 3. Valid Heartbeat on registered runner should succeed
|
|
body = bytes.NewBufferString(`{"runner_id":"runner-123", "status":1}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/heartbeat", body)
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerHeartbeat(registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
|
|
var response otopb.HeartbeatResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode heartbeat response: %v", err)
|
|
}
|
|
if !response.GetSuccess() {
|
|
t.Fatal("heartbeat failed")
|
|
}
|
|
|
|
// Verify status is online
|
|
record, _ := registry.Snapshot("runner-123")
|
|
if record.Status != runnerregistry.StatusOnline {
|
|
t.Fatalf("status = %q, want %q", record.Status, runnerregistry.StatusOnline)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerDisconnect(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
|
|
// 1. Disconnect unregistered runner should fail (404)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/disconnect", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerDisconnect(registry)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
|
|
// 2. Register runner
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-123",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{
|
|
Name: "oto-runner",
|
|
Version: "1.0.0",
|
|
},
|
|
})
|
|
|
|
// 3. Disconnect registered runner should succeed
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/disconnect", nil)
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerDisconnect(registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
|
|
var res struct {
|
|
Success bool `json:"success"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if !res.Success {
|
|
t.Fatal("expected success=true")
|
|
}
|
|
|
|
record, _ := registry.Snapshot("runner-123")
|
|
if record.Status != runnerregistry.StatusDisconnected {
|
|
t.Fatalf("status = %q, want %q", record.Status, runnerregistry.StatusDisconnected)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetRunner(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
|
|
// 1. Get unregistered runner should return 404
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/runners/runner-123", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleGetRunner(registry)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
|
|
// 2. Register runner
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-123",
|
|
Alias: "build-linux",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{
|
|
Name: "oto-runner",
|
|
Version: "1.0.0",
|
|
},
|
|
})
|
|
|
|
// 3. Get registered runner should succeed (200) and return JSON record status
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/runners/runner-123", nil)
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleGetRunner(registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
|
|
var res map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if res["runner_id"] != "runner-123" {
|
|
t.Fatalf("runner_id = %v", res["runner_id"])
|
|
}
|
|
if res["status"] != runnerregistry.StatusAccepted {
|
|
t.Fatalf("status = %v", res["status"])
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerHeartbeatMismatch(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
|
|
// Register runner-a
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-a",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{
|
|
Name: "oto-runner",
|
|
Version: "1.0.0",
|
|
},
|
|
})
|
|
// Register runner-b
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-b",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{
|
|
Name: "oto-runner",
|
|
Version: "1.0.0",
|
|
},
|
|
})
|
|
|
|
// Heartbeat with mismatched runner ID in body vs path
|
|
body := bytes.NewBufferString(`{"runner_id":"runner-b", "status":1}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-a/heartbeat", body)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerHeartbeat(registry)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
var response otopb.HeartbeatResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.GetSuccess() {
|
|
t.Fatal("expected heartbeat to fail due to ID mismatch")
|
|
}
|
|
if response.GetErrorMessage() != "runner id mismatch between path and body" {
|
|
t.Fatalf("error message = %q", response.GetErrorMessage())
|
|
}
|
|
|
|
// Verify runner-b remains accepted (not revived/online)
|
|
rec, _ := registry.Snapshot("runner-b")
|
|
if rec.Status != runnerregistry.StatusAccepted {
|
|
t.Fatalf("runner-b status = %q, want %q", rec.Status, runnerregistry.StatusAccepted)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
|
|
// 1. Missing runner_id or enrollment_token should be rejected
|
|
bodyMissing := bytes.NewBufferString(`{"runner_id":"","enrollment_token":""}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMissing)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %v, want %v for missing runner_id/token", rr.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
// 2. Missing token only should be rejected
|
|
bodyNoToken := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":""}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyNoToken)
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %v, want %v for missing token", rr.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
// 3. Request should be rejected if server is HTTP and OTO_RUNNER_RELEASE_BASE_URL is not set
|
|
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "")
|
|
bodyValid := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
|
req.Host = "localhost:8080"
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 Bad Request when HTTP and no release override, got %v", rr.Code)
|
|
}
|
|
|
|
// 4. Request should be rejected if OTO_RUNNER_RELEASE_BASE_URL is not HTTPS
|
|
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "http://example.com/releases")
|
|
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
|
req.Host = "localhost:8080"
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 Bad Request when override is not HTTPS, got %v", rr.Code)
|
|
}
|
|
|
|
// 5. Valid request should return correct escaped command when OTO_RUNNER_RELEASE_BASE_URL is set to HTTPS
|
|
t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "https://example.com/releases")
|
|
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
|
req.Host = "localhost:8080"
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
|
|
var response otopb.BootstrapCommandResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode bootstrap command response: %v", err)
|
|
}
|
|
|
|
expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'"
|
|
if response.GetBootstrapCommand() != expectedCmd {
|
|
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedCmd)
|
|
}
|
|
|
|
// 6. Request with shell metacharacters in runner_id/token should be safely escaped
|
|
bodyMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMalicious)
|
|
req.Host = "localhost:8080"
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode bootstrap command response: %v", err)
|
|
}
|
|
|
|
expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --agent-id 'runner; rm -rf /' --enrollment-token 'token'\\''$(say hello)'\\''' --release-base-url 'https://example.com/releases'"
|
|
if response.GetBootstrapCommand() != expectedEscapedCmd {
|
|
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd)
|
|
}
|
|
|
|
// 7. Malicious Host header with invalid characters should be rejected
|
|
bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
|
req.Host = "localhost; rm -rf /"
|
|
rr = httptest.NewRecorder()
|
|
|
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 Bad Request for malicious Host header, got %v", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleServeBootstrapScript(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/bootstrap/oto-agent.sh", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleServeBootstrapScript()(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v; body = %s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
|
|
if contentType := rr.Header().Get("Content-Type"); contentType != "application/x-sh" {
|
|
t.Fatalf("Content-Type = %q, want application/x-sh", contentType)
|
|
}
|
|
|
|
if len(rr.Body.Bytes()) == 0 {
|
|
t.Fatal("empty bootstrap script returned")
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateJob(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
|
|
// Valid job creation should succeed
|
|
body := bytes.NewBufferString(`{"id":"job-1","name":"build"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
|
|
}
|
|
|
|
var res map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if res["id"] != "job-1" {
|
|
t.Fatalf("id = %v, want job-1", res["id"])
|
|
}
|
|
if res["name"] != "build" {
|
|
t.Fatalf("name = %v, want build", res["name"])
|
|
}
|
|
if res["state"] != cicdstate.StateQueued {
|
|
t.Fatalf("state = %v, want %s", res["state"], cicdstate.StateQueued)
|
|
}
|
|
|
|
// Get job by ID
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-1", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if res["id"] != "job-1" {
|
|
t.Fatalf("GET job id = %v, want job-1", res["id"])
|
|
}
|
|
|
|
// Unknown job ID should return 404
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/jobs/nonexistent", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown job status = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
|
|
// Duplicate job should return 409
|
|
body = bytes.NewBufferString(`{"id":"job-1","name":"duplicate"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("duplicate job status = %v, want %v", rr.Code, http.StatusConflict)
|
|
}
|
|
|
|
// Missing fields should return 400
|
|
body = bytes.NewBufferString(`{"id":"job-2"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing fields status = %v, want %v", rr.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateJobStoresRunRequest(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
|
|
body := bytes.NewBufferString(`{
|
|
"id":"job-run",
|
|
"name":"build",
|
|
"run_request":{
|
|
"pipeline_yaml":"commands:\n - type: Shell",
|
|
"variables":{"FLAVOR":"release"},
|
|
"command_types":["Shell","Git"]
|
|
}
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", body)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
|
|
}
|
|
|
|
// Stored input must survive on the store.
|
|
job, err := store.GetJob("job-run")
|
|
if err != nil {
|
|
t.Fatalf("GetJob failed: %v", err)
|
|
}
|
|
if job.RunInput == nil || job.RunInput.PipelineYAML != "commands:\n - type: Shell" {
|
|
t.Fatalf("stored RunInput = %+v", job.RunInput)
|
|
}
|
|
if job.RunInput.Variables["FLAVOR"] != "release" {
|
|
t.Fatalf("stored variables = %+v", job.RunInput.Variables)
|
|
}
|
|
|
|
// GET /jobs/{id} must expose the stored input.
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/jobs/job-run", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("get status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
var res map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
runReq, ok := res["run_request"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("run_request missing in GET job: %v", res["run_request"])
|
|
}
|
|
if runReq["pipeline_yaml"] != "commands:\n - type: Shell" {
|
|
t.Fatalf("GET run_request pipeline_yaml = %v", runReq["pipeline_yaml"])
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerCicdClaimReturnsRunRequest(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
registry := runnerregistry.New()
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-123",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
|
})
|
|
if _, err := store.CreateJob("job-1", "build", &cicdstate.RunInput{
|
|
PipelineYAMLPath: "/pipelines/build.yaml",
|
|
CommandTypes: []string{"Shell"},
|
|
}); err != nil {
|
|
t.Fatalf("CreateJob failed: %v", err)
|
|
}
|
|
|
|
claimBody := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"job_id":"job-1",
|
|
"execution_id":"exec-1"
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/jobs/claim", claimBody)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("claim status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
var claim otopb.JobClaimResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &claim); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if claim.GetRunRequest() == nil {
|
|
t.Fatal("claim run_request is nil")
|
|
}
|
|
if claim.GetRunRequest().GetPipelineYamlPath() != "/pipelines/build.yaml" {
|
|
t.Fatalf("claim run_request pipeline_yaml_path = %q", claim.GetRunRequest().GetPipelineYamlPath())
|
|
}
|
|
if claim.GetRunRequest().GetRunnerId() != "runner-123" ||
|
|
claim.GetRunRequest().GetJobId() != "job-1" ||
|
|
claim.GetRunRequest().GetExecutionId() != "exec-1" {
|
|
t.Fatalf("claim run_request ids = %+v", claim.GetRunRequest())
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerCicdNextJobClaim(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
registry := runnerregistry.New()
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-123",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
|
})
|
|
|
|
// Scenario 1: Claim when no job is queued.
|
|
claimBodyNoJob := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"job_id":"",
|
|
"execution_id":""
|
|
}`)
|
|
req1 := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/jobs/claim", claimBodyNoJob)
|
|
rr1 := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr1, req1)
|
|
if rr1.Code != http.StatusOK {
|
|
t.Fatalf("claim next job status = %v, want %v", rr1.Code, http.StatusOK)
|
|
}
|
|
var claim1 otopb.JobClaimResponse
|
|
if err := json.Unmarshal(rr1.Body.Bytes(), &claim1); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if claim1.GetAccepted() {
|
|
t.Fatal("expected claim to not be accepted when no jobs are queued")
|
|
}
|
|
|
|
// Scenario 2: Claim when a job is queued.
|
|
if _, err := store.CreateJob("job-next", "build", &cicdstate.RunInput{
|
|
PipelineYAMLPath: "/pipelines/next.yaml",
|
|
CommandTypes: []string{"Shell"},
|
|
}); err != nil {
|
|
t.Fatalf("CreateJob failed: %v", err)
|
|
}
|
|
|
|
claimBodyQueued := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"job_id":"",
|
|
"execution_id":""
|
|
}`)
|
|
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/jobs/claim", claimBodyQueued)
|
|
rr2 := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr2, req2)
|
|
if rr2.Code != http.StatusOK {
|
|
t.Fatalf("claim next job status = %v, want %v; body=%s", rr2.Code, http.StatusOK, rr2.Body.String())
|
|
}
|
|
var claim2 otopb.JobClaimResponse
|
|
if err := json.Unmarshal(rr2.Body.Bytes(), &claim2); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if !claim2.GetAccepted() {
|
|
t.Fatal("expected claim to be accepted")
|
|
}
|
|
if claim2.GetJobId() != "job-next" {
|
|
t.Fatalf("expected job id 'job-next', got %q", claim2.GetJobId())
|
|
}
|
|
if claim2.GetExecutionId() == "" {
|
|
t.Fatal("expected non-empty generated execution id")
|
|
}
|
|
if claim2.GetRunRequest().GetPipelineYamlPath() != "/pipelines/next.yaml" {
|
|
t.Fatalf("expected run request pipeline_yaml_path '/pipelines/next.yaml', got %q", claim2.GetRunRequest().GetPipelineYamlPath())
|
|
}
|
|
|
|
// Check if the job state has changed in the store.
|
|
job, err := store.GetJob("job-next")
|
|
if err != nil {
|
|
t.Fatalf("GetJob failed: %v", err)
|
|
}
|
|
if job.State != cicdstate.StateRunning {
|
|
t.Fatalf("expected job state running, got %q", job.State)
|
|
}
|
|
if job.ExecutionID != claim2.GetExecutionId() {
|
|
t.Fatalf("expected job execution id %q, got %q", claim2.GetExecutionId(), job.ExecutionID)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerCicdRejectsInvalidRunInput(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
registry := runnerregistry.New()
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-123",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
|
})
|
|
|
|
// Job with no run request → claim is rejected with 409.
|
|
if _, err := store.CreateJob("job-missing", "build", nil); err != nil {
|
|
t.Fatalf("CreateJob failed: %v", err)
|
|
}
|
|
missingBody := bytes.NewBufferString(`{"runner_id":"runner-123","job_id":"job-missing","execution_id":"exec-1"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/jobs/claim", missingBody)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
if rr.Code != http.StatusConflict {
|
|
t.Fatalf("missing run input claim status = %v, want %v; body=%s", rr.Code, http.StatusConflict, rr.Body.String())
|
|
}
|
|
|
|
// Job with both pipeline_yaml and pipeline_yaml_path → claim is rejected with 400.
|
|
if _, err := store.CreateJob("job-both", "build", &cicdstate.RunInput{
|
|
PipelineYAML: "commands: []",
|
|
PipelineYAMLPath: "/pipelines/build.yaml",
|
|
}); err != nil {
|
|
t.Fatalf("CreateJob failed: %v", err)
|
|
}
|
|
bothBody := bytes.NewBufferString(`{"runner_id":"runner-123","job_id":"job-both","execution_id":"exec-2"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/jobs/claim", bothBody)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid run input claim status = %v, want %v; body=%s", rr.Code, http.StatusBadRequest, rr.Body.String())
|
|
}
|
|
|
|
// The rejected job must remain queued (no state mutation / execution created).
|
|
job, err := store.GetJob("job-both")
|
|
if err != nil {
|
|
t.Fatalf("GetJob failed: %v", err)
|
|
}
|
|
if job.State != cicdstate.StateQueued || job.ExecutionID != "" {
|
|
t.Fatalf("rejected job mutated state = %+v", job)
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateExecution(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
store.CreateJob("job-1", "build", nil)
|
|
|
|
body := bytes.NewBufferString(`{"id":"exec-1"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/job-1/executions", body)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
|
|
}
|
|
|
|
var execRes map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &execRes); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if execRes["id"] != "exec-1" {
|
|
t.Fatalf("execution id = %v, want exec-1", execRes["id"])
|
|
}
|
|
|
|
// Get execution by ID
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET execution status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &execRes); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if execRes["id"] != "exec-1" {
|
|
t.Fatalf("GET execution id = %v, want exec-1", execRes["id"])
|
|
}
|
|
|
|
// Unknown execution should return 404
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/nonexistent", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown execution status = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
|
|
// Execution for unknown job should return 400
|
|
body = bytes.NewBufferString(`{"id":"exec-99"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs/unknown/executions", body)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("unknown job execution status = %v, want %v", rr.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestHandleExecutionLogsAndArtifacts(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
store.CreateJob("job-1", "build", nil)
|
|
store.CreateExecution("job-1", "exec-1")
|
|
|
|
// Append log
|
|
logBody := bytes.NewBufferString(`{"line":"build starting"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/logs", logBody)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("append log status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
|
|
}
|
|
|
|
// Append another log
|
|
logBody = bytes.NewBufferString(`{"line":"build done"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/logs", logBody)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("append log 2 status = %v, want %v", rr.Code, http.StatusCreated)
|
|
}
|
|
|
|
// Get logs
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1/logs", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("get logs status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
var logRes map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &logRes); err != nil {
|
|
t.Fatalf("decode log response: %v", err)
|
|
}
|
|
logsArr, ok := logRes["logs"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("logs key not an array")
|
|
}
|
|
if len(logsArr) != 2 {
|
|
t.Fatalf("log count = %d, want 2", len(logsArr))
|
|
}
|
|
|
|
// Append artifact
|
|
artifactBody := bytes.NewBufferString(`{"name":"binary","path":"/dist/app"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/exec-1/artifacts", artifactBody)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("append artifact status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
|
|
}
|
|
|
|
// Get artifacts
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/exec-1/artifacts", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("get artifacts status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
var artRes map[string]interface{}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &artRes); err != nil {
|
|
t.Fatalf("decode artifact response: %v", err)
|
|
}
|
|
artifactsArr, ok := artRes["artifacts"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("artifacts key not an array")
|
|
}
|
|
if len(artifactsArr) != 1 {
|
|
t.Fatalf("artifact count = %d, want 1", len(artifactsArr))
|
|
}
|
|
|
|
// Unknown execution logs/artifacts should return 404
|
|
logBody2 := bytes.NewBufferString(`{"line":"second log line"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/unknown/logs", logBody2)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("unknown log append status = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
|
|
artifactBody2 := bytes.NewBufferString(`{"name":"binary","path":"/dist/app"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/executions/unknown/artifacts", artifactBody2)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("unknown artifact append status = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestHandleCicdUnknownIds(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
|
|
// Get unknown job -> 404
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/nonexistent", nil)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown job = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
|
|
// Create execution for unknown job -> 400
|
|
body := bytes.NewBufferString(`{"id":"exec-1"}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/jobs/nonexistent/executions", body)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("exec unknown job = %v, want %v", rr.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
// Get unknown execution -> 404
|
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/executions/nonexistent", nil)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("GET unknown exec = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestHandleCicdMethodNotAllowed(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
|
|
// GET on /api/v1/jobs (create endpoint) should return method not allowed
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("GET /api/v1/jobs = %v, want %v", rr.Code, http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func TestHandleCicdNotFound(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
|
|
// Unknown path should return 404
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/unknown", nil)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, nil)(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("unknown path = %v, want %v", rr.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerCicdClaimReportLogsAndArtifacts(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
registry := runnerregistry.New()
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-123",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{
|
|
Name: "oto-runner",
|
|
Version: "1.0.0",
|
|
},
|
|
})
|
|
if _, err := store.CreateJob("job-1", "build", &cicdstate.RunInput{
|
|
PipelineYAML: "commands:\n - type: Shell",
|
|
Variables: map[string]string{"FLAVOR": "release"},
|
|
CommandTypes: []string{"Shell", "Git"},
|
|
}); err != nil {
|
|
t.Fatalf("CreateJob failed: %v", err)
|
|
}
|
|
|
|
claimBody := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"job_id":"job-1",
|
|
"execution_id":"exec-1"
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/jobs/claim", claimBody)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("claim status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
var claim otopb.JobClaimResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &claim); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if !claim.GetAccepted() || claim.GetState() != cicdstate.StateRunning {
|
|
t.Fatalf("claim response = %+v, want accepted running", claim)
|
|
}
|
|
if claim.GetRunRequest().GetPipelineYaml() != "commands:\n - type: Shell" {
|
|
t.Fatalf("claim run_request pipeline yaml = %q", claim.GetRunRequest().GetPipelineYaml())
|
|
}
|
|
if claim.GetRunRequest().GetJobId() != "job-1" || claim.GetRunRequest().GetExecutionId() != "exec-1" || claim.GetRunRequest().GetRunnerId() != "runner-123" {
|
|
t.Fatalf("claim run_request ids = %+v", claim.GetRunRequest())
|
|
}
|
|
|
|
job, err := store.GetJob("job-1")
|
|
if err != nil {
|
|
t.Fatalf("GetJob after claim failed: %v", err)
|
|
}
|
|
if job.State != cicdstate.StateRunning || job.ExecutionID != "exec-1" {
|
|
t.Fatalf("job after claim = %+v", job)
|
|
}
|
|
|
|
logBody := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"execution_id":"exec-1",
|
|
"line":"build started"
|
|
}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/executions/exec-1/logs", logBody)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("append log status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
|
|
}
|
|
|
|
artifactBody := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"execution_id":"exec-1",
|
|
"name":"binary",
|
|
"path":"/dist/app"
|
|
}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/executions/exec-1/artifacts", artifactBody)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
if rr.Code != http.StatusCreated {
|
|
t.Fatalf("append artifact status = %v, want %v; body=%s", rr.Code, http.StatusCreated, rr.Body.String())
|
|
}
|
|
|
|
reportBody := bytes.NewBufferString(`{
|
|
"runner_id":"runner-123",
|
|
"job_id":"job-1",
|
|
"execution_id":"exec-1",
|
|
"success":true,
|
|
"exit_code":0,
|
|
"message":"Build completed successfully.",
|
|
"step_events":[{"event":"completed"}]
|
|
}`)
|
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-123/executions/exec-1/report", reportBody)
|
|
rr = httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("report status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
var report otopb.ExecutionReportResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &report); err != nil {
|
|
t.Fatalf("decode report response: %v", err)
|
|
}
|
|
if !report.GetAccepted() || report.GetState() != cicdstate.StateSucceeded {
|
|
t.Fatalf("report response = %+v, want accepted succeeded", report)
|
|
}
|
|
|
|
exec, err := store.GetExecution("exec-1")
|
|
if err != nil {
|
|
t.Fatalf("GetExecution after report failed: %v", err)
|
|
}
|
|
if exec.State != cicdstate.StateSucceeded {
|
|
t.Fatalf("execution state = %q, want %q", exec.State, cicdstate.StateSucceeded)
|
|
}
|
|
if len(exec.Logs) != 2 {
|
|
t.Fatalf("log count = %d, want explicit log plus report message", len(exec.Logs))
|
|
}
|
|
if len(exec.Artifacts) != 1 || exec.Artifacts[0].Name != "binary" {
|
|
t.Fatalf("artifacts = %+v, want binary artifact", exec.Artifacts)
|
|
}
|
|
|
|
job, err = store.GetJob("job-1")
|
|
if err != nil {
|
|
t.Fatalf("GetJob after report failed: %v", err)
|
|
}
|
|
if job.State != cicdstate.StateSucceeded {
|
|
t.Fatalf("job state = %q, want %q", job.State, cicdstate.StateSucceeded)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerCicdRejectsUnknownRunner(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
if _, err := store.CreateJob("job-1", "build", nil); err != nil {
|
|
t.Fatalf("CreateJob failed: %v", err)
|
|
}
|
|
|
|
body := bytes.NewBufferString(`{
|
|
"runner_id":"missing-runner",
|
|
"job_id":"job-1",
|
|
"execution_id":"exec-1"
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/missing-runner/jobs/claim", body)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, runnerregistry.New())(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusNotFound, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerRegisterRejectsIncompatibleProtocol(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
body := bytes.NewBufferString(`{
|
|
"enrollment_token":"token-123",
|
|
"runner_id":"runner-123",
|
|
"protocol_version":"oto.runner.v2",
|
|
"capability":{"name":"oto-runner","version":"1.0.0"}
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/register", body)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerRegister(registry)(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
var response otopb.RegisterRunnerResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.GetAccepted() {
|
|
t.Fatal("incompatible protocol registration was accepted")
|
|
}
|
|
if response.GetRejectReason() != `unsupported protocol version: "oto.runner.v2"` {
|
|
t.Fatalf("reject reason = %q", response.GetRejectReason())
|
|
}
|
|
if response.GetError() == nil {
|
|
t.Fatal("missing structured error")
|
|
}
|
|
if response.GetError().GetCode() != "incompatible_runner" {
|
|
t.Fatalf("error code = %q, want incompatible_runner", response.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerRegisterRejectsIncompatibleCapability(t *testing.T) {
|
|
registry := runnerregistry.New()
|
|
body := bytes.NewBufferString(`{
|
|
"enrollment_token":"token-123",
|
|
"runner_id":"runner-123",
|
|
"protocol_version":"oto.runner.v1",
|
|
"capability":{"name":"oto-runner","version":"2.0.0"}
|
|
}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/register", body)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handleRunnerRegister(registry)(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
var response otopb.RegisterRunnerResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.GetAccepted() {
|
|
t.Fatal("incompatible capability version registration was accepted")
|
|
}
|
|
if response.GetRejectReason() != `incompatible capability major version: "2.0.0", want major version 1` {
|
|
t.Fatalf("reject reason = %q", response.GetRejectReason())
|
|
}
|
|
if response.GetError() == nil {
|
|
t.Fatal("missing structured error")
|
|
}
|
|
if response.GetError().GetCode() != "incompatible_runner" {
|
|
t.Fatalf("error code = %q, want incompatible_runner", response.GetError().GetCode())
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerCancelExecution(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
registry := runnerregistry.New()
|
|
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-1",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
|
})
|
|
|
|
store.CreateJob("job-1", "build", nil)
|
|
store.CreateExecution("job-1", "exec-1")
|
|
store.SetExecutionRunnerID("exec-1", "runner-1")
|
|
store.TransitionJob("job-1", cicdstate.StateRunning)
|
|
store.TransitionExecution("exec-1", cicdstate.StateRunning)
|
|
|
|
// 1. Success cancellation
|
|
body := bytes.NewBufferString(`{"runner_id":"runner-1", "execution_id":"exec-1", "reason":"user request"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-1/executions/exec-1/cancel", body)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("cancel status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String())
|
|
}
|
|
var cancelResp otopb.CancelRunResponse
|
|
json.Unmarshal(rr.Body.Bytes(), &cancelResp)
|
|
if !cancelResp.Success {
|
|
t.Fatal("expected success cancel response")
|
|
}
|
|
|
|
job, _ := store.GetJob("job-1")
|
|
if job.State != cicdstate.StateCanceled {
|
|
t.Fatalf("job state = %q, want canceled", job.State)
|
|
}
|
|
|
|
// 2. Mismatch cancellation
|
|
bodyMismatch := bytes.NewBufferString(`{"runner_id":"runner-wrong", "execution_id":"exec-1"}`)
|
|
reqMismatch := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-1/executions/exec-1/cancel", bodyMismatch)
|
|
rrMismatch := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rrMismatch, reqMismatch)
|
|
if rrMismatch.Code != http.StatusBadRequest {
|
|
t.Fatalf("mismatch cancel status = %v, want %v", rrMismatch.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
// 3. Terminal state cancellation rejection
|
|
store.CreateJob("job-2", "build", nil)
|
|
store.CreateExecution("job-2", "exec-2")
|
|
store.SetExecutionRunnerID("exec-2", "runner-1")
|
|
store.TransitionJob("job-2", cicdstate.StateRunning)
|
|
store.TransitionExecution("exec-2", cicdstate.StateRunning)
|
|
store.TransitionJob("job-2", cicdstate.StateSucceeded)
|
|
store.TransitionExecution("exec-2", cicdstate.StateSucceeded)
|
|
|
|
bodyTerminal := bytes.NewBufferString(`{"runner_id":"runner-1", "execution_id":"exec-2"}`)
|
|
reqTerminal := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-1/executions/exec-2/cancel", bodyTerminal)
|
|
rrTerminal := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rrTerminal, reqTerminal)
|
|
if rrTerminal.Code != http.StatusBadRequest {
|
|
t.Fatalf("terminal cancel status = %v, want %v; body=%s", rrTerminal.Code, http.StatusBadRequest, rrTerminal.Body.String())
|
|
}
|
|
|
|
// 4. Cross-runner execution cancel rejection
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-2",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
|
})
|
|
|
|
store.CreateJob("job-3", "build", nil)
|
|
store.CreateExecution("job-3", "exec-3")
|
|
store.SetExecutionRunnerID("exec-3", "runner-1")
|
|
store.TransitionJob("job-3", cicdstate.StateRunning)
|
|
store.TransitionExecution("exec-3", cicdstate.StateRunning)
|
|
|
|
bodyCross := bytes.NewBufferString(`{"runner_id":"runner-2", "execution_id":"exec-3"}`)
|
|
reqCross := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-2/executions/exec-3/cancel", bodyCross)
|
|
rrCross := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rrCross, reqCross)
|
|
if rrCross.Code != http.StatusBadRequest {
|
|
t.Fatalf("cross-runner cancel status = %v, want %v; body=%s", rrCross.Code, http.StatusBadRequest, rrCross.Body.String())
|
|
}
|
|
|
|
// Verify job and execution remain running
|
|
job3, _ := store.GetJob("job-3")
|
|
if job3.State != cicdstate.StateRunning {
|
|
t.Fatalf("expected job state to remain running, got %q", job3.State)
|
|
}
|
|
exec3, _ := store.GetExecution("exec-3")
|
|
if exec3.State != cicdstate.StateRunning {
|
|
t.Fatalf("expected execution state to remain running, got %q", exec3.State)
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerStatus(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
registry := runnerregistry.New()
|
|
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-1",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
|
})
|
|
|
|
store.CreateJob("job-1", "build", nil)
|
|
store.CreateExecution("job-1", "exec-1")
|
|
store.SetExecutionRunnerID("exec-1", "runner-1")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/runners/runner-1/status", nil)
|
|
rr := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status code = %v, want %v", rr.Code, http.StatusOK)
|
|
}
|
|
|
|
var res map[string]interface{}
|
|
json.Unmarshal(rr.Body.Bytes(), &res)
|
|
if res["runner_id"] != "runner-1" {
|
|
t.Fatalf("runner_id = %v", res["runner_id"])
|
|
}
|
|
if res["current_execution_id"] != "exec-1" {
|
|
t.Fatalf("current_execution_id = %v", res["current_execution_id"])
|
|
}
|
|
if res["current_job_id"] != "job-1" {
|
|
t.Fatalf("current_job_id = %v", res["current_job_id"])
|
|
}
|
|
}
|
|
|
|
func TestHandleRunnerSelfUpdate(t *testing.T) {
|
|
store := cicdstate.NewStore()
|
|
registry := runnerregistry.New()
|
|
|
|
registry.Register(&otopb.RegisterRunnerRequest{
|
|
EnrollmentToken: "token-123",
|
|
RunnerId: "runner-1",
|
|
ProtocolVersion: "oto.runner.v1",
|
|
Capability: &otopb.RunnerCapability{Name: "oto-runner", Version: "1.0.0"},
|
|
})
|
|
|
|
// 1. Invalid URL (not HTTPS)
|
|
bodyHttp := bytes.NewBufferString(`{"runner_id":"runner-1", "version":"2.0.0", "download_url":"http://example.com/binary"}`)
|
|
reqHttp := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-1/self-update", bodyHttp)
|
|
rrHttp := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rrHttp, reqHttp)
|
|
if rrHttp.Code != http.StatusBadRequest {
|
|
t.Fatalf("http url update status = %v, want %v", rrHttp.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
// 2. Idle accepted
|
|
bodyOk := bytes.NewBufferString(`{"runner_id":"runner-1", "version":"2.0.0", "download_url":"https://example.com/binary"}`)
|
|
reqOk := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-1/self-update", bodyOk)
|
|
rrOk := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rrOk, reqOk)
|
|
if rrOk.Code != http.StatusOK {
|
|
t.Fatalf("idle update status = %v, want %v", rrOk.Code, http.StatusOK)
|
|
}
|
|
var resOk map[string]interface{}
|
|
json.Unmarshal(rrOk.Body.Bytes(), &resOk)
|
|
if resOk["accepted"] != true || resOk["deferred"] != false {
|
|
t.Fatalf("idle update response = %+v", resOk)
|
|
}
|
|
|
|
// 3. Active execution defer
|
|
store.CreateJob("job-1", "build", nil)
|
|
store.CreateExecution("job-1", "exec-1")
|
|
store.SetExecutionRunnerID("exec-1", "runner-1")
|
|
store.TransitionJob("job-1", cicdstate.StateRunning)
|
|
store.TransitionExecution("exec-1", cicdstate.StateRunning)
|
|
|
|
bodyActive := bytes.NewBufferString(`{"runner_id":"runner-1", "version":"2.0.0", "download_url":"https://example.com/binary"}`)
|
|
reqActive := httptest.NewRequest(http.MethodPost, "/api/v1/runners/runner-1/self-update", bodyActive)
|
|
rrActive := httptest.NewRecorder()
|
|
handleRouter(store, registry)(rrActive, reqActive)
|
|
if rrActive.Code != http.StatusOK {
|
|
t.Fatalf("active update status = %v, want %v", rrActive.Code, http.StatusOK)
|
|
}
|
|
var resActive map[string]interface{}
|
|
json.Unmarshal(rrActive.Body.Bytes(), &resActive)
|
|
if resActive["accepted"] != false || resActive["deferred"] != true {
|
|
t.Fatalf("active update response = %+v", resActive)
|
|
}
|
|
}
|