Documentation
¶
Overview ¶
Package upgrade implements ADR-021 component upgrade transition records.
A record answers "is this component version transition safe?" as machine-readable data: recipes/components/<component>/upgrades.yaml, referenced from registry.yaml via upgrades.file, holding semver-range-keyed transitions with a verdict, operator steps grouped by deployer, and the evidence backing a safe claim.
Loading versus validating ¶
Load answers "can I read this?" — decode, the apiVersion and kind gate, and the verdict-independent required fields. It fails closed: an unreadable or unrecognized record returns ErrCodeInvalidRequest naming what was found and what was expected. It is never skipped and never degraded to the unknown verdict, because "a record exists and I could not read it" is not "no record exists", and collapsing the two hides which action closes the gap.
Set.Validate answers "is this well-formed?" — the pin-relative, verdict-dependent, and cross-record rules. It aggregates every violation rather than returning the first, so an author sees all of a record's problems in one run. The lint gate calls both.
The nine rules ¶
Rule 1 is Load's; rules 2 through 9 are Validate's, one function each in wellformed.go. A reader told there are nine rules otherwise finds eight, numbered 2 to 9, because rule 1 is named nowhere in the package.
1 apiVersion and kind are recognized decodeRecord 2 to is bounded, its ceiling at or below the pin checkPinCeiling 3 the from domains have no hole up to the pin checkCoverage 4 safe names its verifiedBy checkVerdictFields 5 manual and blocked carry steps in every group checkVerdictFields 6 deployer groups partition the deployers checkStepGroups 7 from is forward-only against to checkDirectional 8 no two transitions share a to floor for the same from checkDistinctBoundaries 9 hooks name a phase and a local manifests/migrations file checkHooks
Matching ¶
MatchIdentities answers "does this move need attention?" over two component-to-identity tables, and is pure: no filesystem, no cluster, no registry. Match is the same question for a caller holding versions alone.
An identity moves on two axes. Only the version axis is assessed by anybody, because that is what a record describes; a namespace move is invisible to a version comparison yet relocates running objects, and Helm cannot move a release between namespaces. So a component that moved on the identity axis alone gets a ChangeIdentity row that a version comparison would not report at all, a component that moved on both gets one row carrying both, and a safe verdict is withdrawn to unknown wherever the identity moved: the record vouched for a version hop and was never asked about a relocation.
On the version axis, a record is *crossed* when the source sits below the floor its `to` names and the target reaches it. Crossing is a property of the jump alone; `from` is not consulted, because a record whose `from` excludes the source still describes a boundary the jump flies over, and skipping it there is how a recorded block goes unreported. `from` answers the separate question of whether that record's guidance was authored for this starting point.
Verdict selection runs in this order:
1 nothing crossed unknown 2 one crossed, from covers the source that record's verdict 2a ... but the target is past its to ceiling blocked, stop at that ceiling 3 another crossed record authored blocked blocked, stop at its to 4 two or more crossed blocked, stop at the lowest 5 one crossed, from does not cover the source blocked, stop at its to
Rule 2 is the only one that attaches a Transition, and it attaches one for every verdict including blocked: that record describes this exact move, so its blocked verdict means "not in one step" and its steps say what to do instead. Rules 2a, 3, 4 and 5 leave Transition nil, so no renderer can print one record's steps for a jump that record does not describe. Rule 2a is the forward-reach case: the record was authored for this starting point but stops assessing before the target, and lending its verdict there would vouch for releases its author cannot have read the migration notes for, which is the same reach checkPinCeiling rejects at authoring time. Rule 5 is the outside-every-recorded-origin case, usually below the lowest `from` floor: nothing describes an upgrade from where the operator is, and an opt-in check errs toward safety there. Every blocked version transition names a StoppedAt, and every result carries a Reason code and an Explanation sentence saying which rule it was and what to do about it.
Matching takes an already validated Set and does not re-run Validate. Validate is therefore not optional: a record that violates a well-formedness rule still applies and still lends its verdict. A safe record missing its verifiedBy (rule 4) is the case that matters, because it reports safe and passes a strict run, which is exactly the false confidence a wrong safe buys. Only two malformed shapes are inert here, and only because they leave nothing to compare against: ranges that do not parse, and a to naming no floor. Callers that did not build the Set through Load plus Validate own that gap.
Reporting ¶
NewReport projects match results into the shape a reader and a CI consumer both see: one row per changed component, each semver distance already rendered as a phrase, and every step list narrowed to the one deployer named. It is a projection rather than an alias because a result points into the Set, and a report has to outlive it. WriteTable renders that report; a blocked row computed from several records, or from none naming the operator's starting point, renders no steps, so its detail block is the Explanation alone.
An identity row held its version, so its FROM and TO columns carry the fields that moved rather than the version printed twice, which is the one rendering that would read as nothing having happened. A row that moved on both axes keeps its versions in those columns and names the relocation in its notes, and in its detail block where it has one: the steps there were authored for a version boundary and neither perform the relocation nor account for it.
The deployer is not inferred. ADR-021 Decision 5 would take it from a `to` bundle, which does record it in bundle-info.yaml, but the check does not read that file, so RequiresDeployer reports when a caller has to supply one. It is true for a manual row, and for a blocked row that carries a record; the step-less blocked rows do not make it true, because a deployer would name a scope nothing renders.
Read-only contract ¶
A Set and everything reachable from it must not be mutated. Consumers share the same pointers, and nothing re-runs Validate afterwards.
Index ¶
- Constants
- func RequiresDeployer(results []ComponentResult) bool
- func WriteTable(w io.Writer, r *Report) error
- type AffectedResource
- type ChangeKind
- type Component
- type ComponentResult
- type ComponentUpgrades
- type Hook
- type Identity
- type IdentityChange
- type Reason
- type Replaces
- type Report
- type ReportComponent
- type ReportIdentityChange
- type ReportOptions
- type ReportStep
- type ReportSummary
- type Set
- type Source
- type Span
- type Step
- type StepGroup
- type Transition
- type Verdict
Constants ¶
const ComponentUpgradesKind = "ComponentUpgrades"
ComponentUpgradesKind is the kind expected on a ComponentUpgrades document.
Variables ¶
This section is empty.
Functions ¶
func RequiresDeployer ¶
func RequiresDeployer(results []ComponentResult) bool
RequiresDeployer reports whether rendering these results would print steps, and therefore whether a deployer must be named.
A blocked row counts only when one record describes the whole jump: the blocked results computed from several records, or from none that names the operator's starting point, deliberately render no steps, so demanding a deployer for one would reject a report over a flag nothing would consume.
ADR-021 Decision 5 would infer the deployer from a `--to` bundle. A bundle does carry it, in bundle-info.yaml's build.deployer, but the check never reads that file, so every caller supplies it instead. Rendering every deployer's path is the failure deployer-scoping exists to prevent.
func WriteTable ¶
WriteTable writes the report as a human-readable table followed by the detail block each row that needs operator attention owns.
A nil report is a malformed call rather than an empty check, and returns ErrCodeInvalidRequest: reporting "no component changes" for a programming error would read as an all-clear.
Types ¶
type AffectedResource ¶
AffectedResource drives the at-risk scan in online mode.
type ChangeKind ¶
type ChangeKind string
ChangeKind says what a component did between the two tables.
ChangeAdded and ChangeRemoved carry no Verdict: the verdict vocabulary describes a transition, and a component that merely arrived or departed did not make one. A departing component stays installed, because AICR dropping it from a recipe is a statement about what AICR now ships rather than an instruction to tear down a running workload.
ChangeIdentity is the kind a version comparison cannot produce at all: the component held its version and moved anyway. It is its own kind rather than a flag on ChangeVersion because the remedy has nothing to do with a version bump, and a report that showed it as one would send the reader to the component's release notes for an answer they do not contain.
const ( ChangeVersion ChangeKind = "version" ChangeIdentity ChangeKind = "identity" ChangeAdded ChangeKind = "added" ChangeRemoved ChangeKind = "removed" ChangeReplaced ChangeKind = "replaced" )
type Component ¶
type Component struct {
// Name is the registry component name.
Name string
// File is ComponentConfig.Upgrades.File. Empty means no record.
File string
// PinnedVersion is defaultVersion (Helm) or defaultTag (Kustomize).
PinnedVersion string
}
Component names a registry entry that may reference an upgrade record.
type ComponentResult ¶
type ComponentResult struct {
// Component is the name the result is keyed and sorted by. On a
// ChangeReplaced row it is the arriving component, which owns the verdict.
Component string
Change ChangeKind
// ReplacedComponent is the departing component a ChangeReplaced row joins
// in, and is that row's FROM column: two different pieces of software
// share no version line, so From stays empty and a renderer reads the
// kind instead of sniffing the string.
ReplacedComponent string
// From and To are the version strings as the tables gave them, not
// normalized, so a report quotes what the artifacts actually said. From is
// empty for ChangeAdded and ChangeReplaced, To for ChangeRemoved.
From string
To string
// IdentityChanges are moves no version comparison can see. Non-empty on a
// row whose Change is ChangeIdentity, and on a ChangeVersion row when both
// axes moved in the same hop.
IdentityChanges []IdentityChange
// Verdict is empty for ChangeAdded and ChangeRemoved.
Verdict Verdict
// Transition is the record that describes this exact move: one crossed
// record whose `from` covers the source. It is nil wherever no single
// record does, including the blocked results computed from several records
// or from none that names this starting point, so a renderer cannot print
// one record's steps for a jump that record does not describe.
Transition *Transition
// Replaces is the arriving component's declaration, set only on a
// ChangeReplaced row.
Replaces *Replaces
// StoppedAt is the `to` range a blocked jump stops at: the interval it must
// not enter in one step. Set on every blocked version transition, and empty
// on every other result including a blocked ChangeReplaced row, where two
// pieces of software share no version line for a boundary to sit on.
StoppedAt string
// Span is how far the matched record's claim reaches, from the source
// version to the ceiling its `to` names. This is the width ADR-021
// requires the report to state. It is zero when no single record matched
// or the record names no ceiling. An exclusive ceiling counts as reached:
// the highest version actually covered is not knowable from the range
// alone.
//
// Span is never narrower than Jump where it is set at all: a target past
// the record's ceiling no longer takes that record's verdict, so a claim
// cannot end up narrower than the move it is covering.
Span Span
// Jump is the distance between the two versions actually compared. Zero
// when either side is unversioned.
Jump Span
// Breaking reports a boundary semver makes no stability promise across: a
// major bump, a minor bump while the major version is 0, or a changed
// prerelease identifier over an otherwise equal release triple. False when
// either side is unversioned, where there is no boundary to classify.
//
// Descriptive only. It used to decide whether an unknown result stopped a
// strict run; ADR-021 Decision 6 dropped that calibration, because an
// unassessed transition is unassessed at any distance.
Breaking bool
// Downgrade reports that the target orders below the source.
Downgrade bool
// Reason and Explanation say why Verdict is what it is: a stable code and
// the sentence an operator reads, naming the versions involved. Both are
// empty exactly when Verdict is.
Reason Reason
Explanation string
}
ComponentResult is one row of an upgrade check.
Every pointer field points into the Set the result was matched against and inherits its read-only contract.
func Match ¶
func Match(set Set, from, to map[string]string) []ComponentResult
Match compares two component-to-version tables, for callers that hold only versions.
It is MatchIdentities over identities that state a version and nothing else, which leaves the identity axis with nothing to compare, so the results are exactly the version results.
func MatchIdentities ¶
func MatchIdentities(set Set, from, to map[string]Identity) []ComponentResult
MatchIdentities compares two component-to-identity tables against the set's records and returns one result per component whose deployment changes, sorted by component name.
It is pure: tables in, results out, with no filesystem, cluster or registry access. A component whose identity is the same on both sides produces no row.
The two axes move independently, and only one of them is assessed by anybody.
On the version axis a record is crossed when the source sits below the floor its `to` names and the target reaches it. Verdict selection then runs in this order: nothing crossed is unknown; one crossed record whose `from` covers the source lends its verdict, blocked included, because it describes this exact move, unless the target lands past the ceiling that record's `to` names, which blocks the jump at that ceiling; any other crossed record authored blocked blocks the jump; two or more crossed records block it; one crossed record whose `from` does not cover the source blocks it, because nothing describes an upgrade from where the operator is. Every result carries a Reason and an Explanation saying which of those it was.
On the identity axis nothing is recorded, so nothing lends a verdict. A component that moved there alone produces a ChangeIdentity row, which a version comparison reports as no change whatsoever; one that moved on both axes in the same hop carries the moves on its ChangeVersion row, and a safe verdict is withdrawn there, because the record vouched for a version hop and was never asked about a relocation.
set must already have passed Validate; MatchIdentities does not re-run it. A malformed record cannot panic here either: a transition whose ranges do not parse, or whose `to` names no floor, simply never applies, leaving the component at unknown rather than lending it a verdict the record cannot support.
func (ComponentResult) FailsRun ¶
func (r ComponentResult) FailsRun() bool
FailsRun reports whether this result should stop a strict run, per ADR-021 Decision 6. It is a method rather than a switch in the CLI so the SDK and the command cannot disagree about what fails.
type ComponentUpgrades ¶
type ComponentUpgrades struct {
APIVersion string `yaml:"apiVersion"`
Kind string `yaml:"kind"`
Component string `yaml:"component"`
Transitions []Transition `yaml:"transitions,omitempty"`
Replaces *Replaces `yaml:"replaces,omitempty"`
}
ComponentUpgrades is one component's transition records.
type Hook ¶
Hook references an AICR-authored migration manifest. Allowed on any verdict, including safe: a hook is AICR doing the work rather than the operator.
type Identity ¶
Identity is what a recipe pins for a component beyond its version. A move here is invisible to a version comparison but relocates running objects, and Helm cannot move a release between namespaces.
type IdentityChange ¶
IdentityChange names one field that moved between the compared artifacts.
Field is the Identity field's name lowercased ("namespace"), so a consumer branches on it without parsing prose. Version is never one of them: the version axis is ComponentResult.From and To, and the records assess it.
type Reason ¶
type Reason string
Reason names why a result carries the verdict it does, as a stable code a consumer can branch on without parsing prose. It is empty exactly when Verdict is: on a ChangeAdded or ChangeRemoved row, where nothing was assessed because no transition was made.
The four reasons that produce unknown stay distinct because they differ in what would close the gap, which is the same test that keeps unknown separate from unversioned: ReasonNoRecord needs somebody to author the first record, ReasonNoBoundaryCrossed needs an existing one widened (or confirmation that no boundary belongs there), ReasonDowngrade needs nothing because nothing can close it, and ReasonIdentityChanged needs the relocation performed as its own piece of work, because no record can close it either: the vocabulary describes version boundaries and says nothing about where a release lives. Rule 7 rejects every reverse record, so a downgrade is unassessable rather than merely unassessed.
ReasonBeyondRecordCeiling is not one of them. A record exists, what it covers is known, and the target is known to sit past that, which is a fact about assessed ground being exceeded rather than an absence of information.
const ( ReasonRecorded Reason = "recorded" ReasonRecordBlocks Reason = "record-blocks" ReasonMultipleBoundaries Reason = "multiple-boundaries" ReasonUndefinedOrigin Reason = "undefined-origin" ReasonNoRecord Reason = "no-record" ReasonNoBoundaryCrossed Reason = "no-boundary-crossed" ReasonBeyondRecordCeiling Reason = "beyond-record-ceiling" ReasonDowngrade Reason = "downgrade" ReasonIdentityChanged Reason = "identity-changed" ReasonNotComparable Reason = "not-comparable" )
type Replaces ¶
type Replaces struct {
Component string `yaml:"component"`
Verdict Verdict `yaml:"verdict"`
VerifiedBy string `yaml:"verifiedBy,omitempty"`
Summary string `yaml:"summary"`
StepsByDeployer []StepGroup `yaml:"stepsByDeployer,omitempty"`
}
Replaces names the component this one supersedes, joining the removed and added registry rows into one migration.
type Report ¶
type Report struct {
From string `json:"from,omitempty" yaml:"from,omitempty"`
To string `json:"to,omitempty" yaml:"to,omitempty"`
Deployer string `json:"deployer,omitempty" yaml:"deployer,omitempty"`
Components []ReportComponent `json:"components" yaml:"components"`
Summary ReportSummary `json:"summary" yaml:"summary"`
}
Report is the presentable form of a match: one row per component whose deployment changes, with every step list already narrowed to one deployer.
It is a projection of []ComponentResult rather than an alias for it. A result points into the Set and inherits its read-only contract; a report owns everything it carries, renders each semver distance as the phrase a reader sees, and names its fields for JSON and YAML consumers.
func NewReport ¶
func NewReport(results []ComponentResult, opts ReportOptions) *Report
NewReport projects match results into a report.
It is pure and copies everything it reads, so the returned Report can outlive the Set the results point into.
type ReportComponent ¶
type ReportComponent struct {
Component string `json:"component" yaml:"component"`
Change ChangeKind `json:"change" yaml:"change"`
// From is the source version, or on a replaced row the departing
// component's name: two different pieces of software share no version
// line to compare. Empty on an added row, as To is on a removed one.
From string `json:"from,omitempty" yaml:"from,omitempty"`
To string `json:"to,omitempty" yaml:"to,omitempty"`
// IdentityChanges are the moves the version columns cannot show: a
// component that held its version and relocated anyway, or one that moved
// on both axes in the same hop. Explanation names them in prose too, but a
// machine consumer reads them here rather than parsing a sentence.
IdentityChanges []ReportIdentityChange `json:"identityChanges,omitempty" yaml:"identityChanges,omitempty"`
// Verdict is empty on an added or removed row, which made no transition.
Verdict Verdict `json:"verdict,omitempty" yaml:"verdict,omitempty"`
// Notes is the one-line justification the table's last column shows.
Notes string `json:"notes,omitempty" yaml:"notes,omitempty"`
// Reason is the stable code for why Verdict is what it is, and Explanation
// the sentence an operator reads. Both are empty exactly when Verdict is.
Reason Reason `json:"reason,omitempty" yaml:"reason,omitempty"`
Explanation string `json:"explanation,omitempty" yaml:"explanation,omitempty"`
// Jump is the distance between the two versions compared, and Covers the
// wider distance the matched record's claim reaches over, both as phrases
// ("1 patch", "11 minors"). Covers is empty when no single record matched
// or the record names no ceiling.
Jump string `json:"jump,omitempty" yaml:"jump,omitempty"`
Covers string `json:"covers,omitempty" yaml:"covers,omitempty"`
Breaking bool `json:"breaking" yaml:"breaking"`
Downgrade bool `json:"downgrade" yaml:"downgrade"`
// StoppedAt is the interval a blocked row must not enter in one step. Set
// on every blocked version row, and empty on a blocked replaced row, which
// joins two pieces of software rather than two versions of one. Such a row
// carries a Summary, Precondition and Steps only when one record describes
// the whole jump: rendering one record's instructions for a jump it does
// not describe is the thing the verdict exists to prevent.
StoppedAt string `json:"stoppedAt,omitempty" yaml:"stoppedAt,omitempty"`
Summary string `json:"summary,omitempty" yaml:"summary,omitempty"`
Precondition string `json:"precondition,omitempty" yaml:"precondition,omitempty"`
// Steps are the selected deployer's steps only. Empty when no deployer was
// named, when the verdict needs none, when no single record describes the
// jump, or when the record's groups do not cover the named deployer.
Steps []ReportStep `json:"steps,omitempty" yaml:"steps,omitempty"`
// FailsRun mirrors ComponentResult.FailsRun so a consumer reading only the
// report reaches the same conclusion the exit code did.
FailsRun bool `json:"failsRun" yaml:"failsRun"`
}
ReportComponent is one row.
type ReportIdentityChange ¶
type ReportIdentityChange struct {
Field string `json:"field" yaml:"field"`
From string `json:"from" yaml:"from"`
To string `json:"to" yaml:"to"`
}
ReportIdentityChange is one move on the identity axis, restated with JSON names.
type ReportOptions ¶
ReportOptions labels a report with the artifacts it was computed from and names the deployer whose steps are rendered.
type ReportStep ¶
type ReportStep struct {
ID string `json:"id" yaml:"id"`
Description string `json:"description" yaml:"description"`
Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`
}
ReportStep is one operator action, restated with JSON names.
type ReportSummary ¶
type ReportSummary struct {
// Components counts the rows, which is the number of components whose
// deployment changes rather than the size of either table.
Components int `json:"components" yaml:"components"`
// Failing counts the rows whose verdict stops a strict run.
Failing int `json:"failing" yaml:"failing"`
}
ReportSummary is the aggregate a pipeline reads instead of the rows.
type Set ¶
type Set map[string]*ComponentUpgrades
Set holds loaded records keyed by component name.
A Set and everything reachable from it is read-only by contract: consumers share the same pointers, and nothing re-runs Validate after a mutation.
func Load ¶
Load reads and decodes each component's record.
It fails closed. An unreadable or unrecognized record returns an error naming what was found and what was expected; it is never skipped and never degraded to the unknown verdict, because "a record exists and I could not read it" is not "no record exists".
type Span ¶
Span is a semver distance, as a magnitude at exactly one level: 1.2.3 to 2.0.1 is one major, not one major and one patch, because the lower levels reset across the boundary and their arithmetic difference names nothing an operator would recognize. All three are zero when two versions differ only in their prerelease identifiers. Direction is ComponentResult.Downgrade rather than a sign, so a renderer never has to test three fields for one.
type Step ¶
type Step struct {
ID string `yaml:"id"`
Description string `yaml:"description"`
Reason string `yaml:"reason,omitempty"`
}
Step is one operator action. ID is unique within its group; the same logical action may reuse an id across groups, since a consumer addresses a step as (deployer, id).
type StepGroup ¶
type StepGroup struct {
Deployers []string `yaml:"deployers,omitempty"`
Steps []Step `yaml:"steps"`
}
StepGroup is one ordered instruction sequence for a set of deployers. A group omitting Deployers covers exactly those deployers no explicit group claims.
type Transition ¶
type Transition struct {
From string `yaml:"from"`
To string `yaml:"to"`
Verdict Verdict `yaml:"verdict"`
VerifiedBy string `yaml:"verifiedBy,omitempty"`
Summary string `yaml:"summary"`
Precondition string `yaml:"precondition,omitempty"`
Reversible *bool `yaml:"reversible,omitempty"`
ReversibleNotes string `yaml:"reversibleNotes,omitempty"`
StepsByDeployer []StepGroup `yaml:"stepsByDeployer,omitempty"`
Hooks []Hook `yaml:"hooks,omitempty"`
AffectedResources []AffectedResource `yaml:"affectedResources,omitempty"`
References []string `yaml:"references,omitempty"`
}
Transition describes one version boundary and what crossing it requires.
type Verdict ¶
type Verdict string
Verdict describes whether a version transition can be performed in place.
Only safe, manual, and blocked are authorable. Unknown and unversioned are computed by the matcher and MUST NOT appear in a record: unknown is a gap in the data, fixed by authoring a record, while unversioned is a gap in the inputs, fixed by pinning something comparable.
func (Verdict) Authorable ¶
Authorable reports whether v may appear in a record file.