recording

package
v0.6.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Overview

Package recording provides storage and HTTP serving of asciicast session recordings produced by wardyn-rec. The Store interface is intentionally minimal so the fs-backed implementation can later be replaced by object storage without touching callers.

Security constraints:

  • All path construction goes through safeRunPath, which rejects any runID containing path separators or dot-sequences (path-traversal prevention).
  • OpenCast returns (nil, ErrNotFound) for absent recordings so callers can distinguish "never recorded" from storage errors.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotFound = errors.New("recording: not found")

ErrNotFound is returned by OpenCast when no recording exists for the run.

Functions

func CastKey

func CastKey(runID, suffix string) string

CastKey builds the composite cast key for a run + optional session suffix. An empty suffix yields the bare runID (the batch-run cast key).

func Handler

func Handler(store Store, authorize Authorizer) http.Handler

Handler returns an http.Handler that serves GET /{runID} as an asciicast stream (Content-Type: application/x-asciicast). Mount it under /api/v1/runs/{id}/recording in the wardynd router.

The {runID} URL parameter is the CAST KEY (extracted via chi) — a bare run id (a batch run's cast) or a "<runID>~<suffix>" composite (an interactive attach session, or a future SSH session — see CastKey). SECURITY: this sub-route's own {runID} is NOT the same path segment as the PARENT mount's {id} (the run this recording is being fetched THROUGH) — the two are enforced equal (case-insensitively, on the run-id PREFIX) before authorize ever runs, so a caller cannot request .../runs/A/recording/B to read run B's cast under an authorization check scoped to run A. Both checks collapse to the SAME 404 "recording not found" a missing cast gets (no existence oracle). Errors from the store produce 500.

func Names

func Names() []string

Names returns the registered store names (for /healthz and error messages).

func Register

func Register(name string, c Constructor)

Register adds a recording-store implementation; call it from an init().

Types

type Authorizer added in v0.5.0

type Authorizer func(r *http.Request, runIDPrefix string) bool

Authorizer decides whether the caller of req may read the recording whose cast key names runIDPrefix — the run id up to an optional "~<suffix>" session marker (see CastKey). It is the ONLY authorization check Handler performs: the route's own auth middleware (wired by the caller, e.g. humanOrAdminAuth in internal/api) proves the caller is SOME authenticated human/admin, not WHICH run's recordings they may read. A false return is turned into the SAME 404 an absent recording gets (see Handler) — no existence oracle distinguishing "not yours" from "never recorded".

type CastHeader

type CastHeader struct {
	Version   int   `json:"version"`
	Width     int   `json:"width"`
	Height    int   `json:"height"`
	Timestamp int64 `json:"timestamp,omitempty"`
}

CastHeader is the asciicast v2 header (line 1 of the stream).

type CastWriter

type CastWriter struct {
	// contains filtered or unexported fields
}

asciicast v2 format: a header object on line 1, then one JSON event array per line. An output event is ["<elapsed seconds>", "o", "<data>"]. Reference: https://docs.asciinema.org/manual/asciicast/v2/

CastWriter incrementally serializes an asciicast v2 stream. It is used to record an interactive attach session: the header is written once by NewCastWriter, then each chunk of PTY OUTPUT (server->client bytes, already secret-masked by the caller) is appended as a timed "o" event via Write. The serialized bytes accumulate in the wrapped io.Writer (e.g. a bytes.Buffer) so the whole cast can be persisted to the RecordingStore when the session ends.

CastWriter is safe for concurrent use by a single producer goroutine; callers that write from one goroutine (the attach Read pump) and read the buffer from another after Close need no extra locking beyond the internal mutex here.

func NewCastWriter

func NewCastWriter(dst io.Writer, width, height int, startedAt time.Time) *CastWriter

NewCastWriter returns a CastWriter that serializes events into dst. width and height are the initial terminal size recorded in the header (0 values fall back to a sane 80x24 so the replay player has a valid geometry). startedAt is the wall-clock session start; event timestamps are elapsed seconds from it.

func (*CastWriter) HadOutput

func (w *CastWriter) HadOutput() bool

HadOutput reports whether any output event was recorded (beyond the header).

func (*CastWriter) Write

func (w *CastWriter) Write(p []byte) (int, error)

Write appends p as a timed asciicast OUTPUT event ["t","o",string(p)]. The elapsed time is computed from the writer's start time. p is the (already masked) terminal output. It satisfies io.Writer so it can sit directly behind a secretmask.MaskingWriter. A zero-length write is a no-op.

type Constructor

type Constructor func(Deps) (Store, error)

Constructor builds a Store from Deps. It may return (nil, nil) to mean "recording disabled" (the fs store with an empty Dir), which callers treat as no-recording.

type Deps

type Deps struct {
	// Dir is the base directory for filesystem-backed stores. Empty => recording
	// disabled (the fs constructor returns a nil Store). fs-specific; pg ignores it.
	Dir string
	// Pool is the shared pgxpool the pg-backed store persists through — the SAME
	// pool the rest of the control plane uses, so a cast is visible to every
	// replica instead of living on one pod's local disk.
	Pool *pgxpool.Pool
}

Deps are the platform primitives a recording.Store constructor may use. New seams keep their own typed Deps so heterogeneous construction stays type-safe.

type FSStore

type FSStore struct {
	// contains filtered or unexported fields
}

FSStore is a filesystem-backed Store. Each recording is stored as <root>/<runID>.cast. The root directory is created on first use.

func NewFSStore

func NewFSStore(root string) (*FSStore, error)

NewFSStore returns an FSStore that persists casts under root. The directory is created with mode 0o750 if it does not exist.

func (*FSStore) OpenCast

func (s *FSStore) OpenCast(_ context.Context, runID string) (io.ReadCloser, error)

OpenCast opens <root>/<runID>.cast for reading. Returns ErrNotFound when the file does not exist.

func (*FSStore) SaveCast

func (s *FSStore) SaveCast(_ context.Context, runID string, r io.Reader) error

SaveCast writes the asciicast stream to <root>/<runID>.cast atomically (write to a temp file then rename). Fails closed on any path-traversal attempt.

func (*FSStore) SaveCastNamed

func (s *FSStore) SaveCastNamed(ctx context.Context, runID, suffix string, r io.Reader) error

SaveCastNamed writes the asciicast stream to <root>/<runID>~<suffix>.cast atomically. An empty suffix is equivalent to SaveCast (bare runID key). Both the runID and the composite key are checked by safeRunPath (fails closed on any path-traversal attempt in either component).

func (*FSStore) Sweep added in v0.4.4

func (s *FSStore) Sweep(olderThan time.Duration) (int, error)

Sweep unlinks every cast (and every orphaned atomic-write temp file) directly under root whose mtime is older than olderThan, returning how many files it removed. It is deliberately NOT on the Store interface: retention is an fs-storage concern, and an object-storage Store would use its bucket's own lifecycle rules. Callers type-assert for it, so a store without retention is visibly without retention rather than silently swept.

Age is measured on ModTime, not birth time: the recordings directory is also mounted into agent containers for wardyn-rec's -out-dir fallback, so a cast may still be being appended to. mtime advances on every write, which is what makes unlinking-by-age safe against an in-flight session — do not "improve" this to birth time.

type PGStore added in v0.5.0

type PGStore struct {
	// contains filtered or unexported fields
}

PGStore is a Postgres-backed Store (migration 0028). Unlike FSStore, a cast saved through one replica's handle is immediately visible to OpenCast through any OTHER replica's handle — FSStore's directory is per-pod, so a replay request that lands on a different pod than the one that recorded the session 404s. The zero value is unusable; use NewPGStore.

func NewPGStore added in v0.5.0

func NewPGStore(pool *pgxpool.Pool) *PGStore

NewPGStore returns a Store backed by pool — the SAME pgxpool the rest of the control plane uses, so there is no separate connection or credential to manage.

func (*PGStore) OpenCast added in v0.5.0

func (s *PGStore) OpenCast(ctx context.Context, key string) (io.ReadCloser, error)

OpenCast returns a ReadCloser over the stored bytes for key (either a bare runID or a "<runID>~<suffix>" composite). Returns ErrNotFound when absent.

func (*PGStore) SaveCast added in v0.5.0

func (s *PGStore) SaveCast(ctx context.Context, runID string, r io.Reader) error

SaveCast persists the asciicast bytestream from r under cast_key = runID, replacing any prior cast stored under that same key (upsert), bounded by maxCastBytes.

func (*PGStore) SaveCastNamed added in v0.5.0

func (s *PGStore) SaveCastNamed(ctx context.Context, runID, suffix string, r io.Reader) error

SaveCastNamed persists r under the composite "<runID>~<suffix>" key (see Store's doc for the addressing contract). validSuffix (store.go) is shared verbatim with FSStore so a suffix is accepted or rejected identically regardless of which store an operator has selected.

func (*PGStore) Sweep added in v0.5.0

func (s *PGStore) Sweep(olderThan time.Duration) (int, error)

Sweep deletes every cast row last written more than olderThan ago, returning how many rows it removed. It mirrors FSStore.Sweep's age semantics — measured on last write via updated_at, not created_at, so a cast that was re-saved is never swept out from under an in-progress session — but, like FSStore.Sweep, is deliberately NOT part of the Store interface (see store.go's package doc: retention is a storage-backend concern, and a future object-storage backend would use its bucket's own lifecycle rules instead of an app-level sweep). cmd/wardynd's startBackgroundWorkers reaches this through the unexported recordingSweepable interface (adapters.go), which both FSStore and PGStore satisfy structurally.

type Store

type Store interface {
	SaveCast(ctx context.Context, runID string, r io.Reader) error
	SaveCastNamed(ctx context.Context, runID, suffix string, r io.Reader) error
	OpenCast(ctx context.Context, key string) (io.ReadCloser, error)
}

Store is the recording persistence contract.

SaveCast persists the asciicast bytestream from r under runID, replacing any prior recording for that run. It must be safe for concurrent saves of different runIDs.

SaveCastNamed persists the asciicast bytestream from r under a composite key "<runID>~<suffix>" (e.g. an interactive attach session id), so an interactive session recording does NOT clobber the batch run's cast (keyed by bare runID) and concurrent/sequential attaches each get their own cast. The same path guardrails (no traversal) apply to both runID and suffix. The composite key is what OpenCast surfaces; the recording HTTP handler can serve it by that key. Passing an empty suffix is equivalent to SaveCast.

OpenCast returns a ReadCloser for the asciicast. The caller is responsible for closing it. Returns ErrNotFound when no recording exists. The key is either a bare runID (batch cast) or a "<runID>~<suffix>" composite.

func New

func New(name string, d Deps) (Store, error)

New constructs the recording store selected by name (empty => default).

Directories

Path Synopsis
Package recordingtest provides a reusable conformance suite for any recording.Store implementation.
Package recordingtest provides a reusable conformance suite for any recording.Store implementation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL