Documentation
¶
Overview ¶
Package ledgerfile is the MACHINE-SCOPED file ledger: one model, the non-hosted store.
It holds exactly the records the hosted control plane holds (forge/pkg/release: Release, EnvRecord, Promotion, Gate, BundleRecord, Apply, ApplyOutcome, LocalSession), serializes them as the SAME canonical JSON, and runs the SAME rules (release.Decide, the caller's compare-and-set, release.CheckRecut). A line in one of these files is exactly what the control plane's ImportLedger accepts, which is what makes `forge ledger import --from-file-ledger` a copy rather than a translation. See pkg/release's package doc for the one-model-two-backends doctrine.
NOT IN THE CHECKOUT, AND THAT IS THE POINT. The retired backend kept promotions in .forge/promotions/<env>.jsonl inside the source tree, which is circular by construction: the commit recording "prod runs v1.5.13" cannot be in v1.5.13, because it is written after v1.5.13 was cut. It was also branch-dependent, so two worktrees of one project saw two different histories of the same environment, and a checkout that had not pulled the latest ledger commit computed every verdict against a stale truth.
So the ledger is keyed by PROJECT, not by directory:
$FORGE_LEDGER_HOME (default ~/.forge/ledger)/<project-id>/ lock flock(2); held for every read-decide-append envs.jsonl EnvRecord, append-only; the newest per env wins releases.jsonl promotions/<env>.jsonl gates/<env>.jsonl the post-promote evidence the old file ledger could not hold bundles.jsonl BundleRecord (the blob is in ./oci/) hosted-cache/<env>.jsonl last-known state of a HOSTED env's ledger (--offline-ledger) hosted-queue/<env>.jsonl promotions made offline, awaiting replay applies/<env>.jsonl Apply and ApplyOutcome lines; outcome joined by apply id sessions.jsonl LocalSession heartbeats are COMPACTED (the only non-append file) verifications.jsonl reserved for source verification (deferred with §9 to #516) oci/ OCI image layout: bundles, content-addressed
Every worktree of a project on this machine therefore shares one ledger, and "what does prod run" stops depending on which branch is checked out.
ONE WRITER AT A TIME. The retired backend documented a race rather than closing it: two writers could both decide from the same history and both append. Here every read-decide-append runs under flock(2) on ./lock, so release.Decide and the caller's CAS see the history that is actually on disk at the moment of the write. flock is tied to the open file description, so the kernel releases it even if the process dies.
THE LOCK DOES NOT SPAN MACHINES, AND THIS PACKAGE SAYS SO. $FORGE_LEDGER_HOME on NFS or SMB is REFUSED at open rather than accepted with a caveat: an advisory lock on a network filesystem is exactly the shape of failure that looks like it works until two machines promote at once. A team's answer is a control plane (which is itself a forge project), not a synced directory.
Index ¶
- Constants
- Variables
- func Home() (string, error)
- func ProjectID(name, remoteURL string) (string, error)
- type Admit
- type ApplyRecord
- type CachedPromotion
- type HostedSnapshot
- type QueuedPromotion
- type Store
- func (s *Store) AppendGates(env, promotionID string, gates []release.Gate) error
- func (s *Store) AppendPromotion(p release.Promotion, admit Admit) (release.Promotion, error)
- func (s *Store) Applies(env string) ([]ApplyRecord, error)
- func (s *Store) AttachQueuedGate(env, key string, g release.Gate) error
- func (s *Store) BeginApply(a release.Apply, supersede bool) (release.Apply, error)
- func (s *Store) Bundle(id string) (*release.BundleRecord, error)
- func (s *Store) Bundles(env string) ([]release.BundleRecord, error)
- func (s *Store) CurrentPromotion(env string) (release.Promotion, bool, error)
- func (s *Store) CutRelease(r release.Release) (created bool, err error)
- func (s *Store) Dir() string
- func (s *Store) DropQueuedPromotion(env, key string) error
- func (s *Store) Env(name string) (*release.EnvRecord, error)
- func (s *Store) Envs() ([]release.EnvRecord, error)
- func (s *Store) FinishApply(env string, o release.ApplyOutcome) error
- func (s *Store) Gates(env, promotionID string) ([]release.Gate, error)
- func (s *Store) HostedSnapshot(env string) (*HostedSnapshot, error)
- func (s *Store) ImportPromotion(p release.Promotion) error
- func (s *Store) ImportPromotionFrom(p release.Promotion, source string) error
- func (s *Store) ImportedFrom(env string) (map[string]string, error)
- func (s *Store) OCIDir() string
- func (s *Store) Promotions(env string) ([]release.Promotion, error)
- func (s *Store) PutEnv(r release.EnvRecord) error
- func (s *Store) PutHostedSnapshot(snap HostedSnapshot) error
- func (s *Store) QueuePromotion(q QueuedPromotion) error
- func (s *Store) QueuedPromotions(env string) ([]QueuedPromotion, error)
- func (s *Store) RebaseQueued(env, afterKey, promotionID string) error
- func (s *Store) RecordBundle(b release.BundleRecord) (release.BundleRecord, bool, error)
- func (s *Store) RecordReleaseMigrations(version string, m release.MigrationManifest) (recorded bool, err error)
- func (s *Store) Release(version string) (*release.Release, error)
- func (s *Store) Releases() ([]release.Release, error)
- func (s *Store) ReportSession(sess release.LocalSession) error
- func (s *Store) Sessions() ([]release.LocalSession, error)
- func (s *Store) SessionsFor(env string) ([]release.LocalSession, error)
- func (s *Store) VerificationsPath() string
Constants ¶
const DefaultHomeEnv = "FORGE_LEDGER_HOME"
DefaultHomeEnv names the environment variable that relocates the ledger.
Variables ¶
var ErrNetworkFilesystem = errors.New("ledger home is on a network filesystem")
ErrNetworkFilesystem is a ledger home on a filesystem whose lock cannot be trusted. A sentinel so a caller can classify the refusal rather than match its message.
var ErrReleaseNotFound = errors.New("release not found")
ErrReleaseNotFound is RecordReleaseMigrations naming a version never cut.
Functions ¶
func Home ¶
Home resolves the ledger home: $FORGE_LEDGER_HOME, else ~/.forge/ledger.
The environment variable is read in exactly one place, and it is not read by Open, so every other entry point into this package can be driven from a temp dir in a test.
func ProjectID ¶
ProjectID is the ledger directory name for one project: its forge.yaml name, plus a hash of its canonical remote URL when it has one.
WHY THE NAME ALONE IS NOT ENOUGH. Two unrelated checkouts can both be called "api" — a fork, a vendored copy, two customers' projects on one consultant's laptop — and merging their ledgers would have one project's promotions answer the other's "what does prod run". So the remote, which is the only durable identity a project has, is folded in.
WHY THE PATH IS NOT IN IT, WHICH IS THE WHOLE POINT. Every worktree of a project shares one id and therefore one ledger, so the answer to "what does prod run" does not depend on which directory or branch you happen to be standing in. release.CanonicalRepo strips scheme, credentials, port and the .git suffix, so git@github.com:o/r.git and https://github.com/o/r hash the same — the same repo reached two ways is one project.
A project with NO remote gets the bare name. That is correct rather than a fallback: a never-pushed project has no identity beyond its name on this machine, and inventing one from its path would re-introduce the per-directory split this design exists to remove.
Types ¶
type Admit ¶
Admit decides whether a promotion may be written, given the env's history.
It is supplied BY THE CALLER rather than implemented here, because the compare-and-set rules (and the refusal type a refusal must carry, with its exit code and --json shape) belong to the command layer that owns them for BOTH backends. This package's job is to make the decision under a lock against the real history, not to re-decide what a refusal means.
A non-nil first return is an idempotent no-op: that existing entry is the answer and nothing is appended. A non-nil error refuses the write.
type ApplyRecord ¶
type ApplyRecord struct {
Apply release.Apply
Outcome *release.ApplyOutcome
}
ApplyRecord is one apply joined to its outcome. A nil Outcome means the apply has not reported: past its deadline that reads as ABANDONED (release.DeriveApplyState), which is deliberately distinct from failed — "we could not look" is its own answer, and a stored "assume success" would be a lie (failure mode F-2).
type CachedPromotion ¶
type CachedPromotion struct {
Promotion release.Promotion `json:"promotion"`
Release *release.Release `json:"release,omitempty"`
}
CachedPromotion is one cached promotion and the release it bound. Release is nil when the release could not be read at refresh time: the promotion is then listed but cannot be deployed offline (its digests are unknown).
type HostedSnapshot ¶
type HostedSnapshot struct {
Env string `json:"env"`
// Endpoint is the control plane the snapshot came from. A snapshot is
// only ever used for the endpoint that produced it.
Endpoint string `json:"endpoint"`
// RefreshedAt is when a successful ledger read or write last produced
// this snapshot — its age is what an offline deploy prints.
RefreshedAt time.Time `json:"refreshed_at"`
// Bound is false when the control plane said the env was never promoted.
Bound bool `json:"bound"`
// Current is nil unless Bound (a Promotion refuses to decode as zero).
Current *release.Promotion `json:"current,omitempty"`
// Release is the release Current names, with the digests it pins. Nil
// when it could not be read at refresh time.
Release *release.Release `json:"release,omitempty"`
// History is the promotions BEFORE Current, newest first, each with the
// release it bound. Together with Current it is the break-glass window:
// the releases an offline deploy can roll back to. Window is how many
// promotions (Current included) the snapshot was asked to keep; zero
// means History was never read, so a reader refetches it.
History []CachedPromotion `json:"history,omitempty"`
Window int `json:"window,omitempty"`
}
HostedSnapshot is what a control plane last said an env runs.
type QueuedPromotion ¶
type QueuedPromotion struct {
// Key identifies this entry. It is the idempotency key of the replay's
// evidence, so a replay interrupted after the control plane accepted it
// converges instead of recording twice.
Key string `json:"key"`
Env string `json:"env"`
Endpoint string `json:"endpoint"`
Release string `json:"release"`
// ExpectedPromotionID is the promotion the cache said was current when
// this was queued; the replay compare-and-sets against it. Empty means
// the cache said the env was never promoted.
ExpectedPromotionID string `json:"expected_promotion_id,omitempty"`
PromotedBy release.Actor `json:"promoted_by,omitempty"`
Note string `json:"note,omitempty"`
// Targets is the deploy's scope (release.Promotion.Targets), replayed
// with it so the control plane records what the offline deploy moved.
Targets []string `json:"targets,omitempty"`
QueuedAt time.Time `json:"queued_at"`
// CacheRefreshedAt is the age of the cache this was decided from.
CacheRefreshedAt time.Time `json:"cache_refreshed_at"`
// AfterKey is the queued record this one was made on top of, when an
// earlier offline deploy in the same outage had not replayed yet (a second
// rollback). The replay sends it only once that record has landed, and
// compare-and-sets against the promotion that record created.
AfterKey string `json:"after_key,omitempty"`
// Gates are the evidence recorded after the apply ran (its verdict).
Gates []release.Gate `json:"gates,omitempty"`
}
QueuedPromotion is a promotion made while the control plane was unreachable, waiting to be replayed.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is one project's machine ledger. Returned as a concrete type — accept interfaces, return structs — so a caller that needs only promotions takes only the methods it uses, and the consumer declares the narrow interface (internal/cli/binding_store.go does exactly that).
A Store holds no open file and no cached history: every operation opens, locks, reads, decides, writes and closes. That costs a few syscalls and buys the thing a cache cannot have — a decision made against the history that is on disk right now, under a lock, rather than one read earlier.
func Open ¶
Open prepares the ledger directory for a project and returns a Store bound to it.
home is passed in rather than read from the environment HERE so that tests are hermetic without t.Setenv (which cannot be used in a parallel test): the caller resolves the home once with Home and hands it over. That is also why Open takes the already-derived project id.
func (*Store) AppendGates ¶
AppendGates records post-promote evidence for env.
THE OLD FILE LEDGER COULD NOT HOLD THIS AT ALL. A promotion line is already written when the wait, the smoke test or the manual sign-off finishes, and an append-only line cannot be edited — so release.Promotion documents that "a file ledger leaves RecordedGates empty". A separate per-env gates file fixes that without rewriting history: the evidence is joined to its promotion by id at read time.
func (*Store) AppendPromotion ¶
AppendPromotion records p under admit's rules and returns the entry the ledger now holds: the newly appended one, or — for an idempotent retry — the EXISTING entry, unchanged.
THE LOCK IS THE POINT. The history is read, the decision is made and the line is appended inside ONE critical section, so two concurrent promoters cannot both decide from the same history and both append. That is the race the retired in-checkout backend documented and declined to close ("CONCURRENCY, STATED RATHER THAN PATCHED"); under this lock, N concurrent appenders of the same move produce exactly one line, and the losers each get back the winner's entry.
func (*Store) Applies ¶
func (s *Store) Applies(env string) ([]ApplyRecord, error)
Applies returns env's applies, each joined to its outcome, OLDEST FIRST.
func (*Store) AttachQueuedGate ¶
AttachQueuedGate adds g to the queued promotion key. A key that is no longer queued is an error: evidence with nothing to attach to is lost, and the caller should say so.
func (*Store) BeginApply ¶
BeginApply records the start of an apply.
It REFUSES when another apply for the same env is still in flight, unless supersede is set — the same semantics the hosted ledger enforces under its env row lock (failure mode F-5). Two appliers racing one env is the case the lock exists for.
func (*Store) Bundle ¶
func (s *Store) Bundle(id string) (*release.BundleRecord, error)
Bundle returns one bundle by id, or (nil, nil) when it is unknown.
func (*Store) Bundles ¶
func (s *Store) Bundles(env string) ([]release.BundleRecord, error)
Bundles returns every bundle for one env, oldest first.
func (*Store) CurrentPromotion ¶
CurrentPromotion is env's newest promotion, and whether it has one. "Never promoted" is a normal state, so it is a bool rather than an error — distinct from a ledger that could not be read at all.
func (*Store) CutRelease ¶
CutRelease records r. created is false when an identical release already held the version (an idempotent retry); a DIFFERENT artifact set under the same version is release.ErrReleaseConflict.
A RELEASE IS IMMUTABLE, and release.CheckRecut is the one implementation of that rule both backends call — here under the lock, and on the hosted side under a unique index plus an append-only trigger. One version label meaning two digest sets would void every guarantee promotion rests on.
func (*Store) DropQueuedPromotion ¶
DropQueuedPromotion removes key from env's queue. Idempotent.
func (*Store) Env ¶
Env returns the newest declaration for one env, or (nil, nil) when it was never declared.
func (*Store) FinishApply ¶
func (s *Store) FinishApply(env string, o release.ApplyOutcome) error
FinishApply records an apply's outcome. Idempotent: re-reporting the SAME outcome is a retry (a lost response, a re-run), while a DIFFERENT outcome for an apply that already reported is refused — an apply ended once, and two answers would make the record meaningless.
func (*Store) HostedSnapshot ¶
func (s *Store) HostedSnapshot(env string) (*HostedSnapshot, error)
HostedSnapshot returns env's snapshot, or (nil, nil) when none was ever taken.
func (*Store) ImportPromotion ¶
ImportPromotion records a promotion from another ledger VERBATIM: its id, its timestamp and its gates exactly as they were.
IT IS NOT AppendPromotion, and the difference is the point. A promote DECIDES — release.Decide collapses a re-promote of the current release to a no-op, the CAS refuses a stale write, and the backend stamps a fresh id and time. Importing history must do none of that: a ledger's past contains consecutive promotions of one release, promotions made from a state this machine never saw, and ids other systems already reference. Running them through the promote path would silently drop lines (every idempotent-looking pair) and renumber the rest.
Preserving the id is also what lets the import be re-run: the unimported-checkout check in internal/cli matches on exactly this id, so a resumed import converges instead of duplicating.
func (*Store) ImportPromotionFrom ¶
ImportPromotionFrom records a promotion from another ledger verbatim — id, timestamp, actor and gates exactly as they were — and notes where the line came from.
source is free-form provenance, and the shape the design names is "git:<path>@<blob sha>": the file AND the exact bytes, so the import can be re-derived from the repository rather than taken on trust. An empty source is refused, because a record that says "imported" without saying from where is strictly less useful than one that does not claim to be imported at all.
IT PRESERVES THE ID, and that is a contract, not a convenience. The unimported-checkout refusal in internal/cli matches a checkout's promotion against the ledger BY ID, so an import that minted fresh ids would leave every record looking absent and the refusal would never go quiet. It is also what makes the import idempotent: a second run finds the id already present and appends nothing.
func (*Store) ImportedFrom ¶
ImportedFrom is the source each of env's promotions was imported from, keyed by promotion id. A promotion this machine made itself is absent, not empty: "recorded here" and "imported from somewhere unnamed" are different facts and a caller must be able to tell them apart.
Read as a raw map rather than through release.Promotion, because the key deliberately is not a field on that type.
func (*Store) OCIDir ¶
OCIDir is where bundle blobs live: an OCI image layout, content-addressed. internal/bundle owns its contents; this package owns only its location, so the two halves of a bundle (the record and the blob) sit together.
func (*Store) Promotions ¶
Promotions returns env's promotions OLDEST FIRST. A missing log is an empty history.
func (*Store) PutEnv ¶
PutEnv records an env's declaration. Append-only: the NEWEST line per env wins, so a re-declaration from a later render supersedes an earlier one without rewriting it. That keeps "what did this env's render say, and when" answerable, which is the whole reason the declaration is recorded rather than recomputed.
func (*Store) PutHostedSnapshot ¶
func (s *Store) PutHostedSnapshot(snap HostedSnapshot) error
PutHostedSnapshot replaces env's snapshot. Atomic: a reader sees the old snapshot or the new one, never a torn file.
func (*Store) QueuePromotion ¶
func (s *Store) QueuePromotion(q QueuedPromotion) error
QueuePromotion appends q to env's queue. Idempotent on Key.
func (*Store) QueuedPromotions ¶
func (s *Store) QueuedPromotions(env string) ([]QueuedPromotion, error)
QueuedPromotions returns env's queue, oldest first.
func (*Store) RebaseQueued ¶
RebaseQueued re-points the records queued on top of afterKey at the promotion that record created, once it has landed. Persisted so a replay interrupted between two records resumes from the right expectation.
func (*Store) RecordBundle ¶
func (s *Store) RecordBundle(b release.BundleRecord) (release.BundleRecord, bool, error)
RecordBundle records a bundle. Idempotent on (env, digest): a bundle IS its content, so re-recording the same digest for the same env is a retry and returns the record already held. That is what makes a push whose RecordBundle failed safely re-runnable (failure mode F-3).
func (*Store) RecordReleaseMigrations ¶ added in v1.0.0
func (s *Store) RecordReleaseMigrations(version string, m release.MigrationManifest) (recorded bool, err error)
RecordReleaseMigrations records m as the migration manifest of a release cut without one — the backfill for releases cut before the cut recorded it. recorded is false when the release already holds an equal manifest; a DIFFERENT one is release.ErrReleaseConflict, because a manifest, once recorded, is what every later deploy judged.
It APPENDS the release again with the manifest set: the log is append-only, and the newest line for a version is the one releasesLocked keeps. The manifest is evidence, not identity (Release.SameContent ignores it), so this does not make the release a different one.
func (*Store) Release ¶
Release returns one release by version, or (nil, nil) when it was never cut — the caller decides whether that is an error.
func (*Store) Releases ¶
Releases returns every release in the order it was cut, OLDEST FIRST.
NOT SORTED NEWEST-FIRST HERE, deliberately. "Newest" is a semver-then- timestamp judgement that internal/cli already implements once (sortReleasesNewestFirst), and both backends' List funnel through it. A second comparator in this package could disagree with that one about which release is latest, which is the kind of split that shows up as two forge commands naming different "latest" releases for one project.
func (*Store) ReportSession ¶
func (s *Store) ReportSession(sess release.LocalSession) error
ReportSession records or refreshes a local-stack presence row.
THE ONLY COMPACTED FILE. Sessions are heartbeats — every 60s while a stack supervises — so appending each one would grow without bound and say nothing: the useful fact is the CURRENT state of each session, not every time it was seen. So a report replaces the row for its (env, host, worktree) identity, written through a temp file and renamed, under the lock. Presence is observation, never a promotion and never a deploy target (owner decision O-8).
func (*Store) Sessions ¶
func (s *Store) Sessions() ([]release.LocalSession, error)
Sessions returns every recorded session. A reader decides which are live (release.LocalSession.Live) and which have gone quiet (Stale) — the store does not filter, because "no sessions" and "sessions I decided not to show" must not render alike.
func (*Store) SessionsFor ¶
func (s *Store) SessionsFor(env string) ([]release.LocalSession, error)
SessionsFor returns the sessions of one env.
func (*Store) VerificationsPath ¶
VerificationsPath is where source verifications will be recorded (verifications.jsonl, §12.3). The file is part of the documented layout, and the records that go in it arrive with protected environments (control-plane #516) — until then nothing writes it, and this names the location so the layout has one owner rather than two guesses.