feat: runtime manager

This commit is contained in:
Ilia Denisov
2026-04-28 20:39:18 +02:00
committed by GitHub
parent e0a99b346b
commit a7cee15115
289 changed files with 45660 additions and 2207 deletions
+384
View File
@@ -0,0 +1,384 @@
package rtmanager
import (
"context"
"net/http"
"path/filepath"
"runtime"
"testing"
"github.com/getkin/kin-openapi/openapi3"
"github.com/stretchr/testify/require"
)
// TestInternalOpenAPISpecValidates loads internal-openapi.yaml and verifies
// it is a syntactically valid OpenAPI 3.0 document.
func TestInternalOpenAPISpecValidates(t *testing.T) {
t.Parallel()
loadInternalOpenAPISpec(t)
}
// TestInternalSpecFreezesOperationIDs verifies that every documented
// endpoint declares the exact operationId required by the Runtime Manager
// internal contract. Missing or renamed operationIds break the contract
// for Game Master and Admin Service.
func TestInternalSpecFreezesOperationIDs(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
cases := []struct {
method string
path string
operationID string
}{
{http.MethodGet, "/healthz", "internalHealthz"},
{http.MethodGet, "/readyz", "internalReadyz"},
{http.MethodGet, "/api/v1/internal/runtimes", "internalListRuntimes"},
{http.MethodGet, "/api/v1/internal/runtimes/{game_id}", "internalGetRuntime"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/start", "internalStartRuntime"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/stop", "internalStopRuntime"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/restart", "internalRestartRuntime"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/patch", "internalPatchRuntime"},
{http.MethodDelete, "/api/v1/internal/runtimes/{game_id}/container", "internalCleanupRuntimeContainer"},
}
for _, tc := range cases {
t.Run(tc.operationID, func(t *testing.T) {
t.Parallel()
op := getOperation(t, doc, tc.path, tc.method)
require.Equal(t, tc.operationID, op.OperationID)
})
}
}
// TestInternalSpecFreezesRuntimeRecordSchema verifies that RuntimeRecord
// declares the required field set documented in
// rtmanager/README.md §Persistence Layout, with the status enum frozen.
func TestInternalSpecFreezesRuntimeRecordSchema(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "RuntimeRecord")
assertRequiredFields(t, schema,
"game_id", "status", "state_path", "docker_network",
"last_op_at", "created_at",
)
for _, optional := range []string{
"current_container_id", "current_image_ref", "engine_endpoint",
"started_at", "stopped_at", "removed_at",
} {
require.Contains(t, schema.Value.Properties, optional,
"RuntimeRecord.%s must be present in properties", optional)
}
assertStringEnum(t, schema, "status", "running", "stopped", "removed")
}
// TestInternalSpecFreezesStartRequest verifies that StartRequest requires
// only image_ref and rejects unknown fields.
func TestInternalSpecFreezesStartRequest(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "StartRequest")
assertRequiredFields(t, schema, "image_ref")
require.NotNil(t, schema.Value.AdditionalProperties.Has)
require.False(t, *schema.Value.AdditionalProperties.Has,
"StartRequest must reject unknown fields")
}
// TestInternalSpecFreezesStopRequest verifies that StopRequest requires
// only reason, that reason references the StopReason schema, and that
// unknown fields are rejected.
func TestInternalSpecFreezesStopRequest(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "StopRequest")
assertRequiredFields(t, schema, "reason")
require.NotNil(t, schema.Value.AdditionalProperties.Has)
require.False(t, *schema.Value.AdditionalProperties.Has,
"StopRequest must reject unknown fields")
reason := schema.Value.Properties["reason"]
require.NotNil(t, reason, "StopRequest.reason must be present")
require.Equal(t, "#/components/schemas/StopReason", reason.Ref,
"StopRequest.reason must reference StopReason")
}
// TestInternalSpecFreezesPatchRequest verifies that PatchRequest requires
// only image_ref and rejects unknown fields.
func TestInternalSpecFreezesPatchRequest(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "PatchRequest")
assertRequiredFields(t, schema, "image_ref")
require.NotNil(t, schema.Value.AdditionalProperties.Has)
require.False(t, *schema.Value.AdditionalProperties.Has,
"PatchRequest must reject unknown fields")
}
// TestInternalSpecFreezesStopReasonEnum verifies that the stop reason enum
// matches the contract recorded in
// rtmanager/README.md §Async Stream Contracts.
func TestInternalSpecFreezesStopReasonEnum(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "StopReason")
got := make([]string, 0, len(schema.Value.Enum))
for _, value := range schema.Value.Enum {
got = append(got, value.(string))
}
require.ElementsMatch(t,
[]string{"orphan_cleanup", "cancelled", "finished", "admin_request", "timeout"},
got)
}
// TestInternalSpecFreezesErrorCodeCatalog verifies that ErrorCode contains
// every stable code declared in rtmanager/README.md §Error Model.
func TestInternalSpecFreezesErrorCodeCatalog(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "ErrorCode")
got := make([]string, 0, len(schema.Value.Enum))
for _, value := range schema.Value.Enum {
got = append(got, value.(string))
}
require.ElementsMatch(t,
[]string{
"invalid_request",
"not_found",
"conflict",
"service_unavailable",
"internal_error",
"image_pull_failed",
"image_ref_not_semver",
"semver_patch_only",
"container_start_failed",
"start_config_invalid",
"docker_unavailable",
"replay_no_op",
},
got)
}
// TestInternalSpecFreezesErrorEnvelope verifies that ErrorResponse uses the
// `{ "error": { "code", "message" } }` shape and that error.code references
// the ErrorCode enum.
func TestInternalSpecFreezesErrorEnvelope(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
envelope := componentSchemaRef(t, doc, "ErrorResponse")
assertRequiredFields(t, envelope, "error")
require.Equal(t, "#/components/schemas/ErrorBody",
envelope.Value.Properties["error"].Ref,
"ErrorResponse.error must reference ErrorBody")
body := componentSchemaRef(t, doc, "ErrorBody")
assertRequiredFields(t, body, "code", "message")
require.Equal(t, "#/components/schemas/ErrorCode",
body.Value.Properties["code"].Ref,
"ErrorBody.code must reference ErrorCode")
require.Equal(t, "string",
body.Value.Properties["message"].Value.Type.Slice()[0],
"ErrorBody.message must be a string")
}
// TestInternalSpecFreezesProbeResponses verifies that /healthz returns 200
// with the probe payload and /readyz declares both 200 and 503.
func TestInternalSpecFreezesProbeResponses(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
healthz := getOperation(t, doc, "/healthz", http.MethodGet)
assertSchemaRef(t, responseSchemaRef(t, healthz, http.StatusOK),
"#/components/schemas/ProbeResponse", "internalHealthz 200")
readyz := getOperation(t, doc, "/readyz", http.MethodGet)
assertSchemaRef(t, responseSchemaRef(t, readyz, http.StatusOK),
"#/components/schemas/ProbeResponse", "internalReadyz 200")
require.NotNil(t, readyz.Responses.Status(http.StatusServiceUnavailable),
"internalReadyz must declare a 503 response")
}
// TestInternalSpecFreezesXGalaxyCallerHeader verifies that the optional
// X-Galaxy-Caller header parameter is declared and referenced from every
// runtime operation. Removing the parameter or detaching it from any of
// the seven runtime endpoints would silently drop the only signal RTM
// uses to distinguish gm_rest from admin_rest in operation_log.
func TestInternalSpecFreezesXGalaxyCallerHeader(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
param := doc.Components.Parameters["XGalaxyCallerHeader"]
require.NotNil(t, param, "XGalaxyCallerHeader parameter must be declared")
require.NotNil(t, param.Value, "XGalaxyCallerHeader parameter must have a value")
require.Equal(t, "header", param.Value.In)
require.Equal(t, "X-Galaxy-Caller", param.Value.Name)
require.False(t, param.Value.Required, "X-Galaxy-Caller must be optional")
enum := param.Value.Schema.Value.Enum
got := make([]string, 0, len(enum))
for _, value := range enum {
got = append(got, value.(string))
}
require.ElementsMatch(t, []string{"gm", "admin"}, got)
runtimeOps := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/internal/runtimes"},
{http.MethodGet, "/api/v1/internal/runtimes/{game_id}"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/start"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/stop"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/restart"},
{http.MethodPost, "/api/v1/internal/runtimes/{game_id}/patch"},
{http.MethodDelete, "/api/v1/internal/runtimes/{game_id}/container"},
}
for _, rop := range runtimeOps {
t.Run(rop.method+" "+rop.path, func(t *testing.T) {
t.Parallel()
op := getOperation(t, doc, rop.path, rop.method)
found := false
for _, ref := range op.Parameters {
if ref.Ref == "#/components/parameters/XGalaxyCallerHeader" {
found = true
break
}
}
require.Truef(t, found,
"%s %s must reference XGalaxyCallerHeader", rop.method, rop.path)
})
}
}
// TestInternalSpecFreezesRuntimesListShape verifies that the list endpoint
// returns the items envelope expected by callers.
func TestInternalSpecFreezesRuntimesListShape(t *testing.T) {
t.Parallel()
doc := loadInternalOpenAPISpec(t)
schema := componentSchemaRef(t, doc, "RuntimesList")
assertRequiredFields(t, schema, "items")
items := schema.Value.Properties["items"]
require.NotNil(t, items, "RuntimesList.items must be declared")
require.Equal(t, "#/components/schemas/RuntimeRecord", items.Value.Items.Ref,
"RuntimesList.items[] must reference RuntimeRecord")
}
func loadInternalOpenAPISpec(t *testing.T) *openapi3.T {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
require.FailNow(t, "runtime.Caller failed")
}
specPath := filepath.Join(filepath.Dir(thisFile), "api", "internal-openapi.yaml")
loader := openapi3.NewLoader()
doc, err := loader.LoadFromFile(specPath)
if err != nil {
require.Failf(t, "test failed", "load spec %s: %v", specPath, err)
}
if doc == nil {
require.Failf(t, "test failed", "load spec %s: returned nil document", specPath)
}
if err := doc.Validate(context.Background()); err != nil {
require.Failf(t, "test failed", "validate spec %s: %v", specPath, err)
}
return doc
}
func getOperation(t *testing.T, doc *openapi3.T, path, method string) *openapi3.Operation {
t.Helper()
if doc.Paths == nil {
require.FailNow(t, "spec is missing paths")
}
pathItem := doc.Paths.Value(path)
if pathItem == nil {
require.Failf(t, "test failed", "spec is missing path %s", path)
}
op := pathItem.GetOperation(method)
if op == nil {
require.Failf(t, "test failed", "spec is missing %s operation for path %s", method, path)
}
return op
}
func responseSchemaRef(t *testing.T, op *openapi3.Operation, status int) *openapi3.SchemaRef {
t.Helper()
ref := op.Responses.Status(status)
if ref == nil || ref.Value == nil {
require.Failf(t, "test failed", "operation is missing %d response", status)
}
mt := ref.Value.Content.Get("application/json")
if mt == nil || mt.Schema == nil {
require.Failf(t, "test failed", "operation is missing application/json schema for %d response", status)
}
return mt.Schema
}
func componentSchemaRef(t *testing.T, doc *openapi3.T, name string) *openapi3.SchemaRef {
t.Helper()
if doc.Components.Schemas == nil {
require.FailNow(t, "spec is missing component schemas")
}
ref := doc.Components.Schemas[name]
if ref == nil {
require.Failf(t, "test failed", "spec is missing component schema %s", name)
}
return ref
}
func assertSchemaRef(t *testing.T, schemaRef *openapi3.SchemaRef, want, name string) {
t.Helper()
require.NotNil(t, schemaRef, "%s schema ref", name)
require.Equal(t, want, schemaRef.Ref, "%s schema ref", name)
}
func assertRequiredFields(t *testing.T, schemaRef *openapi3.SchemaRef, fields ...string) {
t.Helper()
require.NotNil(t, schemaRef)
require.ElementsMatch(t, fields, schemaRef.Value.Required)
}
func assertStringEnum(t *testing.T, schemaRef *openapi3.SchemaRef, property string, values ...string) {
t.Helper()
require.NotNil(t, schemaRef)
propRef := schemaRef.Value.Properties[property]
require.NotNil(t, propRef, "schema property %s", property)
got := make([]string, 0, len(propRef.Value.Enum))
for _, v := range propRef.Value.Enum {
got = append(got, v.(string))
}
require.ElementsMatch(t, values, got)
}