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).
- 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 ¶
- Variables
- func CastKey(runID, suffix string) string
- func Handler(store Store, authorize Authorizer) http.Handler
- func LastOutputElapsed(data []byte) (float64, bool)
- func Names() []string
- func Register(name string, c Constructor)
- type Authorizer
- type CastHeader
- type CastWriter
- type Constructor
- type Deps
- type FSStore
- func (s *FSStore) OpenCast(_ context.Context, runID string) (io.ReadCloser, error)
- func (s *FSStore) SaveCast(_ context.Context, runID string, r io.Reader) error
- func (s *FSStore) SaveCastNamed(ctx context.Context, runID, suffix string, r io.Reader) error
- func (s *FSStore) StatAndTail(_ context.Context, key string, tailBytes int64) (int64, []byte, error)
- func (s *FSStore) Sweep(olderThan time.Duration) (int, error)
- type PGStore
- func (s *PGStore) OpenCast(ctx context.Context, key string) (io.ReadCloser, error)
- func (s *PGStore) SaveCast(ctx context.Context, runID string, r io.Reader) error
- func (s *PGStore) SaveCastNamed(ctx context.Context, runID, suffix string, r io.Reader) error
- func (s *PGStore) StatAndTail(ctx context.Context, key string, tailBytes int64) (int64, []byte, error)
- func (s *PGStore) Sweep(olderThan time.Duration) (int, error)
- type Store
Constants ¶
This section is empty.
Variables ¶
var ErrNotFound = errors.New("recording: not found")
ErrNotFound is returned by OpenCast when no recording exists for the run.
Functions ¶
func CastKey ¶
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 LastOutputElapsed ¶ added in v0.7.2
LastOutputElapsed returns the elapsed-seconds timestamp of the LAST output ("o") event found in data, scanning backwards from the end. It mirrors the console's former client-side probe byte for byte (recordings.ts's lastOutputAt), so a run's server-projected duration (R4-F077) 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 the front of an incomplete first line; that line either fails to parse (silently skipped, same as a malformed line anywhere else) or happens to be the last complete line already, which is exactly the case this exists to answer cheaply. Returns (0, false) when no output event is found (header-only cast, or a tail with none).
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
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 ¶
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 ¶
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 ¶
NewFSStore returns an FSStore that persists casts under root. The directory is created with mode 0o750 if it does not exist.
func (*FSStore) OpenCast ¶
OpenCast opens <root>/<runID>.cast for reading. Returns ErrNotFound when the file does not exist.
func (*FSStore) SaveCast ¶
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 ¶
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) StatAndTail ¶ added in v0.7.2
func (s *FSStore) StatAndTail(_ context.Context, key string, tailBytes int64) (int64, []byte, error)
StatAndTail reports the cast's size and reads its last tailBytes without opening (or copying) the rest of the file. tailBytes is clamped down to size when the cast is smaller.
func (*FSStore) Sweep ¶ added in v0.4.4
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
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
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
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
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) 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 (octet_length/substring on the bytea column) so a caller wanting only a size and a duration never pulls the whole payload across the wire — the same reason FSStore's twin seeks instead of reading the file. tailBytes is clamped down to size when the cast is smaller.
func (*PGStore) Sweep ¶ added in v0.5.0
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)
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 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.
StatAndTail answers "does this key have a recording, how big is it, and what are its last tailBytes" WITHOUT returning the whole payload — R4-F077's server-side half of what used to be a full OpenCast + download per run just to learn a size and a duration. tailBytes is clamped to size when the cast is smaller; a caller wanting the duration parses the tail for the last output event (internal/recording.LastOutputElapsed) rather than the whole document. Returns ErrNotFound when no recording exists, on the same terms as OpenCast.
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. |