feat: runtime manager
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user