Documentation
¶
Overview ¶
Package deployarchive captures and restores a billet deployment as ONE unit.
THE UNIT IS FOUR THINGS AND THEY ARE USELESS APART. The ledger, the deployment identity, the GitHub App private key and the node-wire certificate authority each depend on the others to mean anything:
- a ledger without its identity is a fresh authority that cannot see the compute the old one launched, so it reaps live jobs as orphans;
- an identity without the CA cannot issue a node certificate, and minting a replacement authority drops every node in the fleet at once;
- a CA without the App key cannot get a token, so nothing is ever scheduled.
Restoring a subset produces a deployment that looks healthy and is not, which is why the requirement is worded as a REFUSAL rather than a warning. This package refuses; it never repairs and never guesses.
A POSTGRESQL LEDGER IS THE ONE PIECE BILLET DOES NOT CARRY, and saying so is the whole of schema 2. SQLite's VACUUM INTO produces a consistent copy of the entire ledger as one file; there is no equivalent billet should own for PostgreSQL, because a consistent copy there is pg_dump or the provider's snapshot — the operator's to run and to restore. Copying rows through billet's own connection would produce an archive that LOOKS like a backup and is not.
SO THE ARCHIVE RECORDS THE LEDGER AS EXTERNAL RATHER THAN OMITTING IT SILENTLY, and that distinction is the feature. Until this existed the command FAILED OUTRIGHT on such a deployment, so the half billet does own was not captured either — and for a control plane built by the control-plane-postgres module that is the only recovery path there is: the module has no ledger volume by design, its root volume is delete_on_termination, and the App private key is issued exactly once. An identity-only archive is not a lesser backup of the same thing; it is the whole of what billet is entitled to copy, paired with a statement of where the rest lives.
WHAT THE RESTORE SIDE OWES IN RETURN is a refusal. Pairing the two halves is the invariant, so an identity-only archive may not be installed onto a target whose config says the ledger is local, and may not be installed at all without the operator asserting the ledger it belongs to is back — which is the one thing billet cannot check, because the database is on the other end of a DSN.
It prints nothing. Every refusal is returned as a lifeops.Refusal so the command layer renders them all at once — an operator who has to re-run a command to find the next problem is paying for a diagnostic that already knew.
Index ¶
- Constants
- Variables
- func AuthorityEntry(name string) string
- func CheckDestinationPlace(dest, stateDir string) error
- func EntryAppKeyFor(target string) string
- func Finish(ctx context.Context, plan Plan) error
- func JournalPath(stateDir string) string
- func LedgerPath(stateDir string) string
- func LedgerSidecarPaths(ledgerPath string) []string
- func PrepareDestination(dest, stateDir string) error
- func Upload(ctx context.Context, a *Archive, store ObjectStore, prefix string) ([]string, error)
- type AbandonResult
- type Action
- type Archive
- type AuthorityFacts
- type BackupRequest
- type Disposition
- type ExternalLedger
- type FileRecord
- type GitHubIdentity
- type Intent
- type LedgerFacts
- type Manifest
- type ObjectStore
- type Plan
- type Progress
- type RestoreRequest
- type Result
- type Source
- type Target
- type TargetIdentity
- type TargetKey
- type TargetPath
Constants ¶
const ( EntryManifest = "manifest.json" EntryLedger = "ledger/billet.db" EntryIdentity = "identity/deployment-id" EntryAppKey = "github/app-private-key.pem" EntryConfig = "config/billet.yaml" )
Entry names. STABLE ACROSS VERSIONS: they appear in a manifest that a later billet reads, so renaming one is a schema change.
const FenceReason = "billet local restore"
FenceReason is what a restore writes into the maintenance fence.
EXACT AND STABLE, because ClearMaintenanceFence compares it: a restore clears only the fence a restore established, so it can never reopen a ledger somebody else — an Ansible host upgrade, say — had closed for their own operation.
const Kind = "billet-deployment-backup"
Kind is stamped in the manifest so a directory of unrelated files cannot be handed to restore and produce a confusing failure three checks later.
const RecoverFenceReason = "billet local recover"
RecoverFenceReason is what `billet local recover` writes instead.
ITS OWN STRING, because ClearMaintenanceFence compares the reason exactly: a command clears only the fence it established, so it can never reopen a ledger somebody else closed. The consequence is worth stating rather than discovering — a crashed recover is finished or abandoned by `billet local recover`, not by `billet local restore`, and both diagnostics say which.
const Schema = 3
Schema is the archive format this build WRITES.
A READER ACCEPTS EXACTLY THE SCHEMAS IT KNOWS — see readableSchemas, which is a set rather than this constant. Publishing a version a reader cannot read is a flag day nobody can pull their way out of, so the writer moves only after every build that might read one already understands the new number; that is the rule the runner-image manifest follows and the reason the set exists at all.
2 ADDS THE EXTERNAL LEDGER. Schema 1 could not express one: its entry set REQUIRES ledger/billet.db, so an archive without it is refused as incomplete — correctly, on schema 1, where an absent ledger really is a missing piece rather than a statement about where the ledger lives.
Variables ¶
var ErrObjectExists = errors.New("deployarchive: that object already exists")
ErrObjectExists is what a store's no-clobber Put reports for a key that is already occupied.
DECLARED HERE SO A RESUME CAN RECOGNISE IT. A store refuses to replace an object — which is the property that stops anything overwriting a backup — and without a name for that refusal an upload interrupted half way could never be retried: the second run would stop on the first entry the first run had already published. internal/archivestore's own sentinel wraps this.
Functions ¶
func AuthorityEntry ¶
AuthorityEntry is the archive name for one allowlisted authority file.
func CheckDestinationPlace ¶
CheckDestinationPlace refuses a destination for what it IS rather than for what is in it: not absolute, inside the state directory, a symlink, or not a directory at all.
SPLIT OUT SO ONE CALLER CAN KEEP THE PLACE RULES AND DROP THE EMPTINESS ONE. A fetch that is repeating itself — the operator's first attempt was interrupted and the archive is KEPT, deliberately — lands on a directory that already holds the very archive it is about to fetch, and refusing that is a retry with nowhere to go. Nothing here softens: whoever skips the emptiness check has to prove the contents are the same archive.
func EntryAppKeyFor ¶ added in v0.10.0
EntryAppKeyFor is the archive name of a FURTHER target's App key.
The default target's key keeps EntryAppKey, the name every archive before schema 3 wrote, so an archive of a single-target deployment is the archive it always was; a further target's key sits under the target's name. Schema 3 adds these entries and nothing else.
func Finish ¶
Finish takes a directory Execute deliberately left closed and opens it.
ONLY A RECOVERY REACHES THIS, and only after its caller has made the restored deployment safe to start — which for `billet local recover` means sealing the ledger the archive brought back, whose admission row is the one it had when the backup was taken. Execute cannot do that itself: sealing means writing to the ledger, the ledger is fenced, and the only handle that crosses a fence takes the directory lock Execute is holding. So the sequence is Execute, caller seals, Finish — and every instant in between is one where the fence still refuses a control plane.
func JournalPath ¶
JournalPath is where an interrupted restore records itself, so a command can name it.
func LedgerPath ¶
LedgerPath is where a deployment's ledger lives.
EXPORTED SO THE NAME HAS ONE HOME, for the reason state.DirectoryLockPath and wirecert.AuthorityLockPath are: three places compute it now — the planner, the executor's supersede, and an abandon putting one back — and a second literal among them is a ledger somebody moves aside and nobody restores.
func LedgerSidecarPaths ¶
LedgerSidecarPaths names what SQLite keeps beside a ledger.
EXPORTED SO THE SUFFIXES HAVE ONE HOME. The command layer has to hand these back to the service account after a privileged restore, and a second copy of "-wal" and "-shm" somewhere else is a file a control plane cannot write and nothing explains.
func PrepareDestination ¶
PrepareDestination creates a directory an archive may be written into, and refuses the places one must not be.
INSIDE THE STATE DIRECTORY IS THE ONE THAT MATTERS. A backup written there puts a second copy of the CA key and a second ledger under the path a restore scans, a `billet local uninstall` names as preserved, and the CA allowlist walks — and the ledger snapshot would then be inside the directory it is a snapshot of.
EXPORTED FOR THE FETCH PATH, which puts an archive on disk without a backup having written it: an archive downloaded from a bucket is the same two private keys under the same rules, and giving it a second, laxer implementation is how one of them ends up inside the state directory a restore scans.
func Upload ¶
Upload copies a verified archive to an object store.
THE MANIFEST GOES LAST, and that ordering is the whole crash guarantee. A manifest is what makes a prefix an archive — Open refuses a directory without one as "not a billet backup", and a listing counts a prefix only when its manifest is there — so an upload interrupted anywhere leaves entries that nothing will offer an operator as a backup. Written first, an interruption would leave a prefix that ADVERTISES a complete deployment and holds part of one, which is the failure the whole package is built around.
EVERY ENTRY IS RE-DIGESTED ON THE WAY OUT. Open verified this archive when it was opened, and these bytes are read again from a pathname afterwards — the same reason copyFile re-checks on the way IN. An archive that changed under billet must not be published as one it vouched for.
Types ¶
type AbandonResult ¶
type AbandonResult struct {
Removed []string
// Kept names a path the journal recorded but that no longer holds what this
// restore put there, so it was left alone.
Kept []string
// Restored names a ledger a recover had moved aside and this put back. It is
// the operator's own capacity record, so an abandon that did not return it
// would leave the deployment with none.
Restored []string
}
AbandonResult is what an abandon did.
func Abandon ¶
Abandon undoes an interrupted restore, and only what that restore created.
THE JOURNAL IS THE WHOLE AUTHORITY FOR WHAT MAY BE DELETED. Nothing is removed because it looks like it came from the archive, because it is in the way, or because the directory ought to be empty — only paths this run recorded before creating them.
AND EACH ONE IS RE-READ FIRST. A path that no longer holds the bytes the archive carries is somebody else's file now, and is kept and named. That matters most for the App key: GitHub issues it once, so the only key an abandon may delete is one it can prove is a duplicate of the copy still sitting in the archive.
type Action ¶
type Action struct {
// Entry is the archive entry this action installs, and it is the action's
// IDENTITY: exactly one action exists per entry, and it is never empty. The
// ledger's sidecars are not actions — they are removed by name at execution
// time, because what the planner saw is stale by then. actionsDiffer keys on
// this, so a duplicate or an empty one would let a changed action hide.
Entry string
// Path is where it lands.
Path string
// What names the item in the words an operator uses.
What string
Disposition Disposition
}
Action is one thing a restore would do.
type Archive ¶
Archive is a verified backup directory.
VERIFIED AT OPEN, ONCE. Everything downstream — the planner, the --dry-run report and the executor — works from this value, so there is no path on which a file is installed that was not digest-checked, and no second reading that could see different bytes than the one that was checked.
func Fetch ¶
Fetch downloads an archive into dir and opens it.
NOTHING IS TRUSTED ON THE WAY IN. The manifest arrives first and is decoded before anything else is asked for — so a schema this build does not read is refused before a single credential is written — and every entry name it declares goes through entryPath, which refuses one that escapes the directory. What lands is then handed to Open, which is the ONE verifier: digests, the closed entry set, and the cross-checks between the pieces. There is no second implementation of that here, because two would eventually disagree about what is safe.
func Open ¶
Open reads an archive and proves it is whole before anything acts on it.
NOTHING IS TRUSTED BECAUSE OF ITS NAME. Every declared file is checked against the manifest's digest, every undeclared file is a refusal, and the pieces are checked against EACH OTHER: the identity file must agree with the manifest, the CA pair must hold together, and the authority must name the deployment the archive claims. An archive is read on the worst day of a deployment's life, off media nobody has verified since it was written.
func (*Archive) AuthorityNames ¶
AuthorityNames lists the authority files this archive carries, in publication order.
THE ORDER IS THE CRASH ARGUMENT, not alphabetical convenience, and the two pairs order OPPOSITELY because one is required and the other is not. The CURRENT key leads its certificate, matching how one is created: an interruption between them must leave the half-initialised state billet REFUSES loudly rather than a certificate whose key belongs to something else. The PREVIOUS certificate leads its key, matching how a rotation publishes one: there the certificate says a rotation was started and the key says it is committed, so an interruption between them has to leave a state a control plane treats as inert rather than one no rotation can produce. The marker is last, because its whole job is to make a LATER absence mean loss — written first, an interruption would leave a directory claiming to have had an authority that was never installed, which is the one state ErrAuthorityLost cannot be talked out of.
publicationRank is what the executor actually sorts by; this list must agree with it, and a caller that depends on the order should say so.
func (*Archive) LedgerPath ¶
LedgerPath is where the snapshot lives inside the archive.
func (*Archive) Name ¶
Name is what this archive is called wherever it is stored beside others.
THE INSTANT IT WAS TAKEN, AND A DIGEST, because the instant alone is not unique. CreatedAt is RFC 3339 to the SECOND, so two backups of one deployment taken within the same second — a script running them back to back — produce the same name, and the second one's upload is then refused by the no-clobber write it collides with. The digest makes the name unique while keeping it STABLE for one archive, which is what lets a resumed upload and a resumed recovery both recompute the name they already used.
Short, because it is a filename and a key an operator types: eight hex characters over a manifest that already carries every entry's digest.
type AuthorityFacts ¶
type AuthorityFacts struct {
// Fingerprint is the issuing authority's public-key fingerprint, in the
// shape `billet ca show` prints so an operator can compare the two by eye.
Fingerprint string `json:"fingerprint"`
NotAfter string `json:"not_after"`
// Rotating says a rotation was running when this was taken, which means the
// previous authority's key travels in the archive too — it is what signs
// what the control plane PRESENTS until the fleet has renewed.
Rotating bool `json:"rotating"`
PreviousFingerprint string `json:"previous_fingerprint,omitempty"`
PreviousNotAfter string `json:"previous_not_after,omitempty"`
UnexpectedFilesPresent []string `json:"unexpected_files_present,omitempty"`
}
AuthorityFacts is what the archive holds of the node-wire CA.
type BackupRequest ¶
type BackupRequest struct {
// Dest is the directory to create. Must be absolute.
Dest string
// StateDir is the control plane's state directory.
StateDir string
// ConfigPath is where the deployment's billet.yaml lives.
ConfigPath string
// DeploymentID is what state.PeekDeploymentID answered. Required: a state
// directory with no identity is not a deployment to back up.
DeploymentID string
// GitHub is the DEFAULT target's App identity from the config.
GitHub GitHubIdentity
// AppKeyPEM is the default target's App private key, already validated by
// the caller.
AppKeyPEM []byte
// Targets are the further targets the deployment serves, each with its key.
//
// ALL OR NONE, like every other piece: a backup that captured one target's
// key and not another's restores a control plane that serves half its
// owners, and the half it does not serve fails hours later with a bare 401.
Targets []TargetKey
// ConfigBody is the billet.yaml as it stands. Copied for REFERENCE; restore
// never installs it, because these paths are the source host's.
ConfigBody []byte
// Snapshot writes a consistent ledger copy to the absolute path it is given.
// Nil exactly when ExternalLedger is set.
Snapshot func(ctx context.Context, dest string) error
// ExternalLedger says the ledger is not billet's to copy, and describes the
// one this identity belongs to. Set exactly when Snapshot is nil.
ExternalLedger *ExternalLedger
// Now is the clock, so a test can pin the manifest's timestamp.
Now func() time.Time
// Hostname is recorded in the manifest as provenance.
Hostname string
}
BackupRequest is everything Write needs that it must not go and find for itself.
THE APP KEY ARRIVES AS BYTES, deliberately. Reading it correctly is a page of rules — one descriptor, O_NONBLOCK so a FIFO cannot hang the process, regular file, bounded, mode-checked, and actually parsed — and those rules already exist in the command layer, where `billet check` and `billet server` share them. A second implementation here would be a second thing to keep right.
Snapshot is the ledger copy, injected for the same reason: DB.SnapshotInto is a method on an open handle, and which handle to open (admin, against a possibly-live control plane) is the command's decision rather than this package's.
func (BackupRequest) Format ¶ added in v0.10.0
func (req BackupRequest) Format(s fmt.State, _ rune)
Format makes every verb safe.
func (BackupRequest) GoString ¶ added in v0.10.0
func (req BackupRequest) GoString() string
GoString covers %#v, which does not consult String.
func (BackupRequest) LogValue ¶ added in v0.10.0
func (req BackupRequest) LogValue() slog.Value
LogValue renders the request and never a key.
func (BackupRequest) MarshalJSON ¶ added in v0.10.0
func (req BackupRequest) MarshalJSON() ([]byte, error)
MarshalJSON renders the request and never a key.
func (BackupRequest) String ¶ added in v0.10.0
func (req BackupRequest) String() string
String renders the request and never a key.
type Disposition ¶
type Disposition int
Disposition is what a restore would do with one item.
const ( // Install means the destination is free and the archive's copy goes there. Install Disposition = iota // AlreadyPresent means the destination already holds exactly these bytes, so // there is nothing to do. This is what makes a resume after an interruption // converge rather than refuse. AlreadyPresent // ReplaceEmptyLedger is the ONE destructive disposition a RESTORE can reach, // and it is reachable only for a ledger `billet check` created on a host // nobody has commissioned: no deployment identity, no authority, and every // table provably empty. ReplaceEmptyLedger // SupersedeLedger renames a POPULATED ledger aside and installs the archive's // in its place. Only `billet local recover` reaches it, only for the // deployment the archive already belongs to, and only after that deployment // has been sealed and proved to hold nothing. // // THE OLD LEDGER IS RENAMED, NEVER UNLINKED. It is the only record of the // jobs this operation fails, and an operator who has just accepted losing // them needs to be able to say which they were. SupersedeLedger )
func (Disposition) String ¶
func (d Disposition) String() string
type ExternalLedger ¶
type ExternalLedger struct {
// Backend names the engine, e.g. "postgres". Required: an external ledger
// nothing can name is a claim with no content.
Backend string
// DSNEnv is the environment variable holding the connection string. The
// NAME, never the value.
DSNEnv string
// Migrations is what the live ledger had applied when this ran.
Migrations []state.AppliedMigration
}
ExternalLedger describes a ledger the archive deliberately does not contain.
THE CALLER ESTABLISHES ALL THREE, because each is a fact about the deployment rather than about the archive: which engine, which environment variable the config names for the connection string, and what the live ledger had applied when the backup ran.
MIGRATIONS COME FROM THE LIVE DATABASE HERE, which is the one place this package reads a schema from something that is still moving. There is no snapshot to read back from, and the alternative — recording nothing — would leave a restore unable to refuse an archive whose ledger a newer billet had migrated past this one. See LedgerFacts for what that costs and why the direction is safe.
type FileRecord ¶
type FileRecord struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
}
FileRecord is one entry's identity in the manifest.
THE DIGEST IS THE POINT. A restore reads an archive on the worst day of a deployment's life, off media nobody has verified since it was written, and the files it is about to publish are credentials. Nothing is installed on the strength of a filename.
type GitHubIdentity ¶
type GitHubIdentity struct {
// Org is the organization, or Repository the owner/name, exactly one of
// them: a target is one or the other.
Org string `json:"org,omitempty"`
Repository string `json:"repository,omitempty"`
AppID int64 `json:"app_id"`
ClientID string `json:"client_id,omitempty"`
InstallationID int64 `json:"installation_id"`
}
GitHubIdentity is the App this deployment's key belongs to.
RECORDED SO A RESTORE CANNOT PAIR A KEY WITH UNRELATED CONFIGURATION. The key file itself says nothing about which App it is for, so installing it beside a config naming a different app_id produces a deployment that authenticates as nothing and reports a bare 401 on its first poll.
func (GitHubIdentity) IsZero ¶ added in v0.10.0
func (g GitHubIdentity) IsZero() bool
IsZero reports an identity naming nothing.
func (GitHubIdentity) Same ¶
func (g GitHubIdentity) Same(other GitHubIdentity) bool
Same reports whether two App identities describe the same App on the same target.
ClientID IS NOT COMPARED. It is optional — every config written before the field existed omits it — so requiring it to match would refuse a correct restore on the strength of a field one side simply never recorded.
func (GitHubIdentity) String ¶
func (g GitHubIdentity) String() string
type Intent ¶
type Intent int
Intent is which operation a plan is for.
IT TRAVELS ON THE PLAN because the executor RE-DERIVES the plan inside its exclusion and acts only on that one — so the intent has to be recoverable there, or a recover would re-plan as a restore and refuse its own target.
const ( // RestoreFresh is `billet local restore`: put a deployment onto a host that // is not already one. RestoreFresh Intent = iota // ReplaceLedger is `billet local recover`: put a deployment back over ITSELF, // replacing a ledger that has rows in it. // // A SEPARATE OPERATION RATHER THAN A FLAG, and this is why: the restored // ledger has no lease for compute created after the backup, so node recovery // destroys those instances as orphans and GitHub does not requeue a job that // already started. That is not something a flag on the ordinary path should // be able to reach. ReplaceLedger )
func (Intent) String ¶
String is the word an operator sees, and it is also what the journal records and what two readers compare — so an unrecognised value gets its OWN spelling rather than the nearest real one. Aliasing it to "restore" made every invalid Intent a valid record of the wrong operation, in the field that decides which abandon may act.
type LedgerFacts ¶
type LedgerFacts struct {
// External says the ledger is not in this archive and never was.
//
// NOT DERIVED FROM THE ABSENCE OF THE ENTRY, which would make a truncated
// archive and a deliberate one the same thing — and the truncated one would
// then restore an identity paired with nothing, which is precisely the
// half-deployment this package exists to refuse.
External bool `json:"external,omitempty"`
// Backend names the engine the external ledger lives in, e.g. "postgres".
// Empty on a schema-1 archive and on any archive carrying its own ledger.
Backend string `json:"backend,omitempty"`
// DSNEnv is the ENVIRONMENT VARIABLE the source config named for the
// connection string, and it is the variable's name rather than its value.
// A DSN carries a password; recording one here would put it in a file that
// travels off-site, which is the rule PostgresStateConfig already follows.
//
// INFORMATIONAL. A restore reports it and does not require the target to
// agree: naming the variable differently on a replacement host is ordinary,
// and refusing that would be refusing a correct restore over a label.
DSNEnv string `json:"dsn_env,omitempty"`
Migrations []state.AppliedMigration `json:"migrations"`
}
LedgerFacts is what the archive says about the ledger.
READ BACK FROM THE COMPLETED SNAPSHOT, never from the live database beside it. The live one is moving: a control plane restarted onto a newer binary can migrate between the snapshot being taken and the manifest being written, and the manifest would then describe a schema the archive does not contain.
EXCEPT WHEN THE LEDGER IS EXTERNAL, where there is no snapshot to read back from and the live database is the only source there could be. What that costs is stated rather than hidden: the list is what the ledger carried at the moment of the backup and the database goes on migrating afterwards, so it is PROVENANCE and never a proof. The one thing it is still allowed to decide is the refusal in checkBinaryUnderstandsArchive — a stale list can only be BEHIND the truth, so that check may under-refuse and can never refuse a restore it should have allowed.
func (LedgerFacts) HighestVersion ¶
func (l LedgerFacts) HighestVersion() int
HighestVersion is the newest migration the snapshot carries.
func (LedgerFacts) IsExternal ¶
func (l LedgerFacts) IsExternal() bool
IsExternal reports whether the ledger lives outside this archive.
type Manifest ¶
type Manifest struct {
Schema int `json:"schema"`
Kind string `json:"kind"`
CreatedAt string `json:"created_at"`
BilletVersion string `json:"billet_version"`
// DeploymentID is the identity every other piece belongs to. It is repeated
// here rather than only stored in identity/deployment-id so that a mismatch
// between the manifest and the file is itself detectable.
DeploymentID string `json:"deployment_id"`
Source Source `json:"source"`
// GitHub is the DEFAULT target's App identity, the one the `github:` block
// declares, whose key travels as EntryAppKey.
GitHub GitHubIdentity `json:"github"`
// Targets are the FURTHER targets the deployment serves, each with its own
// App and its key under EntryAppKeyFor. Schema 3; empty on every archive of
// a single-target deployment, which is what keeps such an archive readable
// by the billet that wrote it.
Targets []TargetIdentity `json:"targets,omitempty"`
Authority AuthorityFacts `json:"authority"`
Ledger LedgerFacts `json:"ledger"`
Files []FileRecord `json:"files"`
}
Manifest describes an archive well enough that a restore can refuse a wrong one WITHOUT opening anything it is about to install.
func Write ¶
func Write(ctx context.Context, req BackupRequest) (Manifest, error)
Write captures a deployment into a new archive directory.
IT TAKES THE AUTHORITY LOCK ITSELF. `billet ca rotate` mutates five files in sequence, so a backup that read them without the lock could capture a key from one generation beside a certificate from another — an archive that loads cleanly and verifies nothing, discovered on the day it is restored. The lock is taken here rather than by the command because this is the exported entry point, and a rule enforced only at the CLI has a second way in that does not enforce it.
The ledger snapshot is taken INSIDE the same lock, so the authority in the archive and the certificate records in the ledger describe one moment.
type ObjectStore ¶
type ObjectStore interface {
Put(ctx context.Context, key string, body []byte) error
Get(ctx context.Context, key string) ([]byte, error)
}
ObjectStore is the narrow thing an off-box copy needs.
TWO METHODS AND NO DELETE. The absence is the point rather than an omission: a store this package could delete through is one the control plane's own credential could destroy the history with, on the very host whose loss the off-box copy exists to survive. Retention belongs to the bucket.
AN INTERFACE HERE RATHER THAN A DEPENDENCY ON internal/archivestore, so this package keeps knowing only what an archive IS, and the transport keeps knowing only how to move bytes. The command layer joins them.
type Plan ¶
type Plan struct {
Archive *Archive
Target Target
// Intent is which operation this plan is for. The executor re-plans with it
// rather than assuming a restore.
Intent Intent
Actions []Action
Refusals []lifeops.Refusal
// Superseded is where a replaced ledger is renamed to. Set only under
// ReplaceLedger, and reported so an operator knows where the record of the
// jobs they accepted losing has gone.
Superseded string
// LedgerSidecars are the -wal and -shm files that must go with a replaced
// ledger. Orphaning a -wal beside a restored billet.db would corrupt it.
LedgerSidecars []string
}
Plan is what a restore would do, and every reason it will not.
PURE. Building one opens no ledger through state.Open or state.OpenAdmin — both create and chmod directories, take the process lock and MIGRATE, so using either to ask a question would upgrade a stopped ledger on the way to telling the operator the restore is refused.
func PlanRecover ¶
PlanRecover decides what `billet local recover` would do.
THE SAME PLANNER, ONE INTENT APART, and that is the whole design: the identity, the authority and the App key are decided by exactly the code a restore uses — every one of them will read AlreadyPresent on a healthy target, and any difference is refused there as it always was. The only thing that moves is the ledger, which a restore refuses and this supersedes.
func PlanRestore ¶
PlanRestore decides what would happen, and refuses everything it must.
EVERY REFUSAL IS COLLECTED RATHER THAN RETURNED AT THE FIRST ONE. A restore is run under pressure and each re-run to discover the next problem costs an outage minutes; the command layer renders them together.
An error, as opposed to a refusal, means billet could not LOOK. Those two are kept apart on purpose: "I could not read the target" must never become "the target is empty", because what follows an empty answer is writing credentials into it.
type Progress ¶
type Progress struct {
// Present says a journal is there. A journal billet could not read counts as
// present and comes back with an error: "could not tell" is never "there is
// nothing here".
Present bool
// ArchiveDir is where the interrupted run read its archive from, which is
// what a diagnostic names.
ArchiveDir string
// ManifestSHA identifies that archive.
ManifestSHA string
// Intent is which operation wrote it, in Intent.String()'s words. Recorded
// rather than inferred from the fence, which can be gone while the journal
// is still here.
Intent string
// Phase is how far it got. See phasePublishing and its neighbours.
Phase string
}
Progress is what a state directory's journal says about an operation that stopped part-way.
IT ANSWERS BY MANIFEST RATHER THAN BY PATHNAME. An archive fetched from a bucket lands under a directory whose name a second run does not have to pick the same way, and the question a resume is asking is "is this the same backup", which the manifest digest answers and a pathname does not.
func InProgress ¶
InProgress reports whether a state directory holds an interrupted restore, and which archive it was from.
func (Progress) AbandonFinishesIt ¶
AbandonFinishesIt reports whether `<that operation> --abandon` would actually undo something here, which is what a diagnostic may name.
THREE FACTS AND ALL OF THEM NARROW THE ANSWER. An abandon acts only on a journal it understands (so an absent or unreadable one is not it), only on one its OWN operation wrote (a restore's abandon clears a restore's fence and never reaches a recovery's put-back), and never on one that FINISHED. Naming the command without asking hands an operator something that reports success and moves nothing.
func (Progress) Finished ¶
Finished reports whether the operation completed and only its record remains.
type RestoreRequest ¶
type RestoreRequest struct {
Plan Plan
// InstallAppKey publishes the App private key at a path that must not
// already exist, creating it exactly once and never replacing anything.
//
// INJECTED RATHER THAN WRITTEN HERE. `billet github-app create` already owns
// that publication — a sibling reservation, an os.Link that fails rather
// than replaces, and a recovery path that never declares a credential lost
// while its bytes are still in memory — and four review rounds went into it.
// A restore installing the same kind of file must use the same code, not a
// second implementation of the same idea.
InstallAppKey func(path string, pem []byte) error
// Now is the clock, so a test can pin the journal.
Now func() time.Time
// Actor is who ran this, recorded in the journal for whoever finds it.
Actor string
}
RestoreRequest is what Execute acts on.
type Result ¶
type Result struct {
Installed []Action
Skipped []Action
Removed []string
// Resumed says a journal from an earlier interrupted run was picked up.
Resumed bool
// Strays are staging files a publication could not remove. NOT a failure —
// the destination is installed and correct — but never swallowed either: a
// second copy of a restored ledger that nothing mentions is one nobody
// finds, which is the rule the App-key installer already follows.
Strays []string
// Superseded names what a recover moved aside. It is the only surviving
// record of the work that operation failed, so it is reported rather than
// left for somebody to notice.
Superseded []string
// Unfinished says the publication succeeded and the directory is STILL
// fenced, with its journal in place, because the caller has something left to
// do behind that fence. Only a recovery reaches it; call Finish afterwards.
Unfinished bool
}
Result is what a restore did.
func Execute ¶
func Execute(ctx context.Context, req RestoreRequest) (Result, error)
Execute publishes an archive into a target state directory.
THE EXCLUSION IS THREE THINGS AND NONE OF THEM IS SUFFICIENT ALONE:
- The state-directory lock proves no CONTROL PLANE holds this directory. It proves nothing about an operator command, which opens through OpenAdmin deliberately without it.
- The maintenance fence closes the ledger to every new handle AND to handles that are already open — Tx and View consult it on entry — which is the only thing that reaches an admin command already in flight.
- The writer barrier proves that a transaction which began BEFORE the fence has finished. The fence is checked when a transaction starts, so a handle that got past it a moment earlier is still free to commit.
AND NONE OF THE THREE REACHES ANOTHER MACHINE. Restoring this archive on a second host, or under a second path, produces two authoritative controllers sharing one identity, one CA and one App credential with divergent ledgers. That fence is the operator's to establish and the command's to insist on before calling this.
PUBLICATION NEVER REPLACES THE STATE DIRECTORY. Files go in one at a time, so <stateDir>/billet.lock keeps the inode this call is holding — staging a directory and renaming it into place would leave the restorer holding the OLD lock while a control plane happily locks the new one.
type Source ¶
type Source struct {
Host string `json:"host"`
ConfigPath string `json:"config_path"`
StateDir string `json:"state_dir"`
}
Source is where the archive came from. INFORMATIONAL ONLY — a restore never acts on it, because the target's paths are the target's business.
type Target ¶
type Target struct {
ConfigPath string
StateDir string
// AppKeyPath and GitHub are the DEFAULT target's key path and identity,
// from the `github:` block.
AppKeyPath string
GitHub GitHubIdentity
// Targets are the further targets the config declares, each with the path
// its key goes to. Every one the archive carries must be here with the same
// identity, and every one here must be in the archive.
Targets []TargetPath
// LedgerBackend is what THIS host's config says its ledger is — "sqlite",
// "postgres", or empty for a config that names none (which is sqlite).
//
// COMPARED AGAINST THE ARCHIVE'S, because the halves have to be paired and
// this is the half billet can actually check. An identity-only archive
// restored onto a host whose config says the ledger is local produces a
// control plane that mints a FRESH empty ledger beside a restored identity —
// which is the same lost fleet as restoring an identity with no ledger at
// all, arriving through a config mistake instead of a missing file.
LedgerBackend string
// ExternalLedgerAttached is the operator asserting that the ledger this
// archive's identity belongs to is back and reachable.
//
// REQUIRED FOR AN IDENTITY-ONLY ARCHIVE, and it is a flag rather than a check
// because there is nothing here to check: the database is on the other end of
// a DSN this process has not been given, and its restore is pg_dump or the
// provider's snapshot — the operator's to run. Billet verifies what it can
// (the backend the config names) and asks about exactly the part it cannot.
//
// A ledger that is NOT back produces a control plane that starts against an
// empty database holding a restored identity: it advertises capacity for a
// fleet it has no record of, and reaps as orphans the compute the old one
// launched.
ExternalLedgerAttached bool
}
Target is the deployment a restore would land on.
PATHS AND IDENTITY, NOT A *config.Config. The planner has to be callable against a host whose config is being reasoned about rather than loaded, and narrowing the input is what keeps this function pure — everything it needs is visible in its signature.
type TargetIdentity ¶ added in v0.10.0
type TargetIdentity struct {
Name string `json:"name"`
GitHubIdentity
}
TargetIdentity is a further target's name and the App that serves it.
type TargetKey ¶ added in v0.10.0
type TargetKey struct {
Name string
GitHub GitHubIdentity
AppKeyPEM []byte
}
TargetKey is one further target's identity and its App private key.
func (TargetKey) Format ¶ added in v0.10.0
Format makes every verb safe: fmt consults Stringer only for some verbs and otherwise formats the fields, key bytes included.
func (TargetKey) MarshalJSON ¶ added in v0.10.0
MarshalJSON renders the target and never its key.
type TargetPath ¶ added in v0.10.0
type TargetPath struct {
Name string
AppKeyPath string
GitHub GitHubIdentity
}
TargetPath is where one further target's App key lands, and which App the config says it is.