staterecord

package
v0.22.0 Latest Latest
Warning

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

Go to latest
Published: Oct 6, 2026 License: MPL-2.0 Imports: 42 Imported by: 0

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 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, a Kubernetes resourceVersion — 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.
  • "Conditional" means real compare-and-swap with no read-compare-write race window, on every store: LocalStore, S3Store and KubernetesStore all give it. That is a requirement of the interface and not a property some implementations happen to share. See "The store that was retired".

The three implementations

LocalStore (local.go: a directory of files, the zero-configuration default — solo development, tests, air-gapped runs, mirroring plain local state's own "just works" shape), S3Store (s3.go: S3 conditional writes, for anything more than one operator shares) and KubernetesStore (kubernetes.go: Secrets in one cluster namespace, resourceVersion as the conditional write, for an estate that runs on Kubernetes and has no AWS account to put a bucket in). All three implement the identical Store interface; a caller choosing between them is choosing an operational tradeoff, never a different programming model.

The five things the Kubernetes store had to settle

GitHub issue #1392 named five, and each was measured on kind before it was written down. They are here rather than in the type's own doc because each is a decision about the SHAPE of a record on a substrate, which is what a fourth store would have to answer again.

  1. A key becomes an object NAME by hashing. The Secret is named "tofu-record-" and the key's SHA-256; the key itself is in the choudoufu.intentius.io/record-key annotation, which is where List reads the keys it returns. A record key carries "/" and base64url runs and the conformance suite's own chunked key is 500 characters, against the 253 an object name holds, so no encoding fits. An annotation is capped at 256 KiB in total against the 1,024 bytes of the longest key the S3 store accepts, so the annotation is not close to a limit. A Get whose object holds a different key is refused (KeyCollisionError) rather than answered.
  2. Isolation is the NAMESPACE, one per estate, defaulting to "tofu-records-<estate>". RBAC has no predicate on a label and admission is never consulted for a get or a list, so nothing but the namespace can fence a read. The store does not create it: an absent namespace is refused by name (NamespaceMissingError) with the kubectl line, which it has to be, because a list in a namespace that does not exist answers EMPTY and an empty listing reads as an empty estate. A READ cannot tell that from what it is told - the 404 names the Secret - so a read that would answer "nothing here" asks about the namespace itself, and an identity that may not ask is answered one layer up, by the caller's own provisioning sentinel (#1448).
  3. The estate is a LABEL and the address is an ANNOTATION. tofu-estate has to be a label because live/kubernetes/estate-boundary.yaml selects on it, which is what fences a write to a record object with no policy added. A label value caps at 63 characters and a resource address does not (#1016), so the address cannot be one. Same split, same reason, as the object tags #1337 put on S3 objects.
  4. A record over a Secret's one MiB is refused by name (RecordTooLargeError), before the request and measured after compression, because that is the number the API server measures.
  5. Anyone who can "get secrets" in the records namespace reads every recorded value, which is the same bargain s3:GetObject on the bucket makes for the other remote store.

One consequence outside this package: a record Secret carries the estate's tofu-estate label, and the Kubernetes sweep reads that label as "this object is in the estate". Objects in the estate that no block declares are orphans a plan proposes to DESTROY, so internal/live/kubesweep excludes the store's own objects by name (RecordStoreObject). Measured on kind: without that exclusion an ordinary second plan proposed destroying all five of the estate's own record Secrets.

The store that was retired

Until GitHub issue #1346 there was a third, on AWS Systems Manager Parameter Store, and it was the default recommendation for a team. It was retired as a RECORD store for three reasons. Standard parameters cap at 10,000 per account and region, against the customer's own quota. Past that, every parameter bills monthly on the advanced tier. And it has no general conditional write: it could create-if-absent and nothing else, so every update and delete was a read-compare-write with a race window, where this package's whole consistency story is a per-key conditional write.

No migration was written, because no estate was on it when it was retired. That is the reason, and it is recorded so nobody later assumes a migration path was designed and lost.

This says nothing about Parameter Store for SECRET values. Keeping secret material out of the bucket, in SSM, is planned (#1244 section 3) and not built; nothing in this package does it today.

Index

Constants

View Source
const (
	KMSDeniedByKeyPolicy      = "key-policy"
	KMSDeniedByIdentityPolicy = "identity-policy"
	KMSDeniedExplicitly       = "explicit-deny"
)

What AWS blames a KMS denial on.

View Source
const (
	// KubernetesSecretNamePrefix is the fixed, readable start of every Secret
	// name this store writes. What follows is the key's SHA-256, so an
	// operator reading `kubectl get secrets` can tell a record from anything
	// else in the namespace without knowing the hash.
	KubernetesSecretNamePrefix = "tofu-record-"

	// KubernetesRecordKeyAnnotation holds the record's full, unhashed key.
	// The name is a hash, so this is the only place the key survives, and
	// [KubernetesStore.List] reads the keys it returns out of it.
	KubernetesRecordKeyAnnotation = "choudoufu.intentius.io/record-key"

	// KubernetesNamespaceLabel is the key's first "/"-delimited segment -
	// "tofu-records", "tofu-hints", "tofu-outputs" - or
	// [KubernetesNamespaceLabelOther] when that segment cannot be a label
	// value. It is never what a returned key is read from.
	//
	// Nothing in this store selects on it any more. It narrowed a LIST
	// server-side until #1448 took the selector off the listing entirely (see
	// [KubernetesStore]), and it is still written because an operator reading
	// or sorting a namespace by hand has nothing else to group records by:
	// the name is a hash and the key is an annotation.
	KubernetesNamespaceLabel = "choudoufu.intentius.io/record-namespace"

	// KubernetesNamespaceLabelOther is [KubernetesNamespaceLabel]'s value for
	// a key whose first segment is not a valid label value. Every Secret
	// carries the label with some value, so the label is never absent on an
	// object this store wrote.
	KubernetesNamespaceLabelOther = "other"

	// KubernetesManagedByLabel and KubernetesManagedByValue mark the Secrets
	// this store owns. A listing reads them to tell one estate's records from
	// another's in a shared namespace, and a record that is missing them is
	// refused by name rather than skipped ([UnlabelledRecordError]).
	KubernetesManagedByLabel = "app.kubernetes.io/managed-by"
	KubernetesManagedByValue = "choudoufu"

	// KubernetesEstateLabel is markers.TagEstate. It is spelled out rather
	// than imported: this package holds no choudoufu concepts (see doc.go),
	// and internal/live/markers is one. internal/live/projection's
	// kubernetes_store_test.go pins the two spellings equal, so the store's
	// Secrets cannot stop carrying the label estate-boundary.yaml fences on.
	KubernetesEstateLabel = "tofu-estate"
)
View Source
const (
	EstateGrantGroup    = "choudoufu.intentius.io"
	EstateGrantResource = "estates"
	EstateGrantVerb     = "use"
)

EstateGrantGroup, EstateGrantResource and EstateGrantVerb are the virtual triple the shipped policy's CEL asks the authorizer about, and live/kubernetes/estate-grant.yaml grants. No object of that group or resource exists anywhere: the triple lives in RBAC and nothing but admission reads it.

The estate_boundary assertion asks the same question of THIS identity, so a cluster whose policy is in force and whose identity holds no grant is reported as what it is - every write refused - rather than as four greens (#1448, B6). TestEstateGrantTripleIsTheOneTheShippedPolicyAsksFor pins these three to the shipped policy's own CEL, so an edit to that file moves them here or fails the suite.

View Source
const (
	ControlPlaneEKS = "eks"
	ControlPlaneGKE = "gke"
	ControlPlaneAKS = "aks"
)

Managed control plane providers, as the control_plane block's label spells them.

View Source
const DefaultKubernetesListPageSize = 200

DefaultKubernetesListPageSize is how many Secrets one page of a LIST returns unless KubernetesConfig.ListPageSize says otherwise. A LIST here carries every object's whole payload, so the page bounds a response size and not just a count.

View Source
const DefaultS3GetAllParallelism = 8

DefaultS3GetAllParallelism is how many GetObject calls S3Store.GetAll has in flight at once unless S3Config.GetAllParallelism says otherwise.

Eight is chosen to be unremarkable, not tuned, and it has not been measured at scale. The namespace read here holds one record per managed instance - internal/live/projection's write-back records every instance, an identity envelope for an ordinary taggable resource as well as the whole value of a record-backed one - so the read is N GetObject calls for an estate of N instances and this bound sets how long that takes. An earlier version of this comment called the namespace "the record-backed slice only, a small fraction of an estate" and the bound "not load-bearing". That was the design text's claim and the code never matched it. The bound is configurable because the estate that needs otherwise will know why and should not have to patch the binary to find out. GitHub issue #1336.

View Source
const EstateBoundaryPolicyName = "choudoufu-estate-boundary"

EstateBoundaryPolicyName is the name live/kubernetes/estate-boundary.yaml gives both its ValidatingAdmissionPolicy and its binding.

View Source
const KubernetesRecordNamespacePrefix = "tofu-records-"

KubernetesRecordNamespacePrefix starts the default records namespace of every estate ("tofu-records-" and the estate name). The read-isolation check uses it to recognise ANOTHER estate's records namespace when it can see one. internal/live/projection owns the default itself; this is the string, spelled here so the check does not import it.

View Source
const MaxKubernetesRecordBytes = corev1.MaxSecretSize

MaxKubernetesRecordBytes is the largest compressed payload this store will write. It is corev1.MaxSecretSize: the API server sums a Secret's data values and refuses the object above one MiB.

The refusal happens HERE, before the request, and on the COMPRESSED length, because that is the number the API server will measure. A record refused by the server arrives as a generic Invalid and is indistinguishable at a glance from a dozen other validation failures; refused by name, an operator is told which record and how far over.

The rest of the object is not counted against this. Labels and annotations live outside data, and the whole of what this store puts there - the key, an address, three labels - is under two kilobytes. The serialized object is about 4/3 of the payload once the API server base64s the data, so a record at this limit is roughly 1.4 MiB on the wire, inside etcd's own 1.5 MiB request limit with nothing to spare. A store that wanted to use the last hundred kilobytes would be trading a named refusal for an etcd one.

Variables

BucketSettings is every asserted setting, in the order findings are reported.

ClusterSettings is every asserted property, in the order findings are reported. ClusterTLSVerification comes first because every other answer is read over the connection it is about, and it is the one setting with no finding when it holds; see CheckClusterContract.

ControlPlaneProviders is every provider a control_plane block may name.

View Source
var KubernetesPlanVerbs = []string{"get", "list"}

KubernetesPlanVerbs is what a run that only PLANS needs, measured rather than assumed (GitHub issue #1393, under #1370). A plan reads records and writes none; the one write on its path is the provisioning sentinel, which internal/live/projection carries past a denial when an earlier writing run already left the sentinel behind.

So a plan-only identity is reviewed for these two and not refused for lacking the other three. What it costs is stated where it is chosen, in internal/live/projection: an estate whose sentinel has never been written cannot be planned by an identity that may not write one.

View Source
var KubernetesRecordVerbs = []string{"get", "list", "create", "update", "delete"}

KubernetesRecordVerbs is every verb KubernetesStore uses on a Secret, in the order a review reports them. A run that applies needs all five.

Functions

func BucketContractCheckFailed added in v0.19.0

func BucketContractCheckFailed(bucket string, err error) (summary, detail string)

BucketContractCheckFailed is what an apply says when the contract read itself could not be made - not a finding about the bucket, but nothing known about it at all.

func BucketContractRefusal added in v0.18.0

func BucketContractRefusal(bucket string, f Finding) (summary, detail string)

BucketContractRefusal is the headline and the paragraph for one failed finding, in internal/command's liveCommandRefusals shape: what was refused, then what it protects against and what to do instead. Empty for a finding that passed.

func BucketWaiverCost added in v0.18.0

func BucketWaiverCost(setting Setting) string

BucketWaiverCost says what an estate gives up by waiving setting, as a clause that completes "is waived, so ...". GitHub issue #1340: the warning names the setting and its cost in the same sentence, never a generic "running with reduced checks", because a cost the reader has to look up is a cost they have already decided not to read.

func ClusterContractCheckFailed added in v0.19.0

func ClusterContractCheckFailed(err error) (summary, detail string)

ClusterContractCheckFailed is what an apply says when the contract read itself could not be made - not a finding about the cluster, but nothing known about it at all.

func ClusterContractRefusal added in v0.19.0

func ClusterContractRefusal(namespace string, f Finding) (summary, detail string)

ClusterContractRefusal is the headline and the paragraph for one failed finding, in internal/command's liveCommandRefusals shape: what was refused, then what it protects against and what to do instead. Empty for a finding that passed.

func ClusterContractRefusalClosing added in v0.19.0

func ClusterContractRefusalClosing(refused []Setting) string

ClusterContractRefusalClosing is the line that follows two or more refusals in one message.

A plain kind cluster refuses an estate's first contact twice at once - no --encryption-provider-config and no estate boundary policy - and each refusal's own waiver line names only itself, so a reader following them both would write allow_insecure twice in one block, which is a duplicate argument and does not parse. This is the line they can paste.

func ClusterWaiverArgument added in v0.19.0

func ClusterWaiverArgument(settings ...Setting) string

ClusterWaiverArgument is the argument itself, for a caller writing its own sentence around it - internal/live/projection's closing line when more than one assertion refuses at once.

func ClusterWaiverCost added in v0.19.0

func ClusterWaiverCost(setting Setting) string

ClusterWaiverCost says what an estate gives up by waiving setting, as a clause that completes "is waived, so ...". GitHub issue #1340's rule, on this store: the warning names the setting and its cost in the same sentence, never a generic "running with reduced checks".

func ClusterWaiverLine added in v0.19.0

func ClusterWaiverLine(settings ...Setting) string

ClusterWaiverLine is the other way out of a refusal, as one sentence carrying the exact line to write. Every refusal ends with it.

It is spelled out rather than described because of who reads it: someone following the Kubernetes documentation on kind, who writes `record_store "kubernetes" {}`, applies as cluster-admin and is refused twice - for an API server flag kind does not set and a policy nobody told them to install. Both of those have a real fix and both have a legitimate "not on this cluster, and I know". A refusal that names only the fix leaves that reader with a message they cannot act on, and a refusal that says "waive it" without the line leaves them guessing at the spelling.

func ContractRefusalText added in v0.19.0

func ContractRefusalText(c ContractChecker, findings []Finding) string

ContractRefusalText renders every failed finding as one message, each under its own headline, or "" when all passed. Two or more get the store's own closing line; see ContractChecker.ContractRefusalClosing.

func IsAccessDenied added in v0.19.0

func IsAccessDenied(err error) bool

IsAccessDenied reports whether err is this run's own identity being refused permission by the store, as opposed to the store failing, being unreachable, or answering something about the record itself.

It covers both backends, because the distinction a caller draws on it is about the run's credentials and not about which backend carries them. S3Store surfaces a refusal as AccessDenied or a bare 403; LocalStore surfaces one as the filesystem's EACCES, which reaches here as fs.ErrPermission through whichever os call met it first. A directory an operator mounted or chmodded read-only is the local equivalent of a role with s3:GetObject and no s3:PutObject, and a caller that tolerated one and not the other would be making a distinction neither backend's users would recognise.

A KMS refusal is deliberately NOT one of these, although S3 relays it as AccessDenied and a 403. KMSDeniedError and KMSKeyUnusableError are the bucket refusing the run outright: every object in the store is unreadable while either lasts, so a caller that read one as "this identity may read but not write" would carry on with a store it cannot read at all. GitHub issue #1376 makes both of those refusals, and #1370's reader tolerance must not undo that.

func KubernetesSecretName added in v0.22.0

func KubernetesSecretName(key string) string

KubernetesSecretName is the Secret name a KubernetesStore with no key prefix keeps key's record under. It is KubernetesStore.SecretName for a caller that has no store of its own on that namespace, which is what naming a grant on another estate's records needs: a Role may name the exact Secrets it lets an identity get.

func NamespacePrefix added in v0.18.0

func NamespacePrefix(prefix string) string

NamespacePrefix returns prefix with exactly one trailing "/", and "" for "".

Store.List and BulkReader.GetAll match an ordinary string prefix, and so does S3's ListObjectsV2. A namespace handed to either without its trailing delimiter therefore matches every sibling whose name merely starts the same way: "tofu-records/prod" lists "tofu-records/prod-eu/..." too. GitHub issue #1335 measured that for two estates sharing one store, which the bucket backend (#1332) makes the recommended arrangement. Under that backend's IAM model the listing is defended by the s3:prefix condition ALONE - an object tag cannot condition a LIST, which touches no object - so the delimiter is what the isolation rests on, not tidiness.

Every layer that turns a namespace into a List or GetAll prefix goes through this one function, so the delimiter cannot be present in the key builder and missing from the listing, or the other way round.

func ObjectTags added in v0.18.0

func ObjectTags(ctx context.Context) map[string]string

ObjectTags is what WithObjectTags put in ctx, nil when nothing did.

func ResetRunCacheForTest added in v0.13.0

func ResetRunCacheForTest(t testing.TB)

ResetRunCacheForTest clears the process-wide "something has been written" switch that RunCache uses to decide whether it may still trust its snapshot (see RunCache's doc comment, "Why it cannot serve a stale value", for why that switch is one atomic per process rather than one per cache).

The switch is deliberately sticky for the product - one process is one run, and a run that has written once must never serve a remembered value again - but that same stickiness means it is sticky across every test in a `go test -count=N` process: iteration 1's write turns every cache off for iterations 2..N, so a test whose premise is "the cache is currently serving" has no way to establish that premise from inside the test.

Call it before any assertion that depends on the switch's state. t.Cleanup restores whatever the switch held before the call, so this cannot leak a fixed state into whatever else runs in the same process afterward.

func ShippedEstateBoundaryPolicy added in v0.19.0

func ShippedEstateBoundaryPolicy() (*admissionv1.ValidatingAdmissionPolicy, error)

ShippedEstateBoundaryPolicy is live/kubernetes/estate-boundary.yaml's ValidatingAdmissionPolicy, parsed once and handed out as a copy, so a caller that edits one gets its own.

It is exported for the tests that need a cluster the contract passes: since a policy is now read rather than recognised by name, "the policy this repository ships" is the only fixture that is one, and a hand-written stand-in in another package would be a second copy of the CEL.

func SortedByCount added in v0.5.0

func SortedByCount(m map[string]int) []string

SortedByCount renders a bucket map as lines ordered by descending count, ties broken by name, for a report a human reads.

func WithObjectTags added in v0.18.0

func WithObjectTags(ctx context.Context, tags map[string]string) context.Context

WithObjectTags returns ctx carrying tags for the next write made with it. A backend with nowhere to put tags ignores them. Tags already in ctx are kept, with the new ones winning on a shared key.

Types

type AdmissionDeniedError added in v0.19.0

type AdmissionDeniedError struct {
	Namespace string
	Key       string
	Estate    string

	// Verb is the Secret verb the API server refused: "create", "update" or
	// "delete".
	Verb string

	// Policy is the admission policy the API server named, or "".
	Policy string

	Err error
}

AdmissionDeniedError reports that the API server's ADMISSION stage refused a write, and that the authorizer would have allowed it. GitHub issue #1448, section C.

Both refusals are a 403 with reason Forbidden, and IsAccessDenied read every 403 as the authorizer's, which is what #1370's reader tolerance is for. So a ServiceAccount with full RBAC on Secrets in the records namespace and no `use` grant on its estate - the identity live/kubernetes/estate-boundary.yaml exists to fence - had the policy's own refusal of the provisioning sentinel read as "this run may read and not write". An earlier, granted run had left the sentinel, the List in internal/live/projection's provisionStoreSentinel found it, and the store opened green. The first-contact contract was skipped, the run planned, the apply started, and the same policy then refused every record write it made. The fence's own refusal was read as a benign read-only identity at the one moment walking away was free. Measured on kind.

So this is a refusal rather than an outage or a tolerated denial: the cluster was reached and answered, the answer is about a grant no retry changes, and every record this run would write meets the same policy.

The grant this refusal asks for is named with EstateGrantVerb, EstateGrantResource and EstateGrantGroup, the same three [checkEstateBoundary] puts in its own SelfSubjectAccessReview, so the two cannot come to ask an operator for different grants. The rendered sentence is unchanged by that: the constants spell what the string used to.

Policy is EstateBoundaryPolicyName when the API server named this fork's own estate boundary policy, and "" when some other admission controller refused the write - another policy, a validating webhook, a built-in plugin. Either way it is not reader tolerance: that tolerance is for a denial positively identified as the authorizer's.

func (*AdmissionDeniedError) Error added in v0.19.0

func (e *AdmissionDeniedError) Error() string

func (*AdmissionDeniedError) Unwrap added in v0.19.0

func (e *AdmissionDeniedError) Unwrap() error

type BucketContractAPI added in v0.18.0

type BucketContractAPI interface {
	GetBucketVersioning(ctx context.Context, in *s3.GetBucketVersioningInput, optFns ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error)
	GetBucketLifecycleConfiguration(ctx context.Context, in *s3.GetBucketLifecycleConfigurationInput, optFns ...func(*s3.Options)) (*s3.GetBucketLifecycleConfigurationOutput, error)
	GetPublicAccessBlock(ctx context.Context, in *s3.GetPublicAccessBlockInput, optFns ...func(*s3.Options)) (*s3.GetPublicAccessBlockOutput, error)
}

BucketContractAPI is the three reads the contract needs, and the permissions they cost: s3:GetBucketVersioning, s3:GetLifecycleConfiguration and s3:GetBucketPublicAccessBlock. *s3.Client satisfies it.

type BucketOwnerMismatchError added in v0.19.0

type BucketOwnerMismatchError struct {
	// Bucket is the bucket the request was for.
	Bucket string
	// ExpectedOwner is the account the configuration pinned, as twelve digits.
	ExpectedOwner string
	// Err is the S3 error as it arrived.
	Err error
}

BucketOwnerMismatchError is an S3 request refused while this store was pinning the bucket's owner.

S3 answers a request whose bucket belongs to an account other than ExpectedBucketOwner with 403 AccessDenied, which is byte for byte what an IAM denial looks like: the response cannot tell the two apart, and neither can this type. What it can do is say that the pin exists and name what it expects, so an operator who is about to go through the role's S3 statements for the third time is told there is a second thing to check and how to check it.

It never claims the bucket IS owned by someone else. Proving that takes a call this run is by definition not allowed to make.

func (*BucketOwnerMismatchError) Error added in v0.19.0

func (e *BucketOwnerMismatchError) Error() string

func (*BucketOwnerMismatchError) Headline added in v0.19.0

func (e *BucketOwnerMismatchError) Headline() string

Headline is the one sentence of what happened.

func (*BucketOwnerMismatchError) Remedy added in v0.19.0

func (e *BucketOwnerMismatchError) Remedy() string

Remedy says what to check, and in which order.

func (*BucketOwnerMismatchError) Unwrap added in v0.19.0

func (e *BucketOwnerMismatchError) Unwrap() error

type BulkReader added in v0.5.0

type BulkReader interface {
	GetAll(ctx context.Context, keyPrefix string) (map[string]Record, error)
}

BulkReader is the optional half of Store that loads a whole namespace in one call. It exists because a plan needs the entire estate's records and stock OpenTofu gets its whole equivalent — the state file — in one read. Without it, a converged plan's cheapest possible shape is still one call per instance for information no per-instance decision needed separately.

GetAll returns every record whose key begins with keyPrefix, keyed by the same key Store.Get and Store.List use. An empty keyPrefix means every key. The returned map is the CALLER's; implementations must not retain or reuse it.

The result is complete for that prefix: a key absent from the map holds no record. That is what makes it usable as a snapshot rather than a warm cache — a reader can answer "there is nothing recorded for this address" from it without going back to the store.

It is optional rather than part of Store because it is an optimization and not a semantic: a backend with no way to enumerate values simply does not implement it, and every caller keeps working through Store.Get.

type ClusterContractOptions added in v0.19.0

type ClusterContractOptions struct {
	// Namespace is the records namespace being asserted about. Required.
	Namespace string

	// Estate is the estate whose records live there, for the report's text.
	// May be "".
	Estate string

	// RequiredVerbs is what this run needs on Secrets in Namespace. Nil
	// takes [KubernetesRecordVerbs]; a plan-only identity passes
	// [KubernetesPlanVerbs]. Every verb in [KubernetesRecordVerbs] is
	// reviewed and reported whatever this says - what it changes is which
	// denials fail the finding.
	RequiredVerbs []string

	// NamespaceKnownToExist is set by a caller that has already USED the
	// namespace successfully, which is every caller that reaches this
	// through an open store: the provisioning sentinel's write and List went
	// through it. Such a caller does not re-probe, because the probe needs
	// cluster-scoped get on namespaces that the recommended Role has no
	// reason to hold, and because a namespace the store has just written to
	// being reported absent would be two answers to one question.
	//
	// False, the check probes, and an absent namespace is reported with
	// *[NamespaceMissingError]'s own words, so the contract and the store's
	// refusal say the same thing.
	NamespaceKnownToExist bool

	// InsecureTLS is the record_store block's `insecure = true`: cs was built
	// not to verify the API server's certificate. It is the caller's to say
	// because a clientset does not carry it, and it is a fact about the block,
	// so it needs no request to establish. See [ClusterTLSVerification].
	InsecureTLS bool

	// ControlPlane names the managed control plane (EKS, GKE, AKS) this
	// cluster runs on, when the record_store block's control_plane block
	// names one or the exec credential plugin's arguments do (GitHub issue
	// #1524). Set, encryption_at_rest is read from that provider's own API
	// through ControlPlaneReader instead of off an API server Pod that a
	// managed cluster does not have. Nil keeps the Pod reading.
	ControlPlane *ManagedControlPlane

	// ControlPlaneReader asks ControlPlane's provider. Required whenever
	// ControlPlane is set; a nil one reports NOT CHECKED rather than
	// guessing.
	ControlPlaneReader ControlPlaneReader

	// APIServerHost is the API server address cs reaches (rest.Config's
	// Host). A provider's description of a cluster is believed only when one
	// of its endpoints is this host; see kubernetescontrolplane.go.
	APIServerHost string
}

ClusterContractOptions is what a check needs to know beyond the client.

type ContractChecker added in v0.19.0

type ContractChecker interface {
	// CheckContract reads every property and reports one finding per
	// setting, always all of them and always in the store's own settings
	// order: a caller that refused on the first bad one would make an
	// operator fix them one run at a time.
	//
	// The error return is for a failure that is not about the store's
	// properties at all - a cancelled context, an unreachable endpoint. A
	// denied read is NOT an error: it is a finding that did not pass.
	CheckContract(ctx context.Context, opts ContractOptions) ([]Finding, error)

	// ContractSubject is what a sentence names this store by: the noun it
	// opens with and the name it quotes, as ("Bucket", "records-prod") or
	// ("Namespace", "tofu-records-prod").
	ContractSubject() (label, value string)

	// ContractRefusal is the headline and the paragraph for one finding
	// that did not pass, in internal/command's liveCommandRefusals
	// shape: what was refused, then what it protects against and what to do
	// instead. Empty for a finding that passed.
	ContractRefusal(f Finding) (summary, detail string)

	// ContractCheckFailed is what an apply says when the check itself could
	// not be made - the error return above, not a finding.
	ContractCheckFailed(err error) (summary, detail string)

	// ContractRefusalClosing is one line added after two or more refusals
	// in the same message, or "" for a store that needs none. The cluster
	// has one: each of its refusals carries its own allow_insecure line,
	// and a reader following two of them would write the argument twice in
	// one block, which does not parse.
	ContractRefusalClosing(refused []Setting) string
}

ContractChecker is implemented by a store that has a contract: properties its records depend on, which the store can read and report on, and the words to say when one of them does not hold. The local store does not implement it - a directory has no versioning, no namespace and no admission policy - and a store with nothing to assert is not a store that failed.

A new remote store implements this and nothing in internal/live/projection or internal/command needs an edit to assert it.

func AsContractChecker added in v0.19.0

func AsContractChecker(s Store) (ContractChecker, bool)

AsContractChecker finds the store with a contract under s, looking through this package's own wrappers (RunCache, CountingStore). False means there is nothing to assert - a local store - which is a different answer from a store that failed.

type ContractOptions added in v0.19.0

type ContractOptions struct {
	// Namespaces are the store-relative key namespaces this estate writes
	// under - its records, its hint and its root outputs. The bucket's
	// lifecycle assertion needs them; see [CheckBucketContract].
	Namespaces []string

	// RequiredVerbs is what this run needs on the records themselves. Nil
	// means everything a run that WRITES records asks for, which is what
	// every caller that reached a store through an apply or a first contact
	// has. A plan-only identity names its own; see [KubernetesPlanVerbs].
	RequiredVerbs []string
}

ContractOptions is what a contract check needs from its caller. Each store reads the fields its own contract has a use for and ignores the rest, the way it ignores a key namespace it keeps no keys in.

type ControlPlaneDeniedError added in v0.22.0

type ControlPlaneDeniedError struct {
	// Action is the provider permission the read needs, spelled the way
	// that provider's IAM spells it.
	Action string
	Err    error
}

ControlPlaneDeniedError is a provider refusing this identity the read. It is NOT CHECKED, the same as a forbidden List of kube-system is: the setting exists, this identity may not see it.

func (*ControlPlaneDeniedError) Error added in v0.22.0

func (e *ControlPlaneDeniedError) Error() string

func (*ControlPlaneDeniedError) Unwrap added in v0.22.0

func (e *ControlPlaneDeniedError) Unwrap() error

type ControlPlaneEncryption added in v0.22.0

type ControlPlaneEncryption struct {
	// Cluster is the provider's own name for what it described: an ARN, a
	// selfLink, a resource ID.
	Cluster string

	// Endpoints is every address the provider says this cluster's API server
	// answers at. One of them has to be the host the store's connection
	// reaches before anything else here is believed.
	Endpoints []string

	// Verdict is the answer.
	Verdict EncryptionVerdict

	// Detail is the provider's own fields, in a sentence, saying why the
	// verdict is what it is: the key, the state, the version that made it a
	// default. Always set.
	Detail string
}

ControlPlaneEncryption is one provider's answer about one cluster.

type ControlPlaneNotFoundError added in v0.22.0

type ControlPlaneNotFoundError struct {
	Err error
}

ControlPlaneNotFoundError is a provider answering that it has no cluster by that name. A control_plane block that names a cluster that is not there is a configuration error the finding says by name.

func (*ControlPlaneNotFoundError) Error added in v0.22.0

func (e *ControlPlaneNotFoundError) Error() string

func (*ControlPlaneNotFoundError) Unwrap added in v0.22.0

func (e *ControlPlaneNotFoundError) Unwrap() error

type ControlPlaneReader added in v0.22.0

type ControlPlaneReader interface {
	SecretsEncryption(ctx context.Context, cp ManagedControlPlane) (ControlPlaneEncryption, error)
}

ControlPlaneReader asks a managed control plane's provider about a cluster. internal/live/managedk8s holds the three real ones; tests hold fakes.

type CountingStore added in v0.5.0

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

CountingStore wraps a Store and counts every operation that reaches it, which is the record-store half of what internal/live/flocitest's CountingProxy counts for provider traffic. The proxy stands in front of the AWS endpoint and is therefore blind to these: a record read goes to a local directory or an S3 object, neither of which the provider's endpoint ever sees. Until this existed, no instrument in this repository counted them at all, so "a plan costs N calls" was a partial number by construction.

A "trip" is one call to the wrapped store, because that is the unit that costs something: against LocalStore a stat plus a read, against S3Store a network round trip. Stock OpenTofu makes zero of them — it reads its whole state once, from one file.

Each trip records more than a method name, because a bare per-method total cannot answer either question a reduction needs answered:

  • Site is the first frame outside this package and outside projection.(*RecordStore) — the code that actually wanted the record. This is the per-site breakdown; without it, "158 Gets" names no line to fix.
  • Via is the outermost projection.(*RecordStore) method the call came through (GetIdentity, GetResidue, getProvisioned, ...), so several sites reading the same underlying envelope through different accessors stay distinguishable.
  • Key is the store key, so [CountingStore.RepeatTrips] can report how many trips re-read a key some earlier trip already read. That number is the size of the prize a cache can win, measured rather than assumed.

It is safe for concurrent use; the sweep and the projection both run goroutines.

func NewCountingStore added in v0.5.0

func NewCountingStore(inner Store, log io.Writer) *CountingStore

NewCountingStore wraps inner. log may be nil; when it is not, every trip is written to it as a single Trip.String line followed by a newline, under this store's own lock. A caller sharing one writer between several counting stores is responsible for that writer being safe to call from several goroutines.

func (*CountingStore) Counts added in v0.5.0

func (c *CountingStore) Counts() TripCounts

Counts summarizes this store's own trips.

func (*CountingStore) Delete added in v0.5.0

func (c *CountingStore) Delete(ctx context.Context, key string, expectedVersion string) error

func (*CountingStore) Get added in v0.5.0

func (c *CountingStore) Get(ctx context.Context, key string) ([]byte, string, bool, error)

func (*CountingStore) GetAll added in v0.5.0

func (c *CountingStore) GetAll(ctx context.Context, keyPrefix string) (map[string]Record, error)

GetAll forwards the optional bulk read, counting it as the one trip it is. Forwarding matters as much as counting: without it a RunCache stacked above this counter could not see that the store beneath can bulk-read, and the measurement would report the per-key cost of a stack that only has it because it is being measured.

func (*CountingStore) List added in v0.5.0

func (c *CountingStore) List(ctx context.Context, keyPrefix string) ([]string, error)

func (*CountingStore) PutIfAbsent added in v0.5.0

func (c *CountingStore) PutIfAbsent(ctx context.Context, key string, payload []byte) (string, error)

func (*CountingStore) PutIfVersion added in v0.5.0

func (c *CountingStore) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)

func (*CountingStore) Reset added in v0.5.0

func (c *CountingStore) Reset()

Reset discards every trip recorded so far, so one process can measure several phases separately. It does not touch the log.

func (*CountingStore) Total added in v0.5.0

func (c *CountingStore) Total() int

Total is how many trips reached the wrapped store.

func (*CountingStore) Trips added in v0.5.0

func (c *CountingStore) Trips() []Trip

Trips returns every trip so far, in order, as a copy.

func (*CountingStore) Unwrap added in v0.18.0

func (c *CountingStore) Unwrap() Store

Unwrap returns the wrapped store, for AsContractChecker.

type DuplicateRecordKeyError added in v0.19.0

type DuplicateRecordKeyError struct {
	Namespace   string
	Key         string
	SecretNames []string

	// WantName is the name Key hashes to: the one of SecretNames that is the
	// record, when it is among them at all.
	WantName string
}

DuplicateRecordKeyError reports two or more Secrets in the namespace whose record-key annotation carries the SAME key.

A listing used to return that key once per object and a bulk read kept whichever the cluster listed last, so which payload the run used was decided by list order. Only one of them can be named for the key - a name is a hash of it and a namespace holds one object per name - so the rest are copies, and the refusal says which is which rather than picking one.

func (*DuplicateRecordKeyError) Error added in v0.19.0

func (e *DuplicateRecordKeyError) Error() string

type EncryptionVerdict added in v0.22.0

type EncryptionVerdict uint8

EncryptionVerdict is what a provider's description of a cluster says about Secrets in etcd.

const (
	// EncryptionUndetermined is the zero value: a description that did not
	// settle it, such as a GKE cluster part-way through turning encryption
	// on. It is NOT CHECKED, never a pass.
	EncryptionUndetermined EncryptionVerdict = iota
	// EncryptionOn: the API server encrypts Secrets before etcd.
	EncryptionOn
	// EncryptionOff: it does not.
	EncryptionOff
)

type Finding added in v0.19.0

type Finding struct {
	Setting Setting

	// Outcome is what the store answered. See [Outcome]: four of its five
	// values are not a pass, and which one it is decides whether a run
	// refuses or proceeds saying so.
	Outcome Outcome

	// Unwaivable is true for a failure allow_insecure does not reach. The
	// bucket has the one: an enabled lifecycle rule that expires CURRENT
	// objects under the store's keys, where the waiver's stated cost is
	// that nothing is KNOWN to expire noncurrent versions and here
	// something is known and it is destructive (GitHub issue #1377).
	Unwaivable bool

	// Found says what the store actually has, in one clause, for the
	// refusal to quote: "versioning is Suspended", "no lifecycle
	// configuration", "s3:GetBucketVersioning was denied".
	Found string

	// Verbs is the cluster contract's namespace_access review, one entry
	// per [KubernetesRecordVerbs] element, and empty for every other
	// setting and every other store.
	Verbs []VerbAccess
}

Finding is what one setting turned out to be.

One type for every store, so the callers that decide what a set of findings MEANS - the first-contact assertion, an apply's BeforeApply - are written once. Two of the fields are used by one store each, which is the price of that: a bucket has no verbs to review, and a cluster has no rule that deletes records.

func CheckBucketContract added in v0.18.0

func CheckBucketContract(ctx context.Context, api BucketContractAPI, bucket, expectedOwner string, namespaces []string) ([]Finding, error)

CheckBucketContract reads the three settings of bucket and reports one finding per setting, always all three and always in BucketSettings order: a caller that refuses on the first bad one would make an operator fix them one run at a time.

namespaces are the object-key prefixes the caller writes under - an estate's records, hint and outputs. The lifecycle assertion needs them: a rule scoped to some other prefix expires nothing of ours, and a rule that covers the records but not the outputs leaves the outputs growing. With none given, only a rule with no prefix filter counts.

expectedOwner is the account that must own the bucket, as twelve digits, or "" for no check. Set, each of the three reads carries it as ExpectedBucketOwner, so a bucket of this name in some other account is refused rather than reported on. See bucketowner.go.

The error return is for a failure that is not about the bucket's settings at all - a cancelled context, an unreachable endpoint. A denied read is NOT an error: it is a finding whose outcome is Unreadable.

func CheckClusterContract added in v0.19.0

func CheckClusterContract(ctx context.Context, cs kubernetes.Interface, opts ClusterContractOptions) ([]Finding, error)

CheckClusterContract reads the four properties of the cluster cs reaches and reports one finding per setting, always all four and always in ClusterSettings order: a caller that refused on the first bad one would make an operator fix them one run at a time.

ClusterTLSVerification is ahead of those four when opts.InsecureTLS is set, and absent otherwise. It is a fact about the block, and a passing line for it would claim something nobody asked: a kubeconfig can turn verification off as well, and that is not read here.

The error return is for a failure that is not about the cluster's properties at all - a cancelled context, an unreachable API server, a SelfSubjectAccessReview the server would not accept. A DENIED read is not an error: it is a finding with NotChecked set.

func SplitWaived added in v0.18.0

func SplitWaived(findings []Finding, waived []string) (refused, warned, waivedFailing []Finding)

SplitWaived sorts the findings that did not pass into the ones a RUN must refuse on, the ones it must WARN about, and the ones waived names, leaving passing findings out of all three. A waiver reaches exactly the settings it names, and silences a warning as well as a refusal: waiving one leaves a failure of any other in refused.

A setting that could not be READ is waived by the same name as a wrong one. From the caller's side they are one refusal - the run cannot rely on the setting - and an operator whose identity cannot read what the assertion is about has no other way to proceed.

Why a NotChecked finding warns a run and fails a report

The two callers are asking different questions and the answer differs.

`choudoufu live-bucket` and `choudoufu live-cluster` ask "is this store correct". A property nobody could read is not a pass there: the report prints it, calls the store NOT correct and exits non-zero, so the operator who ran it on purpose goes and gets the answer from outside. Neither command goes through this function.

A RUN asks "may I proceed". There, a refusal on NotChecked would refuse every correctly scoped identity, because scoped is exactly what makes the reads impossible: the Role the Kubernetes docs recommend holds Secrets in one namespace and cannot list kube-system's Pods. Every CI job in the intended arrangement would carry a waiver from its first day, which this repository has paid to learn protects nothing (#1102). So it warns: by name, with its cost, on every run that writes a record, which is #1340's whole standard for a thing a run proceeds past. Unreadable is the other side of that line and refuses, because somebody CAN read it and the fix is a grant.

func (Finding) OK added in v0.19.0

func (f Finding) OK() bool

OK reports whether the store satisfies the assertion. Everything else is not a pass, including a property nobody could read.

type ForeignEstateRecordError added in v0.21.0

type ForeignEstateRecordError struct {
	Namespace  string
	SecretName string
	Key        string
	// Estate is this store's estate; Labelled is the one the label names.
	Estate   string
	Labelled string
}

ForeignEstateRecordError reports a Secret that holds a key of this store's and is labelled as another estate's: its name is the hash of the key, its annotation carries the key, and its tofu-estate label is not this store's estate.

A listing used to skip it as the other estate's record while a Get of the key served it, so a bulk read came back short by that record with no error and a RunCache answered "no record" for it. GitHub issue #1355, ruled 2026-09-30: both reads refuse it, by name. A relabel by hand (`kubectl label --overwrite tofu-estate=...`) or a copy made for another estate under this one's key is what produces it.

func (*ForeignEstateRecordError) Error added in v0.21.0

func (e *ForeignEstateRecordError) Error() string

type KMSDeniedError added in v0.18.0

type KMSDeniedError struct {
	// Action is the KMS action refused, for example "kms:Decrypt".
	Action string
	// KeyARN is the key that refused it. Empty if AWS did not say.
	KeyARN string
	// Principal is who was refused, as AWS names them. Empty if AWS did not say.
	Principal string
	// Where is which policy AWS blamed: KMSDeniedByKeyPolicy,
	// KMSDeniedByIdentityPolicy, KMSDeniedExplicitly, or "" when the message
	// did not say.
	Where string
	// Err is the S3 error as it arrived.
	Err error
}

KMSDeniedError is an S3 request refused because of the bucket's KMS key, and not because of anything about S3.

A bucket whose default encryption is a customer managed key makes every GetObject a kms:Decrypt and every PutObject a kms:GenerateDataKey, made by S3 with the caller's identity. When KMS refuses, S3 reports it as a plain 403 AccessDenied on the S3 operation, and the only sign that the cause is the key is inside the message text. An operator who reads "AccessDenied ... PutObject" goes to the bucket policy and the role's S3 statements, which are correct, and the mistake is almost always somewhere they did not look: a key policy that does not name the role. A customer managed key is usable only by the principals its key policy allows, and an IAM policy alone never grants it unless the key policy delegates to IAM.

The wording below was written against what real AWS says (GitHub issue #1345, us-east-2, 2026-09-18), which is, on one line:

User: arn:aws:sts::<acct>:assumed-role/<role>/<session> is not authorized
to perform: kms:GenerateDataKey on resource: arn:aws:kms:<region>:<acct>:key/<id>
because no resource-based policy allows the kms:GenerateDataKey action

func (*KMSDeniedError) Error added in v0.18.0

func (e *KMSDeniedError) Error() string

func (*KMSDeniedError) Headline added in v0.18.0

func (e *KMSDeniedError) Headline() string

Headline is the one sentence of what happened: which key refused what to whom.

func (*KMSDeniedError) Remedy added in v0.18.0

func (e *KMSDeniedError) Remedy() string

Remedy says where to look, which depends on the policy AWS blamed.

func (*KMSDeniedError) Unwrap added in v0.18.0

func (e *KMSDeniedError) Unwrap() error

type KMSKeyUnusableError added in v0.19.0

type KMSKeyUnusableError struct {
	// Code is the code S3 relayed, for example "KMS.DisabledException".
	Code string
	// KeyARN is the key, when the message named one. Empty otherwise.
	KeyARN string
	// Err is the S3 error as it arrived.
	Err error
}

KMSKeyUnusableError is an S3 request that failed because of the STATE of the bucket's KMS key rather than because of any policy. S3 relays those with the KMS exception's own name under a "KMS." prefix, on an HTTP 400: KMS.DisabledException, KMS.KMSInvalidStateException, KMS.NotFoundException.

They are kept out of KMSDeniedError because every remedy that type offers is a policy edit and none of them helps here. A disabled key stays disabled however its key policy reads, and adding the estate's role to the policy of a key that is pending deletion still leaves the key pending deletion. An operator sent to the key policy for one of these loses the same afternoon KMSDeniedError exists to save, in the other direction. GitHub issue #1383.

func (*KMSKeyUnusableError) Error added in v0.19.0

func (e *KMSKeyUnusableError) Error() string

func (*KMSKeyUnusableError) Headline added in v0.19.0

func (e *KMSKeyUnusableError) Headline() string

Headline is the one sentence of what happened: which key could not be used.

func (*KMSKeyUnusableError) Remedy added in v0.19.0

func (e *KMSKeyUnusableError) Remedy() string

Remedy says what to do, which depends on what KMS said.

func (*KMSKeyUnusableError) Unwrap added in v0.19.0

func (e *KMSKeyUnusableError) Unwrap() error

type KeyCollisionError added in v0.19.0

type KeyCollisionError struct {
	Key        string
	SecretName string
	FoundKey   string
}

KeyCollisionError reports that the Secret a key hashes to holds a DIFFERENT key. A SHA-256 collision is not something to plan for and is something to refuse rather than serve: the alternative is one record silently answering for another.

It is also what an operator gets for hand-editing a record Secret's key annotation, which is the case that will actually happen.

func (*KeyCollisionError) Error added in v0.19.0

func (e *KeyCollisionError) Error() string

type KubernetesConfig added in v0.19.0

type KubernetesConfig struct {
	// Secrets is the namespaced Secret client every call goes through. The
	// caller builds and authenticates it - kubeconfig, in-cluster config, an
	// exec credential plugin - and this package has no opinion on any of
	// that, the same position [S3Config.Client] takes.
	Secrets corev1client.SecretInterface

	// Clientset is the same cluster connection, unscoped, and it is used for
	// exactly one thing: the cluster contract (kubernetescontract.go, GitHub
	// issue #1393), which asks the API server's own authorizer what this
	// identity may do and reads the estate boundary policy. No record ever
	// goes through it.
	//
	// Optional. Nil leaves [KubernetesStore.CheckContract] reporting
	// that it has no client, which is what a caller that built the store
	// from a bare SecretInterface gets - the conformance suite, for one.
	Clientset kubernetes.Interface

	// InsecureTLS says the caller built Secrets and Clientset not to verify
	// the API server's certificate, which is the record_store block's
	// `insecure = true`. The store does nothing differently for it. The
	// cluster contract reports it as a finding (GitHub issue #1448), and it
	// is carried here because neither client says how it was built.
	InsecureTLS bool

	// ControlPlane, ControlPlaneReader and APIServerHost are the cluster
	// contract's [ClusterContractOptions] fields of the same names (GitHub
	// issue #1524): which managed control plane this cluster runs on, how to
	// ask its provider whether Secrets are encrypted at rest, and the host
	// the clients reach, which the provider's answer has to match. All
	// optional; a nil ControlPlane keeps reading the API server's Pod.
	ControlPlane       *ManagedControlPlane
	ControlPlaneReader ControlPlaneReader
	APIServerHost      string

	// Namespace is the Kubernetes namespace Secrets writes into. It is
	// carried here for the error text, since a namespaced client does not
	// say which namespace it is bound to.
	//
	// It is the read isolation boundary. RBAC cannot condition on a label,
	// and admission never sees a get or a list, so anything that can read
	// Secrets in this namespace reads every record in it. One namespace per
	// estate's records is what keeps one estate out of another's.
	Namespace string

	// KeyPrefix is joined ahead of every key this store is asked for, the
	// same opaque string [S3Config.KeyPrefix] is. Empty means keys are used
	// as given, which is what internal/live/projection passes (see its
	// backendKeyPrefix).
	KeyPrefix string

	// Estate is the estate name every Secret this store writes carries as
	// its tofu-estate LABEL, so live/kubernetes/estate-boundary.yaml fences
	// writes to an estate's records with no new policy.
	//
	// Required, and refused at open when it cannot be a label value: an
	// estate name may be 128 characters and a label value caps at 63
	// (#1016, #1396). A store that silently dropped the label would write
	// records the boundary policy does not see.
	Estate string

	// ListPageSize bounds how many Secrets one LIST page returns. Zero takes
	// [DefaultKubernetesListPageSize].
	ListPageSize int64
}

KubernetesConfig configures a KubernetesStore.

type KubernetesStore added in v0.19.0

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

KubernetesStore is a Store backed by Secrets in one Kubernetes namespace, with metadata.resourceVersion as the version and no lock. GitHub issue #1392, under the epic #1398 ruling: a Kubernetes-only estate should not need an AWS account to keep its records anywhere but one operator's disk.

What is genuinely atomic

Every conditional operation is one API request carrying its condition, and the API server enforces it:

  • KubernetesStore.PutIfAbsent and a "" KubernetesStore.PutIfVersion call send Create, which the API server refuses with 409 AlreadyExists if an object of that name is there.
  • A non-"" KubernetesStore.PutIfVersion call sends Update with metadata.resourceVersion set to the version THE CALLER got from Get. The API server refuses with 409 Conflict if the stored object has moved on. The version is never re-read inside the call: the stock backend's own Put does read-then-update (client.go:86) and that is exactly the race window this interface exists to close.
  • KubernetesStore.Delete sends Preconditions.ResourceVersion, the API server's conditional delete.

After a refusal this store issues one extra Get purely to name the version the store now holds in a VersionConflictError; that read is not part of the guarantee, which the refused request already made.

No lock, and no Lease

The stock kubernetes backend takes a coordination.k8s.io Lease per workspace. Nothing here does. #1332 ruled the record store lock-free with a conditional write per record, and a Lease would put one lock back in front of an estate. A run killed mid-apply leaves no Lease to break, because it took none.

What this store does not manage

The namespace. It is the read isolation boundary (see KubernetesConfig.Namespace) and creating it is a cluster-admin act, not something a record write does on the way past. A namespace that is not there is refused by name, with the kubectl line that creates it.

What a listing is, and why it carries no label selector

KubernetesStore.List and KubernetesStore.GetAll LIST the namespace with no selector and attribute each object client-side, by the key its own annotation carries and by its name. They used to select on app.kubernetes.io/managed-by and tofu-estate server-side, which is cheaper and which loses a record the moment either label goes: the object was then in no listing, with a nil error, while a Get of its key still served it, and an estate that reads as having fewer records than it has is planned against as if the missing ones were never created. GitHub issue #1448 measured it on kind. A selector cannot find an object by the label it is missing, so the narrowing and the refusal cannot both be had; the refusal is the one worth keeping (UnlabelledRecordError).

What that costs is that a LIST carries every Secret in the namespace rather than this estate's records only. The default arrangement is one namespace per estate, where the difference is nothing; a namespace shared by configuration pays for the other estates' objects on the wire, and skips them client-side by their own tofu-estate label. No new permission is needed: anything that can list Secrets in the namespace could already read every record in it, which is decision 5 in this package's doc.

func NewKubernetesStore added in v0.19.0

func NewKubernetesStore(cfg KubernetesConfig) (*KubernetesStore, error)

NewKubernetesStore builds a KubernetesStore from cfg.

The estate name is checked here, once, rather than at the first write. An estate name may be 128 characters (#1396) and a Kubernetes label value caps at 63, so there are estate names this store cannot fence. Refusing at open means the operator finds out before a plan is built, and not after half the records were written unlabelled.

func (*KubernetesStore) CheckClusterContract added in v0.19.0

func (s *KubernetesStore) CheckClusterContract(ctx context.Context, opts ClusterContractOptions) ([]Finding, error)

CheckClusterContract is KubernetesStore.CheckContract in this store's own vocabulary, for a caller that has one of these in hand and wants to name the options itself. The namespace, the estate, NamespaceKnownToExist and InsecureTLS are always the store's own whatever opts says.

func (*KubernetesStore) CheckContract added in v0.19.0

func (s *KubernetesStore) CheckContract(ctx context.Context, opts ContractOptions) ([]Finding, error)

CheckContract implements ContractChecker. The namespace and the estate are the store's own, never the caller's: a report about some other namespace than the one the records are in would be a report about nothing. NamespaceKnownToExist is set, because a store that got this far has already written and listed through that namespace.

func (*KubernetesStore) ContractCheckFailed added in v0.19.0

func (s *KubernetesStore) ContractCheckFailed(err error) (summary, detail string)

ContractCheckFailed implements ContractChecker.

func (*KubernetesStore) ContractRefusal added in v0.19.0

func (s *KubernetesStore) ContractRefusal(f Finding) (summary, detail string)

ContractRefusal implements ContractChecker with this cluster's own words.

func (*KubernetesStore) ContractRefusalClosing added in v0.19.0

func (s *KubernetesStore) ContractRefusalClosing(refused []Setting) string

ContractRefusalClosing implements ContractChecker with this cluster's own words.

func (*KubernetesStore) ContractSubject added in v0.19.0

func (s *KubernetesStore) ContractSubject() (label, value string)

ContractSubject implements ContractChecker: a cluster store is named by the namespace its records live in, which is also its read boundary.

func (*KubernetesStore) Delete added in v0.19.0

func (s *KubernetesStore) Delete(ctx context.Context, key string, expectedVersion string) error

Delete implements Store. expectedVersion == "" against an absent key is a no-op, checked with a Get first because a delete precondition has no "only if absent" form; any other value rides as Preconditions.ResourceVersion on the Delete itself.

func (*KubernetesStore) Get added in v0.19.0

func (s *KubernetesStore) Get(ctx context.Context, key string) ([]byte, string, bool, error)

Get implements Store.

func (*KubernetesStore) GetAll added in v0.19.0

func (s *KubernetesStore) GetAll(ctx context.Context, keyPrefix string) (map[string]Record, error)

GetAll implements BulkReader in ONE paginated LIST.

Kubernetes is the backend that genuinely can bulk-fetch: a LIST returns each Secret's data alongside its metadata, where S3's ListObjectsV2 returns keys and ETags and never a body. So there is no fan-out here and nothing to bound - S3Store.GetAll's whole parallelism apparatus, and the completeness hazard that comes with it, has no counterpart.

Complete or fail is still the contract and is easier to keep: any page that fails fails the call, and a payload that will not decompress fails it too rather than dropping a key. A key absent from the returned map holds no record, which is what makes the result a snapshot.

func (*KubernetesStore) List added in v0.19.0

func (s *KubernetesStore) List(ctx context.Context, keyPrefix string) ([]string, error)

List implements Store. One paginated LIST of the whole namespace, then an ordinary string-prefix filter on the key each Secret's annotation carries.

The filter is client-side because a label selector cannot express a prefix and the key is not in the name. So is the rest of the attribution, and that is a deliberate trade rather than an oversight: see KubernetesStore for what a server-side selector lost, and [KubernetesStore.attributeRecord] for what stands in its place.

func (*KubernetesStore) PutIfAbsent added in v0.19.0

func (s *KubernetesStore) PutIfAbsent(ctx context.Context, key string, payload []byte) (string, error)

PutIfAbsent implements Store.

func (*KubernetesStore) PutIfVersion added in v0.19.0

func (s *KubernetesStore) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)

PutIfVersion implements Store. expectedVersion == "" is a Create; any other value is an Update carrying that resourceVersion.

The version sent is the one the CALLER got from Get. Nothing is read inside this call to fill it in, which is what makes this a compare-and-swap rather than the stock backend's read-then-update.

func (*KubernetesStore) SecretName added in v0.19.0

func (s *KubernetesStore) SecretName(key string) string

SecretName is the Secret this store reads and writes key at: a fixed, readable prefix and the SHA-256 of the store key.

A record key is not a DNS-1123 name and cannot be made into one without losing information: keys carry "/" and base64url runs, they are case-sensitive where a name is not, and projection's own encoder already produces keys past a name's 253-character limit (the conformance suite's chunked key is 500). A hash is fixed-length, case-free and total. What it costs is that a name no longer says which record it is, which the key annotation pays back, and that two keys could in principle collide, which KeyCollisionError refuses rather than serves.

Exported so an operator can be told the object to look at - the same reason S3Store.ObjectKey is exported (#916).

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 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

func (s *LocalStore) Delete(ctx context.Context, key string, expectedVersion string) error

Delete implements Store, under the same lockfile discipline as LocalStore.PutIfVersion.

func (*LocalStore) Get

func (s *LocalStore) Get(_ context.Context, key string) ([]byte, string, bool, error)

Get implements Store.

func (*LocalStore) GetAll added in v0.5.0

func (s *LocalStore) GetAll(_ context.Context, keyPrefix string) (map[string]Record, error)

GetAll reads every record under keyPrefix from the store directory in one walk. Costs no network at all, so the whole namespace is one traversal plus one file read each — the local backend's equivalent of stock reading its state file.

The exclusions are LocalStore.List's exactly: a lockfile is not a record, and a temp file is a write in progress that no reader may observe.

A file the walk saw and the read no longer finds fails the whole bulk read, for S3Store.GetAll's reason (GitHub issue #1355): the walk and the read disagreeing about what is in the directory means what came back is not a snapshot of it, and a snapshot short by one key is how an instance drops out of prior state with nothing said.

func (*LocalStore) List

func (s *LocalStore) List(_ context.Context, keyPrefix string) ([]string, error)

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

func (s *LocalStore) PutIfAbsent(_ context.Context, key string, payload []byte) (string, error)

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 ManagedControlPlane added in v0.22.0

type ManagedControlPlane struct {
	// Provider is "eks", "gke" or "aks".
	Provider string

	// Name is the cluster's name in its provider. Required for all three.
	Name string

	// Region is EKS's AWS region.
	Region string

	// Project and Location are GKE's: the Google Cloud project and the
	// cluster's location (a region or a zone).
	Project  string
	Location string

	// ResourceGroup and SubscriptionID are AKS's.
	ResourceGroup  string
	SubscriptionID string

	// Source says where this identification came from, for the finding's
	// text: the record_store block's control_plane block, or what was
	// inferred from the exec credential plugin's arguments.
	Source string
}

ManagedControlPlane names a cluster to its provider's API: which provider, and the coordinates that provider's API takes. Only the fields the provider uses are read.

func (ManagedControlPlane) Describe added in v0.22.0

func (cp ManagedControlPlane) Describe() string

Describe is the cluster named the way an operator would find it in that provider's console.

type MisnamedRecordError added in v0.19.0

type MisnamedRecordError struct {
	Namespace  string
	SecretName string
	Key        string
	WantName   string
}

MisnamedRecordError reports a Secret whose record-key annotation says it holds one key while its NAME is not the name that key hashes to.

The name is how a read finds a record, so such an object is in every listing and reachable by nothing: Get of the key it claims says no record is there, and a PutIfVersion or a Delete carrying the version the listing gave conflicts forever. It is what a renamed record Secret looks like - a copy taken by hand, a restore under a new name.

func (*MisnamedRecordError) Error added in v0.19.0

func (e *MisnamedRecordError) Error() string

type NamespaceMissingError added in v0.19.0

type NamespaceMissingError struct {
	Namespace string
	Err       error
}

NamespaceMissingError reports that the Kubernetes namespace this store writes into does not exist. It carries the kubectl line that creates it.

This is not a missing record. A LIST in a namespace that is not there comes back EMPTY rather than failing, and an empty listing reads as an empty estate - #688's failure whole, arriving through a different door. The namespace is not created on the way past, because it is the read isolation boundary and who may create one is a cluster-admin decision.

func (*NamespaceMissingError) Error added in v0.19.0

func (e *NamespaceMissingError) Error() string

func (*NamespaceMissingError) Unwrap added in v0.19.0

func (e *NamespaceMissingError) Unwrap() error

type NamespaceTerminatingError added in v0.19.0

type NamespaceTerminatingError struct {
	Namespace string
	Err       error
}

NamespaceTerminatingError reports that the Kubernetes namespace this store writes into is being deleted. It is NamespaceMissingError a few seconds early: the API server is removing every object in the namespace, so a LIST of it answers 200 with an empty list as soon as the records are gone, and that empty listing reads as an estate with no records.

It is kept apart from NamespaceMissingError because the operator's next move is different. A missing namespace is created; a terminating one has to finish going away before it can be.

func (*NamespaceTerminatingError) Error added in v0.19.0

func (e *NamespaceTerminatingError) Error() string

func (*NamespaceTerminatingError) Unwrap added in v0.19.0

func (e *NamespaceTerminatingError) Unwrap() error

type Outcome added in v0.19.0

type Outcome uint8

Outcome is what one assertion came to. Four of the five values are not a pass, and they are told apart because a run does something different with each.

const (
	// Failed is the zero value on purpose: a finding nobody filled in has
	// not passed anything. The property was read and is wrong, which is
	// something an operator can act on, so a run refuses.
	Failed Outcome = iota

	// Passed is the store satisfying the assertion.
	Passed

	// Unreadable is a property this identity may not read but SOMEBODY can:
	// the bucket's settings behind an s3:Get* the role was not granted. A
	// run refuses, because a store nobody could check is not a store that
	// passed and the fix is a permission the role should have had.
	Unreadable

	// NotChecked is a property that cannot be answered from inside at any
	// permission level: the API server's --encryption-provider-config on a
	// managed control plane. It is never a pass, and a RUN says so and goes
	// on rather than refusing - refusing would refuse every correctly scoped
	// identity, and a gate everyone waives on their first day protects
	// nothing (#1102). A REPORT still calls the store not correct: see
	// internal/command's live-cluster.
	NotChecked

	// Warned is a finding that was read, is a concern, and is not yet a
	// breach - an identity that MAY read another estate's records on a
	// cluster where no other estate keeps any. Said out loud on every run
	// that writes a record, never a refusal.
	Warned
)

type Record added in v0.5.0

type Record struct {
	Payload []byte
	Version string
}

Record is one stored record's content and version, as BulkReader.GetAll returns it — the same pair Store.Get returns for one key, for a caller that asked for many.

type RecordTooLargeError added in v0.19.0

type RecordTooLargeError struct {
	Key   string
	Bytes int
	Limit int
}

RecordTooLargeError reports a record this store will not write because the backing object cannot hold it. Named so a caller can tell it from a denial or an outage without parsing prose.

func (*RecordTooLargeError) Error added in v0.19.0

func (e *RecordTooLargeError) Error() string

type RunCache added in v0.5.0

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

RunCache is the estate's records loaded the way stock OpenTofu loads its state file: once, in bulk, at the moment something first asks for any of it, held in memory for the rest of the read phase, and never written back through.

What it replaces

A migrated plan asks the store about the same instance three to eight times. Every accessor on projection's RecordStore - identity, residue, the provisioner bit, the deposed set, the envelope kind - decodes the same physical key, and each went to the store for itself. Measured at scale 1 over 78 instances: 377 trips over 80 distinct keys. Caching the repeats takes that to 80, one per instance. Loading the namespace in bulk takes it to 1, which is what stock pays, and is the only figure that makes the two state models comparable at all.

The bulk load is lazy - it happens on the first read under the namespace, not at construction - because that is exactly when stock reads its state file, and because a command that touches no record should pay for none. A store that does not implement BulkReader silently keeps the per-key behaviour: still one trip per key instead of one per accessor.

Why it cannot serve a stale value

One rule, and it is not a balance of risks: **the cache is switched off permanently, process-wide, by the first write through any RunCache.** Nothing it serves was ever read after something was written. A plan writes nothing, so a plan is served entirely from the snapshot; the instant a write-back, a migration or a seeder writes anything, every later read in that process goes to the store, for good.

That is deliberately blunter than invalidating the key that was written. Invalidation has to be right about which reads a write can affect, and the three reads it must never be wrong about - projection's mergeEnvelope read-modify-write, its currentVersion, and the seeders' read-before-write halves - are the ones where being wrong means a lost update rather than a slow run. A switch that is simply off after the first write cannot be wrong about any of them. Those three call sites additionally bypass this cache explicitly, through RunCache.Uncached, so they are correct even before the first write has happened.

Nothing conditional is ever decided here either: PutIfVersion, PutIfAbsent and Delete always go to the wrapped store, so the compare-and-swap that guards against a writer OUTSIDE this process is performed by the store on the store's own current version, exactly as before.

Lifetime

The process, and never longer. A record is live state; whether one has gone stale between runs is the charter's business, and a cache must never be what answers it. There is no expiry, no file, no shared daemon.

func (*RunCache) Delete added in v0.5.0

func (c *RunCache) Delete(ctx context.Context, key string, expectedVersion string) error

func (*RunCache) Get added in v0.5.0

func (c *RunCache) Get(ctx context.Context, key string) ([]byte, string, bool, error)

func (*RunCache) GetAll added in v0.5.0

func (c *RunCache) GetAll(ctx context.Context, keyPrefix string) (map[string]Record, error)

GetAll passes a bulk read through to the wrapped store, so a caller that wants the whole namespace still gets it in one call. It is deliberately NOT served from the snapshot: the only caller of a bulk read that is not this cache is one that wants the store's own current answer.

func (*RunCache) List added in v0.5.0

func (c *RunCache) List(ctx context.Context, keyPrefix string) ([]string, error)

func (*RunCache) PutIfAbsent added in v0.5.0

func (c *RunCache) PutIfAbsent(ctx context.Context, key string, payload []byte) (string, error)

func (*RunCache) PutIfVersion added in v0.5.0

func (c *RunCache) PutIfVersion(ctx context.Context, key string, payload []byte, expectedVersion string) (string, error)

func (*RunCache) Uncached added in v0.5.0

func (c *RunCache) Uncached() Store

Uncached returns the wrapped store, for the reads that must never be served from a snapshot: a read-modify-write's own read, a version observed so a compare-and-swap can catch an outside writer, and a seeder's read-before-write. See this type's doc comment.

It is a method rather than a field so the intent is stated at every call site that needs it, and so a store that is not a RunCache needs no special case: see Fresh.

func (*RunCache) Unwrap added in v0.18.0

func (c *RunCache) Unwrap() Store

Unwrap returns the wrapped store, for AsContractChecker.

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

	// ExpectedBucketOwner is the AWS account that must own Bucket, as twelve
	// digits. Set, every request this store makes carries it as S3's
	// ExpectedBucketOwner and S3 refuses the request if the bucket belongs to
	// any other account. Empty means no check, which is the behaviour of
	// every build before GitHub issue #1381.
	//
	// It comes from record_store's bucket_owner. See bucketowner.go for why a
	// bucket's NAME is not an answer to whose bucket it is, and for what the
	// refusal looks like.
	ExpectedBucketOwner string

	// GetAllParallelism bounds how many GetObject calls [S3Store.GetAll] has
	// in flight at once. Zero or negative takes
	// [DefaultS3GetAllParallelism]; 1 is a sequential read.
	GetAllParallelism int

	// BaseTags go on every object this store writes. The record store sets
	// tofu-estate here, because every object in an estate's namespaces -
	// its records, its sentinel, its hint, its outputs - is the estate's,
	// whether or not it records a resource. See [WithObjectTags] for the
	// per-write half. GitHub issue #1337.
	BaseTags map[string]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:

  • 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

func NewS3Store(cfg S3Config) (*S3Store, error)

NewS3Store builds an S3Store from cfg.

func (*S3Store) CheckContract added in v0.19.0

func (s *S3Store) CheckContract(ctx context.Context, opts ContractOptions) ([]Finding, error)

CheckContract implements ContractChecker. opts.Namespaces are store-relative, like every key this store is handed; S3Config.KeyPrefix is joined ahead of each.

func (*S3Store) ContractCheckFailed added in v0.19.0

func (s *S3Store) ContractCheckFailed(err error) (summary, detail string)

ContractCheckFailed implements ContractChecker.

func (*S3Store) ContractRefusal added in v0.19.0

func (s *S3Store) ContractRefusal(f Finding) (summary, detail string)

ContractRefusal implements ContractChecker with this bucket's own words.

func (*S3Store) ContractRefusalClosing added in v0.19.0

func (s *S3Store) ContractRefusalClosing([]Setting) string

ContractRefusalClosing implements ContractChecker. The bucket needs none: its refusals do not each carry an allow_insecure line of their own, so there is no duplicate argument for a closing line to replace.

func (*S3Store) ContractSubject added in v0.19.0

func (s *S3Store) ContractSubject() (label, value string)

ContractSubject implements ContractChecker: a bucket is named by its name.

func (*S3Store) Delete

func (s *S3Store) Delete(ctx context.Context, key string, expectedVersion string) error

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) Get

func (s *S3Store) Get(ctx context.Context, key string) ([]byte, string, bool, error)

Get implements Store.

func (*S3Store) GetAll added in v0.5.0

func (s *S3Store) GetAll(ctx context.Context, keyPrefix string) (map[string]Record, error)

GetAll reads every record under keyPrefix: one ListObjectsV2 pagination, then one GetObject per key, at most S3Config.GetAllParallelism of them in flight at once.

S3 is the backend that genuinely cannot bulk-fetch. There is no batch-read operation in the S3 API - ListObjectsV2 returns each object's key and ETag but never its body - so N objects cost N GetObject calls whatever this function does. Overlapping them is the only saving there is.

A bulk read is complete or it fails

That is the constraint, and it matters more than the speedup. BulkReader promises the result is complete for its prefix: a key absent from the map holds no record. A plan reads a record key with no configuration behind it as an instruction to destroy, and reads a declared instance with no record as something to create. So a map that silently lacks a key is the worst thing this backend can produce, and parallelism is exactly where it would come from: the sequential loop this replaced got completeness for free by returning on its first error, and a fan-out has to be written to keep it.

So: results are written by index into a slice, never into a shared map. The first failure wins, cancels the rest, and is the error returned, naming its key. The map is built only after every worker has stopped, and only if nothing failed AND every key was handed to a worker - a cancelled context that stopped the feed with no GET in flight would otherwise leave no error at all and a short map behind it.

A key the LIST named and the GET did not find

This used to be the one omission that was kept: the key was read as deleted between the LIST and its GET, and left out of the map as the correct way to say so. GitHub issue #1355 is why it is not kept any more.

The omission is correct about the store and wrong about the run. What consumes this map is RunCache, which holds it for the whole read phase and answers every later question about a key inside the namespace from it WITHOUT going back to the store. So one 404 in one GET does not cost one stale answer; it makes "there is no record for this instance" the run's settled position, and for an instance whose record IS its whole state that is prior state with an instance missing from it. On an ordinary plan that proposes a create for something that exists. On `apply -destroy` it proposes nothing at all, and the run prints a success with one fewer instance destroyed than the estate has - #1355's shape exactly, with no error and no warning anywhere in the output.

So the key is fetched a SECOND time before its absence is believed, and if the second GET does not find it either, the whole bulk read fails and names it. Two reads of one store that disagree is not a snapshot, whichever of them is right. [RunCache.ensureLoaded] treats that failure the way it treats any other - it loads no snapshot and every read goes to the store per key - so a record that really was deleted still reads as absent, but by a read taken now rather than by an omission from a torn snapshot.

func (*S3Store) List

func (s *S3Store) List(ctx context.Context, keyPrefix string) ([]string, error)

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.

func (*S3Store) ObjectKey added in v0.14.0

func (s *S3Store) ObjectKey(key string) string

ObjectKey is the S3 object key this store will read and write key at: S3Config.KeyPrefix joined ahead of it. Exported so a caller can say where a record actually is, in words an operator can paste into the AWS CLI - issue #916.

func (*S3Store) PutIfAbsent

func (s *S3Store) PutIfAbsent(ctx context.Context, key string, payload []byte) (string, error)

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 Setting added in v0.19.0

type Setting string

Setting names one asserted property of a record store. The values are the names an operator writes in allow_insecure, so they are part of the configuration language and do not change casually. They are unique across stores - internal/command pins that by test - because a waiver naming a setting of some other backend is refused rather than quietly ignored.

const (
	BucketVersioning        Setting = "versioning"
	BucketLifecycle         Setting = "lifecycle"
	BucketPublicAccessBlock Setting = "public_access_block"
)

The bucket's settings are named in the shared vocabulary Setting, and a finding about one is a Finding; see contract.go for what every store's contract has in common and what the outcomes mean.

const (
	ClusterTLSVerification  Setting = "tls_verification"
	ClusterNamespaceAccess  Setting = "namespace_access"
	ClusterReadIsolation    Setting = "read_isolation"
	ClusterEncryptionAtRest Setting = "encryption_at_rest"
	ClusterEstateBoundary   Setting = "estate_boundary"
)

The cluster's properties are named in the shared vocabulary Setting, and a finding about one is a Finding; see contract.go for what every store's contract has in common and what the outcomes mean.

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; 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.

func Fresh added in v0.5.0

func Fresh(s Store) Store

Fresh returns the store beneath any read cache in s, or s itself when there is none. A caller that must not read a remembered value asks for this rather than testing for a cache type it should not have to know about.

func NewRunCache added in v0.5.0

func NewRunCache(inner Store, prefix string) Store

NewRunCache wraps inner, snapshotting the namespace at prefix. A nil inner returns nil, so a caller that already treats "no store configured" as nil keeps doing so. An empty prefix disables the bulk load and leaves ordinary per-key caching.

type Trip added in v0.5.0

type Trip struct {
	Method string
	Key    string
	Via    string
	Site   string
}

Trip is one operation that reached the wrapped store. See CountingStore for what each field is for.

func ParseTripLog added in v0.5.0

func ParseTripLog(data []byte) ([]Trip, error)

ParseTripLog reads back what CountingStore's log writer wrote. A line that does not have the four tab-separated fields is an error rather than a skipped line: a partially readable log would under-report, and an under-reported cost is exactly the failure this instrument exists to end.

func (Trip) String added in v0.5.0

func (t Trip) String() string

String renders a trip as the one TSV line CountingStore's log writes and ParseTripLog reads back.

type TripCounts added in v0.5.0

type TripCounts struct {
	Total int

	ByMethod map[string]int
	BySite   map[string]int
	ByVia    map[string]int

	// DistinctKeys is how many different keys the trips touched, and
	// RepeatTrips is Total minus DistinctKeys: the trips that re-read
	// something an earlier trip had already read. A cache can remove at
	// most RepeatTrips of them and never fewer than zero of the rest,
	// which is the whole reason this is reported beside the total.
	DistinctKeys int
	RepeatTrips  int
}

TripCounts is a set of trips summarized several ways at once. Every field is a total over the same trips, so a reader can check them against each other rather than having to trust one.

func Summarize added in v0.5.0

func Summarize(trips []Trip) TripCounts

Summarize buckets trips every way TripCounts names.

type UnannotatedRecordError added in v0.21.0

type UnannotatedRecordError struct {
	Namespace  string
	SecretName string
}

UnannotatedRecordError reports a Secret named the way this store names a record, labelled as this estate's or as no estate's, that carries no record-key annotation.

The annotation is the only place a record's key survives - the name is its hash - so no listing can say which key this object holds, while a Get of that key still finds it by name. Leaving it out of a listing made a bulk read short by one record with no error. GitHub issue #1355; see [KubernetesStore.unattributedRecord].

func (*UnannotatedRecordError) Error added in v0.21.0

func (e *UnannotatedRecordError) Error() string

type UnlabelledRecordError added in v0.19.0

type UnlabelledRecordError struct {
	Namespace  string
	SecretName string
	Key        string

	// Missing is what the Secret has to carry, as "label=value", for each
	// label that is absent or holds another value.
	Missing []string
}

UnlabelledRecordError reports a Secret that is a record object under this store's own keys - it is named the way this store names a record, and its annotation carries a key under the prefix being listed - and that does not carry the labels every record this store writes carries.

It is a refusal rather than an omission because of what the omission cost. The listing used to be a label selector, so a record whose tofu-estate or app.kubernetes.io/managed-by label was stripped - `kubectl label secret NAME tofu-estate-`, a restore that dropped labels, an admission policy mutating an object on the way past - was in neither List nor GetAll, with a nil error, while a Get of its key still served it. An estate then reads as having fewer records than it has, and the next plan proposes creating those live resources a second time. GitHub issue #1448.

The labels are not put back by this store. A write that repaired them would be a write to an object the estate boundary policy does not currently fence, which is the one write that must not happen silently.

func (*UnlabelledRecordError) Error added in v0.19.0

func (e *UnlabelledRecordError) Error() string

type VerbAccess added in v0.19.0

type VerbAccess struct {
	Verb string `json:"verb"`
	// Allowed is the authorizer's own answer.
	Allowed bool `json:"allowed"`
	// Required is whether this run needs the verb. A plan-only identity
	// requires get and list and not the other three (see
	// [KubernetesPlanVerbs]), so a denied verb that is not required is
	// reported and does not fail the finding.
	Required bool `json:"required"`
	// Reason is what the authorizer said, when it said anything.
	Reason string `json:"reason,omitempty"`
}

VerbAccess is one verb's answer from a SelfSubjectAccessReview.

type VersionConflictError

type VersionConflictError struct {
	Key             string
	ExpectedVersion string
	ActualVersion   string
}

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

Jump to

Keyboard shortcuts

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