workspacestore

package
v0.30.1 Latest Latest
Warning

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

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

README

pkg/workspacestore

pkg/workspacestore captures and restores a session's workspace tree as immutable, content-addressed snapshots over a storage.Blobs backend. Snapshot a working directory to a Ref, record the Ref in the session journal, and later materialize it on any host to resume. An agent's files survive the compute they ran on.

What is workspacestore?

  • Store — the facade over a storage.Blobs backend. Holds only that backend and its resolved Options; every operation carries its own state, so a Store is as safe for concurrent use as the backend it wraps. Construct it only via Open.
  • Open(b storage.Blobs, opts...) (*Store, error) — validates the backend, resolves the options (SpoolDir, MaxEntries, MaxBytes) from defaults plus overrides.
  • Ref — the immutable, content-addressed snapshot name. The canonical form is v1:sha256:<64 lowercase hex>; obtain one only from ParseRef or from the store, never by string surgery. The v1: prefix is the evolution seam — a future format would parse under v2: without invalidating stored history.
  • Snapshot / Materialize / Extract / Archive / GC — the operations: snapshot a tree to a Ref, materialize a Ref into a directory, extract an archive stream with bomb guards, archive a ref's bytes, and collect unreachable snapshots.
  • OptionsWithSpoolDir(dir) for the snapshot spool temp file; WithMaxEntries(n) and WithMaxBytes(n) are the decompression-bomb guards Materialize enforces.

How to use

A consumer wires a *workspacestore.Store into a rig with a workspace placement:

import (
    "github.com/looprig/harness/pkg/workspacestore"
    "github.com/looprig/storage"
    // import a backend, e.g.
    // fsstore "github.com/looprig/fsstore"
)

backend, err := fsstore.Composite(rootDir)  // *storage.Composite
if err != nil { return err }

wsStore, err := workspacestore.Open(backend.Blobs,
    workspacestore.WithSpoolDir("/var/tmp/harness-spool"),
    workspacestore.WithMaxEntries(1<<20),  // 1 M entries
    workspacestore.WithMaxBytes(8<<30),    // 8 GiB
)
if err != nil { return err }

r, err := rig.Define(
    rig.WithSessionStore(sessionStore),
    rig.WithExclusiveWorkspace(wsStore, "/repo", backend.Leaser),
    /* ... */
)

A live session exposes the workspace through the SessionController contract:

ref, err := session.CheckpointWorkspace(ctx)        // snapshot the live tree
err     = session.RestoreWorkspace(ctx, ref)        // materialize a prior ref

A Ref can also be parsed from a stored string when you need to pass one through a wire:

ref, err := workspacestore.ParseRef("v1:sha256:" + hexDigest)
if err != nil { /* *InvalidRefError: names the rejected value and the rule */ }

Sibling packages

  • pkg/rigrig.WithExclusiveWorkspace / WithSessionWorkspaces / WithSharedWorkspace configure the placement; WithSeedSnapshot materializes one before any loop starts.
  • pkg/sessionCheckpointWorkspace / RestoreWorkspace on SessionController.
  • pkg/sessionstore — wired alongside this one in a rig; the session journal records the Refs Snapshot produces.
  • github.com/looprig/storagestorage.Blobs, the content-addressed immutable bytes primitive.
  • github.com/looprig/fsstore / looprig/natsstore / looprig/rclonestore — the backend modules that produce a *storage.Composite whose Blobs field this store runs on.

How it is designed

                   live workspace tree (a directory)
                            │
                            │  store.Snapshot
                            ▼
                  ┌──────────────────────┐
                  │ spool archive temp     │  (SpoolDir)
                  │ sha256 the bytes       │
                  └──────────┬───────────┘
                             │
                             ▼
                   workspacestore.Ref
                  "v1:sha256:<64 hex>"
                             │
                             │  Blobs.Put("workspaces/<hex>", archive)
                             ▼
                  storage.Blobs backend
                             │
                             │  store.Materialize
                             ▼
                  ┌──────────────────────┐
                  │ extract archive        │  MaxEntries / MaxBytes bomb guards
                  │ into target directory  │
                  └──────────────────────┘
Ref grammar is the only way in

A Ref is opaque. ParseRef is the only constructor that takes a string and returns one; the store's Snapshot is the only way to mint one from a tree. There is no string surgery at any call site, so every Ref in circulation is grammar-valid and its blob key is derivable without re-validation.

Bomb guards

Materialize enforces two independent ceilings against a hostile archive:

  • MaxEntries (default 2²⁰ entries) — caps how many archive entries Materialize will read before failing closed with an *ArchiveLimitError. A bomb that inflates to countless tiny entries cannot exhaust inodes.
  • MaxBytes (default 8 GiB) — caps the cumulative bytes Materialize will write while extracting. It is enforced against bytes actually written, never a header's declared size, so a lying size field cannot breach it.
GC

GC collects snapshots that are no longer reachable from the session journal. Reachability is determined by replaying the journal for ref references; an unreachable ref's blob is deleted from Blobs. The GC is the only thing that deletes blobs — a Ref is otherwise immutable for the life of its session.

Canonical paths

This package uses internal/pathutil for canonical-path resolution when the workspace root needs symlink resolution. internal/pathutil resolves the deepest existing prefix of a path through symlinks and appends any missing suffix, so a snapshot of ~/repo and a snapshot of /Users/me/repo produce the same canonical root.

Documentation

Overview

Package workspacestore captures a session's working directory as immutable, content-addressed snapshots so an agent's files survive the compute they ran on: snapshot a tree to a Ref, record the Ref in the session journal, and later materialize it on any host to resume. This file defines the Ref name and the package's typed error taxonomy; the snapshot/materialize machinery lands in later tasks.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ArchiveEntryError

type ArchiveEntryError struct {
	Name   string
	Reason string
}

ArchiveEntryError reports that an archive entry was rejected during Materialize as hostile or unsupported: an absolute or ".."-bearing Name (zip-slip), a symlink escaping the destination, or a device/fifo/hardlink entry. Name is the offending entry name and Reason names the rule it broke; both are log-safe.

func (*ArchiveEntryError) Error

func (e *ArchiveEntryError) Error() string

type ArchiveLimit

type ArchiveLimit string

ArchiveLimit names which decompression-bomb guard an archive tripped: the per-archive entry count or the cumulative extracted-byte count. It is a typed enum so callers match a specific breach without stringly-typed comparisons.

const (
	// ArchiveLimitEntries marks a breach of the maximum archive entry count.
	ArchiveLimitEntries ArchiveLimit = "entries"
	// ArchiveLimitBytes marks a breach of the maximum cumulative extracted bytes.
	ArchiveLimitBytes ArchiveLimit = "bytes"
)

type ArchiveLimitError

type ArchiveLimitError struct {
	Limit    ArchiveLimit
	Cap      int64
	Observed int64
}

ArchiveLimitError reports that a snapshot archive exceeded a decompression-bomb guard during Materialize and was rejected before finishing extraction. Limit names the guard (entries or bytes), Cap is the configured maximum, and Observed is the count that breached it. Observed is measured from bytes actually written or entries actually read — never a header's declared size — so a lying header cannot understate the breach. All fields are numeric or a fixed enum, log-safe.

func (*ArchiveLimitError) Error

func (e *ArchiveLimitError) Error() string

type DestNotEmptyError

type DestNotEmptyError struct {
	Dest      string
	Want      Ref
	GotDigest string
}

DestNotEmptyError reports that Materialize was asked to restore Want into a non-empty Dest whose deterministic re-archive digest (GotDigest, a bare hex) does not match Want — a warm volume that has drifted from the checkpointed tree. Materialize never wipes a destination; it returns this and leaves the clear-and-retry decision to the caller. All fields are log-safe.

func (*DestNotEmptyError) Error

func (e *DestNotEmptyError) Error() string

type GCError

type GCError struct {
	Op    string
	Ref   Ref
	Cause error
}

GCError wraps a backend Blobs failure encountered during GC: either the List that enumerates snapshot blobs (Op == gcOpList, Ref empty) or a Delete that removes one unreferenced snapshot (Op == gcOpDelete, Ref naming the blob whose removal failed). Cause is the underlying error, reachable via errors.As and errors.Unwrap. Op is one of the two package constants; a Ref carries no secret; so every field is safe to log.

func (*GCError) Error

func (e *GCError) Error() string

func (*GCError) Unwrap

func (e *GCError) Unwrap() error

type IntegrityError

type IntegrityError struct {
	Ref Ref
	Got string
}

IntegrityError reports that an archive fetched from Blobs extracted cleanly but the sha256 of its bytes (Got, a bare hex) does not equal the digest Ref names — a tampered or corrupted blob whose content still decodes. Materialize wipes the partial destination and fails closed, surfacing this as a *MaterializeError's Cause. Ref is the expected content address; both fields are log-safe. A blob so corrupt it breaks gzip or tar surfaces earlier as an extract error instead.

func (*IntegrityError) Error

func (e *IntegrityError) Error() string

type InvalidRefError

type InvalidRefError struct {
	Value  string
	Reason string
}

InvalidRefError reports a string that does not satisfy the v1 Ref grammar ("v1:sha256:<64 lowercase hex>"). Value is the rejected input and Reason names the specific rule it broke. A Ref carries no secret, so both fields are safe to log.

func (*InvalidRefError) Error

func (e *InvalidRefError) Error() string

type MaterializeError

type MaterializeError struct {
	Ref   Ref
	Dest  string
	Cause error
}

MaterializeError wraps a failure while materializing Ref into Dest — a fetch, decompress, or extract error. Cause is the underlying error, reachable via errors.As and errors.Unwrap. A hostile-entry rejection surfaces as *ArchiveEntryError instead (typically as this error's Cause).

func (*MaterializeError) Error

func (e *MaterializeError) Error() string

func (*MaterializeError) Unwrap

func (e *MaterializeError) Unwrap() error

type NilBlobsError

type NilBlobsError struct{}

NilBlobsError reports that Open was called with a nil Blobs backend. It carries no fields: the failure mode is fully described by its type, which callers match with errors.As.

func (*NilBlobsError) Error

func (e *NilBlobsError) Error() string

type NotDirError

type NotDirError struct {
	Path string
}

NotDirError reports that a path required to be a directory is not one — a snapshot root that resolved to a regular file, a socket, or another non-directory node. Path is the offending path and is safe to log.

func (*NotDirError) Error

func (e *NotDirError) Error() string

type Option

type Option func(*Options)

Option incrementally configures Options at Open time. Options are applied in argument order, so a later Option overrides an earlier one that sets the same field.

func WithMaxBytes

func WithMaxBytes(n int64) Option

WithMaxBytes sets the maximum cumulative number of bytes Materialize will write while extracting a snapshot archive before rejecting it as a decompression bomb. A zero or negative n restores the default (defaultMaxBytes).

func WithMaxEntries

func WithMaxEntries(n int64) Option

WithMaxEntries sets the maximum number of entries Materialize will read from a snapshot archive before rejecting it as a decompression bomb. A zero or negative n restores the default (defaultMaxEntries).

func WithSpoolDir

func WithSpoolDir(dir string) Option

WithSpoolDir directs Snapshot to spool its archive temp file under dir instead of the operating system's default temp directory.

type Options

type Options struct {
	// SpoolDir is the directory in which Snapshot creates its spool temp file. The
	// zero value (empty string) selects the operating system's default temp
	// directory. Open resolves it once to a canonical absolute path. A large working
	// set is spooled here in full, so point it at a volume with room for one archive.
	SpoolDir string

	// MaxEntries caps how many entries Materialize will read from a snapshot
	// archive before failing closed with *ArchiveLimitError — the guard against a
	// bomb that inflates to an unbounded number of tiny entries. A zero or negative
	// value is resolved to defaultMaxEntries by Open.
	MaxEntries int64

	// MaxBytes caps the cumulative number of bytes Materialize will write while
	// extracting a snapshot archive before failing closed with *ArchiveLimitError.
	// It is enforced against bytes actually written, never a header's declared
	// size, so a lying size field cannot breach it. A zero or negative value is
	// resolved to defaultMaxBytes by Open.
	MaxBytes int64
}

Options carries the resolved knobs that tune a Store: where Snapshot spools its archive temp file, and the two bounds that guard Materialize against a hostile archive (a decompression bomb inflating to too many entries or too many bytes).

type PersistencePathError

type PersistencePathError struct {
	Path  string
	Cause error
}

PersistencePathError reports a local persistence path that could not be canonicalized without ambiguity.

func (*PersistencePathError) Error

func (e *PersistencePathError) Error() string

func (*PersistencePathError) Unwrap

func (e *PersistencePathError) Unwrap() error

type Ref

type Ref string

Ref names one immutable, content-addressed snapshot in the canonical form "v1:sha256:<64 lowercase hex>". It is opaque to callers: obtain one only from ParseRef or from the store, never by string surgery, so every Ref in circulation is grammar-valid and its blob key is derivable without re-validation.

func ParseRef

func ParseRef(s string) (Ref, error)

ParseRef validates s against the v1 Ref grammar — the literal refPrefix followed by exactly refHexLen lowercase hexadecimal characters — and returns it as a Ref. Any violation yields a *InvalidRefError naming the rejected value and the specific rule broken; on error the returned Ref is empty, never partially valid.

type SnapshotError

type SnapshotError struct {
	Root  string
	Cause error
}

SnapshotError wraps a failure while snapshotting the tree rooted at Root — a walk, archive, hash, or upload error. Root is the caller-supplied root path; Cause is the underlying error, reachable via errors.As and errors.Unwrap.

func (*SnapshotError) Error

func (e *SnapshotError) Error() string

func (*SnapshotError) Unwrap

func (e *SnapshotError) Unwrap() error

type Store

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

Store captures and restores a session's workspace tree as immutable, content-addressed snapshots over a storage.Blobs backend. It holds only that backend and its resolved Options; every operation carries its own state, so a Store is as safe for concurrent use as the backend it wraps.

func Open

func Open(b storage.Blobs, opts ...Option) (*Store, error)

Open returns a Store over the given Blobs backend with opts applied. A nil backend is rejected up front with *NilBlobsError — a Store has nowhere to put snapshot bytes without one, so Open fails closed rather than hand back a Store that panics on first Snapshot. The effective spool directory is canonicalized and frozen here; an ambiguous path returns *PersistencePathError.

func (*Store) Delete

func (s *Store) Delete(ctx context.Context, ref Ref) error

Delete removes ref's snapshot archive from Blobs. It is idempotent: deleting a ref whose blob is already absent succeeds, mirroring the storage Blobs contract, so a resume that has already discarded its checkpoint is not an error.

func (*Store) GC

func (s *Store) GC(ctx context.Context, live map[Ref]struct{}) (deleted []Ref, err error)

GC deletes every stored snapshot blob whose Ref is not in `live`, returning the Refs it deleted. It is the mark-and-sweep the composition root runs after computing the live set (the Refs reachable from any live session's WorkspaceCheckpointed events).

v1 is LIVE-SET-ONLY: it has no age/last-seen safety net, so it MUST NOT run concurrently with active snapshotting — a Snapshot writing a not-yet-live Ref (e.g. a brand-new checkpoint, or a re-Put of an identical tree) could be deleted mid-flight. Age-based safety would need a Stat/last-modified surface on storage.Blobs (future work).

GC is FAIL-SECURE: a key under the workspaces/ prefix that does not parse as a valid v1 Ref is skipped, never deleted — GC only ever removes blobs it recognizes as its own snapshots, so a foreign object sharing the prefix is left untouched rather than treated as unreferenced. It checks ctx before listing and before each delete: an already-cancelled ctx returns (nil, ctx.Err()) and deletes nothing; a mid-sweep cancellation returns the Refs deleted so far and the ctx error. A backend List or Delete failure returns the Refs deleted so far wrapped in *GCError.

func (*Store) Materialize

func (s *Store) Materialize(ctx context.Context, ref Ref, dest string) error

Materialize restores the snapshot named ref into dest. It has two paths chosen by the state of dest. Truth path — dest missing or an empty directory: fetch ref's compressed archive from Blobs and extract it through the trust boundary while re-verifying, over the whole compressed stream, that its sha256 equals ref; any fetch, extract, or digest mismatch fails closed as *MaterializeError with the partial output wiped. Verified-reuse path — dest a non-empty directory: a warm volume may already hold the tree, but it is never trusted and never wiped; dest is deterministically re-archived and its digest compared to ref. A match is a no-op resume (no fetch); a mismatch returns *DestNotEmptyError so the caller decides whether to clear and retry. A dest that exists but is not a directory is rejected as a wrapped *NotDirError.

func (*Store) PersistencePaths

func (s *Store) PersistencePaths() ([]string, error)

PersistencePaths returns the canonical local roots used by the configured blob provider and Snapshot's effective spool directory. A provider without the optional storage.PathReporter capability contributes no path. Resolution fails closed with *PersistencePathError when any path is ambiguous.

func (*Store) Snapshot

func (s *Store) Snapshot(ctx context.Context, root string) (Ref, error)

Snapshot archives the tree rooted at root into a spooled temp file while teeing every byte through sha256, derives the content-addressed Ref from that digest, and uploads the archive to Blobs under the Ref's blob key — but only if that key is still absent, so an unchanged tree re-snapshots into a no-op upload. Spooling to disk means the working set never has to fit in memory, and because the digest is complete before any byte is sent, the key is final before the upload begins. Every failure mode — an unusable root, a walk/archive error, or a Blobs error — surfaces as *SnapshotError with the underlying cause reachable via errors.As.

Jump to

Keyboard shortcuts

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