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
+2 -2
View File
@@ -33,7 +33,7 @@ Before 1.0, prioritize making the actual user-facing flows work fully. Remove or
- If native MCP tool wrappers return `unsupported call`, do not keep retrying the same wrapper. Read the server-management plugin MCP config and call the local MCP JSON-RPC gateway (`initialize`, `tools/list`, `tools/call`) from a small Node script; never print or commit MCP headers, tokens, host credentials, or raw `.mcp.json` contents. - If native MCP tool wrappers return `unsupported call`, do not keep retrying the same wrapper. Read the server-management plugin MCP config and call the local MCP JSON-RPC gateway (`initialize`, `tools/list`, `tools/call`) from a small Node script; never print or commit MCP headers, tokens, host credentials, or raw `.mcp.json` contents.
- If local access to the MCP gateway fails with `EPERM` on `127.0.0.1`, request sandbox escalation for that MCP gateway call instead of falling back to raw SSH. - If local access to the MCP gateway fails with `EPERM` on `127.0.0.1`, request sandbox escalation for that MCP gateway call instead of falling back to raw SSH.
- On Windows targets, assume `ssh_exec` may run under `cmd`; for PowerShell diagnostics, send `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand <utf16le-base64>` to avoid quoting/glob problems. - On Windows targets, assume `ssh_exec` may run under `cmd`; for PowerShell diagnostics, send `powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand <utf16le-base64>` to avoid quoting/glob problems.
- For SCUM current-service discovery, run bounded read-only scripts on `枣庄服务器` itself and return bounded typed results. Do not download or parse `SCUM.db` on the platform/plugin/browser side; product evidence must flow through Platform durable jobs and Run typed envelopes. Do not apply game-specific redaction to plugin-declared result fields or player records. Logs are opaque terminal data: Run and Platform must not inspect, parse, filter, redact, transform, or derive player/plugin records from stdout, stderr, or declared file-tail payloads. The SCUM plugin companion may consume its declared log streams, perform the one game-specific parse/redaction pass, and create or update its own users and business records; Platform and Run must never do that work. - For SCUM current-service discovery, run bounded read-only scripts on `枣庄服务器` itself and return bounded typed results. Do not download or parse `SCUM.db` on the platform/plugin/browser side; product evidence must flow through Platform durable jobs and Run typed envelopes. Do not apply game-specific redaction to plugin-declared result fields or player records. Logs are opaque terminal data: Run and Platform must not inspect, parse, filter, redact, normalize, transform, correlate, or derive player/plugin records from stdout, stderr, or declared file-tail payloads. They only add transport envelope metadata required for sequencing, integrity, persistence, relay, and display, and retain the log body verbatim. The SCUM plugin companion may consume its declared raw stream, parse it, and create or update its own typed users and business records; this plugin-owned work is never repeated or redacted by Platform or Run.
The machine-side run executor lives in the independent repository `git@git.npc0.com:admin343/run.git`; do not re-add a `run/` source tree to this repository. The machine-side run executor lives in the independent repository `git@git.npc0.com:admin343/run.git`; do not re-add a `run/` source tree to this repository.
@@ -95,7 +95,7 @@ Platform, plugin, and run lifecycle ownership must stay separated. Run is the li
- Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands. - Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands.
- Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport. - Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport.
Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state. Run must preserve plugin-declared game records without per-game content redaction. Supervised stdout/stderr and declared file tails are opaque verbatim channels: Run and Platform only persist, relay, or display them; they never content-filter or use them to produce player/plugin data. A plugin companion may parse its own declared stream and publish typed plugin-owned records through the component boundary. This does not grant plugins or the browser a direct host-path, credential, or socket API outside the log channel. Observed machine/runtime status must flow from run reports, heartbeats, supervised process facts, and job/log channels. Platform must not treat stale persisted server state, such as `running`, as authoritative when evaluating the current machine process state. Run must preserve plugin-declared game records without per-game content redaction. Supervised stdout/stderr and declared file tails are opaque verbatim channels: Run and Platform only persist, relay, or display them; they never inspect, content-filter, redact, normalize, correlate, or use them to produce player/plugin data. A plugin companion may parse its own declared raw stream and publish typed plugin-owned records through the component boundary. This does not grant plugins or the browser a direct host-path, credential, or socket API outside the log channel.
Do not hardcode game-specific deployment behavior in run or platform services. Values such as `SCUMServer.exe`, Steam app `3792580`, `steamcmd +app_update`, SCUM install directories, `-port`, `-MaxPlayers`, or `-log` belong in the SCUM plugin's manifests, action specs, templates, or scripts. Do not hardcode game-specific deployment behavior in run or platform services. Values such as `SCUMServer.exe`, Steam app `3792580`, `steamcmd +app_update`, SCUM install directories, `-port`, `-MaxPlayers`, or `-log` belong in the SCUM plugin's manifests, action specs, templates, or scripts.
+2
View File
@@ -29,6 +29,8 @@ Platform may read plugin manifests, validate lifecycle/action declarations, pack
Platform may persist desired lifecycle state, last-known run reports, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels. Platform may persist desired lifecycle state, last-known run reports, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels.
Log bodies from supervised stdout/stderr and plugin-declared file tails are opaque verbatim payloads. Platform may validate the transport envelope, sequence, checksum, and source binding, then persist, relay, and display the body unchanged. It must not inspect, parse, filter, redact, normalize, correlate, or derive player, login, user, or other plugin records from a log body. A plugin companion owns any game-specific parsing and its typed records arrive through the component boundary, never as a Platform log projection.
Do not add platform service code that hardcodes a game's executable path, Steam app ID, SteamCMD command line, process name, default launch flags, or update policy. For SCUM specifically, `SCUMServer.exe`, app `3792580`, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` must come from the SCUM plugin action assets or plugin-declared startup fields. Do not add platform service code that hardcodes a game's executable path, Steam app ID, SteamCMD command line, process name, default launch flags, or update policy. For SCUM specifically, `SCUMServer.exe`, app `3792580`, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` must come from the SCUM plugin action assets or plugin-declared startup fields.
If a lifecycle job requires checking whether game files exist, installing missing files, updating existing files, stopping before update, or building the final launch command, dispatch the plugin-owned action and keep platform limited to authorization, input validation, job creation, and result handling. If a lifecycle job requires checking whether game files exist, installing missing files, updating existing files, stopping before update, or building the final launch command, dispatch the plugin-owned action and keep platform limited to authorization, input validation, job creation, and result handling.
+1 -1
View File
@@ -497,7 +497,7 @@ func retimestampLogBatchRequest(t *testing.T, request *dto.LogBatchIngestRequest
domainEntries := make([]domain.LogEntry, 0, len(request.Entries)) domainEntries := make([]domain.LogEntry, 0, len(request.Entries))
for index := range request.Entries { for index := range request.Entries {
request.Entries[index].Timestamp = first.Add(time.Duration(index) * time.Millisecond).UTC() request.Entries[index].Timestamp = first.Add(time.Duration(index) * time.Millisecond).UTC()
domainEntries = append(domainEntries, domain.LogEntry{Seq: request.Entries[index].Seq, Timestamp: request.Entries[index].Timestamp, Level: request.Entries[index].Level, Line: request.Entries[index].Line, Fields: request.Entries[index].Fields, Redacted: request.Entries[index].Redacted}) domainEntries = append(domainEntries, domain.LogEntry{Seq: request.Entries[index].Seq, Timestamp: request.Entries[index].Timestamp, Level: request.Entries[index].Level, Line: request.Entries[index].Line, Fields: request.Entries[index].Fields})
} }
checksum, err := validator.LogEntriesChecksum(domainEntries) checksum, err := validator.LogEntriesChecksum(domainEntries)
if err != nil { if err != nil {
-1
View File
@@ -8,7 +8,6 @@ type LogEntry struct {
Level string Level string
Line string Line string
Fields map[string]string Fields map[string]string
Redacted bool
} }
type LogBatchIngest struct { type LogBatchIngest struct {
-3
View File
@@ -12,7 +12,6 @@ type LogEntryBody struct {
Level string `json:"level,omitempty"` Level string `json:"level,omitempty"`
Line string `json:"line"` Line string `json:"line"`
Fields map[string]string `json:"fields,omitempty"` Fields map[string]string `json:"fields,omitempty"`
Redacted bool `json:"redacted"`
} }
type LogBatchIngestRequest struct { type LogBatchIngestRequest struct {
@@ -178,7 +177,6 @@ func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
Level: entry.Level, Level: entry.Level,
Line: entry.Line, Line: entry.Line,
Fields: copyStringMap(entry.Fields), Fields: copyStringMap(entry.Fields),
Redacted: entry.Redacted,
} }
} }
return out return out
@@ -191,7 +189,6 @@ func logEntryFromDomain(entry domain.LogEntry) LogEntryBody {
Level: entry.Level, Level: entry.Level,
Line: entry.Line, Line: entry.Line,
Fields: copyStringMap(entry.Fields), Fields: copyStringMap(entry.Fields),
Redacted: entry.Redacted,
} }
} }
-2
View File
@@ -109,7 +109,6 @@ func LogEntriesChecksum(entries []domain.LogEntry) (string, error) {
Level: entry.Level, Level: entry.Level,
Line: entry.Line, Line: entry.Line,
Fields: entry.Fields, Fields: entry.Fields,
Redacted: entry.Redacted,
} }
} }
encoded, err := json.Marshal(stable) encoded, err := json.Marshal(stable)
@@ -138,5 +137,4 @@ type logEntryChecksumBody struct {
Level string `json:"level,omitempty"` Level string `json:"level,omitempty"`
Line string `json:"line"` Line string `json:"line"`
Fields map[string]string `json:"fields,omitempty"` Fields map[string]string `json:"fields,omitempty"`
Redacted bool `json:"redacted"`
} }
-1
View File
@@ -1593,7 +1593,6 @@ export interface LogEntryBody {
level?: string; level?: string;
line: string; line: string;
fields?: Record<string, string>; fields?: Record<string, string>;
redacted: boolean;
} }
export interface LogStreamCursorRequest { export interface LogStreamCursorRequest {
@@ -255,7 +255,7 @@ function logStream(id: string, logSessionId: string, streamKey: string, updatedA
} }
function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${seq}Z`): LogEntryBody { function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${seq}Z`): LogEntryBody {
return { seq, timestamp, line, redacted: true }; return { seq, timestamp, line };
} }
function sourceRCONDispatch(status: SourceRCONCommandResponse["status"]): SourceRCONCommandResponse { function sourceRCONDispatch(status: SourceRCONCommandResponse["status"]): SourceRCONCommandResponse {
@@ -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 typed player snapshot used by the plugin page. Platform and Run never inspect
or redact those log bodies. 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. 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. 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) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() 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 := companion.NewSCUMPlayerLogProjection(client, config.Component.ServerInstanceID)
projection.SequenceStore = sequenceStore
collector := companion.NewConsoleLogCollector(client, noopLogStore{}, config.Component.ServerInstanceID, os.Getenv(config.Proof.MaterialEnv)) collector := companion.NewConsoleLogCollector(client, noopLogStore{}, config.Component.ServerInstanceID, os.Getenv(config.Proof.MaterialEnv))
collector.OnSemanticEvents = projection.Handle collector.OnSemanticEvents = projection.Handle
runtime := companion.Runtime{ runtime := companion.Runtime{
@@ -20,7 +20,6 @@ type LogEntry struct {
Level string `json:"level,omitempty"` Level string `json:"level,omitempty"`
Line string `json:"line"` Line string `json:"line"`
Fields map[string]string `json:"fields,omitempty"` Fields map[string]string `json:"fields,omitempty"`
Redacted bool `json:"redacted"`
} }
type LogStreamEvent struct { type LogStreamEvent struct {
@@ -18,10 +18,10 @@ type SCUMPlayerLogProjection struct {
KeepForSeconds int KeepForSeconds int
MaxRecords int MaxRecords int
Now func() time.Time Now func() time.Time
SequenceStore SnapshotSequenceStore
mu sync.Mutex mu sync.Mutex
players map[string]scumPlayerProjection players map[string]scumPlayerProjection
sequence uint64
} }
type scumPlayerProjection struct { type scumPlayerProjection struct {
@@ -32,7 +32,7 @@ type scumPlayerProjection struct {
} }
func NewSCUMPlayerLogProjection(client *Client, serverInstanceID string) *SCUMPlayerLogProjection { 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 { 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 { if projection.Now != nil {
now = projection.Now now = projection.Now
} }
observedAt := now().UTC()
projection.mu.Lock() projection.mu.Lock()
for _, event := range batch.Events { for _, event := range batch.Events {
playerID := strings.TrimSpace(event.PlayerID) playerID := strings.TrimSpace(event.PlayerID)
@@ -81,8 +82,16 @@ func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch Sem
for _, player := range projection.players { for _, player := range projection.players {
players = append(players, player) players = append(players, player)
} }
projection.sequence++ store := projection.SequenceStore
sequence := projection.sequence 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() projection.mu.Unlock()
sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID }) sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID })
payloadPlayers := make([]map[string]any, 0, len(players)) 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), "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, Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence,
ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers}, ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers},
KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords, KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords,
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"net/http" "net/http"
"os"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -44,3 +45,55 @@ func TestSCUMPlayerLogProjectionCreatesTypedUserSnapshot(t *testing.T) {
t.Fatalf("unexpected projected player: %#v", players[0]) 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)
}
}
}