Documentation
¶
Overview ¶
Package reconcile is the pull loop: for each application, fetch the repository, render it, plan against the swarm, and — when the sync policy says so — apply.
Each application runs on its own schedule in its own goroutine. One application whose repository is unreachable must not stall the others, and a shared queue would make exactly that happen.
The set of applications is mutable while the loop runs: Add, Remove and Replace start, stop and retune per-application loops under the reconciler's lock. This is what lets the app set itself be reconciled from git (issue #47); the loop that drives those operations from a diff is a separate concern.
Every piece of work done for an application goes through that application's entry, and the only way to start any is to take its lease. That is what makes the set safely mutable: the entry knows what is running for it, can cancel all of it at once, and can be waited on until it has stopped — whether the work was started by the loop, by the API, or by anything added later. Keeping those three facts in three separate places is what issue #106 was.
Index ¶
- Constants
- type Builder
- type Engine
- type Fetcher
- type Options
- type Reconciler
- func (r *Reconciler) AcceptSync(app string) (func(context.Context) error, error)
- func (r *Reconciler) Add(spec application.Spec) error
- func (r *Reconciler) Diffs(app string) ([]application.ReleaseDiff, error)
- func (r *Reconciler) Draining() []string
- func (r *Reconciler) History(ctx context.Context, app string) (application.History, error)
- func (r *Reconciler) Remove(name string) error
- func (r *Reconciler) Replace(spec application.Spec) error
- func (r *Reconciler) Run(ctx context.Context) error
- func (r *Reconciler) SetRegistryAuth(app string, resolver regauth.Resolver)
- func (r *Reconciler) Sync(ctx context.Context, app string) error
- func (r *Reconciler) SyncNow(ctx context.Context, app string) error
- func (r *Reconciler) View(app string) (application.View, bool)
- func (r *Reconciler) Views() []application.View
Constants ¶
const ( // DefaultInterval matches ArgoCD's. Every tick costs a git fetch, a full // render of every release, and a read of the swarm's release records, so // the default is deliberately not aggressive; an application that needs to // be quicker sets its own. DefaultInterval = 3 * time.Minute )
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Builder ¶
type Builder interface {
Build(ctx context.Context, app string, spec application.Source, co git.Checkout) (*source.Built, error)
}
Builder turns a working tree into a plan's inputs. *source.Builder implements it.
type Engine ¶
type Engine interface {
PlanApply(ctx context.Context, rf *charts.ReleaseFile, src charts.ChartSource, opts charts.PlanOptions) (*charts.Plan, error)
Apply(ctx context.Context, plan *charts.Plan, opts charts.InstallOptions) ([]charts.ApplyResult, error)
History(ctx context.Context, release string) ([]charts.Release, error)
// Uninstall removes a release's stack and its recorded revisions, keeping
// its volumes unless asked. Used only by prune, so an application whose
// sync policy does not enable it never reaches this.
Uninstall(ctx context.Context, release string, purgeVolumes bool) (*charts.UninstallResult, error)
}
Engine is the part of the chart engine this loop uses. *charts.Engine implements it.
type Fetcher ¶
type Fetcher interface {
Fetch(ctx context.Context, app string, src application.Source) (git.Checkout, error)
}
Fetcher brings an application's repository to a revision. *git.Sourcer implements it.
type Options ¶
type Options struct {
Fetcher Fetcher
Builder Builder
Swarms swarms.Registry
NewEngine func(charts.Backend) Engine
Interval time.Duration
Log *slog.Logger
Now func() time.Time
// RegistryAuth resolves an application's image-pull credential, keyed by
// application name. An application absent from the map deploys public images
// only. Built at startup by regauth.Load, which is where a missing or
// unparseable secret becomes a startup error.
RegistryAuth map[string]regauth.Resolver
// ForbiddenSecretMounts names the controller's own mounted secrets, which no
// reconciled stack may mount. Controller-wide; built at startup from the
// controller's /run/secrets. Empty disables the check.
ForbiddenSecretMounts map[string]struct{}
// ControllerID is the identity half of the owner stamp this reconciler
// writes, so that a second controller on the same swarm does not read these
// releases as its own. Empty is application.DefaultControllerID.
ControllerID string
}
Options configures a Reconciler. Everything has a working default except the fetcher and the builder, which have no sensible one.
type Reconciler ¶
type Reconciler struct {
// contains filtered or unexported fields
}
Reconciler runs the loop and holds what it last observed.
func New ¶
func New(apps []application.Spec, o Options) *Reconciler
New returns a Reconciler for the given applications.
func (*Reconciler) AcceptSync ¶
AcceptSync reserves an application's manual-sync slot and returns the sync to run, or application.ErrSyncPending if one is already running with another queued behind it.
The split exists so the caller can detach the work and still answer honestly. The API returns 202 before the sync has done anything, so if the decision to coalesce were made inside the detached goroutine the response would already have gone out claiming a sync was started that never was.
func (*Reconciler) Add ¶
func (r *Reconciler) Add(spec application.Spec) error
Add starts reconciling a new application. It returns an error if one of that name is already present: the caller that drives the set from a git diff (issue #52) tells add from replace itself and does not rely on this being an upsert.
func (*Reconciler) Diffs ¶
func (r *Reconciler) Diffs(app string) ([]application.ReleaseDiff, error)
Diffs returns the manifest changes the last plan found. It does not re-render: what it reports is what the status reports, which is the point.
func (*Reconciler) Draining ¶
func (r *Reconciler) Draining() []string
Draining names the applications that have left the set but whose work has not stopped, and forgets the ones that have since finished.
It is the gap Remove's bounded wait leaves, made visible. A departed application whose sync is still running is still deploying services and still writing revision records, so deleting its resources is the same interleaving Remove exists to prevent — one pass later. The app-set sweep consults this for the same reason it waits for every application to have planned once: what it is about to delete has to be something nobody is still writing.
Sweeping on read rather than on a timer, because the only thing that needs the answer is the caller asking for it.
func (*Reconciler) History ¶
func (r *Reconciler) History(ctx context.Context, app string) (application.History, error)
History returns the recorded revisions of every release the application declares, newest first.
It reads the swarm rather than the status cache: history is the one thing that survives a controller restart with no database of its own, because the engine keeps one Docker Config per revision in Raft. Serving it from memory would make it disappear on exactly the restart it is most useful after.
func (*Reconciler) Remove ¶
func (r *Reconciler) Remove(name string) error
Remove stops reconciling an application and drops what was observed of it. It cancels everything being done for the application — the loop and any sync in flight, whoever started it — and waits for all of it to stop. The deployed stack is left running and becomes unmanaged (D-e); pruning it is separate (issue #54).
"Everything" is the change. This used to cancel and wait for the loop goroutine alone, while a sync started through the API ran detached from any context and was tracked by nothing — so the guarantee its comment made was false for precisely the caller relying on it, appset.Loop. With prune enabled the interleaving destroyed data: the sweep found the application gone, uninstalled its releases and volumes, and the sync still running then recreated the whole stack against volumes that were being deleted, writing fresh revision records for something reconciled by nobody.
The wait is bounded, because cancellation does not reach every daemon call and removals run first in an app-set pass — an unbounded wait would hold up every other membership change behind one departing application. So this no longer promises quiescence: it promises removal. Whether the application's work has actually stopped is the separate question Draining answers, and a caller that deletes resources must ask it.
func (*Reconciler) Replace ¶
func (r *Reconciler) Replace(spec application.Spec) error
Replace swaps the spec a running application reconciles against, keyed by name, keeping its recorded status and its loop: the next tick reads the new spec, so a healthy loop is retuned rather than restarted. A change of name is not a replace — it is a Remove of the old and an Add of the new, which the caller does, because the two report as different applications.
func (*Reconciler) Run ¶
func (r *Reconciler) Run(ctx context.Context) error
Run reconciles until ctx is cancelled.
It starts a loop for every application in the set and then blocks, so that applications added while it runs are supervised under the same context and a removed one's loop is cancelled with it. A destination the swarm registry cannot resolve is not fatal here: it fails its own application on the next tick, surfaced on that application's status, rather than stopping the loop that observes every other one.
func (*Reconciler) SetRegistryAuth ¶
func (r *Reconciler) SetRegistryAuth(app string, resolver regauth.Resolver)
SetRegistryAuth installs the image-pull credential an application's images are pulled with, replacing whatever it had. A nil resolver removes it, which is what an application that has just dropped its registryAuth needs — otherwise the credential it no longer declares would keep being sent.
It exists because the set is mutable: regauth.Load resolves the applications declared at startup, and an application that joins later brings a credential that map has never seen. The caller driving the set resolves it and puts it here before the application is added, so the first reconcile already has it.
func (*Reconciler) Sync ¶
func (r *Reconciler) Sync(ctx context.Context, app string) error
Sync reconciles one application now, applying if its policy allows.
func (*Reconciler) SyncNow ¶
func (r *Reconciler) SyncNow(ctx context.Context, app string) error
SyncNow reconciles one application now and applies whatever the plan contains, whether or not the policy is automated. It is what the API's sync action calls: a manual policy means "do not deploy on a schedule", not "never deploy".
Requests that arrive while one is already running collapse onto the single one already queued, and the extras return application.ErrSyncPending. Without that, N requests all serialised on the lease and redeployed the swarm N times, with the scheduled tick queued behind all of them — at twenty requests under a wait policy the application went unobserved for over an hour.
The queue slot goes back the moment this sync starts rather than when it finishes. A request arriving while one runs is asking about a repository this one has already read, so coalescing it there would silently drop a fix somebody had just pushed.
func (*Reconciler) View ¶
func (r *Reconciler) View(app string) (application.View, bool)
View returns one application's spec and last observed status.
The status is cloned, so what the caller gets is a snapshot it owns. A Status is handed out by value but carries slices and pointers, so without this every caller shared the store's backing array and its *ReleaseDrift, *Compat and *SyncResult — safe only while nobody sorted, appended or wrote through them, which is an invariant nothing stated and nothing enforced. See Status.Clone.
func (*Reconciler) Views ¶
func (r *Reconciler) Views() []application.View
Views returns every application, in the order they were declared or added, each a snapshot the caller owns for the reason View gives.