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 ¶
- Variables
- func CastKey(runID, suffix string) string
- func Handler(store Store, authorize Authorizer) http.Handler
- 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) 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) 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 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) 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) 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)
}
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.
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. |