Documentation
¶
Overview ¶
Package charts implements a Helm-inspired package manager for Docker Swarm.
A chart is a versioned package of Docker Stack (Compose) templates plus default values and metadata. Installing a chart produces a release: a Docker stack whose revision history is stored append-only in Docker Configs. The package is pure Go (no Bubble Tea / TUI) so it can back both the non-interactive CLI and, later, a TUI browser view.
Index ¶
- Constants
- func ApplySetFiles(dst map[string]any, sets []SetFile) error
- func EngineVersion() string
- func HasErrors(findings []LintFinding) bool
- func IsPathRef(ref string) bool
- func MergeValues(defaults map[string]any, files [][]byte, sets []string) (map[string]any, error)
- func Render(ch *Chart, ctx RenderContext) (string, error)
- func ResolveManifestFiles(manifest string, files map[string][]byte, values map[string]any) (map[string][]byte, error)
- func ValidateValues(schema []byte, values map[string]any) error
- type Action
- type ApplyResult
- type Backend
- type Chart
- type ChartHit
- type ChartMeta
- type ChartSource
- type Chartfile
- type CompatFinding
- type CompatStatus
- type ConfigMeta
- type Convergence
- type Dependency
- type DeployRequest
- type Engine
- func (e *Engine) Apply(ctx context.Context, plan *Plan, opts InstallOptions) ([]ApplyResult, error)
- func (e *Engine) GetRevision(ctx context.Context, release string, rev int) (*Release, error)
- func (e *Engine) History(ctx context.Context, release string) ([]Release, error)
- func (e *Engine) Install(ctx context.Context, release string, chart ReleaseChart, values map[string]any, ...) (*Release, error)
- func (e *Engine) List(ctx context.Context) ([]Release, error)
- func (e *Engine) PlanApply(ctx context.Context, rf *ReleaseFile, src ChartSource, opts PlanOptions) (*Plan, error)
- func (e *Engine) Prune(ctx context.Context, release string, keep int, dryRun bool) (PruneResult, error)
- func (e *Engine) PruneAll(ctx context.Context, keep int, dryRun bool) ([]PruneResult, error)
- func (e *Engine) Rollback(ctx context.Context, release string, targetRev int, opts InstallOptions) (*Release, error)
- func (e *Engine) Status(ctx context.Context, release string) (*Release, []ServiceState, error)
- func (e *Engine) Uninstall(ctx context.Context, release string, purgeVolumes bool) (*UninstallResult, error)
- func (e *Engine) Upgrade(ctx context.Context, release string, chart ReleaseChart, values map[string]any, ...) (*Release, error)
- type Index
- type IndexEntry
- type InstallOptions
- type LintFinding
- type LintSeverity
- type Maintainer
- type NetworkRequirement
- type OutdatedEntry
- type OwnerRef
- type Phase
- type Plan
- type PlanOptions
- type PruneAction
- type PruneResult
- type Release
- type ReleaseChart
- type ReleaseFile
- type ReleaseMeta
- type ReleasePlan
- type ReleaseSpec
- type RenderContext
- type RepoEntry
- type RepoSpec
- type RepoStore
- func (s *RepoStore) Add(name, repoURL string) error
- func (s *RepoStore) EnsureRepos(specs []RepoSpec) error
- func (s *RepoStore) Indexes() (map[string]*Index, error)
- func (s *RepoStore) List() ([]RepoEntry, error)
- func (s *RepoStore) LoadIndex(name string) (*Index, error)
- func (s *RepoStore) Pull(entry IndexEntry, baseURL string) (*Chart, error)
- func (s *RepoStore) Remove(name string) error
- func (s *RepoStore) Resolve(ref, version string) (IndexEntry, string, error)
- func (s *RepoStore) Search(keyword string) ([]ChartHit, error)
- func (s *RepoStore) Update(name string) (changed, unchanged []string, err error)
- type Requirements
- type ResourceRequirement
- type ServiceState
- type SetFile
- type UninstallResult
Constants ¶
const ( LabelType = "com.swarmcli.type" // always "release" LabelRelease = "com.swarmcli.release" // release name LabelChart = "com.swarmcli.chart" // chart name LabelChartVersion = "com.swarmcli.chart.version" // chart SemVer LabelRevision = "com.swarmcli.revision" // revision number LabelStatus = "com.swarmcli.status" // see Status* constants LabelCreated = "com.swarmcli.created" // RFC3339 timestamp // LabelOwner is the only optional one: "<id>:<kind>/<name>" (see OwnerRef), // absent entirely when nothing claimed the release. LabelOwner = "com.swarmcli.owner" TypeRelease = "release" )
Label keys applied to release-history Docker Configs. They mirror the scheme documented in issue #413 and let list/status/history queries filter Configs by release without an external database.
const ( StatusPendingInstall = "pending-install" StatusDeployed = "deployed" StatusSuperseded = "superseded" StatusFailed = "failed" StatusUninstalled = "uninstalled" )
Release status values, following Helm's deploy/superseded/failed lifecycle.
const AllowPlaintextEnv = "SWARMCLI_CHARTS_ALLOW_PLAINTEXT"
AllowPlaintextEnv is the environment variable a host program may honour to set AllowPlaintext. The name lives here so the refusal message and whatever reads it cannot drift, but this package never reads the environment itself: whether an operator is allowed to opt out is the embedder's call, and cli's answer (yes, for an interactive user's own machine) is not automatically a daemon's.
const OwnerKindRelease = "release"
OwnerKindRelease is the resource kind of a release-history record, the only thing an owner stamp names today.
Variables ¶
This section is empty.
Functions ¶
func ApplySetFiles ¶
ApplySetFiles sets each key to its file's contents, verbatim.
It is --set without the parsing: no comma splitting, no "{a,b}" list literal and no type inference, because the content is a file the operator wrote and every one of those would corrupt it — a config.js full of commas would be cut into fragments, and one reading "true" would become a boolean.
Applied after MergeValues, so a --set-file wins over both a values file and a --set naming the same key, matching the order the flags are documented in.
func EngineVersion ¶
func EngineVersion() string
EngineVersion reports the chart-engine version this binary embeds, or the empty string for an unstamped build.
func HasErrors ¶
func HasErrors(findings []LintFinding) bool
HasErrors reports whether any finding is fatal, i.e. whether the lint failed.
func IsPathRef ¶
IsPathRef reports whether ref names a local chart path rather than a "<repo>/<chart>" reference. The test is deliberately SYNTACTIC: a release file is committed to git and must resolve the same way on every machine, so whether a reference is a path cannot depend on what happens to exist on the disk of whichever CI runner picked up the job.
func MergeValues ¶
MergeValues computes the effective values for a render, applying Helm's precedence: chart defaults < each --values file (in order) < --set overrides. Maps are deep-merged; scalars and sequences replace.
func Render ¶
func Render(ch *Chart, ctx RenderContext) (string, error)
Render evaluates every templates/*.yaml file with text/template + Sprig, deep-merges the resulting Compose fragments into a single document, and returns it as validated YAML. Files whose names start with "_" (e.g. templates/_helpers.tpl) define named templates only and emit no document.
func ResolveManifestFiles ¶
func ResolveManifestFiles(manifest string, files map[string][]byte, values map[string]any) (map[string][]byte, error)
ResolveManifestFiles returns the files a rendered manifest names, and refuses every path that cannot mean either a file in the chart or one of values.
Exactly three compose keys read a path — a config's file:, a secret's file: and a service's env_file: — and the docker CLI resolves all three against the directory of the compose file it was handed, then reads them itself, client-side and as the invoking user, before anything reaches the daemon. In docker/cli v28.5.1 that is absPath in cli/compose/loader/loader.go for all three, then fileObjectConfig in cli/compose/convert/compose.go for the two file: keys and parseEnvFile in cli/compose/loader/envfile.go for env_file:.
swarmcli writes that compose file to a temp directory. So an unguarded relative path has always meant "a file in the system temp directory" — which any local user can plant, against a temp name predictable enough to know when to try — and an absolute one has always meant itself, read as the operator into a swarm config that anyone with Docker access can read.
This therefore refuses on the way past, in order: a path that is absolute, one that escapes the chart, and one that is neither in the chart's files/ nor in values/. None of it depends on where the chart was loaded from. Trusting a local-path chart with an absolute path would grant the most privilege to vendoring a repository chart to disk, which is the workflow that most obscures a chart's origin.
A files/ path comes back keyed exactly as Chart.Files keys it; a values/ path comes back keyed by the path the manifest wrote, carrying the value's bytes. Both are materialised beside the manifest at that relative path — which is what makes file: mean the chart, or the operator's own file, and nothing else. Only the referenced subset is returned: it is persisted in the release record so a rollback replays the bytes the original deploy sent, and that record has a hard size ceiling it shares with the manifest.
A manifest naming no file at all yields a nil map and no error, which is every manifest a chart without a files/ directory can produce.
Types ¶
type ApplyResult ¶
type ApplyResult struct {
Name string `json:"name"`
Action Action `json:"action"`
Revision int `json:"revision,omitempty"` // 0 when unchanged (nothing was recorded)
}
ApplyResult is what Apply actually did to one release.
type Backend ¶
type Backend interface {
DeployStack(ctx context.Context, req DeployRequest) error
RemoveStack(ctx context.Context, name string) error
// RefreshSnapshot invalidates the shared Docker state cache after a mutation
// so subsequent reads (status, convergence polling) do not see stale data.
RefreshSnapshot(ctx context.Context) error
CreateConfig(ctx context.Context, name string, data []byte, labels map[string]string) error
ListConfigs(ctx context.Context) ([]ConfigMeta, error)
InspectConfig(ctx context.Context, name string) ([]byte, error)
DeleteConfig(ctx context.Context, name string) error
StackServices(ctx context.Context, name string) []ServiceState
StackVolumes(ctx context.Context, name string) ([]string, error)
RemoveVolume(ctx context.Context, name string) error
// NetworkScopes returns existing network names mapped to their scope
// (e.g. "swarm", "local"), used to pre-flight a chart's external networks.
NetworkScopes(ctx context.Context) (map[string]string, error)
// CreateOverlayNetwork creates a swarm-scoped network with the given driver
// and attachability (driver defaults to "overlay" when a chart does not
// declare one in requirements.yaml).
CreateOverlayNetwork(ctx context.Context, name, driver string, attachable bool) error
// RemoveOverlayNetwork removes a network by name, used to roll back networks
// auto-created for an install whose deploy then failed. A no-op if absent.
RemoveOverlayNetwork(ctx context.Context, name string) error
// SecretNames returns the set of existing swarm secret names, used to
// pre-flight a chart's external secrets (which cannot be auto-created).
SecretNames(ctx context.Context) (map[string]struct{}, error)
}
Backend abstracts the Docker operations the release engine needs, so the lifecycle logic is unit-testable without a live Swarm.
Every method takes a context and is expected to honour it. Release operations are the long ones — a --wait deploy legitimately runs for minutes — and the daemon is reached over a connection the caller does not hold, so a controller being shut down, or retiring the application it is syncing, has no other way to stop work already in flight.
func NewDockerBackend ¶
NewDockerBackend returns a Backend bound to an explicitly named Docker context, for callers that must address a specific swarm rather than the one the process happens to be pointed at.
Pair it with NewEngineWith. The three pieces of process-global state this avoids are the SDK client singleton, the `docker context show` lookup the exec-based stack commands do, and the shared snapshot cache — all three, not just the last: a backend that deployed to one swarm and read its history, networks and convergence from another would be worse than an honestly single-swarm one, because nothing would report the mismatch.
type Chart ¶
type Chart struct {
Metadata Chartfile
Values map[string]any // parsed values.yaml (defaults)
ValuesRaw []byte // raw values.yaml bytes, nil if absent (preserves comments/order)
Schema []byte // raw values.schema.json, nil if absent
Templates map[string]string // template path -> source, e.g. "templates/stack.yaml"
Readme string // README.md, empty if absent
Requirements *Requirements // parsed requirements.yaml (raw, unrendered), nil if absent
RequirementsRaw []byte // raw requirements.yaml bytes, nil if absent; re-rendered with values at pre-flight
// Files are the chart's files/ directory, keyed by path relative to the
// chart root — "files/nginx.conf", "files/tls/ca.pem". Nil for a chart
// without a files/ directory, which is every chart written before it
// existed.
//
// It is a directory rather than Helm's "everything that is not a known
// member" because a chart's file set ends up inside a swarm config, and a
// rule that sweeps up whatever happens to be lying beside values.yaml is a
// rule that ships an editor backup to the swarm: values.yaml.bak, .env, a
// vim swap file, a stray .git. An explicit directory is one rule, both
// loaders implement it identically, and a chart author can see what they
// are shipping by listing one directory.
//
// Templates are not here; they are in Templates. Unlike templates/, which
// is deliberately flat, files/ is read recursively — files/tls/ca.pem is an
// obvious shape and there is no reason to forbid it.
Files map[string][]byte
}
Chart is a loaded chart: its metadata, default values, optional values schema, and raw template sources keyed by their path under templates/.
func LoadChartArchive ¶
LoadChartArchive loads a chart from a gzipped tar (.tgz) stream. The archive is expected to contain a single top-level directory (the chart), as produced by chart packaging; the leading directory component is stripped.
func LoadChartDir ¶
LoadChartDir loads a chart from a directory on disk.
type ChartHit ¶
type ChartHit struct {
Repo string
Entry IndexEntry
}
ChartHit is a search result: one chart version in a repository.
type ChartSource ¶
type ChartSource interface {
// Load returns the chart named by ref. ref is either a local path (a chart
// directory or a .tgz) or a "<repo>/<chart>" reference resolved through the
// configured repositories. version selects a repository chart version; it is
// meaningless for a local path and rejected there rather than ignored.
Load(ref, version string) (*Chart, error)
}
ChartSource resolves a chart reference to a loaded chart. It is the seam that lets release planning be unit-tested without a repository, a network or a filesystem: the whole of Engine.PlanApply depends on this interface and not on RepoStore.
func NewChartSource ¶
func NewChartSource(store *RepoStore) ChartSource
NewChartSource returns the standard source, backed by the configured chart repositories for "<repo>/<chart>" references.
type Chartfile ¶
type Chartfile struct {
APIVersion string `yaml:"apiVersion"`
Name string `yaml:"name" json:"name"`
Version string `yaml:"version" json:"version"`
AppVersion string `yaml:"appVersion,omitempty" json:"appVersion,omitempty"`
// SwarmcliVersion is a SemVer constraint on the chart engine this chart
// needs, e.g. ">= 1.13.0". Optional; absent means any. It constrains the
// engine's version rather than the running binary's — see buildinfo.go.
SwarmcliVersion string `yaml:"swarmcliVersion,omitempty"`
Description string `yaml:"description,omitempty"`
Maintainers []Maintainer `yaml:"maintainers,omitempty"`
// Dependencies are parsed but not resolved in Phase 1 (subcharts are Phase 3).
Dependencies []Dependency `yaml:"dependencies,omitempty"`
}
Chartfile is the parsed Chart.yaml metadata.
type CompatFinding ¶
type CompatFinding struct {
Chart string `json:"chart"` // "<name> <version>", for messages
Required string `json:"required,omitempty"` // the chart's constraint as declared, e.g. ">= 1.13.0"
Engine string `json:"engine,omitempty"` // this build's chart-engine version; "" when unstamped
Status CompatStatus `json:"status"`
Reason string `json:"reason,omitempty"` // why the check was skipped; set only with CompatUnknown
}
CompatFinding is the result of checking one chart against this build.
func CheckCompat ¶
func CheckCompat(cf Chartfile) CompatFinding
CheckCompat classifies a chart's swarmcliVersion constraint against the chart engine this binary embeds.
func CheckCompatAgainst ¶
func CheckCompatAgainst(cf Chartfile, engine string) CompatFinding
CheckCompatAgainst classifies a chart's swarmcliVersion against an arbitrary chart-engine version rather than this build's. It is what lets `charts lint --for-version` ask "does this chart's declared floor admit X?" without an X to hand.
Note what that question is NOT: whether the chart actually runs on X. This binary carries one engine's behaviour, so it cannot emulate another's — only a real X can prove that. This checks the claim's shape, not its truth.
It never returns an error. A constraint this build cannot make sense of yields CompatUnknown, not a failure: the check is a compatibility aid, not a security boundary — a chart already renders to an arbitrary stack — so failing open on our own inability to parse costs nothing, whereas failing closed would break working charts for a cosmetic reason.
func (CompatFinding) Message ¶
func (f CompatFinding) Message(binaryVersion string) string
Message renders the finding as a one-line diagnostic naming what the chart wants and what this build has.
binaryVersion is the version the binary reports for itself. When it differs from the engine's, both are named: a binary embedding this module may carry its own version, and naming only the engine's would cite a release the user cannot map back to anything they installed. Pass "" to name only the engine.
type CompatStatus ¶
type CompatStatus int
CompatStatus classifies a chart's declared engine requirement against this build.
const ( // CompatUnknown means the chart declared no requirement, this build reports // no engine version, or the declared constraint could not be parsed. // Callers must not block on it: it is not evidence of an incompatible chart. CompatUnknown CompatStatus = iota // CompatOK means this build's chart engine satisfies the constraint. CompatOK // CompatIncompatible means it does not. This is the only status callers // block on. CompatIncompatible )
func (CompatStatus) MarshalJSON ¶
func (s CompatStatus) MarshalJSON() ([]byte, error)
MarshalJSON writes the name rather than the iota. A bare int in an API payload is a number whose meaning lives only in this file: a client cannot read it without a copy of the constant block, and appending a status later would silently renumber nothing but still leave every existing reader guessing. The names are the contract.
func (CompatStatus) String ¶
func (s CompatStatus) String() string
String names the status for humans and for JSON. The zero value is deliberately the safe one, so an unset Status reads as "unknown" rather than as a claim about the chart.
func (*CompatStatus) UnmarshalJSON ¶
func (s *CompatStatus) UnmarshalJSON(b []byte) error
UnmarshalJSON reads what MarshalJSON writes, so the type still round-trips. An unrecognised name decodes to CompatUnknown rather than failing: a newer producer naming a status this build does not know is not a reason to reject the whole document, and "unknown" is the status callers already must not block on.
type ConfigMeta ¶
type ConfigMeta struct {
Name string
Labels map[string]string
// Data is the config payload, set when the Backend's listing already
// carried it. Docker returns a config's payload in the list response, not
// only on inspect, so a Backend that passes it through spares allRevisions
// one inspect per stored revision — which is the entire cost of reading
// release history. Leaving it nil is valid and costs exactly that inspect.
Data []byte
}
ConfigMeta is the edition-agnostic view of a stored release Config.
type Convergence ¶
Convergence is a phase and, when it is not converged, why.
The reason is written for display: it is the sentence a status view or an API response puts next to the phase, so it says what is outstanding rather than naming an internal state.
func Rollup ¶
func Rollup(states []ServiceState) Convergence
Rollup reduces a release's services to one answer: the worst phase wins, and the reason names the service that produced it.
No services is progressing rather than converged. A release whose stack reports nothing has not finished coming up — and reporting it converged would let --wait return the instant a deploy was accepted.
type Dependency ¶
type Dependency struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Repository string `yaml:"repository,omitempty"`
}
Dependency is a declared subchart requirement (resolution deferred to Phase 3).
type DeployRequest ¶
type DeployRequest struct {
// Name is the stack, which is also the release name.
Name string
// Manifest is the rendered compose document.
Manifest string
// Resolve is the --resolve-image mode, empty for the daemon's default.
Resolve string
// Files are the chart files the manifest's file: and env_file: keys name,
// keyed by their chart-relative path. A backend that serves them must make
// each one readable at exactly that relative path from wherever the
// manifest is resolved, and must not let a path escape that root.
//
// Empty for a manifest that names none, which is every manifest a chart
// without a files/ directory can produce.
Files map[string][]byte
}
DeployRequest is one deploy.
A struct rather than a parameter list: Backend is implemented outside this repository (swarmcli-cd's backend.Backend), so a field added later costs an implementation nothing, while widening the parameter list is a breaking change to every one of them. This method has been widened twice; it will not be widened again.
type Engine ¶
type Engine struct {
Backend Backend
// contains filtered or unexported fields
}
Engine drives release lifecycle operations against a Backend.
func NewEngine ¶
func NewEngine() *Engine
NewEngine returns an Engine bound to the live Docker backend.
func NewEngineWith ¶
NewEngineWith returns an Engine bound to a custom backend (used in tests).
func (*Engine) Apply ¶
func (e *Engine) Apply(ctx context.Context, plan *Plan, opts InstallOptions) ([]ApplyResult, error)
Apply converges the swarm to a plan, one wave at a time.
It never deletes. A release on the swarm that is absent from the file is reported and left alone — either as Plan.Unmanaged, where nothing says which manifest produced it and so it may belong to a second file or to a human, or as Plan.Orphaned, where its owner stamp names this file and it is therefore provably obsolete. Only the second is safe to remove, which is what the stamp exists to establish; acting on it is a separate change.
Unchanged releases are skipped entirely. That is not an optimisation but a requirement: history is one Docker Config per revision, so an apply that recorded a revision even when nothing changed would grow the swarm's config store on every CI run, forever.
It stops at the first failure and returns the results completed so far alongside the error, so a partial apply still reports what it did. Re-running is safe: the successful releases become no-ops.
A cancelled context stops it the same way, and at the same seam: the next release is never started, and the error is the context's, so a caller can tell a shutdown from a release that failed. Checking here rather than relying on the deploy to fail is what keeps the boundary clean — Upgrade would otherwise be entered, and a stack half-deployed by a killed CLI is worse than one not deployed at all.
Waves ¶
A plan spanning more than one wave is deployed in groups: every release in a wave is written, the wave is waited for, and only then does the next start. A wave that does not converge is a failure like any other, so nothing in any later wave is deployed at all — no service, no revision record — which is the point of declaring one. A plan with a single wave, which is every plan from a file that declares no wave, takes no barrier and reads the swarm no more often than it ever did.
The barrier does not depend on opts.Wait, and that is deliberate rather than an oversight: a wave is meaningless without it, so requiring a second opt-in would leave "declared waves that silently did nothing" as the default reading of a file. Wait keeps its own meaning underneath — whether each individual release blocks — so setting it inside a wave serialises that wave, which is exactly what it says and exactly what waves exist to stop being necessary.
func (*Engine) GetRevision ¶
GetRevision returns a specific revision of a release, or the current one when rev <= 0.
func (*Engine) History ¶
History returns every stored revision of a release, ascending, with derived display statuses.
func (*Engine) Install ¶
func (e *Engine) Install(ctx context.Context, release string, chart ReleaseChart, values map[string]any, manifest string, opts InstallOptions) (*Release, error)
Install deploys a freshly rendered manifest as revision 1 of a new release and records it. It refuses to install over an existing, non-uninstalled release (use upgrade — Phase 2). manifest must already be rendered and validated.
func (*Engine) PlanApply ¶
func (e *Engine) PlanApply(ctx context.Context, rf *ReleaseFile, src ChartSource, opts PlanOptions) (*Plan, error)
PlanApply computes what Apply would do, without writing anything.
Every release is resolved, merged, schema-validated and rendered BEFORE any of them is deployed. A bad value in the third release therefore aborts the whole apply instead of leaving the swarm half-converged — and `--dry-run` is just "stop after planning".
The releases come back in wave order, sorted here rather than grouped at each consumer. That is what makes plan order execution order everywhere at once: Apply only has to notice where a run of equal waves ends, a renderer shows the sequence things happen in, and a controller walking the plan to correct drift inherits the ordering without knowing waves exist.
func (*Engine) Prune ¶
func (e *Engine) Prune(ctx context.Context, release string, keep int, dryRun bool) (PruneResult, error)
Prune deletes superseded revisions of one release beyond the keep window, always retaining the current (highest) revision. keep <= 0 keeps everything. On a dry run no Config is touched. Deletion failures are aggregated and returned, but pruning of the remaining revisions continues.
func (*Engine) PruneAll ¶
PruneAll prunes every release to the keep window, returning one result per release (sorted by name). Per-release errors are aggregated; a failing release does not stop the others.
func (*Engine) Rollback ¶
func (e *Engine) Rollback(ctx context.Context, release string, targetRev int, opts InstallOptions) (*Release, error)
Rollback deploys a new revision whose content is copied from a previous revision (append-only, mirroring Helm). targetRev must be an existing, non-failed revision.
func (*Engine) Uninstall ¶
func (e *Engine) Uninstall(ctx context.Context, release string, purgeVolumes bool) (*UninstallResult, error)
Uninstall removes the release's stack and its recorded revisions, retaining volumes unless purgeVolumes is set. It returns an UninstallResult describing the auto-created external networks left in place so the caller can surface them; the networks themselves are not removed.
func (*Engine) Upgrade ¶
func (e *Engine) Upgrade(ctx context.Context, release string, chart ReleaseChart, values map[string]any, manifest string, opts InstallOptions) (*Release, error)
Upgrade deploys a new revision of an existing release. When the release does not exist it errors unless opts.Install is set (the `upgrade --install` behavior). manifest must already be rendered and validated.
type Index ¶
type Index struct {
APIVersion string `yaml:"apiVersion"`
Entries map[string][]IndexEntry `yaml:"entries"`
}
Index is the parsed index.yaml of a chart repository, mapping a chart name to its available versions (Helm repository index format, subset).
type IndexEntry ¶
type IndexEntry struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
AppVersion string `yaml:"appVersion,omitempty"`
Description string `yaml:"description,omitempty"`
URLs []string `yaml:"urls"` // tarball download URLs (absolute or index-relative)
Digest string `yaml:"digest,omitempty"`
}
IndexEntry describes one published chart version within an Index.
type InstallOptions ¶
type InstallOptions struct {
DryRun bool
Wait bool
Install bool // upgrade: create the release if it does not exist
Timeout time.Duration
HistoryMax int // 0 = keep all
// ResolveImage selects how the daemon resolves image tags at deploy time
// ("always" | "changed" | "never"); empty leaves Docker's default of
// "always". See docker.ResolveImage for why "changed" suits automation.
ResolveImage string
// Requirements is the chart's parsed requirements.yaml, when present. It
// drives the external-resource pre-flight (auto-create vs validate-only, the
// network driver/attachability, and remediation descriptions) and, when set,
// every external resource the manifest references must be declared in it. Nil
// falls back to manifest-driven pre-flight (auto-create attachable overlays).
Requirements *Requirements
// Files are the chart files the manifest references, already resolved
// against the chart by the caller that still had one (ResolveManifestFiles).
// They are recorded on the revision and handed to the deploy.
//
// Carried the same way Requirements is, and for the same reason: it is
// chart-derived data the engine needs, and the engine has no chart. Nil for
// a manifest that names no file — and nil on a rollback, which is not a
// gap: a rollback replays the files off the revision it is rolling back to.
Files map[string][]byte
// Owner claims the release for a manifest or controller, e.g.
// "apply/prod-swarm" or "cd/edge". It is recorded on every revision this
// call writes, as the id half of an OwnerRef naming the release.
//
// Empty leaves the revision unowned, which is what an imperative install
// does and what keeps "never delete anything" the default: only a release
// stamped with a caller's own owner can ever be a prune candidate, so a
// release installed by hand or by somebody else's manifest is untouchable
// no matter what a later prune is asked to do.
Owner string
}
InstallOptions tune an install or upgrade.
type LintFinding ¶
type LintFinding struct {
Severity LintSeverity
Message string
}
LintFinding is one thing lint noticed about a chart.
func Lint ¶
func Lint(ch *Chart, engine string, files [][]byte, sets []string) []LintFinding
Lint checks a loaded chart against the chart engine named by engine — this build's, or one the caller is asking about via --for-version.
files and sets are extra values layered over the chart defaults for the render check, exactly as `charts template -f/--set` would: a chart that requires an input it deliberately leaves undefaulted (a {{ required }} / {{ fail }} guard) cannot render from bare defaults, so linting it needs the same values a real install would supply. Pass nil for both to lint against defaults alone.
It reports everything it finds instead of stopping at the first problem: a chart author wants the list, not a game of whack-a-mole.
Structural validation already happened in LoadChartDir / LoadChartArchive, which refuse a chart with no name, version or templates, or an apiVersion this build cannot read. Lint covers what only becomes visible once a chart is rendered.
One thing it deliberately cannot do: prove a chart runs on the version it declares. This binary carries one engine's behaviour and cannot emulate another's — rendering with a real binary of that version is the only thing that settles it. See CheckCompatAgainst.
type LintSeverity ¶
type LintSeverity int
LintSeverity ranks a lint finding. Only LintError fails a lint.
const ( // LintWarning is advice: the chart works, but something is worth fixing. LintWarning LintSeverity = iota // LintError means the chart is broken — either outright, or for the engine // version it was linted against. LintError )
func (LintSeverity) String ¶
func (s LintSeverity) String() string
type Maintainer ¶
type Maintainer struct {
Name string `yaml:"name"`
Email string `yaml:"email,omitempty"`
URL string `yaml:"url,omitempty"`
}
Maintainer identifies a chart maintainer.
type NetworkRequirement ¶
type NetworkRequirement struct {
Name string `yaml:"name"`
Driver string `yaml:"driver"` // default "overlay"
Attachable *bool `yaml:"attachable"` // default true
AutoCreate *bool `yaml:"autoCreate"` // default true; false => validate-only
Description string `yaml:"description"`
}
NetworkRequirement declares one external network a chart needs. AutoCreate and Attachable are pointers so an omitted key defaults to true (preserving the historical auto-create-as-attachable-overlay behaviour) while an explicit false is distinguishable. After parseRequirements they are always non-nil and Driver is non-empty.
type OutdatedEntry ¶
type OutdatedEntry struct {
Release string
Chart string
Repo string
Installed string
Latest string
}
OutdatedEntry is one installed release with a newer chart version available.
func Outdated ¶
func Outdated(rels []Release, indexes map[string]*Index) []OutdatedEntry
Outdated joins installed releases against the newest version of their chart in any configured repository index. Releases already at the newest version, and those whose chart appears in no index (a local chart), are omitted.
A Release does not record which repository it came from, so a chart present in two repositories resolves to the highest version across them, reporting the repository that supplied it. That ambiguity is documented rather than designed away: recording the source repository is a change to persisted release state, which is not worth making for a case most users never hit.
type OwnerRef ¶
type OwnerRef struct {
ID string // the manifest or controller that manages the resource
Kind string // OwnerKindRelease
Name string // the resource's name on the swarm
}
OwnerRef identifies both who manages a resource and which resource the stamp was written for. It is encoded as "<id>:<kind>/<name>", e.g.
apply/prod-swarm:release/whoami
The resource half is what makes the stamp verifiable, and it is there deliberately. A bare owner string cannot tell a resource this tool created from a copy of one: ArgoCD shipped exactly that as the app.kubernetes.io/instance label, and replaced it in 3.0 with an annotation naming the resource too. Reading a stamp back therefore means checking that it names the resource carrying it — one that does not is not evidence of ownership, so it is treated as unowned rather than trusted.
Docker label values have no length limit, which is the other half of what ArgoCD had to work around (its label truncated at 63 bytes).
func ParseOwner ¶
ParseOwner decodes a stamp written by OwnerRef.String.
The id may itself contain "/" (the "apply/<name>" convention), so the id is cut at the first ":" and only the remainder is split into kind and name.
type Phase ¶
type Phase string
Phase is how far a rollout has got.
const ( // PhaseConverged means every task is up and has outlived the window in // which swarm would still hold its failure against the rollout. PhaseConverged Phase = "converged" // PhaseProgressing means not yet — and, so far, not a failure either. PhaseProgressing Phase = "progressing" // PhaseWedged means swarm has given up and will not continue on its own. PhaseWedged Phase = "wedged" )
type Plan ¶
type Plan struct {
// Owner is the owner id this plan was classified against: PlanOptions.Owner
// when the caller supplied one, otherwise "apply/" and the release file's
// `owner:` key. Empty when neither names an owner, in which case nothing on
// the swarm is claimable and Orphaned is always empty.
//
// It is also the id Apply should stamp on what it writes — pass it as
// InstallOptions.Owner — so that the next plan recognises these releases as
// its own rather than as somebody else's.
Owner string `json:"owner,omitempty"`
// Releases, in wave order and then file order — which for a file declaring no
// wave is exactly file order, as it always was.
//
// This is the order Apply converges them in, so it is also the order to
// render: a caller showing a plan is showing what will happen, in the
// sequence it will happen.
Releases []ReleasePlan `json:"releases"`
// Unmanaged names releases that exist on the swarm, are absent from the
// file, and carry no stamp saying this file produced them. Apply never
// touches them — see Engine.Apply.
Unmanaged []string `json:"unmanaged,omitempty"`
// Orphaned names releases this file's own owner installed that the file no
// longer declares. Unlike Unmanaged they are provably obsolete rather than
// merely unrecognised, which is what makes deleting them safe. Apply still
// does not delete them.
Orphaned []string `json:"orphaned,omitempty"`
}
Plan is what apply would do to the whole swarm.
type PlanOptions ¶
type PlanOptions struct {
// Owner is the owner id the plan classifies deployed releases against,
// overriding the "apply/<owner>" the release file would imply. A controller
// installs under an id of its own — InstallOptions.Owner documents "cd/edge"
// — and without this its own releases fail the ownership check and report as
// Unmanaged from the first reconcile.
//
// Empty derives the id from the release file, which is what keeps a manifest
// applied from the command line and a controller that happened to pick the
// same name from claiming each other's releases. Set, it replaces that
// derivation entirely: the file's `owner:` key is not consulted.
Owner string
// ReadFile reads one values file named by the release file, by the resolved
// path ReleaseFile.ValuesPaths produced. Nil is os.ReadFile.
//
// It exists so that a caller can see and transform the bytes between "this
// path was named" and "these values were merged" — decrypting a values file
// committed encrypted, or serving it from a git object rather than a local
// path at all — without the material having to reach a filesystem first.
ReadFile func(path string) ([]byte, error)
}
PlanOptions tune planning. The zero value is what `swarmcli charts apply` uses, and reproduces the behaviour of every release before these existed.
type PruneAction ¶
type PruneAction struct {
Revision int
Delete bool
Current bool // the live (highest) revision; never deleted
}
PruneAction is the keep/delete decision for one revision in a prune.
type PruneResult ¶
type PruneResult struct {
Release string
Actions []PruneAction
}
PruneResult reports what a prune did (or, for a dry run, would do) for one release. Actions are ascending by revision.
func (PruneResult) Deleted ¶
func (r PruneResult) Deleted() []int
Deleted returns the revision numbers Prune removed (or would remove).
type Release ¶
type Release struct {
Name string `yaml:"release" json:"release"`
Revision int `yaml:"revision" json:"revision"`
Status string `yaml:"status" json:"status"`
Chart ReleaseChart `yaml:"chart" json:"chart"`
Values map[string]any `yaml:"values" json:"values"`
Manifest string `yaml:"manifest" json:"manifest"` // rendered Compose document
Created string `yaml:"created" json:"created"` // RFC3339
Namespace string `yaml:"namespace" json:"namespace"`
// ManagedNetworks are the external networks swarmcli auto-created for this
// revision. Persisted so uninstall can report what it left behind (it does
// not remove them — they may be shared). Omitted for revisions that created
// none and for records written before this field existed.
ManagedNetworks []string `yaml:"managedNetworks,omitempty" json:"managedNetworks,omitempty"`
// Owner is the stamp naming whichever manifest or controller produced this
// revision, encoded as "<id>:<kind>/<name>" (see OwnerRef). Empty for a
// release nothing claimed — an imperative install, or an apply from a file
// declaring no owner — and an unowned release is never a prune candidate.
Owner string `yaml:"owner,omitempty" json:"owner,omitempty"`
// Files are the chart files this revision's manifest references, keyed by
// their chart-relative path, so that a rollback deploys what the original
// deploy did. Rollback has no chart in scope — it replays a stored manifest,
// with no ChartSource, no re-render and no filesystem — so anything not here
// is gone, and the manifest it replays names files that do not exist.
//
// The referenced subset, not the chart's whole files/ tree: this record is a
// Docker Config with a hard size ceiling it shares with the manifest, so an
// asset the manifest never names must not spend it. Absent for a manifest
// naming none, which is every manifest a chart without a files/ directory
// can produce, and for records written before this field existed.
//
// releaseFiles, not a bare map[string][]byte, only so that YAML stores the
// bytes as base64 rather than as a sequence of decimal integers — see its
// doc comment. The two types are assignable both ways, so nothing that
// produces or consumes a file set has to know about it.
Files releaseFiles `yaml:"files,omitempty" json:"files,omitempty"`
}
Release is the payload stored (gzipped) inside a release-history Config. It fully describes one deployed revision so it can be inspected or rolled back.
type ReleaseChart ¶
type ReleaseChart struct {
Name string `yaml:"name" json:"name"`
Version string `yaml:"version" json:"version"`
AppVersion string `yaml:"appVersion,omitempty" json:"appVersion,omitempty"`
}
ReleaseChart is the chart reference recorded in a Release.
func ReleaseChartOf ¶
func ReleaseChartOf(ch *Chart) ReleaseChart
ReleaseChartOf projects a loaded chart into the metadata recorded on a release.
type ReleaseFile ¶
type ReleaseFile struct {
APIVersion string `yaml:"apiVersion,omitempty"`
Repositories []RepoSpec `yaml:"repositories,omitempty"`
// Owner names this manifest, claiming every release it installs. It is
// optional and there is deliberately no default: a derived one would either
// change between a laptop and a CI checkout (a path hash) or be shared by
// every repository using the conventional filename (a basename), and either
// would let two unrelated manifests claim each other's releases. Absent
// means nothing is claimed, which is exactly today's behaviour.
Owner string `yaml:"owner,omitempty"`
Releases []ReleaseSpec `yaml:"releases"`
// Dir is the directory containing the file. Values files and local chart
// paths resolve against it, never the process working directory, so the
// manifest is relocatable: a CI job gets the same result no matter where it
// invoked swarmcli from.
Dir string `yaml:"-"`
// Path is the file as given, used to prefix error messages.
Path string `yaml:"-"`
}
ReleaseFile is a declarative release manifest: the desired set of releases on a swarm, plus the repositories their charts come from. It is the GitOps entry point — `charts install` and `charts upgrade` are imperative and release state lives in the swarm, so without this file there is nothing in git for an automated updater (Renovate, Dependabot, a bot of your own) to edit.
The key names deliberately mirror Helmfile's. Renovate ships a `helmfile` manager that reads exactly `repositories[].{name,url}` and `releases[].{name,chart,version}`, so pointing it at this file needs one line of config and no hand-written regex:
{"helmfile": {"managerFilePatterns": ["/(^|/)swarmcli-release\\.ya?ml$/"]}}
Unknown keys are a hard error (see ParseReleaseFile), which also contains the obvious hazard of borrowing another tool's vocabulary: pasting real Helmfile syntax fails loudly and names the key, rather than silently doing half of what was meant.
`wave` is the one key that is ours rather than Helmfile's. Renovate's manager reads the three keys above and ignores everything else, so declaring it costs nothing there; what it does cost is that a file using it cannot be read by a swarmcli older than the release that added it, since unknown keys are refused rather than skipped. That is true of every key this file has ever gained.
func LoadReleaseFile ¶
func LoadReleaseFile(path string) (*ReleaseFile, error)
LoadReleaseFile reads and validates a release manifest.
func ParseReleaseFile ¶
func ParseReleaseFile(data []byte, path string) (*ReleaseFile, error)
ParseReleaseFile decodes and validates a release manifest. Unknown keys are rejected: this is a file an automated updater rewrites, so a misspelled `version:` must fail loudly rather than silently leave the release floating.
func (*ReleaseFile) ChartRef ¶
func (rf *ReleaseFile) ChartRef(r ReleaseSpec) string
ChartRef resolves a release's chart reference, joining local paths against the manifest's directory and passing "<repo>/<chart>" references through.
func (*ReleaseFile) ValuesPaths ¶
func (rf *ReleaseFile) ValuesPaths(r ReleaseSpec) []string
ValuesPaths resolves a release's values files against the manifest's directory.
type ReleaseMeta ¶
ReleaseMeta is the .Release object available to templates.
type ReleasePlan ¶
type ReleasePlan struct {
Name string `json:"name"`
Ref string `json:"ref"`
Action Action `json:"action"`
// Wave is the release file's ReleaseSpec.Wave, carried here because Apply
// never sees the file — only this plan — so it is the only place a grouping
// could be read from.
//
// Omitted when zero, which is both the default and "explicitly first". The
// two are the same thing: there is no "unset" wave to tell apart from wave 0,
// so nothing is lost by the omission.
Wave int `json:"wave,omitempty"`
// FromVersion is the currently deployed chart version, empty for an install.
FromVersion string `json:"fromVersion,omitempty"`
ToVersion string `json:"toVersion"`
Chart ReleaseChart `json:"chart"`
Values map[string]any `json:"values,omitempty"`
Manifest string `json:"manifest"`
Requirements *Requirements `json:"requirements,omitempty"`
// Files are the chart files Manifest references, resolved while the chart
// was still in scope. Apply re-attaches them to InstallOptions per release,
// exactly as it does Requirements.
//
// json:"-" because ReleasePlan is a wire type — a controller serialises a
// plan to show what it would do — and base64 file bytes are neither
// readable there nor anything a reader of a plan asked for. It is a
// consequence, not a limitation: nothing reconstructs an Engine.Apply from
// a plan's JSON.
Files map[string][]byte `json:"-"`
// CurrentManifest is the deployed manifest, for diffing. Empty for an install.
CurrentManifest string `json:"currentManifest,omitempty"`
// Compat is the chart's engine requirement checked against this build.
// Planning records it but never acts on it: apply's contract is to plan
// every release before converging any, so the whole plan is gated at once
// by the caller — which is also the layer that knows whether blocking is
// appropriate for the verb being run.
Compat CompatFinding `json:"compat"`
}
ReleasePlan is the computed desired state of one release.
type ReleaseSpec ¶
type ReleaseSpec struct {
Name string `yaml:"name"`
Chart string `yaml:"chart"`
Version string `yaml:"version,omitempty"`
Values []string `yaml:"values,omitempty"`
// Wave groups releases that are applied together. Every release in one wave
// is deployed, the whole wave is waited for, and only then does the next
// start — so a migration that does not converge stops the releases that
// depend on it from ever being deployed.
//
// Ascending, and 0 by default. That is Argo CD's sync-wave, and it is also
// the reading that keeps a file declaring nothing behaving exactly as it
// always has: one wave, applied in file order, with no barrier and no extra
// read of the swarm. Negative is legal, and is how something is put in front
// of an existing set without renumbering it.
//
// Within a wave the order is the file's, and it is not meaningful — that is
// the whole point of putting two releases in one wave. Do not rely on it.
Wave int `yaml:"wave,omitempty"`
}
ReleaseSpec is one desired release.
type RenderContext ¶
type RenderContext struct {
Values map[string]any
Release ReleaseMeta
Chart ChartMeta
}
RenderContext is the data exposed to chart templates, mirroring Helm's top-level objects: .Values, .Release, .Chart.
type RepoStore ¶
type RepoStore struct {
// Warnf, when set, receives non-fatal diagnostics: a chart whose index entry
// publishes no digest, so its integrity could not be verified, and a
// repository whose index could not be refreshed, so a cached one is in use.
// charts is a library with no output of its own; nil is silent, and cli wires
// this to stderr.
Warnf func(format string, a ...any)
// AllowPlaintext permits repositories reached over plain http. It defaults to
// false because a chart repository serves the tarball that *becomes* the
// deployed workload, so whoever sits on the path to it chooses what runs on
// the swarm. The published digest does not close that hole: it travels in the
// same index.yaml over the same channel, so an on-path attacker rewrites both
// — and an entry that publishes no digest only warns. swarmcli-cd refuses
// plaintext git remotes for exactly this reason; the same argument applies one
// layer down, to where the chart itself comes from.
//
// Set it only for an internal registry on a network you already trust. cli
// wires it to AllowPlaintextEnv so an existing plaintext setup keeps working
// with one deliberate, greppable opt-out rather than none.
AllowPlaintext bool
// contains filtered or unexported fields
}
RepoStore persists configured repositories and caches their indexes under a base directory (default: the XDG state dir, ~/.local/state/swarmcli/charts).
func NewRepoStore ¶
NewRepoStore returns a store rooted at the standard charts state directory.
func NewRepoStoreAt ¶
NewRepoStoreAt returns a store rooted at dir (used in tests).
func (*RepoStore) Add ¶
Add registers a repository and downloads its index. It rejects duplicate names, names it will not build a cache path from, and URLs it will not fetch from — all before the download, so a bad request is reported as one.
func (*RepoStore) EnsureRepos ¶
EnsureRepos makes the repositories a release manifest declares available, adding those that are absent and refreshing the rest. It exists so that `charts apply -f file` is the only command a CI job needs to run.
What it writes is a name-to-URL mapping plus a cached index — a cache, not user data. The one thing it will not do is silently repoint an existing repository at a different origin.
func (*RepoStore) Indexes ¶
Indexes returns the cached index of every configured repository, keyed by repository name. Repositories with no cached index are skipped, as in Search.
func (*RepoStore) Pull ¶
func (s *RepoStore) Pull(entry IndexEntry, baseURL string) (*Chart, error)
Pull downloads and loads the chart described by entry, resolving relative URLs against baseURL.
func (*RepoStore) Resolve ¶
func (s *RepoStore) Resolve(ref, version string) (IndexEntry, string, error)
Resolve looks up a "repo/chart" reference and returns the chosen index entry (the requested version, or the latest when version is empty) plus the repo's base URL for resolving relative tarball URLs.
type Requirements ¶
type Requirements struct {
Networks []NetworkRequirement `yaml:"networks"`
Secrets []ResourceRequirement `yaml:"secrets"`
Configs []ResourceRequirement `yaml:"configs"`
}
Requirements is the parsed, defaulted requirements.yaml: the external networks/secrets/configs a chart needs. It is optional — a chart without it falls back to manifest-driven pre-flight. When present it is authoritative: every external resource the rendered manifest references must be declared.
func RenderRequirements ¶
func RenderRequirements(ch *Chart, ctx RenderContext) (*Requirements, error)
RenderRequirements renders the chart's requirements.yaml through the same template engine and context as the manifest, then parses the result. This lets requirements.yaml reference .Values (e.g. an operator-chosen network name) while staying authoritative — the declared names are resolved against the same values that produced the manifest. Returns (nil, nil) when the chart ships no requirements.yaml.
Templated values must be quoted (name: "{{ .Values.x }}") so requirements.yaml still parses as YAML at chart-load time; the real value is resolved here, at the install/upgrade pre-flight.
type ResourceRequirement ¶
type ResourceRequirement struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
ResourceRequirement declares one external secret or config a chart needs. These cannot be auto-created; Description enriches the remediation message.
type ServiceState ¶
type ServiceState struct {
Name string
Mode string
Replicas string // "running/desired" for replicated, "" otherwise — display only
Status string
// Running counts tasks that are actually running on an active node.
// Deliberately not derived from Replicas: that string mirrors `docker
// service ls` and so counts every generation, superseded tasks included, and
// counts them wherever they sit — a down node's stale task included (see
// issue #480).
Running int
// Desired is the target task count over active nodes.
Desired int
// Completed counts tasks that ran to completion, and Job marks a service
// swarm will not restart after a clean exit. A one-shot init or migration
// step is *supposed* to end with nothing running, so without these two a
// finished job reads as a service that never came up (issue #443).
Completed int
Job bool
// UpdateState is swarm's UpdateStatus.State, empty when the service has
// never been updated. Empty means "no rollout has ever run" — NOT "the
// rollout finished", which is why a fresh install cannot rely on it.
UpdateState string
// Monitor is UpdateConfig.Monitor: the window after a task is created in
// which its failure still counts against the rollout.
Monitor time.Duration
// NewestTaskAge is how much of that window the newest running task has
// already lived through, measured from task creation as swarm measures it.
NewestTaskAge time.Duration
}
ServiceState is a live status line for a release's services, plus the facts --wait needs to decide whether the rollout is actually finished.
func ServiceStatesFrom ¶
func ServiceStatesFrom(snap *docker.SwarmSnapshot, name string) []ServiceState
ServiceStatesFrom reads one stack's service states out of a swarm snapshot.
It is exported for the sake of a caller implementing Backend itself, which NewEngineWith exists to allow: producing []ServiceState is the one part of that job with rules rather than plumbing, and a second copy of them would diverge silently in both directions — reporting a release converged when this package would still be waiting, or degraded when it is fine.
The snapshot is the caller's: docker.SnapshotWith builds one against any client without touching the process-wide cache, so a consumer serving several swarms is not forced through the ambient one.
func (ServiceState) Convergence ¶
func (s ServiceState) Convergence() Convergence
Convergence classifies one service.
Every rule here has been wrong once, which is why interpreting a ServiceState belongs to this package rather than to each caller that holds one.
type SetFile ¶
SetFile is one --set-file: the values key to populate, and the bytes read from the path the operator named. The caller does the reading, so nothing in this package opens a file it was not handed.
type UninstallResult ¶
type UninstallResult struct {
OrphanedNetworks []string
}
UninstallResult reports what an uninstall left behind. OrphanedNetworks are the external networks swarmcli auto-created for the release that still exist after the stack is removed — `docker stack rm` does not remove external networks, and swarmcli deliberately leaves them (they may be shared with other stacks) and reports them instead.