feat: runtime manager
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
package internalhttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/rtmanager/internal/api/internalhttp/handlers"
|
||||
domainruntime "galaxy/rtmanager/internal/domain/runtime"
|
||||
"galaxy/rtmanager/internal/ports"
|
||||
"galaxy/rtmanager/internal/service/cleanupcontainer"
|
||||
"galaxy/rtmanager/internal/service/patchruntime"
|
||||
"galaxy/rtmanager/internal/service/restartruntime"
|
||||
"galaxy/rtmanager/internal/service/startruntime"
|
||||
"galaxy/rtmanager/internal/service/stopruntime"
|
||||
|
||||
"github.com/getkin/kin-openapi/openapi3"
|
||||
"github.com/getkin/kin-openapi/openapi3filter"
|
||||
"github.com/getkin/kin-openapi/routers"
|
||||
"github.com/getkin/kin-openapi/routers/legacy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestInternalRESTConformance loads the OpenAPI specification, drives
|
||||
// every runtime operation against the live internal HTTP listener
|
||||
// backed by stub services, and validates each response body against
|
||||
// the spec via `openapi3filter.ValidateResponse`. The test catches
|
||||
// drift between the wire shape produced by the handler layer and the
|
||||
// frozen contract; failure-path response shapes are validated by the
|
||||
// per-handler tests in `handlers/<op>_test.go`.
|
||||
func TestInternalRESTConformance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := loadConformanceSpec(t)
|
||||
|
||||
router, err := legacy.NewRouter(doc)
|
||||
require.NoError(t, err)
|
||||
|
||||
deps := newConformanceDeps(t)
|
||||
server, err := NewServer(newConformanceConfig(), Dependencies{
|
||||
Logger: nil,
|
||||
Telemetry: nil,
|
||||
Readiness: nil,
|
||||
RuntimeRecords: deps.records,
|
||||
StartRuntime: deps.start,
|
||||
StopRuntime: deps.stop,
|
||||
RestartRuntime: deps.restart,
|
||||
PatchRuntime: deps.patch,
|
||||
CleanupContainer: deps.cleanup,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []conformanceCase{
|
||||
{
|
||||
name: "internalListRuntimes",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/runtimes",
|
||||
},
|
||||
{
|
||||
name: "internalGetRuntime",
|
||||
method: http.MethodGet,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID,
|
||||
},
|
||||
{
|
||||
name: "internalStartRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/start",
|
||||
contentType: "application/json",
|
||||
body: `{"image_ref":"galaxy/game:v1.2.3"}`,
|
||||
},
|
||||
{
|
||||
name: "internalStopRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/stop",
|
||||
contentType: "application/json",
|
||||
body: `{"reason":"admin_request"}`,
|
||||
},
|
||||
{
|
||||
name: "internalRestartRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/restart",
|
||||
},
|
||||
{
|
||||
name: "internalPatchRuntime",
|
||||
method: http.MethodPost,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/patch",
|
||||
contentType: "application/json",
|
||||
body: `{"image_ref":"galaxy/game:v1.2.4"}`,
|
||||
},
|
||||
{
|
||||
name: "internalCleanupRuntimeContainer",
|
||||
method: http.MethodDelete,
|
||||
path: "/api/v1/internal/runtimes/" + conformanceGameID + "/container",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
runConformanceCase(t, server.handler, router, tc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceGameID is the path variable used for every per-game
|
||||
// conformance request.
|
||||
const conformanceGameID = "game-conformance"
|
||||
|
||||
// conformanceServerURL mirrors the canonical `servers[0].url` entry in
|
||||
// `rtmanager/api/internal-openapi.yaml`. The legacy router matches
|
||||
// requests against this prefix; updating the spec's server URL
|
||||
// requires updating this constant.
|
||||
const conformanceServerURL = "http://localhost:8096"
|
||||
|
||||
// conformanceCase describes one request the conformance test drives.
|
||||
type conformanceCase struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
contentType string
|
||||
body string
|
||||
}
|
||||
|
||||
func runConformanceCase(t *testing.T, handler http.Handler, router routers.Router, tc conformanceCase) {
|
||||
t.Helper()
|
||||
|
||||
// Drive the handler with the path-only form so the listener's
|
||||
// http.ServeMux matches the registered routes (which use raw paths,
|
||||
// without the OpenAPI server URL prefix).
|
||||
var bodyReader io.Reader
|
||||
if tc.body != "" {
|
||||
bodyReader = strings.NewReader(tc.body)
|
||||
}
|
||||
request := httptest.NewRequest(tc.method, tc.path, bodyReader)
|
||||
if tc.contentType != "" {
|
||||
request.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
request.Header.Set("X-Galaxy-Caller", "admin")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
require.Equalf(t, http.StatusOK, recorder.Code, "operation %s returned %d: %s", tc.name, recorder.Code, recorder.Body.String())
|
||||
|
||||
// kin-openapi's legacy router requires the request URL to match a
|
||||
// `servers[].url` entry; rebuild the validation request with the
|
||||
// canonical local server URL declared in the spec.
|
||||
validationURL := conformanceServerURL + tc.path
|
||||
validationRequest := httptest.NewRequest(tc.method, validationURL, bodyReaderFor(tc.body))
|
||||
if tc.contentType != "" {
|
||||
validationRequest.Header.Set("Content-Type", tc.contentType)
|
||||
}
|
||||
validationRequest.Header.Set("X-Galaxy-Caller", "admin")
|
||||
|
||||
route, pathParams, err := router.FindRoute(validationRequest)
|
||||
require.NoError(t, err)
|
||||
|
||||
requestInput := &openapi3filter.RequestValidationInput{
|
||||
Request: validationRequest,
|
||||
PathParams: pathParams,
|
||||
Route: route,
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
require.NoError(t, openapi3filter.ValidateRequest(context.Background(), requestInput))
|
||||
|
||||
responseInput := &openapi3filter.ResponseValidationInput{
|
||||
RequestValidationInput: requestInput,
|
||||
Status: recorder.Code,
|
||||
Header: recorder.Header(),
|
||||
Options: &openapi3filter.Options{
|
||||
IncludeResponseStatus: true,
|
||||
},
|
||||
}
|
||||
responseInput.SetBodyBytes(recorder.Body.Bytes())
|
||||
require.NoError(t, openapi3filter.ValidateResponse(context.Background(), responseInput))
|
||||
}
|
||||
|
||||
func loadConformanceSpec(t *testing.T) *openapi3.T {
|
||||
t.Helper()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
|
||||
specPath := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "api", "internal-openapi.yaml")
|
||||
loader := openapi3.NewLoader()
|
||||
doc, err := loader.LoadFromFile(specPath)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, doc.Validate(context.Background()))
|
||||
return doc
|
||||
}
|
||||
|
||||
func bodyReaderFor(raw string) io.Reader {
|
||||
if raw == "" {
|
||||
return http.NoBody
|
||||
}
|
||||
return bytes.NewBufferString(raw)
|
||||
}
|
||||
|
||||
// conformanceDeps groups the stub collaborators handed to the listener.
|
||||
type conformanceDeps struct {
|
||||
records *conformanceRecords
|
||||
start *conformanceStart
|
||||
stop *conformanceStop
|
||||
restart *conformanceRestart
|
||||
patch *conformancePatch
|
||||
cleanup *conformanceCleanup
|
||||
}
|
||||
|
||||
func newConformanceDeps(t *testing.T) *conformanceDeps {
|
||||
t.Helper()
|
||||
return &conformanceDeps{
|
||||
records: newConformanceRecords(),
|
||||
start: &conformanceStart{},
|
||||
stop: &conformanceStop{},
|
||||
restart: &conformanceRestart{},
|
||||
patch: &conformancePatch{},
|
||||
cleanup: &conformanceCleanup{},
|
||||
}
|
||||
}
|
||||
|
||||
func newConformanceConfig() Config {
|
||||
return Config{
|
||||
Addr: ":0",
|
||||
ReadHeaderTimeout: time.Second,
|
||||
ReadTimeout: time.Second,
|
||||
WriteTimeout: time.Second,
|
||||
IdleTimeout: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRecord builds a canonical running record used by every
|
||||
// stub service.
|
||||
func conformanceRecord() domainruntime.RuntimeRecord {
|
||||
started := time.Date(2026, 4, 26, 13, 0, 0, 0, time.UTC)
|
||||
return domainruntime.RuntimeRecord{
|
||||
GameID: conformanceGameID,
|
||||
Status: domainruntime.StatusRunning,
|
||||
CurrentContainerID: "container-conformance",
|
||||
CurrentImageRef: "galaxy/game:v1.2.3",
|
||||
EngineEndpoint: "http://galaxy-game-" + conformanceGameID + ":8080",
|
||||
StatePath: "/var/lib/galaxy/" + conformanceGameID,
|
||||
DockerNetwork: "galaxy-engine",
|
||||
StartedAt: &started,
|
||||
LastOpAt: started,
|
||||
CreatedAt: started,
|
||||
}
|
||||
}
|
||||
|
||||
// conformanceRecords is an in-memory record store seeded with one
|
||||
// canonical record so the get / list endpoints have something to
|
||||
// return.
|
||||
type conformanceRecords struct {
|
||||
mu sync.Mutex
|
||||
stored map[string]domainruntime.RuntimeRecord
|
||||
}
|
||||
|
||||
func newConformanceRecords() *conformanceRecords {
|
||||
return &conformanceRecords{
|
||||
stored: map[string]domainruntime.RuntimeRecord{
|
||||
conformanceGameID: conformanceRecord(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) Get(_ context.Context, gameID string) (domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, ok := s.stored[gameID]
|
||||
if !ok {
|
||||
return domainruntime.RuntimeRecord{}, domainruntime.ErrNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) Upsert(_ context.Context, _ domainruntime.RuntimeRecord) error {
|
||||
return errors.New("not used in conformance test")
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) UpdateStatus(_ context.Context, _ ports.UpdateStatusInput) error {
|
||||
return errors.New("not used in conformance test")
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) ListByStatus(_ context.Context, _ domainruntime.Status) ([]domainruntime.RuntimeRecord, error) {
|
||||
return nil, errors.New("not used in conformance test")
|
||||
}
|
||||
|
||||
func (s *conformanceRecords) List(_ context.Context) ([]domainruntime.RuntimeRecord, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]domainruntime.RuntimeRecord, 0, len(s.stored))
|
||||
for _, record := range s.stored {
|
||||
out = append(out, record)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// conformanceStart is the stub StartService used by the conformance
|
||||
// test. Every Handle call returns the canonical record.
|
||||
type conformanceStart struct{}
|
||||
|
||||
func (s *conformanceStart) Handle(_ context.Context, _ startruntime.Input) (startruntime.Result, error) {
|
||||
return startruntime.Result{
|
||||
Record: conformanceRecord(),
|
||||
Outcome: "success",
|
||||
}, nil
|
||||
}
|
||||
|
||||
type conformanceStop struct{}
|
||||
|
||||
func (s *conformanceStop) Handle(_ context.Context, _ stopruntime.Input) (stopruntime.Result, error) {
|
||||
rec := conformanceRecord()
|
||||
rec.Status = domainruntime.StatusStopped
|
||||
stopped := rec.LastOpAt.Add(time.Second)
|
||||
rec.StoppedAt = &stopped
|
||||
rec.LastOpAt = stopped
|
||||
return stopruntime.Result{Record: rec, Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
type conformanceRestart struct{}
|
||||
|
||||
func (s *conformanceRestart) Handle(_ context.Context, _ restartruntime.Input) (restartruntime.Result, error) {
|
||||
return restartruntime.Result{Record: conformanceRecord(), Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
type conformancePatch struct{}
|
||||
|
||||
func (s *conformancePatch) Handle(_ context.Context, in patchruntime.Input) (patchruntime.Result, error) {
|
||||
rec := conformanceRecord()
|
||||
if in.NewImageRef != "" {
|
||||
rec.CurrentImageRef = in.NewImageRef
|
||||
}
|
||||
return patchruntime.Result{Record: rec, Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
type conformanceCleanup struct{}
|
||||
|
||||
func (s *conformanceCleanup) Handle(_ context.Context, _ cleanupcontainer.Input) (cleanupcontainer.Result, error) {
|
||||
rec := conformanceRecord()
|
||||
rec.Status = domainruntime.StatusRemoved
|
||||
rec.CurrentContainerID = ""
|
||||
removed := rec.LastOpAt.Add(time.Minute)
|
||||
rec.RemovedAt = &removed
|
||||
rec.LastOpAt = removed
|
||||
return cleanupcontainer.Result{Record: rec, Outcome: "success"}, nil
|
||||
}
|
||||
|
||||
// Compile-time guards: the stubs must satisfy the handler-level
|
||||
// service ports plus ports.RuntimeRecordStore so the listener accepts
|
||||
// them.
|
||||
var (
|
||||
_ handlers.StartService = (*conformanceStart)(nil)
|
||||
_ handlers.StopService = (*conformanceStop)(nil)
|
||||
_ handlers.RestartService = (*conformanceRestart)(nil)
|
||||
_ handlers.PatchService = (*conformancePatch)(nil)
|
||||
_ handlers.CleanupService = (*conformanceCleanup)(nil)
|
||||
_ ports.RuntimeRecordStore = (*conformanceRecords)(nil)
|
||||
)
|
||||
Reference in New Issue
Block a user