upgrade

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: Apache-2.0 Imports: 19 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 move of namespace, chart, source, kustomize path, deployment type, manifest file set, pre-manifest file set or object name is invisible to a version comparison yet relocates, replaces or renames running objects. Helm cannot move a release between namespaces, and it applies a rename as delete-and-recreate, or refuses it outright where an object keeps its name while its selector labels change, because spec.selector is immutable. 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 the rest.

The identity fields do not all read an empty value the same way, which is the one thing to hold onto here. An absent scalar field is a fact the artifact did not record, so it is not compared. An empty manifest set and an absent object name are facts it did record — a chart with no fullnameOverride names its objects after itself — so dropping one is a move, and reporting it is the point.

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.

A report built from a cluster read carries a Source block, which WriteTable renders above the rows. A read that recognizes nothing is reported rather than failed, so every row under it reads "added"; the block is what separates that from a kubeconfig on the wrong context, and it is useless below the table a reader has already drawn a conclusion from. The two readers are accounted for in separate types whose counts are in different units and must never be summed.

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 move 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 move nor account for it.

A caller that could not read an axis says so on the Report rather than on every row, because an unread axis is one fact about the run and not one fact per component. ObjectNamesCompared is that flag, and it is stated rather than inferred: every versions-only caller leaves it false, which is the reading that claims nothing.

Below the rows, the at-risk section reports objects of the kinds the crossed records name that carry no deployer ownership marker. It is the one section with no omitempty and no skip: a run that scanned nothing says so, because an absent warning reads as an all-clear over resources AICR cannot restore. It is advisory throughout, scoped to what the rows actually crossed, and reaches neither Summary nor FailsRun; a relocation contributes nothing to it, having crossed no boundary at all.

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 reads that file only to locate release values and does not take the deployer from it yet, 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.

Bundle guidance

A bundle knows the versions it pins and not the ones a cluster runs, so it cannot use Match. BundleNotes instead selects every manual or blocked transition whose `to` contains a pin, and WriteGuide, WriteNotice and NoteLines render them as guidance conditional on each record's `from`, for the one deployer the bundle was built with. Nothing is selected for a safe transition, so a bundle whose pins cross only safe boundaries carries none.

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 (
	NotScannedOffline  = "no cluster access requested"
	NotScannedDeclined = "the at-risk scan was explicitly turned off"
)

NotScannedOffline and NotScannedDeclined are the two ways a run reaches the at-risk section with nothing to report, and they are separate strings because they are opposite facts about the same empty section: nobody offered this run a cluster, against a run that had one and was told not to look. Reporting the second as the first would credit an operator's own opt-out to an absence of access.

NotScannedOffline is the default a report is built with, so a caller that never asked for a scan cannot accidentally publish an all-clear.

View Source
const ComponentUpgradesKind = "ComponentUpgrades"

ComponentUpgradesKind is the kind expected on a ComponentUpgrades document.

View Source
const GuideFile = "UPGRADING.md"

GuideFile is the bundle-root file WriteGuide renders into.

Variables

This section is empty.

Functions

func NoteLines added in v1.0.0

func NoteLines(notes []BundleNote) []string

NoteLines returns one line per note for a CLI to print after writing a bundle, or nil when there are none.

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 WriteGuide added in v1.0.0

func WriteGuide(w io.Writer, notes []BundleNote, deployer string) error

WriteGuide renders the notes as the Markdown guide a bundle ships at GuideFile, with every step list narrowed to deployer. No notes writes nothing, so a caller that must not ship an empty file checks len(notes) before creating one.

func WriteNotice added in v1.0.0

func WriteNotice(w io.Writer, notes []BundleNote, deployer string) error

WriteNotice renders the section a deployer README carries to point at GuideFile. It ends in a blank line so the heading that follows it stays separated. No notes writes nothing.

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 AtRiskFinding added in v1.0.0

type AtRiskFinding struct {
	Group string `json:"group,omitempty" yaml:"group,omitempty"`
	Kind  string `json:"kind" yaml:"kind"`

	// Components is whose crossed records put this object at risk, sorted. It
	// is the actionable half of the finding: the object's identity says what
	// might be lost, and this says which upgrade would do it.
	Components []string `json:"components,omitempty" yaml:"components,omitempty"`

	// Namespace is empty for a cluster-scoped object.
	Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
	Name      string `json:"name" yaml:"name"`
}

AtRiskFinding is one object an upgrade could disturb and AICR does not own.

type AtRiskKind added in v1.0.0

type AtRiskKind struct {
	Group string `json:"group,omitempty" yaml:"group,omitempty"`
	Kind  string `json:"kind" yaml:"kind"`

	// Components is whose crossed records named this kind, sorted. See
	// ResourceKind, which carries it into the scan.
	Components []string `json:"components,omitempty" yaml:"components,omitempty"`

	// Present reports the cluster serving this kind. False means discovery
	// enumerated the kind's API group and found no match, which is ordinary:
	// the CRD a record names may simply not be installed. Examined is then
	// zero because nothing was listed, not because nothing exists.
	Present bool `json:"present" yaml:"present"`

	// Examined is how many objects of this kind the scan read, at risk or not.
	// It is the denominator Findings is read against.
	Examined int `json:"examined" yaml:"examined"`
}

AtRiskKind accounts for one kind the scan looked for.

type AtRiskReport added in v1.0.0

type AtRiskReport struct {
	// Scanned reports a cluster having been examined. False is the offline
	// case and the failed-scan case alike, which Reason separates.
	Scanned bool `json:"scanned" yaml:"scanned"`

	// Reason says why no scan ran, and is empty exactly when Scanned.
	Reason string `json:"reason,omitempty" yaml:"reason,omitempty"`

	// Kinds is what the scan resolved and read, one entry per kind whatever
	// came of it. It is what separates "checked and clean" from "the kind is
	// not installed here", which an empty Findings alone conflates.
	Kinds []AtRiskKind `json:"kinds,omitempty" yaml:"kinds,omitempty"`

	// Findings is every object carrying no recognized ownership marker.
	Findings []AtRiskFinding `json:"findings,omitempty" yaml:"findings,omitempty"`
}

AtRiskReport is the advisory scan's answer: what it looked at, and what it found that nothing appears to own.

Scanned is carried rather than inferred from the other two fields, because they cannot answer it. A scan that examined every object and found none at risk and a run that never contacted a cluster both leave Findings empty, and those are opposite facts.

It restates nothing from pkg/inventory, which performs the scan: this package must not import it, for the reason Component gives. The caller that holds both adapts.

type BundleNote added in v1.0.0

type BundleNote struct {
	Component  string
	Pin        string
	Transition *Transition
}

BundleNote is a transition whose `to` range contains the version a bundle pins and that asks something of an operator upgrading into it. Transition points into the Set it was selected from.

func BundleNotes added in v1.0.0

func BundleNotes(set Set, pins []BundlePin) []BundleNote

BundleNotes selects, in pin order, the manual and blocked transitions whose `to` contains each pin.

A bundle knows where it lands and not where the operator starts, so this is deliberately not Match: every selected transition is guidance conditional on its `from`, which the renderers state. A safe transition asks nothing and is left out; a pin that is not semver has no range to fall inside.

type BundlePin added in v1.0.0

type BundlePin struct {
	Component string
	Version   string
}

BundlePin is a component's pinned version as a bundle ships it.

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

	// Crossed is every record this jump passes a boundary of, ordered by that
	// boundary. Unlike Transition it is set whatever the verdict turned out to
	// be, including where no single record describes the whole jump, so it is
	// what the at-risk scan reads: an intermediate record names resources the
	// jump disturbs whether or not its guidance was written for this starting
	// point. Empty on every result but a version transition, ChangeIdentity
	// included: a component that held its version passed no boundary, so the
	// scan draws nothing from a relocation however far it moves the release.
	Crossed []*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
	Chart            string
	Source           string
	Path             string
	Type             string
	ManifestFiles    []string
	PreManifestFiles []string

	// ObjectNames are the merged values naming the objects the chart owns,
	// keyed by dotted value path ("fullnameOverride",
	// "grafana.fullnameOverride").
	//
	// Empty is not the same as a component pinning none: it is also what a
	// caller that could not read the values hands over, because a resolved
	// recipe records them by reference rather than by value. The caller
	// reports that gap once for the whole run rather than per component, so
	// nothing here distinguishes the two.
	ObjectNames map[string]string
}

Identity is what a recipe pins for a component beyond its version. A move here is invisible to a version comparison but relocates, replaces or renames running objects. Helm cannot move a release between namespaces. A renamed chart or moved source shares no version line with the old one. A kustomize path selects a different manifest set at the same tag. A dropped manifest file deletes a live object under GitOps prune, and a pre-install manifest is a prerequisite the bundle applies before the release. A moved object name renames what the chart owns.

Every field but Version, ManifestFiles, PreManifestFiles and ObjectNames counts only when both sides state it. The two manifest lists are sets, compared without regard to order.

type IdentityChange

type IdentityChange struct {
	Field   string
	From    string
	To      string
	Added   []string
	Removed []string
}

IdentityChange names one field that moved between the compared artifacts.

Field is either an Identity field's name in lower camel case ("namespace", "manifestFiles") or, for an object name, a dotted value path into the merged values ("fullnameOverride", "grafana.fullnameOverride"), 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.

For manifestFiles and preManifestFiles From and To are the whole sorted sets joined by commas, and Added and Removed name the entries that differ. They are empty on every other field. From or To is empty where an object name appeared or disappeared, which is a rename rather than a missing observation; the scalar fields never report an empty side.

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

	// Source names where the `from` table was read when it came from a
	// cluster. Nil for an artifact comparison, which needs no such statement.
	Source *ReportSource `json:"source,omitempty" yaml:"source,omitempty"`

	// ObjectNamesCompared reports whether the object-name axis was assessed at
	// all. False means no row can be read as evidence that object names held:
	// they were never looked at. ObjectNamesSkipped says why.
	ObjectNamesCompared bool   `json:"objectNamesCompared" yaml:"objectNamesCompared"`
	ObjectNamesSkipped  string `json:"objectNamesSkipped,omitempty" yaml:"objectNamesSkipped,omitempty"`

	Components []ReportComponent `json:"components" yaml:"components"`
	Summary    ReportSummary     `json:"summary" yaml:"summary"`

	// AtRisk is always present, never omitted. A run that did not scan says
	// so: an absent warning reads as an all-clear, and the resources this
	// section covers are exactly the ones AICR cannot fix if it is wrong.
	//
	// It is not part of Summary and never reaches FailsRun, per ADR-021
	// Decision 3: AICR blocking an upgrade over resources it does not own is a
	// claim it has not earned.
	AtRisk AtRiskReport `json:"atRisk" yaml:"atRisk"`
}

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 the report stops a strict run: a row that does, or a cluster read whose name mapping is shown broken.

The second has no row to carry it. A record AICR stamped that matched no component is one this build no longer recognizes as its own, and the component it belongs to has dropped out of the `from` table, where it reads as newly installed and fails nothing. Left to the count alone, a broken mapping exits zero.

func (*Report) UnmatchedStamps added in v1.0.0

func (r *Report) UnmatchedStamps() int

UnmatchedStamps is the AICR-stamped records a cluster read matched to no component, zero for an artifact comparison.

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.

Field is one of namespace, type, chart, source, path, manifestFiles or preManifestFiles. For the two file sets From and To are the whole sorted sets joined by commas.

type ReportOptions

type ReportOptions struct {
	From     string
	To       string
	Deployer string

	// Source is where the `from` table was read when it came from a cluster,
	// and nil for an artifact comparison. NewReport copies it rather than
	// retaining the pointer, so the report keeps the ownership contract the
	// rest of its fields hold.
	Source *ReportSource

	// AtRisk is the advisory scan's answer, and nil for a run that did not
	// scan. NewReport then fills the report's own section with NotScannedOffline
	// rather than leaving it zero, so silence is never publishable as an
	// all-clear. Copied for the reason Source is.
	AtRisk *AtRiskReport

	// ObjectNamesCompared says the object-name axis was assessed, and
	// ObjectNamesSkipped says why it was not.
	//
	// Stated rather than derived from the other being empty, because the zero
	// value has to mean "not compared": Match and every other versions-only
	// caller sets neither, and defaulting those to compared would claim an
	// axis was checked that nothing looked at.
	//
	// They are report-level rather than per-row because an unread axis is one
	// fact about the run, not one fact per component; a row each would bury
	// the rows that did find something. Saying nothing at all is not an
	// option: a source artifact that cannot state its object names is
	// indistinguishable from one whose names held, and reporting the second
	// when the first is true is the failure this axis exists to prevent.
	ObjectNamesCompared bool
	ObjectNamesSkipped  string
}

ReportOptions labels a report with the artifacts it was computed from and names the deployer whose steps are rendered.

type ReportSource added in v1.0.0

type ReportSource struct {
	// Kubeconfig is the file the read resolved to and Context the context
	// within it. Either can be empty: an in-cluster run has no file, a merged
	// multi-file KUBECONFIG has no single path, and the cluster read does not
	// yet report the context at all. WriteTable renders the gap rather than
	// dropping the line, so an unknown reads as unknown rather than as a line
	// the renderer skipped.
	Kubeconfig string `json:"kubeconfig,omitempty" yaml:"kubeconfig,omitempty"`
	Context    string `json:"context,omitempty" yaml:"context,omitempty"`

	// Matched is how many components the read resolved to an installed
	// version. It is carried rather than derived from Components, which cannot
	// answer it: a component installed at the target version produces no row
	// at all, so the rows do not distinguish "found, unchanged" from "never
	// found".
	Matched int `json:"matched" yaml:"matched"`

	Helm ReportSourceHelm `json:"helm" yaml:"helm"`
	Argo ReportSourceArgo `json:"argo" yaml:"argo"`
}

ReportSource accounts for a cluster read: which cluster was read, how much of it the two readers examined, and how much of that became a `from` entry.

It restates pkg/inventory's SourceInfo rather than embedding it, for the reason Component does: this package must not import pkg/inventory. The caller that holds both adapts.

The readers are accounted for in two types that share no field name, and their counts must never be summed. The Helm side counts storage records, so one release with ten retained revisions contributes ten; the Argo side counts Applications, of which a component has one. A single total over the two reads fine and means nothing, and separate types are what leave no `+` to write by accident.

type ReportSourceArgo added in v1.0.0

type ReportSourceArgo struct {
	// Read reports whether the deployer installs through Argo CD and the read
	// therefore listed its Applications. The counts are zero when it did not.
	Read bool `json:"read" yaml:"read"`

	// Applications is every Application examined, including the ones belonging
	// to no component.
	Applications int `json:"applications" yaml:"applications"`

	Unattributed int `json:"unattributed" yaml:"unattributed"`
	Unreadable   int `json:"unreadable" yaml:"unreadable"`

	// Remote is Applications matching a component that deploy to another
	// cluster, excluded so a remote install is never this cluster's baseline.
	Remote int `json:"remote" yaml:"remote"`
}

ReportSourceArgo accounts for the Argo CD Applications a read examined.

There is deliberately no StampedUnmatched counterpart, and WriteTable says so rather than printing a zero. The generated Application carries no AICR stamp — it lives in the wrapper Chart.yaml the Application points at, which the reader never opens — so a zero would report a mapping verified against something nothing looked for.

type ReportSourceHelm added in v1.0.0

type ReportSourceHelm struct {
	// Read reports whether the deployer installs through Helm releases and
	// the read therefore listed them. The counts are zero when it did not.
	Read bool `json:"read" yaml:"read"`

	// Records is every storage object examined, including the ones belonging
	// to no component. It is the denominator the other counts read against.
	Records int `json:"records" yaml:"records"`

	// Unattributed is records carrying no release name.
	Unattributed int `json:"unattributed" yaml:"unattributed"`

	// Unreadable is releases whose records could not be read.
	Unreadable int `json:"unreadable" yaml:"unreadable"`

	// Uninstalled is releases excluded because `helm uninstall
	// --keep-history` left the record behind.
	Uninstalled int `json:"uninstalled" yaml:"uninstalled"`

	// StampedUnmatched is records carrying an AICR stamp that matched no
	// component. Anything above zero means the release-name mapping is broken
	// rather than that the cluster is bare: AICR wrote those records and no
	// longer recognizes them.
	StampedUnmatched int `json:"stampedUnmatched" yaml:"stampedUnmatched"`
}

ReportSourceHelm accounts for the Helm storage records a read examined.

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 ResourceKind added in v1.0.0

type ResourceKind struct {
	Group      string   `json:"group,omitempty" yaml:"group,omitempty"`
	Kind       string   `json:"kind" yaml:"kind"`
	Components []string `json:"components,omitempty" yaml:"components,omitempty"`
}

ResourceKind is one group and kind an upgrade's records name as affected, with the components whose crossed transitions named it. An empty Group is the core API group, as it is everywhere in Kubernetes.

The components travel with the kind because the warning is meant to be acted on, and "something you are upgrading may delete these" is materially weaker guidance than naming whose transition puts them at risk: the operator deciding whether to proceed needs to know whether it is the component they care about. Several is unusual rather than wrong — two components can own overlapping kinds — and costs nothing, since the scan still lists a kind once however many records named it.

func AffectedKinds added in v1.0.0

func AffectedKinds(results []ComponentResult) []ResourceKind

AffectedKinds is the union of the resource kinds named by the transitions results actually crossed, deduplicated and ordered by group then kind.

Only crossed records contribute. An upgrade that is not being made cannot put anything at risk, so a component whose records describe boundaries this jump never reaches contributes nothing, however many resources those records name. ComponentResult.Crossed rather than Transition is what carries them, because the jumps most likely to destroy something are exactly the ones no single record describes.

A kind naming nothing is dropped: an empty kind would resolve to no resource at best and to an unintended one at worst. A result with no component name contributes its kinds without attributing them, rather than being dropped: the objects are at risk either way, and a nameless row is a matcher bug that must not also silence the warning.

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