Documentation
¶
Overview ¶
Package gitops reads an Argo CD GitOps repository and plans, applies and verifies byte-minimal image promotions between its environments.
The pipeline is Discover → BuildPlan → Apply, with Verify run on every write:
- Discover reads every kind: Application under <apps-root>/*.yaml. The wrapper's spec.source.path names a family directory and spec.destination.namespace names the env — never the file or directory name. Every YAML file in a family directory is scanned for image: scalars sitting inside items of a containers, initContainers or ephemeralContainers sequence; an image key anywhere else (ConfigMap data, a CRD field) is not an occurrence. Directories with manifests but no wrapper are reported as unmanaged and never scanned.
- BuildPlan produces one Edit per occurrence of each promotable source-env image repo in the target env. Every edit writes a pinned <repo>:<tag>@sha256:<digest>; a plan that would write a bare tag fails to build (AGENTS.md §4.2). Disagreement between source occurrences is a Warning with a deterministic choice, never a silent guess.
- ApplyBytes replaces only the scalar text at the recorded line and column, preserving quoting, comments and line count. Apply writes files only after Verify accepts the result.
- Verify compares every unedited line byte-for-byte, reconstructs each edited line, and walks the yaml.v3 node trees of both versions in lockstep — comments included — so a changed comment, a reordered key or a scalar change at an unplanned path is rejected.
Nothing in this package runs git, talks to a cluster or registry, or reads config: it is activity-shaped (AGENTS.md §4.3) and takes everything it needs as arguments.
Index ¶
- Constants
- func Apply(root string, edits []Edit) (changed []string, err error)
- func ApplyBytes(before []byte, edits []Edit) ([]byte, error)
- func ChooseRef(occ []Occurrence) (chosen image.Ref, reason string, disagree bool)
- func IsPromotable(repo string, prefixes []string) bool
- func MatchesPrefix(repo, prefix string) bool
- func ResolvePath(root, rel string) (string, error)
- func UnifiedDiff(path string, before, after []byte) string
- func Verify(before, after map[string][]byte, edits []Edit) error
- type ArgoApp
- type Edit
- type Env
- type Family
- type Occurrence
- type Plan
- func BuildDeployPlan(r *Repo, env string, ref image.Ref, promotable []string) (Plan, error)
- func BuildPlan(r *Repo, src, dst string, promotable []string, digests map[string]image.Ref) (Plan, error)
- func BuildPlanWith(r *Repo, src, dst string, promotable []string, digests map[string]image.Ref, ...) (Plan, error)
- type Repo
- type Warning
Constants ¶
const ( // WarnSourceDisagrees: the source env's occurrences of one repo carry different refs. WarnSourceDisagrees = "source-disagrees" // WarnMissingInTarget: a promotable repo runs in the source env but has no occurrence in // the target env, so there is nothing to move. WarnMissingInTarget = "missing-in-target" // WarnSourceOnlyUnwritable: a promotable repo runs in the source env as a ref hoist could // never write (a bare tag, or a digest with no tag) and has no occurrence in the target // env. Nothing would be written, so the refusal is reported rather than failing the plan // (AGENTS.md principle 5); a digest override for the repo turns it into missing-in-target. WarnSourceOnlyUnwritable = "source-only-unwritable" // WarnProductionTarget: this plan writes into an env the operator's own config lists as // production. Informational and never blocking (AGENTS.md §4.5: the registry-pick path's // production warning "informs; it does not block") — what production actually forces is a // PR, always, plus whatever approval mode the repo configures for that env: the comment is // the default, but an explicit `approval: auto` is permitted (§4.5, RepoConfig.Approval), // so this must not claim a human comment is unconditional. The engine enforces both. // // Constructed by the caller rather than by BuildPlan/BuildDeployPlan: which envs are // production is config, and pkg never imports internal. That is the same shape // pkg/resolve's warnings already arrive in. WarnProductionTarget = "production-target" )
Warning codes emitted by BuildPlan.
const ( VariantPromote = "" VariantDeploy = "deploy" )
Plan variants. The zero value is VariantPromote so that an unset Variant keeps the behaviour every caller had before deploys existed.
const DefaultAppsRoot = "cluster/apps"
DefaultAppsRoot is where Argo Application wrappers live when the caller names nothing.
const DefaultOverrideReason = "caller-supplied digest"
DefaultOverrideReason is what a source-disagrees warning says about an override whose origin the caller did not name: the --digest flag's own case.
Variables ¶
This section is empty.
Functions ¶
func Apply ¶
Apply rewrites the files named by edits under root, one ApplyBytes + Verify per file, and returns the files whose bytes changed. A file is written only after Verify accepts it; a failure on any file stops before that file is written (earlier files stay written — the caller's git worktree is the unit of rollback).
func ApplyBytes ¶
ApplyBytes returns before with each edit's scalar replaced at its recorded line and column. It is pure: nothing but the scalar text changes, and the result has the same line count, quoting, comments and trailing newline as the input. Edits on one line are applied right to left so flow-style items keep their columns valid.
func ChooseRef ¶
func ChooseRef(occ []Occurrence) (chosen image.Ref, reason string, disagree bool)
ChooseRef picks the reference to promote from one repo's source occurrences: the only reference when they agree; otherwise the unique pinned reference if exactly one distinct pinned reference exists; otherwise the most frequent, ties broken by first occurrence in path order. reason names the rule that decided; disagree is true when a choice was needed. It is exported so that pkg/resolve reads the manifest's own pin by the same rule BuildPlan plans by, rather than a second copy of it.
func IsPromotable ¶
IsPromotable reports whether repo matches any of prefixes.
func MatchesPrefix ¶
MatchesPrefix reports whether repo is covered by one configured registry prefix. A prefix ending in "/" must literally prefix repo; one that doesn't must still be followed by "/" or nothing. A bare strings.HasPrefix would let "ghcr.io" match "ghcr.io.attacker.example/org/app" — a different host that merely shares the leading bytes — and hand that host the credentials scoped to ghcr.io (AGENTS.md §4.4/§4.10, R-002). This is the one place that decision is made; IsPromotable and every registry entry selection (cmd/hoist's registryEntryFor) call it rather than keeping their own copy of the rule.
func ResolvePath ¶
ResolvePath joins rel (slash-separated, relative) to root, proves the result stays inside root — lexically (checkRelative) and physically: a symlink inside the repo that points outside it is refused — and returns the symlink-free path, so the caller's ReadDir, ReadFile or WriteFile opens the file that was checked rather than re-following the link (a second traversal, and a window in which the link could be repointed). When the path does not exist yet (a write target), the deepest existing ancestor is resolved and checked and the missing tail is appended, so the caller's own read reports the missing file. Every join of a repo-relative path to a root goes through here: the apps root, each family directory and each YAML file on the read side (Discover), and each Edit.File on the write side (Apply). A checkout is attacker-shaped on both sides — a symlink committed in a PR is followed by git, and a manifest read through it would supply the reference a later plan writes into an in-repo target.
func UnifiedDiff ¶
UnifiedDiff renders before → after for one file as a unified diff with three lines of context. It is written for the shape hoist produces — line-for-line replacements with an unchanged line count — and needs no LCS. Should the line counts differ (Verify would have rejected that), it falls back to one whole-file hunk so the output is still truthful. Returns "" when the inputs are identical.
func Verify ¶
Verify checks that after differs from before only by the planned edits. Both maps are keyed by file path and must hold the same files. For each file it requires an unchanged line count, byte-identical unedited lines, edited lines equal to the reconstruction of the planned replacement, and yaml.v3 node trees that match in lockstep — kind, tag, style, anchors, comments (head, line and foot), line numbers and children — except at the planned scalars, each of which must be matched exactly once.
Types ¶
type ArgoApp ¶
type ArgoApp struct {
Name string
SourcePath string // spec.source.path
Namespace string // spec.destination.namespace
// File is the wrapper file that declared it, relative to the repo root — so a report can
// point at the wrapper rather than guessing from the family name.
File string
}
ArgoApp is one kind: Application wrapper as read from the apps root.
type Env ¶
type Env struct {
Name string
// Dir is the directory holding this env's family directories when every family shares
// one parent, else "". It is informational; families carry their own Dir.
Dir string
Families map[string]*Family // keyed by Family.Name
}
Env is one environment: the Argo destination namespace of its Applications.
type Family ¶
type Family struct {
Name string // base name of Dir
Dir string // spec.source.path, relative to the repo root
App string // the Application's metadata.name
Occurrences []Occurrence
}
Family is one deployable unit inside an env, backed by exactly one Argo Application.
type Occurrence ¶
type Occurrence struct {
// File is the manifest path relative to the repo root, slash-separated.
File string
// Doc is the 0-based index of the document within File.
Doc int
// Line is 1-based and file-absolute: yaml.v3 does not reset it per document.
Line int
// Col is yaml.v3's Column for the scalar node: 1-based, counted in characters, and
// pointing at the first character of the scalar *token*. For a quoted scalar that is the
// opening quote, so the value itself starts one column later (verified against yaml.v3
// v3.0.1 — the docs are silent on it).
//
// This deviates knowingly from the M1 brief, which describes the column as "after any
// quote": Col records what yaml.v3 emits, unchanged, rather than a derived value that
// would have to be kept in step with the parser. Apply and Verify compensate — both step
// past the opening quote for DoubleQuotedStyle/SingleQuotedStyle before matching Raw — so
// the brief's intent (the edit replaces exactly the value, never the quotes) holds.
// Callers should treat Col as opaque.
Col int
// Style is the scalar's yaml.v3 style: 0 plain, DoubleQuotedStyle, SingleQuotedStyle, or a
// block style (LiteralStyle/FoldedStyle), which is recorded but refused by Apply.
Style yaml.Style
// Kind and Name identify the enclosing document (kind, metadata.name).
Kind, Name string
// Container is the item's name field, "" when absent.
Container string
// Path is the dotted YAML path of the scalar, e.g. spec.template.spec.containers[0].image.
Path string
// Raw is the scalar text exactly as decoded (node.Value). Apply searches for this text at
// Line/Col; it exists as a separate field because Ref.String() normalises (drops a
// docker-pullable:// prefix, for one) and so cannot serve as the search key.
Raw string
// Ref is the parsed reference.
Ref image.Ref
}
Occurrence is one image: scalar inside a container item of one manifest document.
type Plan ¶
type Plan struct {
// Variant says which operation produced this plan and therefore how it should describe
// itself — VariantPromote (an env pair, BuildPlan) or VariantDeploy (one image into one
// env, BuildDeployPlan). The empty string means VariantPromote, so a Plan built before
// this field existed, or by a test that does not care, keeps its original meaning.
//
// It exists only for rendering. Nothing that drives a promotion reads it, or SourceEnv:
// engine.DeriveID hashes the target env and the resulting refs, BranchName and the PR
// marker are id-only, and ClaimInFlight/findInFlight key on the target env — so a deploy
// and a promotion into the same env already contend correctly, and a deploy that would
// land the same refs as a promotion is deliberately the same promotion (see DeriveID).
Variant string
// SourceEnv is the env a promotion reads its refs from. Empty for VariantDeploy, which
// has no source: the caller names the reference outright.
SourceEnv string
TargetEnv string
Edits []Edit
// Untouched lists the distinct target-env references no Edit touches: third-party images
// (repo outside the promotable prefixes), repos absent from the source env, and — for a
// deploy — every repo other than the one being deployed.
Untouched []image.Ref
Warnings []Warning
GeneratedAt time.Time
}
Plan is the result of BuildPlan for one env pair.
func BuildDeployPlan ¶
BuildDeployPlan plans writing one image reference into one env: every occurrence of ref.Repo in env is rewritten to ref. This is the "image bump" half of hoist's problem statement, where BuildPlan is the "promote an env pair" half.
It is a sibling of BuildPlan rather than a mode of it because the two differ in where the reference comes from, not in what they do with it. A promotion derives the ref by reading a source env (ChooseRef, the disagreement warning, the source-only-unwritable case); a deploy is handed one outright by the operator — from the tag picker, or --image. Everything after "which ref" is identical, so the two share envOccurrences, checkEditable, unwritable and sortOccurrences, and produce the same Plan for the same engine to drive.
Three differences from BuildPlan worth stating, all of them consequences of having no source env:
- An unwritable ref (bare tag, or digest with no tag — invariant 1) is always an error here. BuildPlan can afford to downgrade it to WarnSourceOnlyUnwritable when the target has nothing to write, because the repo was merely observed in the source; a deploy's ref is the whole request, so there is no lesser thing to do with a bad one.
- A repo with no occurrence in env is an error, not WarnMissingInTarget. For a promotion that repo is one of many being moved and the others still proceed; for a deploy it means there is nothing to deploy into, and reporting success would be a lie.
- Untouched lists every other distinct ref in the env, since exactly one repo is planned. A promotion's Untouched means "not part of this promotion"; a deploy's means the same thing, and is the larger list.
func BuildPlan ¶
func BuildPlan(r *Repo, src, dst string, promotable []string, digests map[string]image.Ref) (Plan, error)
BuildPlan plans the promotion of every promotable image repo from env src to env dst.
promotable lists repo prefixes (e.g. "ghcr.io/example/"); a repo matching none is third-party and only reported. digests overrides the reference chosen for a repo and always wins; the override must be well-formed, pinned and tagged. The plan fails — rather than the later write — if the chosen ref is a bare tag (AGENTS.md §4.2) or a tagless digest (the pod imageID form; the written form is <repo>:<tag>@sha256:<digest>) and the target env has an occurrence to write; with no target occurrence the same ref is a WarnSourceOnlyUnwritable warning, since invariant 1 forbids writing a bare tag, not reading one. The plan also fails if any edit would touch a block scalar.
func BuildPlanWith ¶
func BuildPlanWith(r *Repo, src, dst string, promotable []string, digests map[string]image.Ref, reasons map[string]string) (Plan, error)
BuildPlanWith is BuildPlan with a reason per override, so the source-disagrees warning can say where the chosen ref actually came from. A caller that resolved the ref from the source env's running pods (pkg/resolve) passes that as the reason; the warning then reads "resolved from pods" rather than "caller-supplied digest", which was true only of the mechanism (the override map) and false about the provenance the operator is checking (issue #25). A repo with no entry in reasons gets DefaultOverrideReason.
type Repo ¶
type Repo struct {
Root string
AppsRoot string // relative to Root
Envs map[string]*Env
Apps []ArgoApp
// Unmanaged lists directories (relative to Root) under the apps root, beside a managed
// family, or nested inside one, that hold YAML manifests but are the source path of no
// Application. They are never scanned for promotion: a family is read one level deep, so
// a nested subdirectory's manifests are reported here rather than silently ignored.
Unmanaged []string
}
Repo is the discovered shape of one GitOps checkout.
type Warning ¶
type Warning struct {
Code string
Message string
Occurrences []Occurrence
}
Warning is something the operator should see but that does not block the plan (AGENTS.md principle 5). Code is one of the Warn* constants.