Keep Run and Platform logs opaque

This commit is contained in:
npc0-hue
2026-09-02 12:23:16 +08:00
parent aeda833294
commit 6e614d3fa3
15 changed files with 282 additions and 20 deletions
@@ -6,6 +6,10 @@ boundary; this companion alone parses the SCUM login format and publishes the
typed player snapshot used by the plugin page. Platform and Run never inspect
or redact those log bodies.
The long-running companion stores only its typed snapshot sequence state in
`snapshot-sequences.json` beside `config.yaml`. This keeps the `players/current`
stream monotonic across process restarts; it contains no raw log records.
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
@@ -27,7 +27,13 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
sequenceStore, err := companion.NewFileSnapshotSequenceStore(companion.SnapshotSequenceFilename)
if err != nil {
log.Printf("SCUM companion sequence state failed: %v", err)
os.Exit(1)
}
projection := companion.NewSCUMPlayerLogProjection(client, config.Component.ServerInstanceID)
projection.SequenceStore = sequenceStore
collector := companion.NewConsoleLogCollector(client, noopLogStore{}, config.Component.ServerInstanceID, os.Getenv(config.Proof.MaterialEnv))
collector.OnSemanticEvents = projection.Handle
runtime := companion.Runtime{
@@ -20,7 +20,6 @@ type LogEntry struct {
Level string `json:"level,omitempty"`
Line string `json:"line"`
Fields map[string]string `json:"fields,omitempty"`
Redacted bool `json:"redacted"`
}
type LogStreamEvent struct {
@@ -18,10 +18,10 @@ type SCUMPlayerLogProjection struct {
KeepForSeconds int
MaxRecords int
Now func() time.Time
SequenceStore SnapshotSequenceStore
mu sync.Mutex
players map[string]scumPlayerProjection
sequence uint64
mu sync.Mutex
players map[string]scumPlayerProjection
}
type scumPlayerProjection struct {
@@ -32,7 +32,7 @@ type scumPlayerProjection struct {
}
func NewSCUMPlayerLogProjection(client *Client, serverInstanceID string) *SCUMPlayerLogProjection {
return &SCUMPlayerLogProjection{Client: client, ServerInstanceID: serverInstanceID, KeepForSeconds: 86400, MaxRecords: 1000, Now: time.Now, players: map[string]scumPlayerProjection{}}
return &SCUMPlayerLogProjection{Client: client, ServerInstanceID: serverInstanceID, KeepForSeconds: 86400, MaxRecords: 1000, Now: time.Now, SequenceStore: NewMemorySnapshotSequenceStore(), players: map[string]scumPlayerProjection{}}
}
func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch SemanticEventBatch) error {
@@ -46,6 +46,7 @@ func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch Sem
if projection.Now != nil {
now = projection.Now
}
observedAt := now().UTC()
projection.mu.Lock()
for _, event := range batch.Events {
playerID := strings.TrimSpace(event.PlayerID)
@@ -81,8 +82,16 @@ func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch Sem
for _, player := range projection.players {
players = append(players, player)
}
projection.sequence++
sequence := projection.sequence
store := projection.SequenceStore
if store == nil {
store = NewMemorySnapshotSequenceStore()
projection.SequenceStore = store
}
sequence, err := store.Next("players", "current", snapshotSequenceFloor(observedAt))
if err != nil {
projection.mu.Unlock()
return err
}
projection.mu.Unlock()
sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID })
payloadPlayers := make([]map[string]any, 0, len(players))
@@ -94,8 +103,7 @@ func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch Sem
"lastSeenAt": player.LastSeenAt.Format(time.RFC3339Nano),
})
}
observedAt := now().UTC()
_, err := projection.Client.UploadSnapshot(ctx, Snapshot{
_, err = projection.Client.UploadSnapshot(ctx, Snapshot{
Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence,
ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers},
KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords,
@@ -5,6 +5,7 @@ import (
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
@@ -44,3 +45,55 @@ func TestSCUMPlayerLogProjectionCreatesTypedUserSnapshot(t *testing.T) {
t.Fatalf("unexpected projected player: %#v", players[0])
}
}
func TestSCUMPlayerLogProjectionKeepsSequenceAcrossRestart(t *testing.T) {
stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
sequences := make([]uint64, 0, 2)
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path != snapshotPath {
t.Fatalf("unexpected projection request path: %s", request.URL.Path)
}
var body snapshotRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode snapshot request: %v", err)
}
sequences = append(sequences, body.Sequence)
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current"}`))}, nil
}), stamp)
client.mu.Lock()
client.sessionToken = "component-session"
client.sessionExpiresAt = stamp.Add(time.Hour)
client.mu.Unlock()
originalDirectory, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
t.Chdir(t.TempDir())
t.Cleanup(func() { _ = os.Chdir(originalDirectory) })
store, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("create sequence store: %v", err)
}
project := func(name string) error {
projection := NewSCUMPlayerLogProjection(client, "server-example")
projection.Now = func() time.Time { return stamp }
projection.SequenceStore = store
return projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 1, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: name, OccurredAt: stamp}}})
}
if err := project("Ada"); err != nil {
t.Fatalf("project before restart: %v", err)
}
restarted, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("restart sequence store: %v", err)
}
store = restarted
if err := project("Ada Lovelace"); err != nil {
t.Fatalf("project after restart: %v", err)
}
if len(sequences) != 2 || sequences[1] <= sequences[0] {
t.Fatalf("expected restart-safe monotonic snapshots, got %v", sequences)
}
}
@@ -0,0 +1,166 @@
package companion
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const SnapshotSequenceFilename = "snapshot-sequences.json"
// SnapshotSequenceStore is plugin-local state used only to keep the plugin's
// typed snapshot stream monotonic. It never stores or examines raw log text.
type SnapshotSequenceStore interface {
Next(snapshotType string, streamKey string, floor uint64) (uint64, error)
}
type MemorySnapshotSequenceStore struct {
mu sync.Mutex
sequences map[string]uint64
}
func NewMemorySnapshotSequenceStore() *MemorySnapshotSequenceStore {
return &MemorySnapshotSequenceStore{sequences: map[string]uint64{}}
}
func (store *MemorySnapshotSequenceStore) Next(snapshotType string, streamKey string, floor uint64) (uint64, error) {
if store == nil {
return 0, fmt.Errorf("snapshot sequence store is not configured")
}
key, err := snapshotSequenceKey(snapshotType, streamKey)
if err != nil {
return 0, err
}
store.mu.Lock()
defer store.mu.Unlock()
next, err := nextSnapshotSequence(store.sequences[key], floor)
if err != nil {
return 0, err
}
store.sequences[key] = next
return next, nil
}
type FileSnapshotSequenceStore struct {
filename string
mu sync.Mutex
}
type snapshotSequenceDocument struct {
SchemaVersion int `json:"schemaVersion"`
Sequences map[string]uint64 `json:"sequences"`
}
func NewFileSnapshotSequenceStore(filename string) (*FileSnapshotSequenceStore, error) {
filename = strings.TrimSpace(filename)
if filename == "" || filepath.Base(filename) != filename || filename == "." {
return nil, fmt.Errorf("snapshot sequence filename is invalid")
}
return &FileSnapshotSequenceStore{filename: filename}, nil
}
func (store *FileSnapshotSequenceStore) Next(snapshotType string, streamKey string, floor uint64) (uint64, error) {
if store == nil {
return 0, fmt.Errorf("snapshot sequence store is not configured")
}
key, err := snapshotSequenceKey(snapshotType, streamKey)
if err != nil {
return 0, err
}
store.mu.Lock()
defer store.mu.Unlock()
document, err := store.read()
if err != nil {
return 0, err
}
next, err := nextSnapshotSequence(document.Sequences[key], floor)
if err != nil {
return 0, err
}
document.Sequences[key] = next
if err := store.write(document); err != nil {
return 0, err
}
return next, nil
}
func (store *FileSnapshotSequenceStore) read() (snapshotSequenceDocument, error) {
document := snapshotSequenceDocument{SchemaVersion: 1, Sequences: map[string]uint64{}}
body, err := os.ReadFile(store.filename)
if os.IsNotExist(err) {
return document, nil
}
if err != nil {
return snapshotSequenceDocument{}, fmt.Errorf("read snapshot sequences: %w", err)
}
if err := json.Unmarshal(body, &document); err != nil {
return snapshotSequenceDocument{}, fmt.Errorf("decode snapshot sequences: %w", err)
}
if document.SchemaVersion != 1 || document.Sequences == nil {
return snapshotSequenceDocument{}, fmt.Errorf("snapshot sequence state is invalid")
}
return document, nil
}
func (store *FileSnapshotSequenceStore) write(document snapshotSequenceDocument) error {
body, err := json.Marshal(document)
if err != nil {
return err
}
temporary, err := os.CreateTemp(".", ".snapshot-sequences-")
if err != nil {
return fmt.Errorf("create snapshot sequence state: %w", err)
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if _, err := temporary.Write(body); err != nil {
temporary.Close()
return fmt.Errorf("write snapshot sequence state: %w", err)
}
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("protect snapshot sequence state: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close snapshot sequence state: %w", err)
}
if err := os.Rename(temporaryName, store.filename); err != nil {
return fmt.Errorf("commit snapshot sequence state: %w", err)
}
return nil
}
func snapshotSequenceKey(snapshotType string, streamKey string) (string, error) {
snapshotType = strings.TrimSpace(snapshotType)
streamKey = strings.TrimSpace(streamKey)
if snapshotType == "" || streamKey == "" || len(snapshotType) > 80 || len(streamKey) > 80 {
return "", fmt.Errorf("snapshot stream identity is invalid")
}
return snapshotType + "\x00" + streamKey, nil
}
func nextSnapshotSequence(current uint64, floor uint64) (uint64, error) {
if current == math.MaxUint64 {
return 0, fmt.Errorf("snapshot sequence is exhausted")
}
next := current + 1
if floor > next {
next = floor
}
if next == 0 {
return 0, fmt.Errorf("snapshot sequence is exhausted")
}
return next, nil
}
func snapshotSequenceFloor(observedAt time.Time) uint64 {
if observedAt.IsZero() || observedAt.UnixNano() <= 0 {
return 1
}
return uint64(observedAt.UnixNano())
}
@@ -0,0 +1,31 @@
package companion
import "testing"
func TestFileSnapshotSequenceStorePersistsMonotonicSequence(t *testing.T) {
t.Chdir(t.TempDir())
store, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("create sequence store: %v", err)
}
first, err := store.Next("players", "current", 100)
if err != nil || first != 100 {
t.Fatalf("reserve first sequence: sequence=%d err=%v", first, err)
}
restarted, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("restart sequence store: %v", err)
}
second, err := restarted.Next("players", "current", 1)
if err != nil || second != 101 {
t.Fatalf("reserve persisted sequence: sequence=%d err=%v", second, err)
}
}
func TestFileSnapshotSequenceStoreRejectsHostPaths(t *testing.T) {
for _, filename := range []string{"../snapshot-sequences.json", "/tmp/snapshot-sequences.json", ""} {
if _, err := NewFileSnapshotSequenceStore(filename); err == nil {
t.Fatalf("expected invalid filename rejection: %q", filename)
}
}
}