upgrade

package
v0.22.0 Latest Latest
Warning

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

Go to latest
Published: Sep 21, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

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

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

func WriteTable(w io.Writer, r *Report) error

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

type AffectedResource struct {
	Group string   `yaml:"group"`
	Kinds []string `yaml:"kinds"`
}

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

type Hook struct {
	File  string `yaml:"file"`
	Phase string `yaml:"phase"`
}

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

type Identity struct {
	Version   string
	Namespace string
}

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

type IdentityChange struct {
	Field string
	From  string
	To    string
}

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.

func (*Report) FailsRun

func (r *Report) FailsRun() bool

FailsRun reports whether any row stops a strict run.

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

type ReportOptions struct {
	From     string
	To       string
	Deployer string
}

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

func Load(ctx context.Context, src Source, comps []Component) (Set, error)

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

func (Set) Validate

func (s Set) Validate(comps []Component) error

Validate reports every well-formedness violation across the set.

It aggregates rather than failing on the first problem: an author fixing a record should see all of it in one run.

type Source

type Source interface {
	ReadFile(ctx context.Context, path string) ([]byte, error)
}

Source is the subset of recipe.DataProvider this package needs.

type Span

type Span struct {
	Majors  int
	Minors  int
	Patches int
}

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.

const (
	VerdictSafe        Verdict = "safe"
	VerdictManual      Verdict = "manual"
	VerdictBlocked     Verdict = "blocked"
	VerdictUnknown     Verdict = "unknown"
	VerdictUnversioned Verdict = "unversioned"
)

func (Verdict) Authorable

func (v Verdict) Authorable() bool

Authorable reports whether v may appear in a record file.

Jump to

Keyboard shortcuts

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