appset

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 3, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package appset sources the set of applications the controller reconciles, so that the applications file is itself GitOps-managed (issue #47).

Two tiers

The bootstrap config says where the app set lives and is the single anchor nothing in git can repoint. The app set — today's applications.yaml, the same config.File schema — lives wherever that says, and changing it is an ordinary commit rather than a controller redeploy.

Two modes, one rule

The set arrives either from a repository the controller pulls itself, reusing the sourcer and the credential the applications already use, or from a path on a volume that an external process — a git-sync sidecar, the Flux source-controller pattern — keeps current. The two modes differ in nothing but how they produce bytes: parsing, validation, change detection and the swap are written once here, so the guarantee below is the same whichever is configured.

Validate before swap, keep last-good

A load parses and fully validates before anything is swapped in. On any failure — unreachable remote, missing file, malformed YAML, an unknown key, a duplicate application name — the last set that validated stays current and the error is returned. The set is never partially applied, so a bad commit cannot break a running controller. Until the first successful load there is no current set and a failure is all the caller gets.

The path mode's one extra rule

The writer must publish atomically: write a temporary file and rename it over the app-set file. Rename is atomic, so a reader sees one whole version or the other and never a half-written one. A writer that rewrites the file in place instead can be read mid-write; that read fails to parse and is therefore a failed load with the last-good set kept, which is the safe outcome but not a substitute for publishing atomically — a truncation that happens to still be valid YAML would be indistinguishable from a set an operator meant to shrink.

Not here

No timer and no goroutine: Load is called by whoever owns the interval. What to do with a changed set — diffing it and driving per-application loops — is the reconcile loop's (issue #52), and the bootstrap flags that choose a mode are issue #53's.

Index

Constants

View Source
const DefaultInterval = 3 * time.Minute

DefaultInterval is how often the app set is re-read. It matches the reconciler's own default: an operator tuning "how quickly does a commit take effect" should not have to learn that the two tiers move at different speeds.

Variables

This section is empty.

Functions

This section is empty.

Types

type Fetcher

type Fetcher interface {
	Fetch(ctx context.Context, key string, src application.Source) (git.Checkout, error)
}

Fetcher brings a repository to a revision. *git.Sourcer implements it, which is the whole of this package's dependency on git: the app set is pulled by the same client, with the same credential, as the applications it declares.

type GitConfig

type GitConfig struct {
	// RepoURL is https:// or an absolute path, as the sourcer accepts.
	RepoURL string
	// Revision is a branch, tag or commit. A branch is the useful case here:
	// the point of app-of-apps is that a commit to it is picked up.
	Revision string
	// Path is the app-set file within the repository.
	Path string
}

GitConfig locates the app set in a repository the controller pulls itself.

type Loader

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

Loader loads the app set and keeps the last one that validated.

It is safe for concurrent use. The *config.File it hands out is shared with every other caller and with Current, and must be treated as immutable.

func NewGit

func NewGit(fetch Fetcher, cfg GitConfig) *Loader

NewGit returns a Loader that pulls the app set with the given fetcher.

The fetcher should cache under a root of its own rather than the one the applications clone into: the app set is the bootstrap tier, and keeping its clone separate is what makes its cache key unambiguous.

func NewPath

func NewPath(cfg PathConfig) *Loader

NewPath returns a Loader that reads the app set from a directory an external process keeps current. That writer must publish atomically; see the package documentation.

func (*Loader) Current

func (l *Loader) Current() *config.File

Current returns the last set that loaded successfully, or nil if none ever has. It is what keeps running while a load is failing.

func (*Loader) LastLoad

func (l *Loader) LastLoad() (revision string, at time.Time)

LastLoad reports the commit the current set came from and when it was loaded. The revision is empty in path mode, where there is no commit to report, and the time is zero until the first successful load.

func (*Loader) Load

func (l *Loader) Load(ctx context.Context) (*config.File, bool, error)

Load reads, parses and validates the app set.

It returns the current set and whether it changed since the last successful load. A failure returns no set at all — not the last-good one — so that a caller cannot mistake a stale set for a fresh one; the last-good set is reached deliberately, through Current.

type Loop

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

Loop keeps the running set equal to the app set.

On each tick it loads the set and diffs it by application name against what is actually running, adding, replacing and removing accordingly. A load that fails changes nothing: the last-good set keeps running and the reason is reported.

It is deliberately not the thing that decides an application is out of sync — that is the reconciler's, per application, on its own schedule. This loop only decides which applications there are.

func NewLoop

func NewLoop(src *Loader, rec Reconciler, o LoopOptions) *Loop

NewLoop returns a Loop driving rec from src.

func (*Loop) Once

func (l *Loop) Once(ctx context.Context) (err error)

Once loads the app set and brings the running set to it. It is what Run calls, exported so a caller — a test, or a webhook later — can drive one pass without waiting for a tick.

The diff runs on every pass, not only when the file has changed. What is being reconciled towards is the desired state, not the last edit to it: an application that could not be started last tick is still missing, and a loop that only acted on changes would have recorded it as added once and never mentioned it again. Diffing an unchanged set costs a map and a comparison per application and almost always decides to do nothing.

A panic becomes this pass's error rather than the process's death. That is the same judgement the load path already makes and for the same reason: what is running is a last-good set, and a controller that keeps reconciling it while reporting why the newest one could not be applied is more useful than one that exits. It also matches the per-application recover in reconcile — one bad application must not take the others down — since a panic here would be reached through exactly such an application's spec.

func (*Loop) Run

func (l *Loop) Run(ctx context.Context) error

Run keeps the running set current until ctx is cancelled.

There is no backoff. A failing load is one small file read again in three minutes, and backing off would mean the fix an operator has just committed takes longer to land the longer the mistake went unnoticed — which is the wrong way round for the one loop whose job is to pick up corrections.

func (*Loop) Status

func (l *Loop) Status() application.ControllerStatus

Status reports where the set came from and how the last attempt went.

type LoopOptions

type LoopOptions struct {
	// Mode labels how the set is sourced for the status endpoint: "static",
	// "git" or "path". The source does not name itself — the bootstrap decides
	// what to call the thing it configured.
	Mode string
	// Source is where that set lives, for the status endpoint to report: the
	// repository and revision, or the directory. Like Mode it is passed in
	// rather than derived, because it is the bootstrap anchor and describing it
	// is the bootstrap's to do.
	Source   string
	Interval time.Duration
	Log      *slog.Logger

	// Credentials resolves the image-pull credential of an application joining
	// or changing in the set. The default reads the Docker secret the
	// application's registryAuth names, which is the same thing the controller
	// does at startup for the applications it booted with.
	Credentials func(spec application.Spec) (regauth.Resolver, error)

	// Pruner deletes the resources of an application that has left the set.
	// Nil is report-only — the default, and what every deployment that has not
	// asked for prune gets.
	Pruner Pruner

	// Reclaimer deletes the on-disk caches — the clone and the chart cache — of
	// an application that has left the set. Unlike Pruner it is not an opt-in:
	// it deletes nothing that is deployed and nothing that cannot be rebuilt
	// from git. Nil is a loop with no data directory to sweep, which is what a
	// test wants.
	Reclaimer Reclaimer
}

LoopOptions tunes a Loop. Everything has a working default.

type PathConfig

type PathConfig struct {
	Dir  string
	Path string
}

PathConfig locates the app set on a volume an external process keeps current.

It is a directory plus a path within it rather than one file path, matching the git mode: what a sidecar syncs is a repository, its content is no more trusted than a checkout's, and both modes then resolve the file through the same containment check.

type Pruner

type Pruner interface {
	Departed(ctx context.Context, desired, declared []string) ([]string, error)
}

Pruner deletes the deployed resources of applications absent from the set it is given. *prune.Pruner implements it.

An interface rather than the concrete type because it is the one seam in this loop that deletes things, and a test of the loop's guards must be able to assert that it was never called.

type Reclaimer

type Reclaimer interface {
	Sweep(keep []string) error
}

Reclaimer deletes the on-disk caches of applications absent from the set it is given. *reclaim.Sweeper implements it.

An interface for the reason Pruner is one: it is the other thing in this loop that removes something, and a test has to be able to read exactly what it was told to keep.

type Reconciler

type Reconciler interface {
	Views() []application.View
	Add(spec application.Spec) error
	Replace(spec application.Spec) error
	Remove(name string) error
	SetRegistryAuth(app string, resolver regauth.Resolver)
	// Draining names applications that have left the set but whose work has not
	// stopped. Remove's wait is bounded, so "removed" and "quiescent" are two
	// questions and this is the second one. Only the sweep needs it.
	Draining() []string
}

Reconciler is the running set the loop steers. *reconcile.Reconciler implements it.

Add and Replace are separate rather than one upsert because the distinction is the loop's to make: replacing keeps an application's recorded status and its running loop, and adding starts one. A caller that could not tell them apart would silently restart every application on every change.

Jump to

Keyboard shortcuts

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