Documentation
¶
Overview ¶
Package releasesource describes what one billet release contains and what has to be true about it before any of its bytes replace a running binary.
WHY A MANIFEST AND NOT checksums.txt. A checksum file answers "did these bytes arrive intact" and nothing else. Deciding whether a release may replace THIS process needs four more facts, and every one of them has to be readable before anything is downloaded, let alone installed: which node-wire versions the candidate speaks, which ledger schema it expects, which guest contract its images must satisfy, and which release it can be rolled back to. A rollout that learns any of those after the switch has already stopped the control plane.
WHY THE MANIFEST IS THE ONLY THING THAT NEEDS SIGNING. It carries the digest of every artifact, so one signature over it transitively covers every binary and package in the release. The large files are then checked with a hash rather than public-key arithmetic — and, more importantly, an attacker who can serve a manifest can serve digests of bytes they chose, so without the signature every other check in this package is a checksum against itself.
Index ¶
- Constants
- Variables
- func ArtifactURL(repo, tag, name string) string
- func BundleURL(repo, tag string) string
- func CanRollBack(from, to *Manifest) error
- func ChannelBundleURL(repo, channel string) string
- func ChannelURL(repo, channel string) string
- func Compatibility(m *Manifest, current Current) ([]error, error)
- func KnownChannel(name string) bool
- func ManifestURL(repo, tag string) string
- type Artifact
- type BuildRequest
- type ChannelStatement
- type Client
- func (c *Client) Download(ctx context.Context, tag string, a *Artifact, dir string) (string, error)
- func (c *Client) Manifest(ctx context.Context, tag, expectDigest string, p Policy) (*Manifest, string, error)
- func (c *Client) Resolve(ctx context.Context, channel string, p Policy) (*ChannelStatement, error)
- type Current
- type GuestContractChange
- type Manifest
- type Policy
- type Range
Constants ¶
const ( ChannelStable = "stable" ChannelCandidate = "candidate" )
The named channels billet publishes.
A CHANNEL IS A POINTER TO ONE IMMUTABLE MANIFEST, never a range or a policy. It is the only thing in the release contract that moves, which is exactly why a rollout resolves it once, records the digest it resolved to, and never consults it again — a channel advancing mid-rollout must not retarget work already underway.
const ( // ChannelSchema is the layout of a channel statement. ChannelSchema = 1 // MaxChannelBytes bounds the pointer document. MaxChannelBytes = 4 << 10 )
const ( ManifestName = "release-manifest.json" BundleName = "release-manifest.sigstore.json" )
The names the release publishes the manifest and its signature under.
const ( KindArchive = "archive" KindDeb = "deb" KindRPM = "rpm" )
The artifact kinds a reader will act on. An unknown kind is refused, because installing a .deb the way an archive is unpacked is not a degradation.
const DefaultRepo = "junioryono/billet"
DefaultRepo is the repository billet's releases come from.
const MaxArtifactBytes int64 = 512 << 20
MaxArtifactBytes bounds one published artifact at 512 MiB.
billet's own binary is around 22MB and an archive of it rather less. This is an order of magnitude above anything the release could legitimately contain, and still refuses a manifest whose size field would have an updater write until the disk filled.
const MaxArtifacts = 50
MaxArtifacts bounds how many files one release may declare.
Three platforms times an archive, a .deb and an .rpm is nine, plus checksums. Fifty is generous against that and refuses a manifest that would turn one update into thousands of requests.
const MaxManifestBytes = 256 << 10
MaxManifestBytes bounds what a reader will parse.
A manifest is a few kilobytes — a dozen artifacts and their digests. The bound exists because the document is fetched over the network before anything about the far end has been proven, and an unbounded read of an untrusted stream is how a fetch becomes a memory exhaustion.
const PublishWorkflow = ".github/workflows/release.yml"
PublishWorkflow is the workflow allowed to sign billet's releases.
NAMED, NOT JUST THE REPOSITORY, for the reason imagesource.PublishWorkflow gives: a certificate's SAN identifies the WORKFLOW that requested it, so pinning only the repository accepts a signature from any other workflow in it — including one a pull request adds, which is a far lower bar than compromising the release process.
const (
SchemaV1 = 1
)
The manifest layouts this build knows about.
Bumped when a field changes meaning. A reader refuses a schema it does not know rather than interpreting unfamiliar fields, because the failure mode of guessing is installing the wrong bytes over a running control plane.
const SchemaVersion = SchemaV1
SchemaVersion is the layout this build WRITES.
DELIBERATELY ALLOWED TO LAG WHAT IT READS, and that gap is the whole migration path. A reader accepts exactly the schemas it understands, so publishing a new layout in the same change that teaches the reader about it is a FLAG DAY: every deployment already in the field cannot read the release, and the thing that would fix them is the release they can no longer read. Readers learn the new schema and ship; only once the fleet carries them does the writer move.
The same rule as imagesource.SchemaVersion, and for a worse reason: a guest image a node cannot read is a node that keeps running the old image, while a release a controller cannot read is a fleet with no way to update at all.
Variables ¶
var DefaultSigningIdentity = `^https://github\.com/` + regexp.QuoteMeta(DefaultRepo) + `/` + regexp.QuoteMeta(PublishWorkflow) + `@` + PublishRefPattern + `$`
DefaultSigningIdentity is the certificate SAN a billet release manifest must carry.
ANCHORED AT BOTH ENDS AND ESCAPED EXCEPT WHERE THE PATTERN IS THE POINT. The repository and the workflow are literals and go through QuoteMeta; the ref is a deliberate alternation, because a release is cut from whichever minor branch carries it.
var ErrIncompatible = errors.New("releasesource: this release cannot replace the running build")
ErrIncompatible means a candidate release must not replace this build.
A SENTINEL SO THE ROLLOUT CAN TELL IT FROM A FAILURE TO LOOK. "This release cannot be installed here" is a durable verdict a rollout records as a blocker and stops on; "the manifest could not be fetched" is a retry. Collapsing the two makes a network blip look like an incompatible release and a genuinely incompatible release look like something worth retrying forever.
var ErrNotFound = errors.New("releasesource: no such artifact at this source")
ErrNotFound means the source has no such artifact.
DISTINGUISHED FROM EVERY OTHER FAILURE because callers act on it differently: a channel that has never been published is a normal state with an instruction attached, while a network failure is a retry.
var PublishRefPattern = `refs/(heads/release/v[0-9]+\.[0-9]+|tags/v[0-9]+\.[0-9]+\.[0-9]+)`
PublishRef is the git ref releases may be signed from.
A RELEASE IS BUILT FROM A MAINTAINED BRANCH, not from main. cut-release.yml commits the generated release surface onto `release/vX.Y` and tags that, and release.yml runs against the tag — so pinning `refs/heads/main` would refuse every release billet actually publishes. What must stay excluded is `refs/pull/N/head`, which is the low bar the imagesource comment names.
Functions ¶
func ArtifactURL ¶
ArtifactURL is where one published artifact lives.
func CanRollBack ¶
CanRollBack reports whether a failed update TO `from` can restore `to`.
THE NAMES READ THE WAY THE ROLLBACK MOVES: out of `from` and back to `to`, which is what the diagnostic says and what the comparison does. Its doc comment used to have the two the other way round, which made a reader believe the candidate was the second argument.
THE SAME COMPARISON IN THE OTHER DIRECTION, and it exists because a rollback is where a schema migration becomes irreversible. If the candidate migrated the ledger past what the previous release knows, restoring that binary produces a control plane that refuses its own database — so an updater has to know this BEFORE it migrates, not after its candidate has failed.
A ROLLBACK IS AUTHORISED BY THE SNAPSHOT, NOT BY THIS. The transactional updater takes a ledger snapshot before it migrates and restores that on failure, so the schema comparison here is what decides whether the candidate may migrate at all while still leaving a way back.
func ChannelBundleURL ¶
ChannelBundleURL is where that statement's signature lives.
func ChannelURL ¶
ChannelURL is where a channel's signed statement lives.
func Compatibility ¶
Compatibility reports every reason a candidate must not be installed here, and separately every reason it needs work first.
TWO RETURN VALUES BECAUSE ONE WAS A DEFECT. This used to join everything into a single error and let callers pick the guest-contract change out with errors.As — which meant a candidate that ALSO shared no wire version, or published nothing for this platform, was waved through by a caller that found the change and proceeded. A warning that can hide a refusal is worse than no warning, and the type is what stops it: a fatal problem can only be returned as an error, and an error is not something a caller can mistake for advice.
IT RUNS BEFORE ANY LIVE MUTATION. The whole point of carrying these facts in a signed manifest is that a rollout can learn them while the control plane is still running: a candidate that turns out to speak no wire version in common with the fleet, or to refuse the ledger it would inherit, is discovered after the switch — with the old binary already hidden and the services already stopped.
EVERYTHING AT ONCE. An operator planning an upgrade should see all of it in one diagnostic rather than clearing one obstacle per attempt, each of which costs a maintenance window.
func KnownChannel ¶
func ManifestURL ¶
ManifestURL is where one release's manifest lives.
Types ¶
type Artifact ¶
type Artifact struct {
// Name is the file's name within the release. A BARE FILENAME, validated as
// one; see artifactName.
Name string `json:"name"`
// OS and Arch are Go's spellings, matching the archive names the install
// script already derives from uname.
OS string `json:"os"`
Arch string `json:"arch"`
// Kind separates the forms one platform publishes: an archive, a .deb, a
// .rpm. An updater picks by kind, and an unknown one is refused rather than
// treated as an archive.
Kind string `json:"kind"`
// SHA256 is the digest of the bytes as published, lowercase hex.
SHA256 string `json:"sha256"`
// Size is the published length in bytes, carried so a reader can bound the
// download rather than discovering the length by exhausting a disk. A digest
// alone cannot do that: it is only checkable after the last byte.
Size int64 `json:"size"`
}
Artifact is one downloadable file and the digest that proves it arrived as published.
type BuildRequest ¶
type BuildRequest struct {
// Dist is the directory GoReleaser wrote its artifacts into.
Dist string
Version string
Commit string
BuiltAt time.Time
Wire Range
LedgerSchema int
GuestContract string
// RollbackTo is the release a failed update of this one restores. Empty for
// the first release billet ever publishes.
RollbackTo string
}
BuildRequest is everything a publisher knows that the files themselves do not.
type ChannelStatement ¶
type ChannelStatement struct {
Schema int `json:"schema"`
Channel string `json:"channel"`
// Tag is the release this channel currently names.
Tag string `json:"tag"`
// ManifestSHA256 is the digest of that release's manifest.
//
// THE THING A ROLLOUT PERSISTS. A tag names a release and a digest names its
// CONTENTS, and only the second survives somebody moving a tag. GitHub release
// immutability means that should be impossible, which is a reason to record
// the digest rather than a reason not to: an assumption billet can check for
// free is one it should check.
ManifestSHA256 string `json:"manifest_sha256"`
PublishedAt time.Time `json:"published_at"`
ExpiresAt time.Time `json:"expires_at"`
// ReleaseImmutable is the publisher asserting it PROVED the release immutable
// before advancing the channel.
//
// CARRIED AND CHECKED rather than assumed from the repository setting. GitHub's
// release immutability applies only to releases created after it was enabled,
// so "the repository is protected now" says nothing about a given release. The
// workflow proves it per release and signs the assertion; a reader that
// accepted an absent or false value would be trusting a property nobody
// checked.
ReleaseImmutable bool `json:"release_immutable"`
}
ChannelStatement is one signed pointer from a channel to an immutable release.
func NewChannelStatement ¶
func NewChannelStatement(channel, tag, manifestDigest string, now time.Time, ) (*ChannelStatement, error)
NewChannelStatement builds one pointer for a publisher to sign.
func ParseChannel ¶
ParseChannel decodes and validates a channel statement.
EVERY REFUSAL HERE FAILS CLOSED — an unreadable, expired, replayed, or unattested pointer resolves to nothing rather than to a default, because the only thing worse than not knowing which release is current is guessing.
`now` IS A PARAMETER because expiry is the interesting behaviour and a test that has to wait ten days is a test nobody runs.
func (*ChannelStatement) Marshal ¶
func (c *ChannelStatement) Marshal() ([]byte, error)
Marshal renders a channel statement the way the publisher writes it.
HERE RATHER THAN IN THE WORKFLOW, so the writer and the reader agree by construction. A statement assembled by a shell heredoc is a second implementation of this schema, and the two drift on the first field anybody adds — which is the failure the vendored toolset declaration exists to prevent one directory over.
type Client ¶
type Client struct {
// HTTP is the transport. Nil means a bounded default.
HTTP *http.Client
// Repo is the repository releases come from.
Repo string
// Now is the clock the channel's expiry is judged against. Nil means
// time.Now — a parameter because expiry is the behaviour worth testing and a
// test that waits ten days is a test nobody runs.
Now func() time.Time
// contains filtered or unexported fields
}
Client resolves a channel to one immutable release and proves what it fetches.
func (*Client) Download ¶
func (c *Client) Download(ctx context.Context, tag string, a *Artifact, dir string, ) (string, error)
Download fetches one artifact into dir and proves it against the manifest.
THE DIGEST IS CHECKED AS THE BYTES ARRIVE and the file is only named once it passes, so nothing downstream can ever open a partially-written or unverified artifact. The size is enforced too: a digest is only checkable after the last byte, so it cannot stop a response that never ends.
func (*Client) Manifest ¶
func (c *Client) Manifest(ctx context.Context, tag, expectDigest string, p Policy, ) (*Manifest, string, error)
Manifest fetches one release's manifest, proves it, and validates it.
THE SIGNATURE IS CHECKED OVER THE BYTES THAT ARRIVED, before anything is parsed out of them. Verifying a re-serialised manifest would verify a document this program produced rather than the one that was signed, and the two can differ in whitespace, key order, or any field a future reader drops.
expectDigest COMES FROM THE CHANNEL and is checked here. A tag names a release; a digest names its contents. GitHub release immutability should make the two equivalent, which is a reason to check rather than a reason not to — an assumption billet can verify for free is one it should verify.
THE DIGEST IS RETURNED because every caller needs to persist it, and computing it a second time from a re-serialised manifest would hash a document this program produced rather than the one that was published. An exact version pin has no channel to take it from, and a rollout still has to record WHICH bytes it decided on.
func (*Client) Resolve ¶
func (c *Client) Resolve(ctx context.Context, channel string, p Policy, ) (*ChannelStatement, error)
Resolve turns a channel name into the immutable release it currently points at.
ONE ANSWER, RECORDED BY THE CALLER. A rollout persists what this returns and never asks again: a channel that advances mid-rollout must not retarget work already underway, which is why a channel is a pointer rather than a subscription.
type Current ¶
type Current struct {
// Version is the release the running binary reports itself as.
Version string
// Wire is the node-wire range the running control plane speaks.
Wire Range
// LedgerSchema is the highest migration the running binary knows.
LedgerSchema int
// GuestContract is the guest protocol the running binary speaks.
GuestContract string
// OS and Arch are the platform this deployment runs on.
OS string
Arch string
}
Current is what the running deployment is, for the compatibility check.
PASSED IN RATHER THAN READ HERE, and that is what makes this package testable and honest. Reading nodeapi and internal/state directly would make the check assert facts about the process running it, so a test could only ever confirm that this build is compatible with itself — which is the one case that never fails in production.
type GuestContractChange ¶
GuestContractChange is a candidate that needs different guest images.
A WARNING RATHER THAN A REFUSAL. Every other problem Compatibility reports is a reason the binary cannot be installed at all; this one is a reason each NODE needs an image before it converges, which `billet images compatible` answers per node against that node's own configured images. Refusing the whole rollout would block a deployment whose nodes have already imported a compatible generation.
IT IS RETURNED IN A SEPARATE LIST, not mixed into the error. Callers used to pick it out with errors.As and proceed, which waved through a candidate that also shared no wire version — the warning hiding a refusal.
func (*GuestContractChange) Error ¶
func (c *GuestContractChange) Error() string
type Manifest ¶
type Manifest struct {
// Schema is the layout version of this document.
Schema int `json:"schema"`
// Version is the release, as its git tag spells it.
Version string `json:"version"`
// Commit is the commit the release was built from, for an operator
// reconciling a running binary against what it claims to be.
Commit string `json:"commit"`
// BuiltAt is when the release was produced, in UTC.
BuiltAt time.Time `json:"built_at"`
// Wire is the node-wire range this release speaks.
//
// THE FIELD THAT DECIDES WHETHER A ROLLOUT CAN EVEN BEGIN. A candidate whose
// range does not overlap the range the running control plane speaks cannot be
// bridged to — there is no version both halves implement, so nodes could not
// register against it — and that has to be knowable before the binary is
// replaced rather than after the fleet has fallen off.
Wire Range `json:"wire"`
// SchemaVersion is the ledger migration this release expects.
//
// Migrations are append-only and a binary refuses a database carrying a
// version it has never heard of, so a candidate BELOW the installed schema
// cannot open the ledger it would inherit. That is a rollback the updater has
// to refuse rather than discover after it has stopped the control plane.
LedgerSchema int `json:"ledger_schema"`
// GuestContract is the protocol a guest image's baked agent must speak.
//
// A STRING COMPARED FOR EQUALITY, never ordered, for the reason
// imagesource.Manifest.GuestContract gives: a newer contract is not backward
// compatible by default, and treating it as "greater than or equal" turns a
// clean refusal into a guest that boots and never reports.
GuestContract string `json:"guest_contract"`
// Actions is the tag billet's bundled composite actions resolve to in this
// release. It is the release's own version for an ordinary cut; it is carried
// explicitly so a reader can check that rather than assume it.
Actions string `json:"actions"`
// RollbackTo is the release a failed update of this one restores.
//
// PART OF THE MANIFEST RATHER THAN DERIVED, because "the previous tag" is not
// the same question as "a release this one can be rolled back to". A candidate
// that migrated the ledger cannot be rolled back to a binary that refuses the
// new schema, and only the release knows that about itself.
//
// EMPTY IS A VALUE. The first release billet ever publishes has nothing behind
// it, and refusing that would mean no release could ever be the first.
RollbackTo string `json:"rollback_to,omitempty"`
// Artifacts is every published file, keyed by nothing — a reader selects by
// os and arch.
Artifacts []Artifact `json:"artifacts"`
}
Manifest is one immutable billet release: every artifact it publishes, and every fact a deployment needs in order to refuse it.
func Build ¶
func Build(req BuildRequest) (*Manifest, error)
Build assembles a manifest by hashing what was actually produced.
IN GO RATHER THAN IN THE WORKFLOW, and that is the point. A manifest assembled by a shell heredoc is a second implementation of this schema, and the two drift on the first field anybody adds — the two-pins problem the vendored toolset declaration exists to prevent one directory over. Here the writer and the reader are the same type, and the result is put through the reader's own validation before it is published.
THE DIGESTS COME FROM THE FILES, never from checksums.txt. Reading a digest out of a file GoReleaser wrote would make the manifest a restatement of another document's opinion; hashing the bytes that are about to be uploaded is the only thing that makes the signature over this manifest mean what it claims.
func ParseManifest ¶
ParseManifest decodes and validates a manifest.
THE ONLY WAY ONE IS PRODUCED FROM BYTES, so no caller can hold an unvalidated manifest — which is what lets the update path treat its digests, names and sizes as constrained rather than re-checking each at every use.
STRICT DECODING, because a field this build does not know is a field whose meaning it cannot honour. A release describing a constraint through a key added after this binary shipped would otherwise be installed as though the constraint were absent. That is the opposite of the node wire's rule, and deliberately so: the wire negotiates a version both sides agreed on, while a manifest is a take-it-or-leave-it document about whether this binary may be replaced.
func (*Manifest) Marshal ¶
Marshal renders a manifest the way the publisher writes it.
ONE RENDERER, for the reason ChannelStatement.Marshal is one: the bytes that get signed have to be the bytes the reader was written against. A publisher that serialised the document its own way would be signing something this package has never seen, and the difference would surface as an unverifiable signature on a release nobody had tampered with.
func (*Manifest) Select ¶
Select finds the artifact for one platform and kind.
AN ABSENCE IS AN ERROR RATHER THAN A ZERO VALUE. A release that does not publish darwin/arm64 is a release a Mac cannot install, and returning an empty Artifact would have the caller verify an empty digest against no bytes and report success.
func (*Manifest) Validate ¶
Validate reports everything wrong with a manifest, or nil.
CALLED BEFORE ANY FIELD IS USED, including before the digests are trusted enough to check bytes against. The document arrives over the network from a service nobody here controls, so every field is an assertion by a stranger until it has been through this.
EVERYTHING AT ONCE, rather than the first failure, because an operator fixing a hand-written manifest should not discover its problems one release at a time.
type Policy ¶
type Policy = imagesource.Policy
Policy is what a source demands before a manifest may be trusted.
imagesource's, DELIBERATELY, and not a second one. Both consumers verify a signed document against sigstore's public-good roots using the SAME embedded trust root, and duplicating either the root or the verifier is the two-pins problem: two copies of a security-critical decision that agree today and drift on the next rotation. If a third consumer ever appears, the verifier should be lifted into a package of its own — with two, the import is cheaper than the move and carries the same guarantee.
func PolicyForRelease ¶
PolicyForRelease decides what verification billet's own releases demand.
A MISSING POLICY IS AN ERROR RATHER THAN A SKIP, which is imagesource's rule and applies here with more force. The manifest is the only thing that makes every digest in it mean anything: an attacker who can serve one names digests of bytes they chose, and every check downstream then passes against those bytes. Here the bytes in question replace a running control plane.
THE WAIVER IS EXPLICIT AND IT WINS. An air-gapped deployment mirroring its own releases has a real reason; the point is that skipping verification is an act somebody performed rather than what happens when nothing is configured.
type Range ¶
Range is the span of node-wire versions a release speaks, inclusive.
ITS OWN TYPE RATHER THAN nodeapi.Range, because this package must be readable by a build whose nodeapi says something different — that is the entire point of carrying it. Converting happens at the comparison, where both sides are known.