package ports import ( "context" "encoding/json" "fmt" "strings" "time" "galaxy/rtmanager/internal/domain/health" ) // HealthEventPublisher emits one entry on the `runtime:health_events` // Redis Stream and updates `health_snapshots` with the latest observation // for the affected game. Adapters publish and snapshot in one call so // every emission durably advances both surfaces; partial publishes (event // without snapshot, or vice versa) are not allowed. // // The start service emits `container_started` through this port; the // periodic Docker inspect, the active probe, and the Docker events // listener publish the rest of the event types through the same port // without changing its surface. type HealthEventPublisher interface { // Publish records envelope on the configured `runtime:health_events` // stream and upserts the matching `health_snapshots` row. A non-nil // error reports a transport or storage failure; the caller treats it // as a degraded emission per `rtmanager/README.md §Notification // Contracts` (the underlying business state is the source of truth, // not the event stream). Publish(ctx context.Context, envelope HealthEventEnvelope) error } // HealthEventEnvelope carries the payload published on // `runtime:health_events`. The fields mirror the AsyncAPI schema frozen // in `rtmanager/api/runtime-health-asyncapi.yaml`; adapters serialise // every field verbatim so consumers see the contracted shape. type HealthEventEnvelope struct { // GameID identifies the platform game the event refers to. GameID string // ContainerID identifies the Docker container observed by the event // source. May differ from the record's current container id after a // restart race; consumers are expected to treat the value as the // observation's container, not the record's. ContainerID string // EventType classifies the event per the frozen vocabulary in // `galaxy/rtmanager/internal/domain/health.EventType`. EventType health.EventType // OccurredAt stores the wall-clock at which Runtime Manager observed // the event. Adapters convert it to UTC milliseconds for the wire // payload (`occurred_at_ms`). OccurredAt time.Time // Details stores the event-type-specific JSON payload. Adapters // persist and stream it verbatim; nil and empty values are treated as // the canonical empty-object payload. Details json.RawMessage } // Validate reports whether envelope satisfies the structural invariants // implied by the AsyncAPI schema. func (envelope HealthEventEnvelope) Validate() error { if strings.TrimSpace(envelope.GameID) == "" { return fmt.Errorf("health event envelope: game id must not be empty") } if strings.TrimSpace(envelope.ContainerID) == "" { return fmt.Errorf("health event envelope: container id must not be empty") } if !envelope.EventType.IsKnown() { return fmt.Errorf("health event envelope: event type %q is unsupported", envelope.EventType) } if envelope.OccurredAt.IsZero() { return fmt.Errorf("health event envelope: occurred at must not be zero") } if len(envelope.Details) > 0 && !json.Valid(envelope.Details) { return fmt.Errorf("health event envelope: details must be valid JSON when non-empty") } return nil }