charts

package
v1.13.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: 34 Imported by: 0

README

SwarmCLI Charts

A Helm-inspired package manager for Docker Swarm. Charts package Docker Stack (Compose) templates with default values and metadata; installing a chart produces a release — a Docker stack whose revision history is stored in Docker Configs.

Implemented so far (issue #413): repository management, discovery, templating, the full release lifecycle — install, upgrade, rollback, uninstall, list, status, history, diff, get, and prune — and declarative releases (apply, outdated). Chart-dev tooling (create, lint, dependency) and subchart resolution are the remaining phase.

Invocation

Charts run through swarmcli's non-interactive CLI: when the binary is given arguments it executes a one-shot command and exits (a bare swarmcli still launches the TUI). This makes charts scriptable for CI/CD and GitOps.

swarmcli charts repo add eldara https://charts.example.com
swarmcli charts repo update
swarmcli charts search traefik
swarmcli charts show values eldara/traefik > values.yaml
swarmcli charts template my-traefik eldara/traefik -f values.yaml
swarmcli charts install  my-traefik eldara/traefik -f values.yaml
swarmcli charts status   my-traefik
swarmcli charts diff upgrade my-traefik eldara/traefik --set replicas=3
swarmcli charts upgrade  my-traefik eldara/traefik --set replicas=3
swarmcli charts history  my-traefik
swarmcli charts get manifest my-traefik      # also: get values
swarmcli charts rollback my-traefik 1
swarmcli charts list
swarmcli charts uninstall my-traefik

A chart reference is either a configured repo/chart or a local path to a chart directory or packaged .tgz. --version selects a version from a repository index, so it applies only to a repo/chart reference — a local chart carries its version in its own Chart.yaml, and passing --version with one is an error rather than being silently ignored.

Declarative releases (GitOps)

The commands above are imperative, and release state lives in the swarm — so nothing in git says what should be running. charts apply closes that gap: it converges the swarm to a file you commit.

# swarmcli-release.yaml
apiVersion: v1
owner: prod-swarm              # optional; see Ownership below
repositories:
  - name: swarmcli-charts
    url: https://eldara-tech.github.io/swarmcli-charts
releases:
  - name: edge
    chart: swarmcli-charts/traefik
    version: "0.1.1"
    values: [./traefik.yaml]     # relative to THIS FILE, not the working directory
  - name: hello
    chart: swarmcli-charts/whoami
    version: "0.1.8"
swarmcli charts apply -f swarmcli-release.yaml --dry-run   # plan
swarmcli charts apply -f swarmcli-release.yaml --diff      # plan + manifest diffs
swarmcli charts apply -f swarmcli-release.yaml             # converge
swarmcli charts outdated                                   # what has a newer chart?
Behaviour
Missing release installed
Changed chart version, values, rendered manifest, or referenced files/ content upgraded
Identical skipped — no new revision
On the swarm but not in the file reported, never removed
Installed by this file, no longer in it reported as an orphan, still never removed
A wave that does not converge every later wave is skipped entirely

Two of those deserve the emphasis. Releases are never deleted — apply prints the uninstall command and leaves the decision to you. And an unchanged release is skipped entirely: history is one Docker Config per revision, so re-applying on every CI push would otherwise grow the swarm's config store without bound.

Ownership

owner: names the manifest, and every release it installs is stamped with that name — in the release-history Config's com.swarmcli.owner label and in the stored record — so a later apply can tell a release this file installed from one it has simply never seen. Dropping a release from the file then reports it as an orphan: provably obsolete, because the stamp says this manifest produced it and nothing else claims it. A release with no stamp, or another manifest's stamp, stays merely unmanaged.

Nothing is deleted either way. The distinction is the prerequisite for a prune that could be: it is what separates "this is obsolete" from "I do not recognise this", and only the first is ever safe to act on.

com.swarmcli.owner: apply/prod-swarm:release/hello
                    └──── id ─────┘ └── resource ──┘

The stamp names the resource as well as the owner, and both halves must match for it to count. A bare owner string cannot tell a release this file installed from a copy of one — ArgoCD shipped exactly that as app.kubernetes.io/instance and replaced it in 3.0 for the same reason. The apply/ prefix keeps a manifest applied from the command line from colliding with a controller that happened to pick the same name. The controller is the other side of that: a library consumer passes its own id as PlanOptions.Owner and plans against it instead, so the releases it installed under cd/<app> read back as its own.

owner: is optional and has 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. Omit it and nothing is claimed, which is exactly the behaviour of every version before this one.

For a repo/chart, version is required — a floating pin would silently upgrade production on the next apply. For a local chart path (chart: ./charts/mine, resolved against the file) it must be omitted: the chart's own Chart.yaml sets the version, so there is nothing to select.

Unknown keys are rejected, so a typo fails loudly instead of quietly doing nothing. Releases are applied in wave order, then file order — see below.

Sync waves

wave groups releases that go out together. Every release in a wave is deployed, the whole wave converges, and only then does the next wave start:

releases:
  - name: db
    chart: ./charts/postgres          # no wave: means wave 0
  - name: migrate
    chart: ./charts/migrate
    wave: 1
  - name: api
    chart: ./charts/api
    wave: 2
  - name: worker
    chart: ./charts/worker
    wave: 2                           # with api — the order between them is not meaningful

The failure semantics are the point as much as the ordering. A wave that does not converge stops every wave after it: nothing later is deployed at all, no service and no revision record, so a migration that fails can never let the API that depends on it start.

Waves are ascending and default to 0, so a file that declares none is one wave applied in file order — exactly what it has always done, with no waiting added. Negative numbers are legal, and are how you put something in front of an existing set without renumbering it.

wave is not --wait, and does not need it. The barrier between waves always happens; --wait is the separate, older question of whether each individual release blocks until it is live. Setting both serialises everything inside a wave, which is the thing waves exist to stop being necessary — so with waves declared, leave --wait off unless you also want the last wave waited for. --timeout bounds each wave, and defaults to five minutes.

wave is the one key here that is not Helmfile's, so Renovate ignores it (see below). Note that a release file using it cannot be read at all by a swarmcli older than the release this landed in: unknown keys are refused rather than skipped, which is the same trade every key in this file has made.

apply honours --wait, --timeout and --history-max. It rejects --set, --version, --reuse-values, --install, --purge-volumes, --requirements and --revision rather than ignoring them — the file is the only source of truth, so a value passed on the command line would be a lie. --diff implies --dry-run and never deploys.

Keeping it up to date automatically

If your charts come from swarmcli-charts, extend its Renovate preset — that is the whole configuration:

{ "extends": ["github>Eldara-Tech/swarmcli-charts"] }

For any other chart repository, one line does the same job:

{ "helmfile": { "managerFilePatterns": ["/(^|/)swarmcli-release\\.ya?ml$/"] } }

Both work because the file's key names match Helmfile's, so Renovate's built-in helmfile manager reads it — no custom regex to maintain. Renovate resolves each chart against the repositories you declared and opens a PR bumping version: when a new chart version is published, with the chart's release notes attached. Merge it, and swarmcli charts apply in CI rolls it out.

Chart format

mychart/
├── Chart.yaml          # apiVersion, name, version, appVersion, swarmcliVersion, …
├── values.yaml         # default values
├── values.schema.json  # optional JSON Schema validated before render
├── README.md
├── templates/          # Go-templated Compose fragments
│   ├── stack.yaml
│   ├── configs.yaml
│   ├── secrets.yaml
│   └── volumes.yaml
└── files/              # optional; files the chart carries, read recursively
    ├── nginx.conf
    └── tls/ca.pem

apiVersion is v1 (the only format this build reads; absent means v1).

Files a chart ships

files/ is collected and carried with the chart, keyed by each file's path relative to the chart root — files/nginx.conf, files/tls/ca.pem. It is deliberately not Helm's .Files: only files/ is collected, never "everything that is not a known member", because a chart's file set is destined for a swarm config and a rule that sweeps up whatever is lying beside values.yaml ships values.yaml.bak, a .env or an editor swap file to the swarm. Templates stay in templates/, which unlike files/ is flat.

Compose gives a config or a secret its content in exactly one way, which is what these are for:

# templates/configs.yaml
configs:
  nginx:
    file: files/nginx.conf

A config's file:, a secret's file: and a service's env_file: all mean the chart: the path is resolved against the chart root, and the file the manifest names is carried to the deploy and written beside the rendered manifest for the docker CLI to read.

That is new, and it replaces something worse. Those three keys are resolved by the docker CLI against the directory of the compose file it is handed, which was the temp file swarmcli wrote — so file: ./nginx.conf read $TMPDIR/nginx.conf, a path any local user can plant a file in, and file: /etc/shadow was read as the invoking operator into a Docker config readable by anyone with Docker access. A relative path has therefore never meant what a chart author intended.

So a path that cannot mean a file in the chart is refused, and the rule is the same for every chart however it was loaded — from a repository, from a .tgz, or from a directory on your own disk:

file: / env_file:
files/nginx.conf, files/tls/ca.pem resolved against the chart
values/config resolved against the values — see below; a config's file: only
nginx.conf refused — outside files/, so not something the chart ships
files/missing.conf refused — the chart does not contain it
../../etc/shadow refused — escapes the chart
/etc/shadow refused — absolute, for every chart

A local-directory chart is not an exception, deliberately: vendoring a repository chart to disk would otherwise convert it from the refused case to the permitted one, granting the most privilege to the workflow that most obscures where a chart came from.

To keep a file the operator manages, create the resource outside the chart and reference it — the same answer as for any other input a chart must not carry:

docker config create nginx-site /etc/myapp/nginx.conf   # or docker secret create
configs:
  nginx-site:
    external: true
A config the operator supplies

The section above is about files a chart ships. A config the operator writes — a config.js they keep in their own git repository — is the other half, and values/ is how a chart accepts one:

# templates/configs.yaml
configs:
  config:
    file: values/config
    # Swarm config data is immutable, so the name has to change when the
    # content does. Hashing the content makes that automatic and idempotent.
    name: "{{ .Release.Name }}_config_{{ .Values.config | sha256sum | trunc 12 }}"
# values.yaml — "" so the name above renders; the operator supplies the content
config: ""
swarmcli charts upgrade renovate swarmcli-charts/renovate \
  -f values.yaml --set-file config=./config.js

--set-file <key>=<path> reads the file into .Values.<key> verbatim — no comma splitting, no {a,b} list literal and no type inference, all of which --set does and all of which would corrupt a file. values/<key> then materialises that value beside the manifest for the docker CLI to read, exactly as files/ does for a file the chart ships. The key is the full values path, so a nested one is values/renovate.config.

Two rules keep this as safe as the refusals above:

  • The operator names the path, never the chart. That is the whole asymmetry. A path on the operator's own command line carries exactly the authority they already have; a path a chart chose does not, which is why file: /etc/shadow stays refused.
  • Only a config's file: may name values/. A secret's is refused, and so is env_file:. Values are stored in the release record — see below — so secret material would land in the one place a secret exists to keep it out of. Secrets stay docker secret create + external: true.

An absent or empty value is refused rather than deployed, so an operator who forgets --set-file gets a message and not an empty config over a working one.

Why rotation, rather than editing the config? Swarm config data is immutable. docker stack deploy reacts to changed content under an existing name by calling ConfigUpdate, and swarmkit refuses that with only updates to Labels are allowed — so a stable name does not update the config, it fails the deploy. A content-derived name is the only mechanism Swarm offers, and it is idempotent for free: unchanged content resolves to the same name and the same bytes, which is the one ConfigUpdate swarmkit does allow. Superseded configs are left in place — Swarm refuses to delete one still in use, and a custom name: still carries the stack's namespace label, so docker stack rm collects them.

Declare a swarmcliVersion floor on any chart that uses files/ or values/ (see Declaring the swarmcli a chart needs, and set it to the release that introduced them). A swarmcli older than that parses Chart.yaml leniently, ignores both entirely, carries no guard, and resolves file: files/nginx.conf against a temp directory of its own — so it does not fail, it deploys something else. Nothing can be done to an already-released binary, which makes the constraint the only thing that turns that into a refusal.

Two consequences worth knowing, and they apply to values/ exactly as they do to files/:

  • The referenced files are stored in the release record, so a rollback deploys the bytes the original deploy sent rather than whatever the chart says today (it may say nothing: rollback replays a stored manifest and never reads a chart). Only the files the manifest names are stored, and they share the record's ~500 KiB gzipped Docker Config budget with the manifest — an install whose record would not fit is refused before anything is deployed, naming the sizes.
  • That record is as readable as the manifest beside it. A file referenced by a chart is stored verbatim in the same Docker Config, with the same exposure issue #465 describes for the manifest — and so is every value, for every retained revision, not just the current one. That is the reason a values/ path may only give a config its content: secret material still belongs in a Docker secret created outside the chart and referenced with external: true.
Bind mounts name the node, so their source must be absolute

A file: reads the chart. A bind mount does not read anything — it names a path on whichever node runs the task, which is a machine the chart author has never seen. So a source that is not absolute has nothing to resolve against, and is refused before the deploy:

services:
  web:
    volumes:
      - ./data:/data          # refused — relative
      - ~/data:/data          # refused — that is your home, not the node's
      - /srv/app/data:/data   # deployed — a path on the node
      - data:/data            # deployed — a named volume

This is the same defect as the one above, one key over: the docker CLI resolved those sources against the temp directory swarmcli deploys from, so ./data meant a directory swarmcli itself deletes when the deploy returns. Nothing was read or disclosed — a bind source is a string the daemon acts on — but the mount was never the one the chart meant.

An absolute source is deployed as written, and is deliberately privileged: a bind of /var/run/docker.sock on a manager is the swarm's control plane. That is a property of compose, and CE leaves the decision with the operator who installs the chart. swarmcli-cd, which reconciles charts nobody is watching, additionally requires the application's own allowlist to name the path.

Declaring the swarmcli a chart needs

A chart may state the chart engine it requires, as a SemVer constraint:

# Chart.yaml
swarmcliVersion: ">= 1.13.0"

install, upgrade and apply refuse a chart this build is too old for, so the failure names the version to upgrade to rather than surfacing as whatever error the missing feature happens to produce (function "toYamlPretty" not defined). install and upgrade ask before refusing when run interactively; apply never does — it is meant to run unattended. template, diff and show only warn: they change nothing, and show is how you find out what a chart wants. Pass --skip-compat-check to proceed anyway.

The constraint is checked against the chart engine's version, which is not necessarily the version the binary reports for itself — a binary embedding this package carries whichever engine it pinned. A build that reports no engine version (any go build) warns rather than refusing: not knowing is not evidence of incompatibility. Charts declaring nothing are unaffected.

Note that only builds carrying this check can honour it: Chart.yaml is parsed leniently, so an older swarmcli silently ignores swarmcliVersion — the same bootstrapping limit Helm's own apiVersion gate has.

Linting a chart
swarmcli charts lint ./mychart                       # against this build
swarmcli charts lint ./mychart -f ./ci/default-values.yaml
swarmcli charts lint ./mychart --for-version 1.12.0  # against another version

lint renders the chart and reports every problem it finds — a broken template, values that fail values.schema.json, a swarmcliVersion this build does not satisfy — rather than stopping at the first. It renders from the chart defaults, layering any -f/--set on top: a chart with a required, undefaulted input (a {{ required }} / {{ fail }} guard) cannot render from bare defaults, so lint it with the values a real install would supply. A chart that declares no swarmcliVersion gets a warning, not an error: the field is optional, but a chart naming no floor leaves an operator on an old build nothing to act on.

--for-version asks whether the chart's declared floor admits that version. It cannot tell you the chart runs on it: this binary carries one engine's behaviour and cannot emulate another's, so it checks the claim's shape, not its truth. Rendering with a real binary of that version is the only thing that settles it — which is what a chart repository's CI should do:

SWARMCLI_REF=v1.10.0 scripts/install-swarmcli.sh ./bin  # build the declared floor
./bin/swarmcli charts template t ./mychart              # prove the chart runs on it

Templates are rendered with Go text/template + Sprig, exposing:

  • .Values — merged values (defaults < -f files < --set)
  • .Release.Name, .Namespace, .Revision
  • .Chart.Name, .Version, .AppVersion

Each templates/*.yaml is rendered then deep-merged into a single Compose document (Compose is one document, unlike Helm's concatenated manifests). Files beginning with _ (e.g. _helpers.tpl) define named templates only. The merged manifest is validated as a Docker stack before use.

Repositories

A repository is an HTTPS-served index.yaml listing chart versions, each with a tarball URL (Helm repository format) — hostable on GitHub Pages/Releases, S3, or any static host. Configured repos and cached indexes live under $XDG_STATE_HOME/swarmcli/charts (default ~/.local/state/swarmcli/charts); a repository's name is a component of its cache filename, so it is limited to letters, digits, -, _ and ..

Transport

Repositories are HTTPS by default. A repository serves the tarball that becomes the deployed workload, so anything on the network path to it decides what runs on your swarm — and the digest below does not close that, because it is published in the same index.yaml and fetched over the same connection.

Plain http:// is therefore refused wherever bytes would cross the network: adding a repository, refreshing its index, and downloading a tarball an index points at — a repository already configured over http:// included.

An internal registry on a network you already trust is a legitimate setup, so this is a default rather than a rule. Opt that machine out:

export SWARMCLI_CHARTS_ALLOW_PLAINTEXT=1

It is read once, where the CLI builds its repository store, so it covers every command that touches a repository — repo add, repo update, search, install, upgrade, apply. One line in a shell profile or a CI job's environment restores a plaintext setup in full; nothing about it is one-way. Programs embedding this package get the https-only default and decide for themselves — the charts package never reads the environment.

Integrity

The index is fetched over HTTPS from the repository, but a chart's tarball URL may point anywhere — a GitHub Release asset, a CDN. The digest the index publishes for each version is what binds the two together, so swarmcli verifies it:

Index entry Behaviour on install/upgrade/template
digest: sha256:<hex> matches the download installs
digest does not match fataldigest mismatch … refusing to install
digest uses an algorithm other than sha256 fatal — verification cannot be performed, so it is not skipped
entry publishes no digest installs, with a warning on stderr

An absent digest warns rather than fails because nothing was verified before this existed, so rejecting would break every repository that publishes none — including older and hand-written ones. Both the bare-hex form (helm repo index) and the sha256:-prefixed form are accepted.

Chart archives are also capped at 20 MiB on the wire (the decompressed contents have their own limits), so a hostile or truncated download cannot exhaust memory before it is hashed.

TLS is not sufficient on its own here: it authenticates the host you downloaded from, not that the bytes are the ones the repository index vouched for.

Release storage

Each release revision is stored as an immutable, gzipped Docker Config named swarmcli.release.<release>.v<N>, labeled com.swarmcli.*. The log is append-only: the highest revision is the current state; lower deployed revisions display as superseded. This gives HA (Swarm Raft) and rollback with no external database.

docker config ls --filter label=com.swarmcli.release=my-traefik

Pruning release history

Because every revision is its own Config, history grows without bound. Trim it with the retention window — keep only the newest N revisions:

# Apply a window inline, after the deploy:
swarmcli charts install my-traefik eldara/traefik --history-max 20
swarmcli charts upgrade my-traefik eldara/traefik --history-max 20

# Or prune existing history on demand:
swarmcli charts prune my-traefik --history-max 20   # one release
swarmcli charts prune --history-max 20              # every release
swarmcli charts prune my-traefik --history-max 20 --dry-run  # preview only

prune deletes the oldest revisions beyond the window and always keeps the current (highest) revision, so the live release and rollback targets inside the window are preserved. Without --history-max (or with 0) it keeps everything and reports that no window was given. --dry-run prints the keep/delete decision without touching Docker.

Use swarmcli charts prune, not raw Docker. docker config prune, docker system prune and docker config rm swarmcli.release.* are not SwarmCLI-aware and will corrupt release history, rollback targets and audit lineage. SwarmCLI is the only sanctioned way to delete a release's resources.

Per-revision protection labels (com.swarmcli.keep, com.swarmcli.protected) are a planned Phase-3 addition; today the current revision plus the newest-N window are the protections.

Notes & limitations

  • install --dry-run renders, validates, and computes the next revision but does not deploy. For fully offline rendering use charts template.
  • --purge-volumes removes volumes on the connected node only (the CE single-node volume scope); cross-node purge is a future extension.
  • Secrets: the rendered manifest is stored unredacted in a Docker Config, which is readable by anyone with Docker access — as are charts get manifest and the TUI config viewer, which read it back. Do not inline secret material in templates: reference Docker secrets as separate objects instead. Nothing currently enforces that. A redaction pass is issue #465; treat this as a limitation to design around rather than something the engine will catch for you.
  • Chart integrity is only as good as the index: a repository that publishes no digest gets a warning, not a refusal (see Integrity). Chart archives are capped at 20 MiB on the wire.
  • docker stack deploy is used under the hood, so only the Compose-on-Swarm subset is supported and the docker CLI must be on PATH.

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

View Source
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.

View Source
const (
	StatusPendingInstall = "pending-install"
	StatusDeployed       = "deployed"
	StatusSuperseded     = "superseded"
	StatusFailed         = "failed"
	StatusUninstalled    = "uninstalled"
)

Release status values, following Helm's deploy/superseded/failed lifecycle.

View Source
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.

View Source
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

func ApplySetFiles(dst map[string]any, sets []SetFile) error

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

func IsPathRef(ref string) bool

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

func MergeValues(defaults map[string]any, files [][]byte, sets []string) (map[string]any, error)

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.

func ValidateValues

func ValidateValues(schema []byte, values map[string]any) error

ValidateValues checks merged values against a chart's values.schema.json (JSON Schema). A nil/empty schema is a no-op.

Types

type Action

type Action string

Action is what apply will do to one release.

const (
	ActionInstall   Action = "install"
	ActionUpgrade   Action = "upgrade"
	ActionUnchanged Action = "unchanged"
)

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

func NewDockerBackend(ctxName string) Backend

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

func LoadChartArchive(r io.Reader) (*Chart, error)

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

func LoadChartDir(dir string) (*Chart, error)

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 ChartMeta

type ChartMeta struct {
	Name       string
	Version    string
	AppVersion string
}

ChartMeta is the .Chart object available to templates.

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

type Convergence struct {
	Phase  Phase  `json:"phase"`
	Reason string `json:"reason,omitempty"`
}

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

func NewEngineWith(b Backend) *Engine

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

func (e *Engine) GetRevision(ctx context.Context, release string, rev int) (*Release, error)

GetRevision returns a specific revision of a release, or the current one when rev <= 0.

func (*Engine) History

func (e *Engine) History(ctx context.Context, release string) ([]Release, error)

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) List

func (e *Engine) List(ctx context.Context) ([]Release, error)

List returns the current (highest) revision of every release.

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

func (e *Engine) PruneAll(ctx context.Context, keep int, dryRun bool) ([]PruneResult, error)

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) Status

func (e *Engine) Status(ctx context.Context, release string) (*Release, []ServiceState, error)

Status returns the current release plus live service states for its stack.

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

func ParseOwner(s string) (OwnerRef, error)

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.

func (OwnerRef) String

func (o OwnerRef) String() string

String encodes the reference for storage in a label or a release payload.

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.

func (*Plan) Counts

func (p *Plan) Counts() (install, upgrade, unchanged int)

Counts summarises a plan.

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

type ReleaseMeta struct {
	Name      string
	Namespace string
	Revision  int
}

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 RepoEntry

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

RepoEntry is a configured chart repository (name -> index URL).

type RepoSpec

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

RepoSpec is a chart repository the file depends on.

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

func NewRepoStore() (*RepoStore, error)

NewRepoStore returns a store rooted at the standard charts state directory.

func NewRepoStoreAt

func NewRepoStoreAt(dir string) *RepoStore

NewRepoStoreAt returns a store rooted at dir (used in tests).

func (*RepoStore) Add

func (s *RepoStore) Add(name, repoURL string) error

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

func (s *RepoStore) EnsureRepos(specs []RepoSpec) error

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

func (s *RepoStore) Indexes() (map[string]*Index, error)

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) List

func (s *RepoStore) List() ([]RepoEntry, error)

List returns the configured repositories sorted by name.

func (*RepoStore) LoadIndex

func (s *RepoStore) LoadIndex(name string) (*Index, error)

LoadIndex returns the cached, parsed index for a repository.

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) Remove

func (s *RepoStore) Remove(name string) error

Remove deletes a repository and its cached index.

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.

func (*RepoStore) Search

func (s *RepoStore) Search(keyword string) ([]ChartHit, error)

Search returns the latest version of every chart across all repos whose name or description contains keyword (case-insensitive). An empty keyword lists all charts. Results are sorted by "repo/name".

func (*RepoStore) Update

func (s *RepoStore) Update(name string) (changed, unchanged []string, err error)

Update downloads and caches the index for one repository (or all when name is empty) and returns the names refreshed.

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

type SetFile struct {
	Key  string
	Data []byte
}

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.

Jump to

Keyboard shortcuts

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