imagepin

package
v2.9.0-dev.6 Latest Latest
Warning

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

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

Documentation

Overview

Package imagepin discovers where this repo pins a container image.

It answers the mechanical half of two different questions that are deliberately kept apart:

  • "Can this daemon run this config?" — examples/internal/recipecheck (#680), an offline in-tree oracle that runs in test-unit.
  • "Is this pin still current upstream?" — dev/lookout-pin-check (#787), a weekly job that has to call another repo.

Both need the same fact — where is this image declared, and what tag does the declaration carry — so that fact is computed once, here. Everything version-judging stays with the caller.

Two entry points, on purpose

Resolve answers "what does an operator running `kubectl apply -k` against this overlay actually deploy". It walks the kustomize composition graph in kustomize's own precedence order and returns the ONE effective pin. That is the right shape for a capability check: a base's `images:` transformer applies to the overlay too, so an overlay that inherits a pin instead of restating it is judged on the pin it really gets.

[Sites] answers "where is this tag written down". It is a flat filesystem walk that returns EVERY declaration with a byte range, so a caller can rewrite them. Resolution would be wrong here: the sites an inherited pin hides are exactly the ones a bump must not miss.

The two share the per-file extractors, the image-name predicate, and the positional comment-marker matching — which is the part that took the work and the part that must not be written twice.

Families are a parameter

Every scan path is scoped by a Family, not by a hardcoded image name. recipecheck passes its own daemon family; dev/lookout-pin-check passes Lookout. Adding a third image family is a declaration, not a fork.

The daemon family is declared in recipecheck, where its one consumer lives; Lookout is declared here because it has two consumers, in internal trees that cannot import each other.

Index

Constants

This section is empty.

Variables

View Source
var Lookout = &Tracked{
	Family:       NewFamily(nil, lookoutImage),
	UpstreamRepo: "go-steer/k8s-lookout",

	Roots: []string{
		"examples",
		"dev/tools/e2e-recipe-gke-troubleshoot-agent",
	},

	Docs: []string{
		"examples/gke-troubleshoot-agent/README.md",
		"examples/gke-troubleshoot-agent/DEMO.md",
		"README.md",
		"docs/site/src/content/docs/reference/troubleshooting-agent.md",
		"docs/site/src/content/docs/examples/index.md",
	},

	Literals: []Literal{{
		Path: "examples/gke-troubleshoot-agent/recipe_test.go",
		Re:   regexp.MustCompile(`wantWatcherTag\s*=\s*"(` + semverTagPattern + `)"`),
		Why: "wantWatcherTag, the constant TestWatcherImagePinIsConsistent and " +
			"TestWatcherTagInDocsIsCurrent hold every other site to",
	}},

	ImageRefRe: regexp.MustCompile(regexp.QuoteMeta(lookoutImage) + `:(` + semverTagPattern + `)`),
	ProseRe:    regexp.MustCompile("pin(?:s|ned) `?(" + semverTagPattern + ")`?"),
	TagRe:      regexp.MustCompile(`^` + semverTagPattern + `$`),

	Frozen: []string{"examples/kube-platform-agent"},

	FrozenMarker: regexp.MustCompile(`(?m)^#[ \t]?pin-frozen:[ \t]*(\S.*?)[ \t]*$`),
}

Lookout is the tracked declaration for the watcher image.

Scope note: the core-agent daemon images are DELIBERATELY not tracked here. #680's recipecheck gate already reads those same declaration sites to answer a different question ("can this daemon run this config?"), and a weekly upstream-bump job would mix a mechanical third-party bump with a question about our own release cadence. The walker is parameterised by Family so adding that second family is a declaration plus a resolver, not a rewrite — see the package doc.

Functions

func CommentMarkerAbove

func CommentMarkerAbove(body []byte, off int, match func(string) (string, bool)) string

CommentMarkerAbove reads a marker out of the contiguous block of `#` comment lines immediately above the byte at off. Adjacency is what scopes it: a commented-out example elsewhere in the file is separated from the live line by the live line's own neighbours.

"Contiguous" is literal — a blank line ends the block. A comment with whitespace between it and the line below is a comment about something else, and reading through the gap would let a detached block claim a pin nobody wrote it about.

That blank-line stop is NEW as of #787, and it is a real semantics change for the two pre-existing callers — [Family.pinFromManifest] and [Family.PinsFromDockerfile], both resolving DigestVersion provenance for #680's gate. Before, a `# core-agent-version:` comment separated from its digest pin by a blank line still resolved; now it does not, and the pin reads as having no declared version. Nothing in the tree relied on that (no family image is digest-pinned with a detached provenance comment, and a differential run of CheckDeployPins across the change produced byte-identical findings), but a provenance comment that stops working after someone inserts a blank line is a surprise worth having written down.

The line off lands in is dropped first, so a caller may point at the reference itself rather than at the margin. Every caller in this package passes a line-start offset, where that makes no difference; it is here for the mid-line callers in the site scanner.

func CommentMarkerAboveText

func CommentMarkerAboveText(body []byte, off int) string

CommentMarkerAboveText returns the contiguous block of `#` comment lines immediately above the byte at off, as one string.

"Contiguous" is literal: a blank line ENDS the block. A comment separated from a pin by whitespace is a comment about something else, and reading through the gap is what lets a visually detached documentation block attach itself to the next pin down the file.

off may point mid-line — a reference sits after `image: `, not at the margin — so the partial line it lands in is dropped before the walk upward. That is a capability this package's own reference scanner needs, not a fix for a defect: CommentMarkerAbove's pre-existing callers all pass a line-start offset, where the two behave alike.

func KustomizationPath

func KustomizationPath(dir string) (string, bool)

KustomizationPath returns the kustomization file in dir, if any.

dir is a directory inside the checkout being inspected — a recipe's deploy/overlays/<name> or a path declared in a Tracked. Nothing here is reachable from a request, and the caller wants exactly the file it names, so there is no traversal to defend against: the whole job is to read the repository it was pointed at.

func OverlayDirs

func OverlayDirs(overlaysDir string) ([]string, error)

OverlayDirs returns the immediate subdirectories of overlaysDir that hold a kustomization, in stable order.

"An overlay is a directory under deploy/overlays" is this repo's own convention across every deployable recipe, and it maps exactly onto "the thing an operator runs kubectl apply -k against". The alternatives are worse: "any kustomization" pulls in the base and the components, which are pinned by their consumers; and "any kustomization nothing else references" wrongly drops gke-troubleshoot-agent's example overlay, which is both directly applicable AND composed by example-otel.

func ReviewLapsed

func ReviewLapsed(review, now time.Time) bool

ReviewLapsed reports whether now is past the end of the review date.

The comparison is by DATE in UTC, not by instant: a review date is something a person wrote in a comment, so "2027-02-01" means the whole of that day and the freeze goes overdue on the 2nd. Comparing instants would call it overdue from one second past midnight in a timezone nobody chose. A zero review date never lapses — it cannot occur on a frozen site (see [reviewClause]), and defaulting the unset case to "overdue" would turn any future caller's oversight into a red job.

func SplitImageRef

func SplitImageRef(ref string) (image, tag, digest string)

SplitImageRef splits `name[:tag][@digest]` the way a registry client does: the digest wins if both are present, and a colon inside a registry's host:port is not a tag separator.

Types

type Family

type Family struct {
	// contains filtered or unexported fields
}

Family is one container image — plus any alternate names that pin the same thing — whose declarations are read as a unit.

Build one with NewFamily; the zero value matches nothing.

func NewFamily

func NewFamily(provenance *regexp.Regexp, names ...string) *Family

NewFamily builds a family from one or more image names.

Names are matched exactly and on the PRE-rename spelling, which is what kustomize keys its transformer on: a `newName:` remap to a private mirror still pins the same image.

func (*Family) Matches

func (f *Family) Matches(name string) bool

Matches reports whether an image name belongs to this family.

func (*Family) Names

func (f *Family) Names() []string

Names returns the image names this family matches, longest first.

type Literal

type Literal struct {
	// Path is repo-relative.
	Path string
	// Re must capture the tag in group 1.
	Re *regexp.Regexp
	// Why records what the site is, so a reader of the declaration does
	// not have to open the file to find out.
	Why string
}

Literal declares a bare-tag site that no image-adjacent rule can find, because the file states the image name and the tag separately.

type Pin

type Pin struct {
	// Source is the file that carries the pin, relative to the artifact
	// being judged. It can differ from the overlay when the overlay
	// composes another one (example-otel composes example) or inherits
	// the image straight off a base's Deployment (gke-deploy does).
	Source string
	// Image is the matched family name.
	Image string
	// Tag is the tag, empty for a digest pin.
	Tag string
	// Digest is the digest, empty for a tag pin.
	Digest string
	// DigestVersion is the release a digest pin declared itself resolved
	// from, via the family's provenance comment. Empty otherwise.
	DigestVersion string
}

Pin is one image reference a deploy artifact resolves to.

func PinsFromDockerfile

func PinsFromDockerfile(body []byte, fam *Family) []Pin

PinsFromDockerfile returns one Pin per FROM naming the family. A multi-stage build can legitimately have several.

ARG defaults declared above a FROM are substituted into it, because that is a shape this repo documents:

ARG CORE_AGENT_VERSION=2.8.0
FROM ghcr.io/go-steer/core-agent-slim:${CORE_AGENT_VERSION}

Reading `${CORE_AGENT_VERSION}` as a tag would advise the author to "pin a released semver instead" when that is exactly what they did. A `--build-arg` at build time can override the default and this cannot see it, which is the same limit `docker build` puts on anyone reading the file: the default is what the artifact says it deploys, and a substitution that resolves to nothing stays unresolved and is reported.

func PinsFromShell

func PinsFromShell(body []byte, fam *Family) []Pin

PinsFromShell returns one Pin per literal family reference in a script. cloud-run-deploy/scripts/deploy-from-prebuilt-image.sh is the artifact this exists for: it defaults IMAGE_REF to a full daemon reference, and that default is what most people who run the script deploy.

Only lines that are not comments are read. A reference whose tag is a shell variable is kept rather than skipped — the script does deploy something, and "the file does not say what" is a finding, not a pass.

func Resolve

func Resolve(dir string, fam *Family) (*Pin, error)

Resolve finds the image from fam that an overlay ends up deploying.

Two passes over the same composition graph, in kustomize's own precedence order:

  1. The `images:` transformer, depth-first in declaration order. A base's transformer applies to the overlay too, so an overlay that composes a pinned base or a pinned sibling IS pinned — gke-troubleshoot-agent's example-otel composes example. Both restate the pin today; resolving through composition is what stops a caller from becoming a false positive the first time someone stops restating it.
  2. Failing that, the container `image:` written into a manifest the composition pulls in. This is not a fallback for tidiness: it is how gke-deploy pins, with a bare `image: ghcr.io/...:2.8.0` on the base Deployment and every `images:` example in the overlay commented out. A caller that stopped after pass 1 reported that recipe as unpinned while a perfectly good pin sat one file away.

Returns nil when the composition names no image from fam anywhere. Pin.Source comes back relative to dir.

What is NOT covered, and why: an image set by a kustomize `patches:` entry or by a generator. That needs an evaluator, not a parser. A literal `<image>:${TAG}` IS returned — that is the honest answer ("this deploys something, and the file does not say what") rather than a skip.

type PinBearingFile

type PinBearingFile struct {
	Path    string
	Extract func([]byte, *Family) []Pin
}

PinBearingFile is one non-manifest artifact plus the extractor that knows its syntax.

func PinBearingFiles

func PinBearingFiles(root string) ([]PinBearingFile, error)

PinBearingFiles finds every Dockerfile and shell script under root, in stable order.

The whole tree is walked rather than just the root: a recipe may keep build files under deploy/ (kube-platform-agent does), and a vendored tree that builds an image is exactly as deployable as one at the top. A walk error on one subtree is returned rather than skipped — the alternative is a check that quietly stops looking.

type RewritePlan

type RewritePlan struct {
	// contains filtered or unexported fields
}

RewritePlan is a validated set of edits that has not been applied.

Planning and applying are separate because a PARTIAL rewrite is worse than none: a tree where the Deployment moved and the README did not is a tree that lies to its reader, and the in-tree consistency test would then fail on a change nobody can explain. Every site is located and checked before the first byte is written, and RewritePlan.Revert puts the originals back if verification of the finished tree fails.

func PlanRewrite

func PlanRewrite(repoRoot string, sites []Site, newTag string) (*RewritePlan, error)

PlanRewrite computes the edit that sets every site in the slice to newTag, without touching the disk.

Callers must have filtered out frozen sites first; the plan does not second-guess the selection. Splices run back to front within a file so earlier offsets stay valid.

func (*RewritePlan) Apply

func (p *RewritePlan) Apply(repoRoot string) error

Apply writes the plan.

func (*RewritePlan) NewTag

func (p *RewritePlan) NewTag() string

NewTag is the tag every planned site will be set to.

func (*RewritePlan) Paths

func (p *RewritePlan) Paths() []string

Paths are the repo-relative files the plan touches, sorted.

func (*RewritePlan) Revert

func (p *RewritePlan) Revert(repoRoot string) error

Revert restores the contents every planned file had when the plan was computed. It is the undo for a rewrite whose result failed verification.

func (*RewritePlan) Sites

func (p *RewritePlan) Sites() int

Sites is how many individual tags the plan rewrites.

type Site

type Site struct {
	// Path is repo-relative and slash-separated.
	Path string
	// Line is 1-based, for messages.
	Line int
	// Tag is the tag as written.
	Tag string
	// Kind is the shape it was written in.
	Kind SiteKind
	// Text is the surrounding match, trimmed, for the report.
	Text string
	// Group is the unit freezing applies to: the recipe directory this
	// site belongs to, or the file's own path when it sits outside any
	// scanned root.
	Group string
	// Frozen is set on every site in a group where a pin declared
	// itself frozen. See [Tracked.FrozenMarker].
	Frozen bool
	// FrozenReason is the text the marker carried, review clause and all.
	FrozenReason string
	// FrozenReview is the date the marker says the freeze should be
	// looked at again, at UTC midnight. Never the zero time on a site
	// that survived [Tracked.Sites] with Frozen set: a marker with no
	// parseable review date does not freeze anything, it errors.
	FrozenReview time.Time
	// contains filtered or unexported fields
}

Site is one written-down tag, located precisely enough to rewrite.

func (Site) FreezeOverdue

func (s Site) FreezeOverdue(now time.Time) bool

FreezeOverdue reports whether the freeze on this site has outlived the review date its marker committed to.

func (Site) String

func (s Site) String() string

type SiteKind

type SiteKind string

SiteKind names the shape a tag was written in. It exists for reporting: a reviewer reading "prose" behaves differently from one reading "kustomize newTag".

const (
	// KindKustomizeTag is a `newTag:` under an `images:` entry.
	KindKustomizeTag SiteKind = "kustomize newTag"
	// KindImageRef is a literal `<image>:<tag>` anywhere in a file — a
	// container `image:`, a Dockerfile FROM, a shell default, or a
	// documented `crane digest` command.
	KindImageRef SiteKind = "image reference"
	// KindProse is an English statement of the pin ("the recipe pins
	// v0.21.0") that names no image.
	KindProse SiteKind = "prose"
	// KindLiteral is a bare tag located by a declared regexp, for the
	// sites no image-adjacent rule can reach — a Go test constant, say.
	KindLiteral SiteKind = "declared literal"
)

type Tracked

type Tracked struct {
	// Family is the image-name predicate every scan path is scoped by.
	Family *Family
	// UpstreamRepo is the "owner/name" the resolver asks for releases.
	UpstreamRepo string
	// Roots are repo-relative paths walked for declarations. A directory
	// is walked whole and a site's freeze group is its immediate
	// subdirectory of that root — the recipe directory; a single file is
	// scanned as itself and is its own freeze group.
	//
	// Naming one file is not a special case bolted on: a deploy artifact
	// can live outside the tree it deploys, and the e2e harness that
	// pins the watcher image sits in dev/tools precisely because it is
	// CI's, not the recipe's.
	Roots []string
	// Docs are repo-relative documents that state the pin to a reader.
	// They are read for image references AND for [Tracked.ProseRe].
	//
	// This list is DECLARED rather than discovered because the prose
	// matchers are deliberately narrow, and a discovered list would
	// have to be "every markdown file", which sweeps in the changelog
	// and every historical version mention in it.
	//
	// Non-vacuity — "each of these still states the pin at least once"
	// — is NOT enforced here. That is an offline, in-tree property, and
	// it is already gated in test-unit by the recipe's own
	// TestWatcherTagInDocsIsCurrent, which reads this same list.
	Docs []string
	// Literals are the declared bare-tag sites.
	Literals []Literal
	// ImageRefRe matches a full image reference with the tag captured
	// in group 1. It is the narrow, assertion-facing sibling of the
	// family's internal discovery regexp: discovery deliberately also
	// matches `:latest` and `:${VAR}` refs so it can SEE them and skip
	// them on purpose, while this one only ever matches a real tag.
	// Exported so the recipe's own offline test asserts against the
	// same matcher the rewriter uses.
	ImageRefRe *regexp.Regexp
	// ProseRe matches an English statement of the pin, capturing the
	// tag in group 1. Applied ONLY to Docs.
	ProseRe *regexp.Regexp
	// TagRe is the shape a rewritable tag has. A reference whose tag
	// fails it — `:latest`, `:${WATCHER_IMAGE##*:}`, a digest — is not
	// a site: there is nothing there to bump.
	TagRe *regexp.Regexp
	// Frozen names the freeze groups — recipe directories, exactly as
	// [freezeGroup] renders them — that deliberately do not track
	// upstream.
	//
	// This list is the SAFETY and [Tracked.FrozenMarker] is the
	// EXPLANATION, and both are required. A marker alone used to be
	// enough, and that was a hole of the same class this whole package
	// exists to close: the natural place to document how freezing works
	// is next to a pin, an adjacent comment is indistinguishable from an
	// attached one, and a tree that exempts itself by accident reports
	// drift=false forever while going stale. So the two disagreeing is
	// an ERROR in both directions — a marker outside a declared group,
	// and a declared group with no marker — never a silent exemption.
	Frozen []string
	// FrozenMarker matches the opt-out comment, capturing the reason in
	// group 1. It must match a whole comment line. A marker with no
	// reason, or one whose reason is an angle-bracketed placeholder,
	// does not count: an unexplained freeze is how a gate rots, and a
	// `<why>` placeholder is a worked example rather than a decision.
	//
	// The reason must also carry a review date — see [reviewClause].
	FrozenMarker *regexp.Regexp
}

Tracked declares an image family this repo keeps current against upstream, and everywhere a tag for it is written down.

Adding a second tracked family is this declaration plus a resolver — not a change to any of the walking code above.

func (*Tracked) Sites

func (t *Tracked) Sites(repoRoot string) ([]Site, error)

Sites returns every declaration of t's tag under repoRoot, sorted by path then position.

A declared Doc or Literal path that does not exist is skipped silently: the tool must still answer on an older tree (that is how the gate was demonstrated against the pre-#788 checkout), and the in-tree test is what fails when a live document stops stating its pin.

Jump to

Keyboard shortcuts

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