recording

package
v0.9.2 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: Apache-2.0 Imports: 25 Imported by: 0

Documentation

Overview

Package recording provides storage and HTTP serving of asciicast session recordings produced by wardyn-rec. Store 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).
  • Reads use os.OpenInRoot because shared-mount writers can create symlinks; lexical validation alone cannot keep reads inside the recording directory.
  • 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 ErrErased = errors.New("recording: erased")

ErrErased refuses access to a run whose recordings were durably erased.

View Source
var ErrInvalidCast = errors.New("recording: invalid asciicast v2")

ErrInvalidCast identifies a recording that cannot establish an output stream. Its error text never contains sandbox-controlled recording content.

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

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

Functions

func AdmissionGroup added in v0.9.0

func AdmissionGroup(ctx context.Context, key string) string

AdmissionGroup is the group a stream save of key queues in: the one ctx names, else the key's run.

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 CopyOutput added in v0.8.9

func CopyOutput(ctx context.Context, dst io.Writer, src io.Reader) error

CopyOutput decodes a joined asciicast v2 stream into consecutive output payloads. Mask dst as one stream: event and part boundaries may split secrets.

func Handler

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

Handler returns an http.Handler serving GET /{runID} as an asciicast stream (Content-Type: application/x-asciicast); mount it under /api/v1/runs/{id}/recording.

The {runID} param is the CAST KEY: 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 {runID} is NOT the parent mount's {id} (the run this recording is fetched THROUGH) — the two are enforced equal (case-insensitive, on the run-id prefix) before authorize runs, so a caller can't request .../runs/A/recording/B to read run B's cast under an authorization scoped to run A. Both checks collapse to the same 404 (no existence oracle). Store errors produce 500. A bare run id is served joined across its tail-uploaded parts (OpenJoined).

A key servableKey or canonicalRunID refuses gets that same 404 before authorize or the store is asked anything.

func LastOutputElapsed added in v0.7.2

func LastOutputElapsed(data []byte) (float64, bool)

LastOutputElapsed returns the elapsed-seconds timestamp of the last output ("o") event in data, scanning backwards from the end. Mirrors the console's former client-side probe byte for byte, so a run's server-projected duration and a manually-fetched cast's duration read the same number.

data need not be the whole document — StatAndTail hands this only a tail slice, so the loop may run off an incomplete first line, which just fails to parse like any other malformed line. Returns (0, false) when no output event is found.

func Names

func Names() []string

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

func OpenJoined added in v0.8.0

func OpenJoined(ctx context.Context, s Store, runID string) (io.ReadCloser, error)

OpenJoined opens runID's cast as one asciicast v2 document: part 1 as stored, then each later part with its repeated header line dropped, until a part is missing. A part whose header differs from part 1's belongs to an earlier cast of the same run (a revive starts a new cast and replaces part 1), so the join ends there rather than splicing two recordings. Parts are opened one at a time, so a store that buffers a whole cast (PGStore) holds one part, not the run.

ponytail: part 1 anchors the join, so retention sweeping part 1 of a run that outlived the retention window hides its later parts from replay; a part index would lift that if retention and year-long runs ever meet.

func PartSuffix added in v0.8.0

func PartSuffix(n int) string

PartSuffix is the cast-key suffix part n (n >= 2) of a run's cast is stored under. It cannot collide with an attach session's suffix, which is a UUID.

func Register

func Register(name string, c Constructor)

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

func StatJoined added in v0.8.0

func StatJoined(ctx context.Context, s Store, runID string, tailBytes int64) (int64, []byte, error)

StatJoined is StatAndTail over every part of runID's cast: the size is the bytes stored across all of them, and the tail is the last part's — event times run on from part to part because every part is cut from one cast file.

ponytail: unlike OpenJoined it does not read each part's header, so after a revive it still counts an earlier cast's later parts until they are swept; replay itself stays correct.

func WithAdmissionGroup added in v0.9.0

func WithAdmissionGroup(ctx context.Context, group string) context.Context

WithAdmissionGroup names the group ctx's stream saves queue in before they queue for the stream slot. The upload route passes the run's owner, so one person's uploads wait behind each other, not ahead of everyone else's.

Types

type Authorizer added in v0.5.0

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

Authorizer decides whether req's caller may read the recording whose cast key names runIDPrefix (the run id, up to an optional "~<suffix>" session marker; see CastKey). It is Handler's ONLY authorization check — route auth middleware proves SOME authenticated human/admin, not WHICH run's recordings they may read. False collapses to the SAME 404 an absent recording gets: no existence oracle for "not yours" vs "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 to record an interactive attach session: the header is written once by NewCastWriter, then each chunk of PTY output is appended as a timed "o" event via Write.

Safe for concurrent use by a single producer goroutine; a caller writing from one goroutine and reading the buffer from another after Close needs 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 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)]. p is the (already masked) terminal output; Write 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 => disabled
	// (fs-specific; pg ignores it).
	Dir string
	// Pool is the shared pgxpool the pg-backed store persists through, so
	// recordings are visible to every replica instead of 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) DeleteRun added in v0.8.6

func (s *FSStore) DeleteRun(ctx context.Context, key string) (int, error)

DeleteRun installs a durable fence and removes the run's bare and suffixed casts, plus wardyn-rec's shared-volume .log fallback. Repeating it is safe.

func (*FSStore) OpenCast

func (s *FSStore) OpenCast(ctx context.Context, key 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(ctx context.Context, key 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. Both runID and the composite key are checked by safeRunPath, failing closed on any path-traversal attempt in either component.

func (*FSStore) SaveRecordingFile added in v0.8.9

func (s *FSStore) SaveRecordingFile(ctx context.Context, name string, r io.Reader) error

SaveRecordingFile preserves wardyn-rec's shared-volume .cast/.log filename and existing-file permissions/ownership while applying the same run fence as SaveCast. Its final local copy retains the fallback's in-place overwrite.

func (*FSStore) StatAndTail added in v0.7.2

func (s *FSStore) StatAndTail(ctx context.Context, key string, tailBytes int64) (int64, []byte, error)

StatAndTail reports the cast's size and reads its last tailBytes without copying the rest of the file. tailBytes clamps down to size when the cast is smaller.

func (*FSStore) Sweep added in v0.4.4

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

Sweep unlinks every cast (and orphaned atomic-write temp file) directly under root whose mtime is older than olderThan, returning the count removed. Deliberately not on the Store interface: retention is an fs-storage concern, so callers type-assert for it.

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 appended to and mtime keeps advancing — 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. Unlike FSStore (per-pod directory), a cast saved via one replica is immediately visible through any other. 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.

func (*PGStore) DeleteRun added in v0.8.6

func (s *PGStore) DeleteRun(ctx context.Context, key string) (int, error)

DeleteRun durably fences the run and removes its bare and suffixed casts in one transaction. Absent casts are not an error.

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 is shared verbatim with FSStore so a suffix is accepted or rejected identically regardless of the selected store.

func (*PGStore) StatAndTail added in v0.7.2

func (s *PGStore) StatAndTail(ctx context.Context, key string, tailBytes int64) (int64, []byte, error)

StatAndTail reports the cast's byte size and its last tailBytes, computed and sliced server-side so a caller wanting only a size and duration never pulls the whole payload across the wire. tailBytes is clamped to size when the cast is smaller.

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. Measured on updated_at, not created_at, so a re-saved cast is never swept from under an in-progress session. Deliberately not part of the Store interface — retention is a storage-backend concern, reached via the unexported recordingSweepable interface instead.

type RunDeleter added in v0.8.6

type RunDeleter interface {
	DeleteRun(ctx context.Context, runID string) (int, error)
}

RunDeleter durably fences a run before deleting all of its recordings. A successful erase prevents later saves, opens and stat calls across restart. A composite runID erases that exact key and its "~" descendants only.

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)
	StatAndTail(ctx context.Context, key string, tailBytes int64) (size int64, tail []byte, err error)
}

Store is the recording persistence contract.

SaveCast persists the asciicast bytestream from r under runID, replacing any prior recording; safe for concurrent saves of different runIDs.

SaveCastNamed persists under a composite key "<runID>~<suffix>" (e.g. an interactive attach session id) so it does not clobber the batch run's cast (keyed by bare runID). Same path guardrails as runID; an empty suffix is equivalent to SaveCast.

OpenCast returns a ReadCloser (caller closes it) or ErrNotFound. The key is either a bare runID or a "<runID>~<suffix>" composite.

StatAndTail reports a key's size and its last tailBytes without returning the whole payload; tailBytes clamps to size when the cast is smaller. ErrNotFound on the same terms as OpenCast.

Save, open and stat return ErrErased after a run is erased. A key belongs to the run before its first "~", including keys passed directly to SaveCast. Already-open readers may retain bytes; erasure does not revoke them.

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, holding a blessed default and any future alternate to the identical contract.
Package recordingtest provides a reusable conformance suite for any recording.Store implementation, holding a blessed default and any future alternate to the identical contract.

Jump to

Keyboard shortcuts

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