application

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: 10 Imported by: 0

Documentation

Overview

Package application defines the Application spec, its observed status, and the JSON both serialise to. It is the wire contract shared by the config loader, the reconciler, the HTTP API and the CLI.

Per D3 the API is designed UI-first, and these types are that design: a shape that is awkward to render is a bug here rather than in a handler.

The package is deliberately top-level rather than internal. Per D6 a private companion repository imports this one, and Go's internal rule is per-module, so anything the companion touches cannot live under internal/ — the same reason Eldara-Tech/swarmcli has no internal/ directory at all.

It depends on nothing but the standard library and a YAML decoder: not on Docker, not on the reconciler, not on the CE charts package. The types that mirror charts concepts are re-expressed here so that the API's shape does not move when CE's does.

Index

Constants

View Source
const DefaultControllerID = "default"

DefaultControllerID is the identity a controller stamps with when the deployment does not choose one.

It is a real default rather than a required flag because the single-controller case is the overwhelmingly common one and should not need ceremony. Two controllers sharing a swarm must be given distinct ids — see OwnerID.

View Source
const DefaultHistoryMax = 10

DefaultHistoryMax is how many revisions of each release are kept when an application does not say.

Every revision is one Docker Config in the swarm's raft log carrying the whole rendered manifest, written by every deploy that changes anything. Left unbounded that is one of the two ways this controller can fill the disk of the manager it runs on — a chart whose render is not reproducible being the other, and the two compound: at the three-minute default such a chart writes some 480 of them per release per day. Ten is Helm's own --history-max default and is far more than is ever read back; rollback context is the last few revisions, and the record of what was deployed when is git's.

View Source
const MinInterval = 10 * time.Second

MinInterval is the floor under syncPolicy.interval.

A tick is a git fetch, a full render of every release the application declares, and a read of that application's release records off the swarm. Below this the controller spends its time asking rather than doing, and one application can starve every other — the app set is reconciled from git, so the number is not necessarily written by whoever runs the controller.

Ten seconds rather than something rounder: it is far enough below the three-minute default to leave a demo or a tight feedback loop room, and far enough above zero that the pathological case is bounded. It is a floor, not a default; an application that asks for less is clamped to it and told so.

Variables

View Source
var (
	// ErrNotPlanned reports that an application has not been reconciled yet, so
	// there is nothing to diff and no releases to read a history for.
	//
	// Not a missing application and not a failure: it exists, and the first
	// reconcile has simply not run. The API answers it with an empty result and
	// a flag saying so, which a UI renders as an empty panel rather than as an
	// error.
	ErrNotPlanned = errors.New("no plan yet")

	// ErrSyncPending reports that a manual sync was not started because one is
	// already running with another already queued behind it.
	//
	// Not an error the caller can do anything about, and deliberately not a
	// failure: the queued sync will read the same repository and deploy the same
	// state, so the request has been honoured by the time it matters. It exists
	// so the API can say which of the two happened rather than claiming to have
	// started something it did not.
	ErrSyncPending = errors.New("a sync is already queued for this application")
)

The two sentinels a reconciler answers with that are not failures, and that the API turns into a particular response rather than into an error.

They live here, beside the types those responses are built from, rather than in the package that produces them. api declares a Reconciler interface precisely so that an alternative reconciler can serve the same endpoints, and matching on a sentinel exported by the OSS applier would have made that interface decorative: any replacement would still have had to import the whole of reconcile — go-git, the chart engine, the moby client — for two error values. Errors are part of a contract in Go, so a contract stated as an interface has to state its errors in the same place.

Functions

func AppFromOwnerID

func AppFromOwnerID(controller, id string) (string, bool)

AppFromOwnerID reports which of this controller's applications an owner id names, and whether the id belongs to this controller at all.

False for everything else on the swarm: an "apply/" stamp from the command line, another tool's id, a bare prefix naming no application, and — the case this exists for — an id belonging to a different swarmcli-cd. Prune treats false as "not mine", which is what stops it deleting a release it did not install.

A stamp in the pre-controller-id format ("cd/<app>") is likewise not this controller's. That is deliberate — it reads as unmanaged, so prune leaves it alone, and the migration errs towards not deleting.

It does heal, and it is worth being exact about when. Ownership is part of what decides whether a release needs deploying (swarmcli#511), so a stamp in this format contradicts the one this controller would write, and the first reconcile that plans the release redeploys it once and stamps it properly. That is the next pass for an automated application and not until it is asked for a manual one, so the old format outlives the upgrade by an interval at least. What keeps that interval safe is not the stamp but prune's second signal: a release an application still declares is never swept, whatever it is stamped with (#62).

func OwnerID

func OwnerID(controller, app string) string

OwnerID is the id this controller stamps a release with and classifies deployed releases against: "cd/<controller>/<application>".

Both halves are load-bearing and for different reasons.

The application half is what keeps sibling applications apart. Several applications share one swarm, and an id that named only the controller would make each of them report the others' releases as its own orphans.

The controller half is what keeps whole controllers apart, and exists because prune acts on the difference. A sweep asks "which releases on this swarm belong to an application my app set no longer declares", and without a controller in the id, a second swarmcli-cd on the same swarm answers that question about the first one's applications — and deletes them. Two controllers sharing a swarm must therefore be given distinct ids, or each will treat the other's work as departed.

It lives here, in the wire contract, rather than in the reconciler that writes it, because it is also what prune reads back off the swarm to decide what may be deleted. Two copies of this format that drifted apart would not fail loudly — the reconciler would keep stamping and prune would quietly stop recognising, which is a deletion bug in whichever direction it broke.

func ValidRepositoryName

func ValidRepositoryName(name string) bool

ValidRepositoryName reports whether a chart repository may be called name.

Declaring a name is not all it does. The chart engine builds its cache file by concatenating the name into "index-<name>.yaml" and joining that onto its store directory, so the name is a path component before it is an identifier. The concatenation is what makes it dangerous: "index-.." is an ordinary segment, so Join's Clean does not neutralise a traversal, it only costs one extra "../" — and a name that escapes writes attacker-chosen YAML wherever a process holding the docker socket can reach, up to and including the app set naming every repository the controller then deploys from.

It lives here, in the wire contract, rather than in the config loader, because a name reaches the engine by two routes and only one of them is the operator's file: a release file committed to a tenant's repository carries repositories of its own, and package source checks those on the way past. Two copies of this charset that drifted apart would leave one route open, which is the whole of #100.

The engine validates the name itself since Eldara-Tech/swarmcli#531, so the traversal above is no longer open at the pin this module carries. This is kept as the outer of two checks rather than deleted: it is stricter — the engine permits a leading '.', '-' or '_' — and it is the one this repository owns, which is what makes the guarantee independent of a version in go.mod.

func ValidateControllerID

func ValidateControllerID(id string) error

ValidateControllerID refuses an id that would produce an unparseable stamp or one that cannot be told apart from another.

A slash would make "cd/<controller>/<application>" ambiguous about where the controller ends, and a colon is what the chart engine itself rejects. Space is refused because an id that differs from another only by trailing whitespace is the kind of distinction an operator cannot see and prune would act on.

Types

type Allow

type Allow struct {
	// HostPaths are the paths on a node that this application's charts may bind.
	//
	// A listed path permits itself and everything under it, because that is what
	// a bind of a directory already is: a container given /srv/app has
	// /srv/app/data. So "/var/run" grants the docker socket and "/" grants the
	// node, both of which are the operator having said so.
	//
	// This is the one entry that gives a capability back rather than only
	// narrowing one. A bind of /var/run/docker.sock is what Traefik's swarm
	// provider, a Portainer agent and an autoheal sidecar *are*, and #103 had to
	// refuse all three flat because there was nowhere to say otherwise.
	HostPaths []string `json:"hostPaths,omitempty" yaml:"hostPaths,omitempty"`

	// Secrets are the Docker secrets a chart may reference without declaring.
	Secrets []string `json:"secrets,omitempty" yaml:"secrets,omitempty"`
	// Configs are the Docker configs a chart may reference without declaring.
	Configs []string `json:"configs,omitempty" yaml:"configs,omitempty"`
	// Volumes are the named volumes a chart may mount that are not its own.
	Volumes []string `json:"volumes,omitempty" yaml:"volumes,omitempty"`
	// Networks are the networks a chart may join that it does not create.
	Networks []string `json:"networks,omitempty" yaml:"networks,omitempty"`
}

Allow is what an operator permits one application's charts to reach outside the releases they install: paths on the nodes those releases run on, and the cluster-global names of resources some other stack owns.

An allowlist, and why it is not a denylist

Anything not named here is refused. The other direction leaves whatever nobody thought of permitted, and the family of holes swarmcli-cd#103 closed — a bind of the docker socket, a mount of the controller's own volume, a join of its network — was "nobody thought of it" in each case until somebody did. The compose language keeps growing and the swarm's namespace of names is shared by every tenant on it, so the set of things worth forbidding is not one anybody can finish enumerating; the set an application actually needs is.

Why it lives in the app set

The app-set repository is root-equivalent and is meant to be protected as such, separately from any application's own chart repository, "so that being able to change an app's chart is not the same permission as being able to add an app" (docs/configuration.md). A chart author who could widen their own permissions — a field in a release file, a value in a chart — would collapse those two into one, which is the boundary the whole design rests on. So this is a field of Spec and there is deliberately no equivalent anywhere a chart can write.

What it cannot say

It cannot name the controller's own secrets, configs, volume or network, or the chart engine's release records. Those are refused before this is consulted (backend.rejectForbiddenResources), and no entry here changes that. Permitting one would not be an operator granting an application something of the operator's; it would be handing the chart author the controller's own credentials and, with them, the app set — the boundary above, approached from the other side. An operator who wants an application on the controller's network attaches the controller to a shared network instead, which is a topology decision and was never refused.

func (Allow) PermitsPath

func (a Allow) PermitsPath(source string) bool

PermitsPath reports whether source is a host path this application's charts may bind.

Containment rather than equality, in one direction only: a listed path permits everything under it, and nothing above it. Permitting /srv/app therefore does not permit /srv or /, which contain it — the entry an operator wrote is a ceiling, not a hint.

Lexical, and on the path package rather than path/filepath deliberately: the string names a filesystem on whichever node runs the task, which is the one filesystem this controller cannot read, so there is no symlink to resolve and no separator but "/" that could apply. Clean folds "..", doubled separators and a trailing slash, which is the whole of what a manifest can vary without naming somewhere else.

It takes an absolute source, which is what the caller has already established: a relative bind source is refused before this, because it has no referent at all rather than an impermissible one.

type AppSetStatus

type AppSetStatus struct {
	// Mode is how the set is sourced, as the deployment configured it —
	// "static", "git" or "path". It is a label passed in rather than something
	// the source infers about itself, so the bootstrap's own vocabulary is what
	// an operator sees reflected back.
	Mode string `json:"mode"`

	// Source is what the bootstrap was pointed at, for a human: the repository,
	// the revision it tracks and the file within it, or the directory and file
	// an external process keeps current. Mode alone cannot be checked against
	// anything, and "which branch is this controller following" is the first
	// question a set that does not look right raises — and the one thing nothing
	// in git can change (D-f of #47).
	Source string `json:"source,omitempty"`

	// Revision is the commit the running set was loaded from. Empty when the
	// set does not come from a repository.
	Revision string `json:"revision,omitempty"`

	// LoadedAt is when the running set last loaded successfully — not when it
	// was last checked. A load that changed nothing does not move it, so the
	// pair with Error answers "how old is what I am running".
	LoadedAt time.Time `json:"loadedAt"`

	// Error is why the last attempt failed: a load that was refused, or the
	// applications a diff could not apply. Cleared by the next attempt that
	// succeeds.
	Error string `json:"error,omitempty"`

	// Stale reports that the running set is a last-good one and a newer version
	// is being refused. It is the field a UI colours; Error is what it shows
	// beside it. An error with Stale false is one of two different problems: a
	// set that loaded but could not be fully applied, or — with Applications at
	// zero and LoadedAt unset — a controller that has never managed to load one,
	// which is the louder of the three.
	Stale bool `json:"stale"`

	// Orphaned names applications that left the set. Their loops are stopped and
	// their stacks are still deployed and no longer reconciled by anyone —
	// reported rather than removed, unless prune is enabled.
	//
	// This list is what the running loop watched leave, so a restart empties it.
	// That is a gap in the reporting and not in the cleanup: the owner stamps
	// the releases carry are the durable record, so a controller with prune
	// enabled still finds and removes an application that departed before it
	// started. With prune disabled a restart does forget, and the swarm is the
	// only place left that knows.
	Orphaned []string `json:"orphaned,omitempty"`

	// Pruned names applications whose resources this controller has deleted,
	// most recent last. Empty whenever prune is disabled, which is the default.
	//
	// It exists because a departed application otherwise leaves no trace at all
	// once prune has run: it is gone from the app set, gone from Orphaned, and
	// gone from the swarm. "Did it actually go, or did the controller never
	// notice" is then only answerable from the logs.
	//
	// Like Orphaned, in memory: a restart empties it. What it reports is this
	// process's own deletions, not an audit log.
	Pruned []string `json:"pruned,omitempty"`

	// PruneHeldBy names the applications that have not reconciled yet and are
	// therefore holding the sweep back. Empty whenever prune is disabled, and
	// on any controller whose applications have all planned at least once —
	// which after a settled startup is every controller.
	//
	// The sweep deletes what no application declares, so it cannot run while an
	// application has not said what it declares; it would read that silence as
	// a departure. Waiting is the safe half of that trade and this is the other
	// half: an operator who enabled prune and sees nothing being pruned has to
	// be able to find out which application is holding it, or the safety
	// measure is indistinguishable from a broken feature.
	PruneHeldBy []string `json:"pruneHeldBy,omitempty"`
}

AppSetStatus is where the set of applications came from and how that is going.

type ChartSource

type ChartSource struct {
	// Release is the release name to install as, and a release name *is* the
	// Swarm stack namespace — so this, and not the application's Name, is what
	// `docker stack ls` shows. The two are separate on purpose: a releaseFile
	// application installs several releases under one name, so the application
	// cannot be the release.
	//
	// Empty means the application's name. That is Argo CD's default for
	// `source.helm.releaseName`, which is the reflex an operator arrives with
	// (#139), and it is the one choice that cannot collide: application names
	// are unique within an app set, so a set that never writes this down can
	// never have two applications claiming one namespace.
	//
	// The config loader resolves the default, so by the time a spec is served,
	// planned or synthesised this holds the name that will reach the swarm.
	// Deriving it again anywhere downstream would be a second copy of the rule.
	Release      string           `json:"release" yaml:"release"`
	Path         string           `json:"path,omitempty" yaml:"path,omitempty"` // chart directory within the repo
	Ref          string           `json:"ref,omitempty" yaml:"ref,omitempty"`   // repo/chart
	Version      string           `json:"version,omitempty" yaml:"version,omitempty"`
	Values       []string         `json:"values,omitempty" yaml:"values,omitempty"` // paths within the repo
	Repositories []RepositorySpec `json:"repositories,omitempty" yaml:"repositories,omitempty"`
}

ChartSource is the "one application, one chart" case that does not deserve a release file in the repository. Its rules are the ones charts itself enforces: Version is required for a repository reference and forbidden for a path, because a floating pin would silently upgrade production on the next reconcile.

type Compat

type Compat struct {
	Status   CompatState `json:"status"`
	Required string      `json:"required,omitempty"`
	Engine   string      `json:"engine,omitempty"`
	Reason   string      `json:"reason,omitempty"`
}

Compat is a chart's declared swarmcliVersion verdict. Planning records it and never enforces it, so an unattended controller has to surface it or the operator never learns the chart wanted a newer engine.

type CompatState

type CompatState string

CompatState is a chart's swarmcliVersion verdict against the engine this controller embeds.

const (
	CompatUnknown      CompatState = ""
	CompatOK           CompatState = "ok"
	CompatIncompatible CompatState = "incompatible"
)

func (CompatState) MarshalJSON

func (c CompatState) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (*CompatState) UnmarshalJSON

func (c *CompatState) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

type ControllerStatus

type ControllerStatus struct {
	AppSet       AppSetStatus `json:"appSet"`
	Applications int          `json:"applications"`
}

ControllerStatus is the controller itself as last observed, as distinct from the applications it reconciles.

It exists because the app set became something that can fail on its own: once the set is pulled from git rather than mounted at deploy time, "every application looks fine" and "the controller has been refusing every commit for an hour" are both true at the same time, and nothing in the per-application views can say the second. This is where that is said.

Applications counts what is actually being reconciled, which is not necessarily what the last-loaded file declares: an application the set added but that could not be started is in the file and not in this count.

type Destination

type Destination struct {
	Swarm string `json:"swarm,omitempty" yaml:"swarm,omitempty"`
}

Destination names the swarm, resolved through the SwarmRegistry seam. Empty means the local swarm, which is the only one Phase 1 can resolve.

type Drift

type Drift struct {
	State DriftState `json:"state"`
	// Services counts the services that differ, across every release. It is the
	// number a list row shows without descending into Releases.
	Services int `json:"services"`
	// Resources counts the networks, configs and secrets a sync will delete,
	// across every release. Separate from Services because they are different
	// findings: a service here differs from what the repository declares,
	// whereas a resource here is one the repository has stopped declaring at
	// all.
	Resources int `json:"resources,omitempty"`
	// Message names the worst release, or says why the comparison could not be
	// made when State is Unknown.
	Message string `json:"message,omitempty"`
}

Drift is the live-drift rollup for a whole application.

It is a separate axis from Sync in the same way Health is: Sync.State says the application does not match git, and this says the reason is that the swarm moved rather than that git did. Those need different actions from an operator — one is a commit to review, the other is a change nobody recorded.

type DriftDetection

type DriftDetection string

DriftDetection is how an application's drift is decided.

manifest compares the rendered manifest against what was last applied, which catches a changed chart version, changed values and a changed template. live additionally compares the running ServiceSpec against the one the repository renders to, which is the only thing that can catch a change made to the swarm afterwards — Swarm has no server-side apply, so `docker service update --replicas 10` produces no conflict signal at all.

const (
	DriftUnknown  DriftDetection = ""
	DriftManifest DriftDetection = "manifest"
	DriftLive     DriftDetection = "live"
)

func (DriftDetection) MarshalJSON

func (d DriftDetection) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (*DriftDetection) UnmarshalJSON

func (d *DriftDetection) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

func (DriftDetection) Valid

func (d DriftDetection) Valid() bool

Valid reports whether d names a mode this build implements. It is what the config loader checks: unlike the wire, applications.yaml gets no leniency.

type DriftReason

type DriftReason string

DriftReason is how one service differs. The four are genuinely different problems: Modified is a service whose spec was changed, Missing is one that is declared and not running at all, Unexpected is one running under the stack's namespace that the manifest does not declare, and RolledBack is one Swarm itself reverted because the spec this controller wrote would not converge.

Only two of them are worth redeploying for. An Unexpected service is not there to be rewritten, and a RolledBack one has already had the repository's answer rejected by the platform — see reconcile.convergeable.

const (
	DriftReasonUnknown DriftReason = ""
	DriftModified      DriftReason = "modified"
	DriftMissing       DriftReason = "missing"
	DriftUnexpected    DriftReason = "unexpected"
	DriftRolledBack    DriftReason = "rolled-back"
)

func (DriftReason) MarshalJSON

func (d DriftReason) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (*DriftReason) UnmarshalJSON

func (d *DriftReason) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

type DriftState

type DriftState string

DriftState is whether the running services match what the repository renders to, under driftDetection: live. It is a separate axis from SyncState rather than a member of it, because "git moved" and "the swarm moved" need different actions from an operator even though both make an application out of sync.

Unknown is not merely "not evaluated". It is also what a release whose live state could not be read reports, and the reconciler will not converge one: the controller does not write on the strength of a read it could not make.

const (
	DriftStateUnknown  DriftState = ""
	DriftStateNone     DriftState = "none"
	DriftStateDetected DriftState = "detected"
)

func (DriftState) MarshalJSON

func (d DriftState) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (*DriftState) UnmarshalJSON

func (d *DriftState) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

type Duration

type Duration time.Duration

Duration is a time.Duration that reads and writes as a string ("30s", "10m") rather than as a count of nanoseconds. These values are written by hand in applications.yaml and read by humans in API output, and 600000000000 is neither writable nor readable.

func (Duration) MarshalJSON

func (d Duration) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (Duration) MarshalYAML

func (d Duration) MarshalYAML() (any, error)

MarshalYAML implements yaml.Marshaler.

func (Duration) String

func (d Duration) String() string

String renders d in the form time.ParseDuration accepts.

func (*Duration) UnmarshalJSON

func (d *Duration) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

func (*Duration) UnmarshalYAML

func (d *Duration) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML implements yaml.Unmarshaler. It is needed separately because gopkg.in/yaml.v3 does not honour encoding.TextUnmarshaler.

type FieldDrift

type FieldDrift struct {
	Field   string `json:"field"`
	Desired string `json:"desired"`
	Live    string `json:"live"`
}

FieldDrift is one field that differs, rendered rather than typed: this package deliberately depends on nothing but the standard library, so a swarm.ServiceSpec value cannot appear here.

Environment values are never rendered. What is running is whatever an operator set out of band, this is served to anyone with read scope, and an environment variable is exactly where a credential would be — so an env difference reports "set", "absent" or "differs" and never the value itself.

type Health

type Health struct {
	State    HealthState   `json:"state"`
	Message  string        `json:"message,omitempty"`
	Services ServiceCounts `json:"services"`
}

Health answers "is what is running actually working". It is a separate axis from Sync — a stack can be synced and degraded at once, and collapsing the two loses the distinction that makes the view useful. Services carries the counts a list row renders without descending into Releases.

type HealthState

type HealthState string

HealthState is whether what is running is working. Missing — declared but not present — is deliberately distinct from Degraded, which is present and unhealthy: a UI needs to tell those apart and so does an operator.

const (
	HealthUnknown     HealthState = ""
	HealthHealthy     HealthState = "healthy"
	HealthProgressing HealthState = "progressing"
	HealthDegraded    HealthState = "degraded"
	HealthMissing     HealthState = "missing"
)

func (HealthState) MarshalJSON

func (h HealthState) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (*HealthState) UnmarshalJSON

func (h *HealthState) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

type History

type History struct {
	Releases []ReleaseHistory `json:"releases"`
}

History is one application's release history: every release it declares, with the revisions recorded for each.

It is served by its own endpoint rather than carried on Status for the same reason ReleaseDiff is — a list view rendering twenty applications must not drag their histories along.

type ReleaseDiff

type ReleaseDiff struct {
	Release string     `json:"release"`
	Action  SyncAction `json:"action"`
	Diff    string     `json:"diff"`
}

ReleaseDiff is the manifest change one release would undergo.

It is deliberately not part of ReleaseStatus: a diff carries whole manifests, and a list view rendering twenty applications must not drag them along. It is served by its own endpoint, for one application at a time.

type ReleaseDrift

type ReleaseDrift struct {
	State    DriftState     `json:"state"`
	Services []ServiceDrift `json:"services,omitempty"`
	// Resources are the networks, configs and secrets a sync will delete:
	// carrying the release's namespace label, absent from the render, and
	// declared by a revision this controller stamped for this application.
	//
	// Every entry is an orphan by construction, which is why there is no
	// Orphaned field to set. Unlike a service, none of these kinds is compared
	// field by field — Swarm cannot update a network in place, and configs and
	// secrets are immutable — so a difference in one is already a manifest-level
	// difference and there is nothing else a resource entry could mean.
	Resources []ResourceDrift `json:"resources,omitempty"`
	// Message is why State is Unknown. A release whose live state could not be
	// read is not converged, so this is the only record that the question was
	// asked and went unanswered.
	Message string `json:"message,omitempty"`
}

ReleaseDrift is what the live comparison found for one release.

type ReleaseHistory

type ReleaseHistory struct {
	Name      string     `json:"name"`
	Revisions []Revision `json:"revisions"`
}

ReleaseHistory is one release's revisions, newest first.

A release the repository declares but that has never been deployed has an empty Revisions rather than being absent: a history view shows the release with nothing under it, which is the honest answer and a different one from "no such release".

type ReleaseStatus

type ReleaseStatus struct {
	Name     string          `json:"name"`
	Chart    string          `json:"chart"`
	Version  string          `json:"version"`
	Revision int             `json:"revision"` // charts revision number; 0 when never installed
	Action   SyncAction      `json:"action"`
	Sync     SyncState       `json:"sync"`
	Health   Health          `json:"health"`
	Services []ServiceStatus `json:"services,omitempty"`
	Compat   *Compat         `json:"compat,omitempty"`

	// Wave is the release file's `wave:`, the group this release is applied in.
	// Releases are listed in wave order, every release in a wave converges before
	// the next wave starts, and a wave that does not converge stops the ones
	// after it — so this is how a reader tells which releases a stalled sync
	// never reached.
	//
	// Omitted when zero, which is both the default and "explicitly first"; there
	// is no unset wave to tell apart from wave 0. An application whose release
	// file declares no wave therefore serves exactly the payload it always has.
	Wave int `json:"wave,omitempty"`

	// Drift is what the live comparison found for this release, nil when it was
	// not made: manifest mode, a release the plan would install or upgrade
	// (there is nothing settled to compare against), or a backend that cannot
	// read service specs.
	Drift *ReleaseDrift `json:"drift,omitempty"`
}

ReleaseStatus is one release of one application.

type RepositorySpec

type RepositorySpec struct {
	Name string `json:"name" yaml:"name"`
	URL  string `json:"url" yaml:"url"`
}

RepositorySpec names a chart repository a ChartSource resolves Ref against.

type ResourceDrift

type ResourceDrift struct {
	Kind ResourceKind `json:"kind"`
	Name string       `json:"name"`
}

ResourceDrift is one network, config or secret a sync will delete.

Name is namespace-scoped, as it is on the swarm — the name docker network ls or docker config ls shows, and what an operator would type to remove it by hand. Kind is needed because the three share one namespace of names and a name alone does not say what to look at.

type ResourceKind

type ResourceKind string

ResourceKind names the kind of a ResourceDrift entry.

const (
	ResourceNetwork ResourceKind = "network"
	ResourceConfig  ResourceKind = "config"
	ResourceSecret  ResourceKind = "secret"
)

type Revision

type Revision struct {
	Revision int    `json:"revision"`
	Chart    string `json:"chart"`
	Version  string `json:"version"`
	// Status is the engine's derived status: the highest revision keeps its
	// stored status and every lower deployed one reads "superseded".
	Status string `json:"status"`
	// Created is RFC3339, as the engine recorded it.
	Created string `json:"created,omitempty"`
	// Owner is the stamp naming what produced the revision, empty when
	// unclaimed. A history view showing a revision this controller did not
	// install is telling the operator something worth knowing.
	Owner string `json:"owner,omitempty"`
}

Revision is one recorded revision of one release.

Deliberately not charts.Release. That type carries the rendered manifest and the merged values of every revision, and a history response covering six releases at ten revisions each would then be sixty manifests — for a view that renders a table of numbers, dates and chart versions. The manifest of a specific revision is a different request, if it is ever wanted.

type ServiceCounts

type ServiceCounts struct {
	Healthy int `json:"healthy"`
	Total   int `json:"total"`
}

ServiceCounts is the "3/4" a list row shows.

type ServiceDrift

type ServiceDrift struct {
	Name   string       `json:"name"`
	Reason DriftReason  `json:"reason"`
	Fields []FieldDrift `json:"fields,omitempty"`
	// Truncated counts the differences beyond those listed. A service whose
	// every field was rewritten would otherwise put an unbounded list into a
	// status payload served on every poll.
	Truncated int `json:"truncated,omitempty"`
	// Orphaned marks an Unexpected service this application provably installed
	// and no longer declares: a revision carrying its owner stamp declared it,
	// which the stack's namespace label alone could never establish. Read it as
	// "this goes on the next sync".
	//
	// Only ever set when SyncPolicy.PruneResources is on, because that is the
	// only case where the proof is worth computing. An unexpected service on an
	// application that has not enabled it carries no marker and is not a
	// candidate for anything — the same distinction charts.Plan draws between
	// an orphaned release and an unmanaged one.
	Orphaned bool `json:"orphaned,omitempty"`
	// Message is the daemon's own account of why a RolledBack service was
	// reverted, verbatim from the service's UpdateStatus ("update paused due to
	// failure or early termination of task …").
	//
	// Passed through rather than reworded: it names the task that failed, which
	// is the thread an operator pulls next, and this controller did not observe
	// the failure and has nothing to add to it. Empty for every other reason.
	Message string `json:"message,omitempty"`
}

ServiceDrift is one service that does not match.

Name is namespace-scoped — "<release>_<service>", what `docker service ls` shows and what an operator would type. Descoping would be a guess for a service whose own name contains the separator, and there is no unscoped name at all for one the manifest does not declare.

type ServiceStatus

type ServiceStatus struct {
	Name        string      `json:"name"`
	Mode        string      `json:"mode"`
	Running     int         `json:"running"`
	Desired     int         `json:"desired"`
	Completed   int         `json:"completed,omitempty"`
	Health      HealthState `json:"health"`
	UpdateState string      `json:"updateState,omitempty"` // "" means never updated, not finished
	Message     string      `json:"message,omitempty"`
}

ServiceStatus is one Swarm service under a release.

type Source

type Source struct {
	RepoURL  string `json:"repoURL" yaml:"repoURL"`
	Revision string `json:"revision" yaml:"revision"` // branch, tag or SHA, as written

	ReleaseFile string       `json:"releaseFile,omitempty" yaml:"releaseFile,omitempty"` // path within the repo
	Chart       *ChartSource `json:"chart,omitempty" yaml:"chart,omitempty"`
}

Source locates the desired state in git. Exactly one of ReleaseFile and Chart is set, and which one is present is the source type — a separate discriminator field would be a second thing to keep consistent with it.

type Spec

type Spec struct {
	Name   string `json:"name" yaml:"name"`
	Source Source `json:"source" yaml:"source"`

	// RegistryAuth names a Docker secret holding a docker config.json
	// ({"auths":{...}}). The controller uses only this application's secret to
	// authenticate the pulls its images need, so one application cannot pull
	// another's private images even though both credentials are mounted in the
	// same controller. Empty means the application's images are public.
	//
	// It is a secret name, not a path: the controller reads it from the default
	// mount /run/secrets/<name>, so the secret must be mounted there — the
	// short form `secrets: [<name>]` in stack.yml does exactly that.
	RegistryAuth string `json:"registryAuth,omitempty" yaml:"registryAuth,omitempty"`

	// Allow is what this application's charts may reach outside the releases
	// they install. Absent means nothing, which is the safe reading of a field
	// nobody wrote.
	Allow Allow `json:"allow" yaml:"allow,omitempty"`

	Destination    Destination    `json:"destination" yaml:"destination"`
	SyncPolicy     SyncPolicy     `json:"syncPolicy" yaml:"syncPolicy"`
	DriftDetection DriftDetection `json:"driftDetection" yaml:"driftDetection"`
}

Spec is what an operator declares in applications.yaml. It is read-only over the API: the file is the only source of truth, whether it is mounted at deploy time or committed to git, and the API serves it rather than owning it.

func (Spec) Clone

func (s Spec) Clone() Spec

Clone returns a Spec that shares no memory with this one.

Same reason as Status, and the same two callers: a Spec is handed out by value but carries *ChartSource — whose Values and Repositories are slices — and SyncPolicy's *int. The store's copy is what the app-set loop diffs the next desired set against, so a caller writing through any of those would change what "unchanged" means for that application on every pass afterwards.

type Status

type Status struct {
	Sync   Sync   `json:"sync"`
	Health Health `json:"health"`

	// Drift is the live-drift rollup, and is nil for an application whose
	// driftDetection is manifest — the mode that does not ask the question. Nil
	// rather than an Unknown state so that a manifest-mode payload is exactly
	// what it was before this axis existed, and so that "not asked" and "asked,
	// could not tell" stay distinguishable.
	Drift *Drift `json:"drift,omitempty"`

	Releases   []ReleaseStatus `json:"releases,omitempty"`
	Error      string          `json:"error,omitempty"` // last reconcile error; not a failed sync
	ObservedAt time.Time       `json:"observedAt"`
}

Status is one application as last observed. The list view renders it without Releases and the detail view renders it with. Releases is never legitimately empty once populated — charts rejects a release file declaring no releases — so its absence unambiguously means "not requested" rather than "none".

func (Status) Clone

func (s Status) Clone() Status

Clone returns a Status that shares no memory with this one.

It exists because a Status is handed out by value while carrying slices and pointers, so "by value" copies the headers and not what they point at. The reconciler holds one per application and answers every read from it, so every caller — the list endpoint, the detail endpoint, the app-set loop's diff, and whatever the companion adds — was given the same backing array and the same *ReleaseDrift, *Compat and *SyncResult. That was safe only because the store is replaced wholesale on each reconcile and no consumer happened to mutate: an undocumented invariant that the first caller to sort Releases in place, or to append to it, would break — and break in the store, for every other caller at once, under the reconciler's read lock where nothing would look.

Answering with a snapshot is the contract that removes the invariant rather than documenting it. The cost is one shallow copy per release per read, on a path that already serialises the result to JSON.

Every reference-typed field has to be copied here, and TestCloneSharesNothing WithItsOriginal is what enforces that: it builds a value with every reference field populated *by reflection*, clones it, and fails on any pointer or slice the two still share. Populating by reflection rather than by hand is the whole of what makes it a guard — the first version of this test used a hand-written fixture, and Spec.Allow was added the same day and went unchecked, because a fixture can only cover the fields somebody remembered to write into it.

type Sync

type Sync struct {
	State    SyncState   `json:"state"`
	Revision string      `json:"revision,omitempty"` // resolved SHA, never a branch name
	Summary  SyncSummary `json:"summary"`
	LastSync *SyncResult `json:"lastSync,omitempty"`
}

Sync answers "does the swarm match git".

Revision is the commit the assessment was made against; LastSync.Revision is what was actually deployed. When the two differ there is a newer commit that has not been applied, which is a different condition from being OutOfSync, and a UI shows both.

type SyncAction

type SyncAction string

SyncAction is what a sync would do to one release. The names mirror the chart engine's own vocabulary value-for-value; diverging from it would mean translating in both directions for no gain.

const (
	ActionUnknown   SyncAction = ""
	ActionUnchanged SyncAction = "unchanged"
	ActionInstall   SyncAction = "install"
	ActionUpgrade   SyncAction = "upgrade"
)

func (SyncAction) MarshalJSON

func (a SyncAction) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (*SyncAction) UnmarshalJSON

func (a *SyncAction) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

type SyncPolicy

type SyncPolicy struct {
	Automated bool     `json:"automated" yaml:"automated"`
	Interval  Duration `json:"interval,omitempty" yaml:"interval,omitempty"`
	Wait      bool     `json:"wait,omitempty" yaml:"wait,omitempty"`
	Timeout   Duration `json:"timeout,omitempty" yaml:"timeout,omitempty"`

	// HistoryMax bounds a release's stored revision history: the newest N are
	// kept and the rest deleted after a deploy that succeeded. Absent means
	// DefaultHistoryMax; an explicit 0 means keep every revision, which is the
	// chart engine's own reading of the number.
	//
	// A pointer for exactly that reason. "Not set" and "set to keep all" have to
	// stay different answers, and they were the same one — zero — while the
	// default was to keep all, which is how a controller ends up writing one
	// Docker config per deploy into a manager's raft log for ever. See Retention.
	HistoryMax *int `json:"historyMax,omitempty" yaml:"historyMax,omitempty"`

	// Prune deletes the resources of a release this application used to declare
	// and no longer does. Off by default: reporting an orphan is safe and
	// deleting one is not, so it is a deliberate choice per application.
	//
	// It governs only this application's own releases — those carrying its
	// owner stamp. A release another application or the command line installed
	// is unmanaged here and is never touched, whatever this says.
	//
	// Not to be confused with HistoryMax, which prunes an individual release's
	// revision history rather than the release itself. Two senses of the word,
	// one of which is the chart engine's; see the prune package.
	Prune bool `json:"prune,omitempty" yaml:"prune,omitempty"`

	// PruneVolumes extends Prune to the named volumes of what it deletes, and
	// means nothing without it — a config declaring one and not the other is
	// refused rather than half-obeyed.
	//
	// Separate from Prune because it is the one irreversible part. Everything
	// else prune removes can be recreated from git on the next reconcile; the
	// data in a volume cannot be recreated from anything.
	PruneVolumes bool `json:"pruneVolumes,omitempty" yaml:"pruneVolumes,omitempty"`

	// PruneResources deletes a service, network, config or secret that this
	// application's own chart used to declare and no longer does. Off by
	// default, for the same reason as Prune.
	//
	// It is a sibling of Prune rather than an extension of it, and deliberately
	// does not require it: Prune decides what happens to a whole release the
	// application stopped declaring, this decides what happens inside a release
	// it still declares, and an operator may reasonably want the second without
	// the first. Applying deletes nothing, so without this a resource dropped
	// from a template stays until somebody removes it by hand.
	//
	// One consent covers all four kinds because it is one statement: this
	// chart is authoritative about what exists in its own release. Configs and
	// secrets are the ones that accumulate fastest — they are immutable, so a
	// chart that hashes content into the name as the applier tells it to
	// strands the previous copy on every value change, not only when a
	// declaration is removed.
	//
	// A resource is deleted only when the swarm, git and this controller's own
	// records all agree: it carries the release's stack namespace label, the
	// rendered manifest does not declare it, and a revision this controller
	// stamped for this application did. The namespace label alone is not
	// evidence — anything can carry it — which is why the third clause exists.
	// See the prune package.
	//
	// Volumes are never included, and neither are the external networks
	// swarmcli auto-created for a release: both are reported and left in place.
	PruneResources bool `json:"pruneResources,omitempty" yaml:"pruneResources,omitempty"`

	// PruneFirst deletes before installing, instead of after.
	//
	// The default order applies and then prunes, so a failed apply leaves the
	// old release running rather than nothing at all. The cost is that a
	// renamed release briefly coexists with the name it replaced: the new name
	// is an install and the old one an orphan, and between the two steps both
	// are deployed.
	//
	// For a workload where two instances running at once is worse than none —
	// a blockchain validator that would double-sign and be slashed, a job
	// runner that must not process a queue twice — that trade is the wrong way
	// round. This inverts it: the departing release is deleted before its
	// replacement is installed, so they never overlap, and a failed apply
	// leaves a gap instead.
	//
	// It bounds the overlap this controller creates deliberately; it is not a
	// distributed lock. Nothing here can prevent two instances during a network
	// partition or a node recovering with stale state, so a workload that
	// cannot tolerate that at all needs an external guard — a remote signer
	// with an anti-slashing record, or a lease.
	//
	// Means nothing without Prune or PruneResources — it orders whichever of
	// them is on — and is refused rather than ignored.
	PruneFirst bool `json:"pruneFirst,omitempty" yaml:"pruneFirst,omitempty"`
}

SyncPolicy governs when and how a plan is applied. Wait, Timeout and HistoryMax map onto charts.InstallOptions; Interval overrides the controller-wide poll interval for one application.

func (SyncPolicy) Retention

func (p SyncPolicy) Retention() int

Retention is how many revisions of each release to keep: what HistoryMax says, or DefaultHistoryMax when it says nothing. Zero — which only an explicit `historyMax: 0` produces — keeps every revision, as the chart engine reads it.

It lives on the type rather than in the config loader's defaults so that every way a spec is built gets the same answer, including the ones that never pass through a file.

type SyncResult

type SyncResult struct {
	Revision   string    `json:"revision"`
	StartedAt  time.Time `json:"startedAt"`
	FinishedAt time.Time `json:"finishedAt"`
	Succeeded  bool      `json:"succeeded"`
	Error      string    `json:"error,omitempty"`
}

SyncResult records the outcome of the last sync that was actually attempted.

type SyncState

type SyncState string

SyncState is whether the swarm matches git.

const (
	SyncUnknown   SyncState = ""
	SyncSynced    SyncState = "synced"
	SyncOutOfSync SyncState = "out-of-sync"
)

func (SyncState) MarshalJSON

func (s SyncState) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler.

func (*SyncState) UnmarshalJSON

func (s *SyncState) UnmarshalJSON(data []byte) error

UnmarshalJSON implements json.Unmarshaler.

type SyncSummary

type SyncSummary struct {
	Install   int `json:"install"`
	Upgrade   int `json:"upgrade"`
	Unchanged int `json:"unchanged"`
	// Drifted counts releases whose live services differ from the manifest,
	// under driftDetection: live. Omitted rather than zero when there is none,
	// unlike its three neighbours: a plan always has counts, whereas this
	// question is only asked in one mode, and omitting it keeps a manifest-mode
	// payload exactly what it was before this axis existed.
	Drifted int `json:"drifted,omitempty"`
}

SyncSummary is the plan that made the state OutOfSync, counted by action.

Install, Upgrade and Unchanged describe the plan; Drifted describes the verdict on top of it, and the two are deliberately not exclusive. A release the manifest leaves alone but whose running services were changed by hand is both Unchanged and Drifted — the plan really would do nothing to it, and it really does not match git.

type View

type View struct {
	Spec   Spec   `json:"spec"`
	Status Status `json:"status"`
}

View is what every API read returns: the declared spec beside what the controller last observed. Keeping them separate is what lets applications become writable later without moving anything.

Jump to

Keyboard shortcuts

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