393 lines
12 KiB
Go
393 lines
12 KiB
Go
package rtmanager
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var expectedStopReasonEnum = []string{
|
|
"orphan_cleanup",
|
|
"cancelled",
|
|
"finished",
|
|
"admin_request",
|
|
"timeout",
|
|
}
|
|
|
|
var expectedJobResultErrorCodeEnum = []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",
|
|
}
|
|
|
|
var expectedHealthEventTypeEnum = []string{
|
|
"container_started",
|
|
"container_exited",
|
|
"container_oom",
|
|
"container_disappeared",
|
|
"inspect_unhealthy",
|
|
"probe_failed",
|
|
"probe_recovered",
|
|
}
|
|
|
|
var expectedHealthDetailsBranches = []struct {
|
|
schema string
|
|
required []string
|
|
}{
|
|
{schema: "ContainerStartedDetails", required: []string{"image_ref"}},
|
|
{schema: "ContainerExitedDetails", required: []string{"exit_code", "oom"}},
|
|
{schema: "ContainerOomDetails", required: []string{"exit_code"}},
|
|
{schema: "ContainerDisappearedDetails", required: nil},
|
|
{schema: "InspectUnhealthyDetails", required: []string{"restart_count", "state", "health"}},
|
|
{schema: "ProbeFailedDetails", required: []string{"consecutive_failures", "last_status", "last_error"}},
|
|
{schema: "ProbeRecoveredDetails", required: []string{"prior_failure_count"}},
|
|
}
|
|
|
|
func TestRuntimeJobsAsyncAPISpecLoads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
|
|
require.Equal(t, "3.1.0", getStringValue(t, doc, "asyncapi"))
|
|
}
|
|
|
|
func TestRuntimeJobsSpecFreezesChannelAddresses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
|
|
channels := getMapValue(t, doc, "channels")
|
|
|
|
require.Equal(t, "runtime:start_jobs",
|
|
getStringValue(t, getMapValue(t, channels, "startJobs"), "address"))
|
|
require.Equal(t, "runtime:stop_jobs",
|
|
getStringValue(t, getMapValue(t, channels, "stopJobs"), "address"))
|
|
require.Equal(t, "runtime:job_results",
|
|
getStringValue(t, getMapValue(t, channels, "jobResults"), "address"))
|
|
}
|
|
|
|
func TestRuntimeJobsSpecFreezesOperationActions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
|
|
operations := getMapValue(t, doc, "operations")
|
|
|
|
cases := []struct {
|
|
operation string
|
|
action string
|
|
channel string
|
|
}{
|
|
{operation: "consumeStartJob", action: "receive", channel: "#/channels/startJobs"},
|
|
{operation: "consumeStopJob", action: "receive", channel: "#/channels/stopJobs"},
|
|
{operation: "publishJobResult", action: "send", channel: "#/channels/jobResults"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.operation, func(t *testing.T) {
|
|
t.Parallel()
|
|
op := getMapValue(t, operations, tc.operation)
|
|
require.Equal(t, tc.action, getStringValue(t, op, "action"))
|
|
require.Equal(t, tc.channel,
|
|
getStringValue(t, getMapValue(t, op, "channel"), "$ref"))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRuntimeJobsSpecFreezesMessageNames(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
|
|
messages := getMapValue(t, doc, "components", "messages")
|
|
|
|
for _, name := range []string{"RuntimeStartJob", "RuntimeStopJob", "RuntimeJobResult"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
message := getMapValue(t, messages, name)
|
|
require.Equal(t, name, getStringValue(t, message, "name"))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRuntimeJobsSpecFreezesStartJobPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
|
|
payload := getMapValue(t, doc, "components", "schemas", "RuntimeStartJobPayload")
|
|
|
|
require.ElementsMatch(t,
|
|
[]string{"game_id", "image_ref", "requested_at_ms"},
|
|
getStringSlice(t, payload, "required"))
|
|
require.False(t, getBoolValue(t, payload, "additionalProperties"),
|
|
"RuntimeStartJobPayload must reject unknown fields")
|
|
}
|
|
|
|
func TestRuntimeJobsSpecFreezesStopJobPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
|
|
payload := getMapValue(t, doc, "components", "schemas", "RuntimeStopJobPayload")
|
|
|
|
require.ElementsMatch(t,
|
|
[]string{"game_id", "reason", "requested_at_ms"},
|
|
getStringSlice(t, payload, "required"))
|
|
require.False(t, getBoolValue(t, payload, "additionalProperties"),
|
|
"RuntimeStopJobPayload must reject unknown fields")
|
|
|
|
reason := getMapValue(t, payload, "properties", "reason")
|
|
require.Equal(t, "#/components/schemas/StopReason",
|
|
getStringValue(t, reason, "$ref"),
|
|
"RuntimeStopJobPayload.reason must reference StopReason")
|
|
|
|
stopReason := getMapValue(t, doc, "components", "schemas", "StopReason")
|
|
require.ElementsMatch(t, expectedStopReasonEnum,
|
|
getStringSlice(t, stopReason, "enum"))
|
|
}
|
|
|
|
func TestRuntimeJobsSpecFreezesJobResultPayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-jobs-asyncapi.yaml"))
|
|
payload := getMapValue(t, doc, "components", "schemas", "RuntimeJobResultPayload")
|
|
|
|
require.ElementsMatch(t,
|
|
[]string{"game_id", "outcome", "container_id", "engine_endpoint", "error_code", "error_message"},
|
|
getStringSlice(t, payload, "required"))
|
|
require.False(t, getBoolValue(t, payload, "additionalProperties"),
|
|
"RuntimeJobResultPayload must reject unknown fields")
|
|
|
|
outcome := getMapValue(t, payload, "properties", "outcome")
|
|
require.ElementsMatch(t, []string{"success", "failure"},
|
|
getStringSlice(t, outcome, "enum"))
|
|
|
|
errorCode := getMapValue(t, payload, "properties", "error_code")
|
|
require.Equal(t, "#/components/schemas/ErrorCode",
|
|
getStringValue(t, errorCode, "$ref"),
|
|
"RuntimeJobResultPayload.error_code must reference ErrorCode")
|
|
|
|
errorCodeSchema := getMapValue(t, doc, "components", "schemas", "ErrorCode")
|
|
require.ElementsMatch(t, expectedJobResultErrorCodeEnum,
|
|
getStringSlice(t, errorCodeSchema, "enum"))
|
|
}
|
|
|
|
func TestRuntimeHealthAsyncAPISpecLoads(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
|
|
require.Equal(t, "3.1.0", getStringValue(t, doc, "asyncapi"))
|
|
}
|
|
|
|
func TestRuntimeHealthSpecFreezesChannelAndOperation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
|
|
|
|
channel := getMapValue(t, doc, "channels", "healthEvents")
|
|
require.Equal(t, "runtime:health_events", getStringValue(t, channel, "address"))
|
|
|
|
operation := getMapValue(t, doc, "operations", "publishHealthEvent")
|
|
require.Equal(t, "send", getStringValue(t, operation, "action"))
|
|
require.Equal(t, "#/channels/healthEvents",
|
|
getStringValue(t, getMapValue(t, operation, "channel"), "$ref"))
|
|
|
|
message := getMapValue(t, doc, "components", "messages", "RuntimeHealthEvent")
|
|
require.Equal(t, "RuntimeHealthEvent", getStringValue(t, message, "name"))
|
|
}
|
|
|
|
func TestRuntimeHealthSpecFreezesEnvelope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
|
|
payload := getMapValue(t, doc, "components", "schemas", "RuntimeHealthEventPayload")
|
|
|
|
require.ElementsMatch(t,
|
|
[]string{"game_id", "container_id", "event_type", "occurred_at_ms", "details"},
|
|
getStringSlice(t, payload, "required"))
|
|
require.False(t, getBoolValue(t, payload, "additionalProperties"),
|
|
"RuntimeHealthEventPayload must reject unknown fields")
|
|
|
|
eventType := getMapValue(t, payload, "properties", "event_type")
|
|
require.Equal(t, "#/components/schemas/EventType",
|
|
getStringValue(t, eventType, "$ref"),
|
|
"RuntimeHealthEventPayload.event_type must reference EventType")
|
|
}
|
|
|
|
func TestRuntimeHealthSpecFreezesEventTypeEnum(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
|
|
schema := getMapValue(t, doc, "components", "schemas", "EventType")
|
|
|
|
require.ElementsMatch(t, expectedHealthEventTypeEnum,
|
|
getStringSlice(t, schema, "enum"))
|
|
}
|
|
|
|
func TestRuntimeHealthSpecFreezesDetailsOneOfBranches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
doc := loadAsyncAPISpec(t, filepath.Join("api", "runtime-health-asyncapi.yaml"))
|
|
details := getMapValue(t, doc, "components", "schemas", "RuntimeHealthEventPayload",
|
|
"properties", "details")
|
|
|
|
branches := getSliceValue(t, details, "oneOf")
|
|
require.Lenf(t, branches, len(expectedHealthDetailsBranches),
|
|
"details.oneOf must have %d branches", len(expectedHealthDetailsBranches))
|
|
|
|
gotRefs := make([]string, 0, len(branches))
|
|
for _, raw := range branches {
|
|
branch, ok := raw.(map[string]any)
|
|
require.True(t, ok, "details.oneOf entry must be a mapping")
|
|
gotRefs = append(gotRefs, getStringValue(t, branch, "$ref"))
|
|
}
|
|
|
|
wantRefs := make([]string, 0, len(expectedHealthDetailsBranches))
|
|
for _, branch := range expectedHealthDetailsBranches {
|
|
wantRefs = append(wantRefs, "#/components/schemas/"+branch.schema)
|
|
}
|
|
require.ElementsMatch(t, wantRefs, gotRefs)
|
|
|
|
for _, branch := range expectedHealthDetailsBranches {
|
|
t.Run(branch.schema, func(t *testing.T) {
|
|
t.Parallel()
|
|
schema := getMapValue(t, doc, "components", "schemas", branch.schema)
|
|
require.False(t, getBoolValue(t, schema, "additionalProperties"),
|
|
"%s must reject unknown fields", branch.schema)
|
|
if branch.required == nil {
|
|
_, hasRequired := schema["required"]
|
|
require.False(t, hasRequired,
|
|
"%s must not declare required fields", branch.schema)
|
|
return
|
|
}
|
|
require.ElementsMatch(t, branch.required,
|
|
getStringSlice(t, schema, "required"))
|
|
})
|
|
}
|
|
}
|
|
|
|
func loadAsyncAPISpec(t *testing.T, relativePath string) map[string]any {
|
|
t.Helper()
|
|
|
|
payload := loadTextFile(t, relativePath)
|
|
|
|
var doc map[string]any
|
|
if err := yaml.Unmarshal([]byte(payload), &doc); err != nil {
|
|
require.Failf(t, "test failed", "decode spec: %v", err)
|
|
}
|
|
|
|
return doc
|
|
}
|
|
|
|
func loadTextFile(t *testing.T, relativePath string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(moduleRoot(t), relativePath)
|
|
payload, err := os.ReadFile(path)
|
|
if err != nil {
|
|
require.Failf(t, "test failed", "read file %s: %v", path, err)
|
|
}
|
|
|
|
return string(payload)
|
|
}
|
|
|
|
func moduleRoot(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
_, thisFile, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
require.FailNow(t, "runtime.Caller failed")
|
|
}
|
|
|
|
return filepath.Dir(thisFile)
|
|
}
|
|
|
|
func getMapValue(t *testing.T, value map[string]any, path ...string) map[string]any {
|
|
t.Helper()
|
|
|
|
current := value
|
|
for _, segment := range path {
|
|
raw, ok := current[segment]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing map key %s", segment)
|
|
}
|
|
next, ok := raw.(map[string]any)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a map", segment)
|
|
}
|
|
current = next
|
|
}
|
|
|
|
return current
|
|
}
|
|
|
|
func getStringValue(t *testing.T, value map[string]any, key string) string {
|
|
t.Helper()
|
|
|
|
raw, ok := value[key]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing key %s", key)
|
|
}
|
|
result, ok := raw.(string)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a string", key)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func getBoolValue(t *testing.T, value map[string]any, key string) bool {
|
|
t.Helper()
|
|
|
|
raw, ok := value[key]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing key %s", key)
|
|
}
|
|
result, ok := raw.(bool)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a bool", key)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func getStringSlice(t *testing.T, value map[string]any, key string) []string {
|
|
t.Helper()
|
|
|
|
raw := getSliceValue(t, value, key)
|
|
result := make([]string, 0, len(raw))
|
|
for _, item := range raw {
|
|
text, ok := item.(string)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a string slice", key)
|
|
}
|
|
result = append(result, text)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func getSliceValue(t *testing.T, value map[string]any, key string) []any {
|
|
t.Helper()
|
|
|
|
raw, ok := value[key]
|
|
if !ok {
|
|
require.Failf(t, "test failed", "missing key %s", key)
|
|
}
|
|
result, ok := raw.([]any)
|
|
if !ok {
|
|
require.Failf(t, "test failed", "value at %s is not a slice", key)
|
|
}
|
|
|
|
return result
|
|
}
|