198 lines
6.5 KiB
Go
198 lines
6.5 KiB
Go
// Package runtime defines the runtime-record domain model, status machine,
|
|
// and sentinel errors owned by Runtime Manager.
|
|
//
|
|
// The package mirrors the durable shape of the `runtime_records`
|
|
// PostgreSQL table (see
|
|
// `galaxy/rtmanager/internal/adapters/postgres/migrations/00001_init.sql`).
|
|
// Every status / transition / required-field rule already documented in
|
|
// `galaxy/rtmanager/README.md` lives here as code so adapter and service
|
|
// layers do not re-derive it.
|
|
package runtime
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Status identifies one runtime-record lifecycle state.
|
|
type Status string
|
|
|
|
const (
|
|
// StatusRunning reports that an engine container is live and bound to
|
|
// the record. The associated container id and image ref are non-empty
|
|
// and StartedAt is set.
|
|
StatusRunning Status = "running"
|
|
|
|
// StatusStopped reports that the engine container has exited (graceful
|
|
// stop, observed Docker exit, or reconciled exit). The container is
|
|
// still present in Docker until the cleanup worker removes it.
|
|
StatusStopped Status = "stopped"
|
|
|
|
// StatusRemoved reports that the container has been removed from
|
|
// Docker (admin cleanup or reconcile_dispose). The record stays in
|
|
// PostgreSQL for audit; there is no transition out of this state.
|
|
StatusRemoved Status = "removed"
|
|
)
|
|
|
|
// IsKnown reports whether status belongs to the frozen runtime status
|
|
// vocabulary.
|
|
func (status Status) IsKnown() bool {
|
|
switch status {
|
|
case StatusRunning, StatusStopped, StatusRemoved:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// IsTerminal reports whether status can no longer accept lifecycle
|
|
// transitions.
|
|
func (status Status) IsTerminal() bool {
|
|
return status == StatusRemoved
|
|
}
|
|
|
|
// AllStatuses returns the frozen list of every runtime status value. The
|
|
// slice order is stable across calls and matches the README §Persistence
|
|
// Layout listing.
|
|
func AllStatuses() []Status {
|
|
return []Status{
|
|
StatusRunning,
|
|
StatusStopped,
|
|
StatusRemoved,
|
|
}
|
|
}
|
|
|
|
// RuntimeRecord stores one durable runtime record owned by Runtime
|
|
// Manager. It mirrors one row of the `runtime_records` table.
|
|
//
|
|
// CurrentContainerID and CurrentImageRef are stored as plain strings; an
|
|
// empty value represents SQL NULL and is bridged at the adapter layer.
|
|
// StartedAt, StoppedAt, and RemovedAt are *time.Time so a missing value
|
|
// is unambiguous and aligns with the jet-generated model.
|
|
type RuntimeRecord struct {
|
|
// GameID identifies the platform game owning this runtime record.
|
|
GameID string
|
|
|
|
// Status stores the current lifecycle state.
|
|
Status Status
|
|
|
|
// CurrentContainerID identifies the bound Docker container. Empty
|
|
// when status is removed and after a reconciler observes
|
|
// disappearance.
|
|
CurrentContainerID string
|
|
|
|
// CurrentImageRef stores the Docker reference of the currently-bound
|
|
// engine image. Non-empty when status is running or stopped.
|
|
CurrentImageRef string
|
|
|
|
// EngineEndpoint stores the stable URL Game Master uses to reach the
|
|
// engine container, in `http://galaxy-game-{game_id}:8080` form.
|
|
EngineEndpoint string
|
|
|
|
// StatePath stores the absolute host path of the bind-mounted engine
|
|
// state directory.
|
|
StatePath string
|
|
|
|
// DockerNetwork stores the Docker network the container was attached
|
|
// to at create time.
|
|
DockerNetwork string
|
|
|
|
// StartedAt stores the wall-clock at which the container became
|
|
// running. Non-nil when status is running or stopped.
|
|
StartedAt *time.Time
|
|
|
|
// StoppedAt stores the wall-clock at which the container exited.
|
|
// Non-nil when status is stopped or removed (when the record passed
|
|
// through stopped before removal).
|
|
StoppedAt *time.Time
|
|
|
|
// RemovedAt stores the wall-clock at which the container was removed
|
|
// from Docker. Non-nil when status is removed.
|
|
RemovedAt *time.Time
|
|
|
|
// LastOpAt stores the wall-clock of the most recent operation
|
|
// affecting this record. Drives the cleanup TTL.
|
|
LastOpAt time.Time
|
|
|
|
// CreatedAt stores the wall-clock at which Runtime Manager first saw
|
|
// this game.
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// Validate reports whether record satisfies the runtime-record invariants
|
|
// implied by README §Lifecycles and the SQL CHECK on `runtime_records`.
|
|
func (record RuntimeRecord) Validate() error {
|
|
if strings.TrimSpace(record.GameID) == "" {
|
|
return fmt.Errorf("game id must not be empty")
|
|
}
|
|
if !record.Status.IsKnown() {
|
|
return fmt.Errorf("status %q is unsupported", record.Status)
|
|
}
|
|
if strings.TrimSpace(record.EngineEndpoint) == "" {
|
|
return fmt.Errorf("engine endpoint must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.StatePath) == "" {
|
|
return fmt.Errorf("state path must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.DockerNetwork) == "" {
|
|
return fmt.Errorf("docker network must not be empty")
|
|
}
|
|
if record.LastOpAt.IsZero() {
|
|
return fmt.Errorf("last op at must not be zero")
|
|
}
|
|
if record.CreatedAt.IsZero() {
|
|
return fmt.Errorf("created at must not be zero")
|
|
}
|
|
if record.LastOpAt.Before(record.CreatedAt) {
|
|
return fmt.Errorf("last op at must not be before created at")
|
|
}
|
|
|
|
switch record.Status {
|
|
case StatusRunning:
|
|
if strings.TrimSpace(record.CurrentContainerID) == "" {
|
|
return fmt.Errorf("current container id must not be empty for running records")
|
|
}
|
|
if strings.TrimSpace(record.CurrentImageRef) == "" {
|
|
return fmt.Errorf("current image ref must not be empty for running records")
|
|
}
|
|
if record.StartedAt == nil {
|
|
return fmt.Errorf("started at must not be nil for running records")
|
|
}
|
|
if record.StartedAt.IsZero() {
|
|
return fmt.Errorf("started at must not be zero when present")
|
|
}
|
|
|
|
case StatusStopped:
|
|
if strings.TrimSpace(record.CurrentImageRef) == "" {
|
|
return fmt.Errorf("current image ref must not be empty for stopped records")
|
|
}
|
|
if record.StoppedAt == nil {
|
|
return fmt.Errorf("stopped at must not be nil for stopped records")
|
|
}
|
|
if record.StoppedAt.IsZero() {
|
|
return fmt.Errorf("stopped at must not be zero when present")
|
|
}
|
|
|
|
case StatusRemoved:
|
|
if record.RemovedAt == nil {
|
|
return fmt.Errorf("removed at must not be nil for removed records")
|
|
}
|
|
if record.RemovedAt.IsZero() {
|
|
return fmt.Errorf("removed at must not be zero when present")
|
|
}
|
|
}
|
|
|
|
if record.StartedAt != nil && record.StartedAt.Before(record.CreatedAt) {
|
|
return fmt.Errorf("started at must not be before created at")
|
|
}
|
|
if record.StoppedAt != nil && record.StartedAt != nil && record.StoppedAt.Before(*record.StartedAt) {
|
|
return fmt.Errorf("stopped at must not be before started at")
|
|
}
|
|
if record.RemovedAt != nil && record.RemovedAt.Before(record.CreatedAt) {
|
|
return fmt.Errorf("removed at must not be before created at")
|
|
}
|
|
|
|
return nil
|
|
}
|