Documentation
¶
Overview ¶
Package staterecord is a small, versioned key/value Store with first-class conditional writes: Get, PutIfVersion, PutIfAbsent, Delete, List — nothing else. It backs the micro-state records issue #73's charter describes (record-less residue: null_resource, terraform_data, time_*, non-sensitive random_* run through the stock provider lifecycle against an in-memory state hydrated from and CAS-persisted to one small record per resource), but this package itself knows nothing about that. It has no notion of an estate, a resource, redaction, or anything else choudoufu-specific — keys are opaque strings, payloads are opaque bytes, and every choudoufu concept (what a key names, what goes in a payload, which resources get one) lives entirely in the caller.
Why that separation is the point ¶
This package is meant to be upstream-adoptable verbatim: proposable to OpenTofu as a lightweight state backend on its own merits, independent of choudoufu ever existing. Concretely, that shapes three decisions:
- The Store interface follows upstream's own backend conventions — a clean Get/Put-with-condition/Delete surface, no fork-specific types anywhere in its signatures, workspace-agnostic naming (a "key", not a "workspace" or a "resource address").
- Conditional-write/CAS is a first-class interface concept, not something bolted onto a plain Put as an optional flag. Upstream's own s3-locking-with-conditional-writes RFC (20250211) already shows appetite for exactly this primitive as a first-class one.
- The package directory holds only store implementations and their tests — nothing that imports estate configuration, redaction rules, or resource-selection logic. A third store (issue #73's ruling: "design the interface so a third store is a new file, not a refactor") is one new file implementing Store, never a change to this one.
The interface contract, precisely ¶
- Keys are opaque strings. Every implementation accepts a reasonably portable subset — this package itself only rejects the empty string, a NUL byte, and a ".." path segment (see validateKey) — but each store's own backend (a filesystem, an SSM parameter name, an S3 object key) may reject a key its own naming rules forbid; that surfaces as an ordinary error, not a Store-defined one.
- Payloads are opaque []byte. No implementation inspects, parses, or redacts a payload's content; that is the caller's job, every time, before a payload reaches this package and after one leaves it.
- Versions are opaque strings with exactly one universal meaning: "" denotes "no record exists here." No implementation ever assigns "" as a live record's version, so a caller can treat it as a stable sentinel without inspecting which store it is talking to. Beyond that, a version's shape is entirely implementation-defined — a content hash, an S3 ETag, an SSM parameter version number — and Store callers are expected to hold it opaque too: compare it for equality, pass it to PutIfVersion/Delete, never parse it.
- Every conditional operation that fails on a version mismatch reports exactly one error type: *VersionConflictError, naming both the version the caller expected and the version the store actually found (or "" for "no record"). A caller never has to distinguish "conflict" from "some other failure" by parsing prose.
- What "conditional" guarantees varies by store, and each implementation's own doc comment states its own store's true strength honestly rather than implying parity with the others: LocalStore and S3Store give real compare-and-swap with no read-compare-write race window; SSMStore gives real CAS only for create (SSMStore.PutIfAbsent), and a documented weaker, best-effort race story for everything that updates or removes an existing record. Nothing in this package's exported API hides that difference behind a uniform-looking success/failure return — it is written out in full in ssm.go's package-level doc comment.
The three implementations ¶
Per issue #73's maintainer rulings: LocalStore (a directory of files, the zero-configuration default — solo development, tests, air-gapped runs, mirroring plain local state's own "just works" shape), SSMStore (AWS Systems Manager Parameter Store, the zero-infrastructure team default), and S3Store (S3 conditional writes, true CAS end-to-end, for teams that want it). All three implement the identical Store interface; a caller choosing between them is choosing an operational tradeoff, never a different programming model.
Index ¶
- type LocalStore
- func (s *LocalStore) Delete(ctx context.Context, key string, expectedVersion string) error
- func (s *LocalStore) Get(_ context.Context, key string) ([]byte, string, bool, error)
- func (s *LocalStore) List(_ context.Context, keyPrefix string) ([]string, error)
- func (s *LocalStore) PutIfAbsent(_ context.Context, key string, payload []byte) (string, error)
- func (s *LocalStore) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)
- type S3Config
- type S3Store
- func (s *S3Store) Delete(ctx context.Context, key string, expectedVersion string) error
- func (s *S3Store) Get(ctx context.Context, key string) ([]byte, string, bool, error)
- func (s *S3Store) List(ctx context.Context, keyPrefix string) ([]string, error)
- func (s *S3Store) PutIfAbsent(ctx context.Context, key string, payload []byte) (string, error)
- func (s *S3Store) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)
- type SSMConfig
- type SSMStore
- func (s *SSMStore) Delete(ctx context.Context, key string, expectedVersion string) error
- func (s *SSMStore) Get(ctx context.Context, key string) ([]byte, string, bool, error)
- func (s *SSMStore) List(ctx context.Context, keyPrefix string) ([]string, error)
- func (s *SSMStore) PutIfAbsent(ctx context.Context, key string, payload []byte) (string, error)
- func (s *SSMStore) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)
- type Store
- type VersionConflictError
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type LocalStore ¶
type LocalStore struct {
// contains filtered or unexported fields
}
LocalStore is a Store backed by a directory of files: one file per key, nested directories mirroring any "/" the key contains. It is the zero-configuration default — solo development, tests, air-gapped runs — mirroring plain local state's own "just works, no backend to configure" shape.
Version ¶
A record's version is its content hash ("sha256:<hex>"), not a timestamp or a counter: two writes of byte-identical payloads carry the same version, and the version never depends on the clock or on how many times the key has been written.
Atomicity and its limit: single-operator only ¶
LocalStore.PutIfAbsent is a single O_CREATE|O_EXCL open — atomic on its own, no locking needed, exactly like a real filesystem's create primitive already guarantees. LocalStore.PutIfVersion and LocalStore.Delete are a read, a compare, and a write (or removal); nothing in POSIX makes that sequence atomic by itself, so each one holds a sidecar "<file>.lock" file — itself an O_CREATE|O_EXCL create — for the duration, and the write half lands via a temp file plus os.Rename so a reader never observes a half-written file.
That gives real compare-and-swap for every writer on one machine: two goroutines in one process, or two separate `tofu` invocations racing on the same directory, serialize through the lockfile and the loser gets a *VersionConflictError rather than a silently clobbered write. It gives nothing across machines — there is no network protocol here, only local filesystem primitives — which is the store's fundamental limit rather than an oversight: LocalStore is for a single operator (or a single machine's worth of concurrent processes), never for a team sharing state across laptops. Reaching for SSMStore or S3Store is what "more than one operator" means in this package.
What this store does not manage ¶
The directory's location, its presence or absence in version control, and its backup story are the caller's to decide — this store only reads and writes files under the directory it is given. That is an acceptable hands-off position specifically because a micro-state record's blast radius is small (an effect re-runs, a random id regenerates) in a way a full Terraform state file's loss never was.
func NewLocalStore ¶
func NewLocalStore(dir string) (*LocalStore, error)
NewLocalStore builds a LocalStore rooted at dir, creating dir (and any missing parents) if it does not exist yet.
func (*LocalStore) Delete ¶
Delete implements Store, under the same lockfile discipline as LocalStore.PutIfVersion.
func (*LocalStore) List ¶
List implements Store by walking the whole directory tree and filtering by a plain string prefix — the store's own layout already mirrors key hierarchy in directories, but List's contract is the interface's ordinary string-prefix match, not a path-boundary match, so this walks everything under s.dir rather than trying to shortcut to a subdirectory.
func (*LocalStore) PutIfAbsent ¶
PutIfAbsent implements Store. It is a single O_CREATE|O_EXCL open, so unlike LocalStore.PutIfVersion it needs no lockfile of its own: the filesystem's own create primitive is already the atomicity.
func (*LocalStore) PutIfVersion ¶
func (s *LocalStore) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)
PutIfVersion implements Store. expectedVersion == "" delegates to LocalStore.PutIfAbsent, which needs no lockfile; any other value takes this key's lockfile for a read-compare-write critical section — see the type doc's "Atomicity and its limit" section for exactly what that does and does not protect against.
type S3Config ¶
type S3Config struct {
// Client is the S3 client every call goes through. The caller builds
// and authenticates it — region, credentials, any endpoint override
// for a local emulator — this package has no opinion on any of that.
Client *s3.Client
// Bucket is the S3 bucket every key lives in.
Bucket string
// KeyPrefix is joined ahead of every key this store is asked for, so
// one bucket can host more than one caller's keyspace without either
// seeing the other's keys in [S3Store.List]. Empty means keys map
// directly to object keys. This package does not interpret
// KeyPrefix's structure at all — it is an opaque string, the same as
// every key passed to the [Store] interface.
KeyPrefix string
}
S3Config configures an S3Store.
type S3Store ¶
type S3Store struct {
// contains filtered or unexported fields
}
S3Store is a Store backed by S3 object versions via conditional writes: If-Match and If-None-Match, the ETag-based compare-and-swap primitive S3 added for general-purpose buckets. This is the store's strongest offering — a real, server-enforced CAS, not a read-compare-write approximation — and a version here is exactly an object's ETag, unmodified.
What is genuinely atomic ¶
Every conditional operation is a single S3 request carrying the condition; there is no read-compare-write window for this store to document a caveat about, unlike SSMStore:
- S3Store.PutIfAbsent and a "" S3Store.PutIfVersion call send If-None-Match: * — S3 rejects the write with HTTP 412 if any object already exists at the key.
- A non-"" S3Store.PutIfVersion call sends If-Match: <version> — S3 rejects the write with HTTP 412 if the object's current ETag does not match.
- S3Store.Delete sends If-Match: <version> on DeleteObject, which S3 honors for general-purpose buckets, not only the directory-bucket case the S3 API docs otherwise reserve conditional deletes for.
On a 412, this store issues one extra read (Get) purely to populate VersionConflictError.ActualVersion with an accurate answer; that read is not part of the conditional guarantee itself; the conditional write already failed atomically before it.
What this store does not manage ¶
Bucket creation, lifecycle policy, and encryption configuration are the caller's concern — S3Store only issues GetObject/PutObject/DeleteObject/ ListObjectsV2 against a bucket and (optional) key prefix it is given. It does not build or authenticate the s3.Client itself; the caller supplies one already configured for the target account, region and endpoint, which is what keeps this store's own surface free of anything AWS-credential-shaped.
func NewS3Store ¶
NewS3Store builds an S3Store from cfg.
func (*S3Store) Delete ¶
Delete implements Store. expectedVersion == "" against an absent key is a no-op (checked with a HeadObject first, since DeleteObject's If-Match has no "only if absent" form); any other value sends If-Match: <expectedVersion> on DeleteObject itself, S3's real conditional delete.
func (*S3Store) List ¶
List implements Store by paginating ListObjectsV2 with Prefix set to this store's own key prefix plus keyPrefix — S3's list primitive is already an ordinary string prefix, the same contract Store.List promises, so no client-side filtering beyond stripping s.keyPrefix back off is needed (unlike SSMStore.List).
func (*S3Store) PutIfAbsent ¶
PutIfAbsent implements Store.
func (*S3Store) PutIfVersion ¶
func (s *S3Store) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)
PutIfVersion implements Store. expectedVersion == "" sends If-None-Match: *; any other value sends If-Match: <expectedVersion> — see the type doc for why both are a single atomic S3 request rather than a read-compare-write.
type SSMConfig ¶
type SSMConfig struct {
// Client is the SSM client every call goes through. The caller builds
// and authenticates it; this package has no opinion on region,
// credentials, or any endpoint override.
Client *ssm.Client
// KeyPrefix is the parameter-name hierarchy every key lives under,
// e.g. "/myteam/mystate". A leading "/" is added if missing; a
// trailing one is trimmed. Every key this store is asked for becomes
// the parameter name KeyPrefix + "/" + key. This package does not
// interpret KeyPrefix beyond that join — it is an opaque string, the
// same as every key passed to the [Store] interface.
KeyPrefix string
}
SSMConfig configures an SSMStore.
type SSMStore ¶
type SSMStore struct {
// contains filtered or unexported fields
}
SSMStore is a Store backed by AWS Systems Manager Parameter Store — the zero-infrastructure team default: no bucket to create, nothing beyond IAM to provision.
What SSM actually offers, investigated against its real API surface ¶
Parameter Store's only server-enforced condition is PutParameter's Overwrite flag, a bare boolean with no version attached to it. That is real, atomic create-only CAS, and this store uses it exactly that way: SSMStore.PutIfAbsent and a "" SSMStore.PutIfVersion call send Overwrite: false, and a create racing an existing parameter fails atomically with ParameterAlreadyExists — no read-compare-write window, no weaker-race caveat, the same strength S3Store offers for creation.
Updating a specific version is a different story, and this is the honest limit: there is no "overwrite if the current version is N" primitive anywhere in the PutParameter API — Overwrite is boolean, not versioned. So SSMStore.PutIfVersion on an existing key is a read-compare-write: GetParameter to read the current Version, a client-side compare against expectedVersion, then PutParameter with Overwrite: true if they match. Between that read and that write, a second caller's PutParameter can land — SSM has nothing that would reject it — and this store's write then silently overwrites it. The one honest mitigation available is best-effort detection after the fact: PutParameterOutput.Version reports the version the write just created, and if that is not exactly expectedVersion+1, some other write landed in the window. This store checks that and returns a *VersionConflictError when it does not line up — but by then the write has already happened. The conflict is reported, not prevented: weaker than LocalStore or S3Store, where a conflicting write never reaches the backend's stored state at all.
SSMStore.Delete is weaker again: DeleteParameter takes only a Name, no version-shaped parameter whatsoever, and returns nothing an after-the-fact check could compare. This store still performs a read-compare-delete for the loud-failure behavior a caller expects going in, but a write that lands between the read and the delete is invisible to it — there is no returned value here analogous to PutParameterOutput.Version, so unlike PutIfVersion, a Delete race is not even detected after the fact. Teams that need real delete CAS want S3Store.
Payload encoding ¶
Parameter Store values are UTF-8 strings, not bytes, so this store base64-encodes payload before calling PutParameter and decodes it back in Get — invisible to a Store caller (opaque []byte in, the identical []byte out), but worth knowing before reading a parameter's Value directly in the AWS console or CLI: it is base64, not the raw payload.
List's approximation ¶
GetParametersByPath — the only enumeration primitive Parameter Store has — matches whole "/"-delimited hierarchy segments, not an arbitrary string prefix: a path of "/foo" matches "/foo/bar" but not "/foobar", where Store.List's contract is an ordinary string prefix and would expect the latter too. SSMStore.List asks GetParametersByPath for the nearest enclosing hierarchy folder (recursively) and then filters the result down to keyPrefix itself with a plain string comparison, so the contract holds; it is one broader read than the strictly minimal one, not an incorrect one.
func NewSSMStore ¶
NewSSMStore builds an SSMStore from cfg.
func (*SSMStore) Delete ¶
Delete implements Store as a read-compare-delete: SSM's DeleteParameter has no conditional form at all (see the type doc), so this is the same race-window caveat SSMStore.PutIfVersion carries, minus even the after-the-fact detection PutParameterOutput.Version gives that call — DeleteParameter returns nothing to compare.
func (*SSMStore) List ¶
List implements Store. See the type doc's "List's approximation" section: GetParametersByPath matches hierarchy segments, not a bare string prefix, so this asks for the nearest enclosing folder and filters client-side down to the interface's actual contract.
func (*SSMStore) PutIfAbsent ¶
PutIfAbsent implements Store, via PutParameter's Overwrite: false — real atomic create-only CAS, not a read-compare-write.
func (*SSMStore) PutIfVersion ¶
func (s *SSMStore) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)
PutIfVersion implements Store. expectedVersion == "" delegates to SSMStore.PutIfAbsent. Any other value is the read-compare-write this type's doc comment describes in full — read the "What SSM actually offers" section before relying on this for anything where a lost update would matter.
type Store ¶
type Store interface {
// Get reads the current record at key. exists is false when no record
// is there; payload and version are then the zero value and err is nil
// — a missing key is not itself an error. version is "" if and only if
// exists is false: no implementation ever assigns "" as a live record's
// version, so a caller may treat it as a stable "absent" sentinel.
Get(ctx context.Context, key string) (payload []byte, version string, exists bool, err error)
// PutIfVersion writes payload to key, but only if the record's current
// version equals expectedVersion. expectedVersion == "" asserts that no
// record exists yet at key (the same assertion PutIfAbsent makes,
// reachable here for callers that hold a uniform "expected version"
// value rather than branching on whether they have seen the key
// before). On success it returns the record's new version. On a
// mismatch, it returns a *VersionConflictError naming both
// expectedVersion and the version the store actually found — never a
// bare error a caller has to parse.
PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (newVersion string, err error)
// PutIfAbsent creates key with payload, but only if no record exists
// there yet. It is PutIfVersion(ctx, key, payload, "") under a name
// that does not require the caller to know the empty-string
// convention. On success it returns the new record's version; on
// conflict it returns a *VersionConflictError with ExpectedVersion ""
// and ActualVersion set to whatever is already there.
PutIfAbsent(ctx context.Context, key string, payload []byte) (version string, err error)
// Delete removes key, but only if its current version equals
// expectedVersion — the same conditional discipline PutIfVersion
// applies to writes, applied here to removal. Deleting an
// already-absent key with expectedVersion == "" succeeds silently
// (idempotent); deleting an already-absent key with a non-empty
// expectedVersion, or a present key whose version does not match, both
// return a *VersionConflictError.
Delete(ctx context.Context, key string, expectedVersion string) error
// List returns every key currently stored whose name begins with
// keyPrefix, as an ordinary Go string prefix (not a path-hierarchy
// match), sorted lexically. keyPrefix == "" lists every key. See each
// implementation's own doc comment for how closely its underlying
// primitive matches this — [SSMStore], notably, does not have a native
// string-prefix list and approximates one; the returned set is exactly
// this contract regardless.
List(ctx context.Context, keyPrefix string) ([]string, error)
}
Store is a conditional-write key/value backend: a name for a small blob, versioned so a writer can prove it is updating what it last read rather than clobbering someone else's change. It has no notion of what a key names or what a payload contains — that belongs entirely to the caller. See doc.go for the full contract every implementation must honor.
type VersionConflictError ¶
VersionConflictError reports that a conditional operation's expected version did not match what the store actually holds for Key. It names both versions so a caller can decide how to react — reread and retry, surface a merge conflict, give up loudly — without parsing an error string.
ActualVersion is "" when the store holds no record at Key at all (including the case where ExpectedVersion was itself "" and the key turned out to already exist would instead set ActualVersion to that existing version — "" only ever means "no record").
func (*VersionConflictError) Error ¶
func (e *VersionConflictError) Error() string