backend

package
v1.0.0 Latest Latest
Warning

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

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

Documentation

Overview

Package backend applies compose-derived Swarm specs to a swarm through the moby client. It is the half of charts.Backend that needs a daemon; turning a manifest into those specs is package compose.

Index

Constants

View Source
const (
	// ResolveAlways asks the registry to resolve the tag to a digest on every
	// deploy. Docker's default.
	ResolveAlways = "always"
	// ResolveChanged resolves only when the manifest names a different image
	// than the last deploy did, which is what suits automation: an unchanged
	// tag does not become a redeploy just because the registry moved.
	ResolveChanged = "changed"
	// ResolveNever leaves the tag as written.
	ResolveNever = "never"
)

Image resolution modes, matching charts.InstallOptions.ResolveImage and the daemon's own query parameter.

Variables

This section is empty.

Functions

func MountedSecretNames

func MountedSecretNames(dir string) (map[string]struct{}, error)

MountedSecretNames returns the names of the secrets Swarm has mounted into this controller, by listing dir (each secret is a file at /run/secrets/<name>). That set is exactly what a reconciled stack must not be allowed to mount: the admin token, the git token and every registryAuth are the controller's own credentials, and a stack mounting one by an `external` reference would read it.

A dir that does not exist yields an empty set and no error: a controller run outside a swarm has no mounted secrets to protect, and must still start.

Types

type Backend

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

Backend applies specs to one swarm.

func New

func New(api client.APIClient, o Options) *Backend

New returns a Backend applying to the swarm the client is connected to.

func (*Backend) ApplyServices

func (b *Backend) ApplyServices(ctx context.Context, stack *cdcompose.Stack, resolve string) error

ApplyServices creates the stack's services that do not exist and updates those that do, in the order the stack lists them.

It deletes nothing. Phase 1 is explicitly no prune, and charts.Apply itself never deletes either — a service the manifest no longer declares is left alone, not reaped.

It also cannot detect an out-of-band change. Swarm has no server-side apply, so `docker service update --replicas 10` produces no conflict signal at all: the next reconcile simply computes the same desired spec and writes it back, silently. The only conflict this can see is a write that races one of ours.

func (*Backend) CreateConfig

func (b *Backend) CreateConfig(ctx context.Context, name string, data []byte, labels map[string]string) error

CreateConfig stores one release revision.

The extra swarmcli.created label matches what the CE backend writes, so a release recorded by this controller and one recorded from the command line look the same to the TUI's config view.

func (*Backend) CreateOverlayNetwork

func (b *Backend) CreateOverlayNetwork(ctx context.Context, name, driver string, attachable bool) error

func (*Backend) DeclaredResources

func (b *Backend) DeclaredResources(ctx context.Context, manifest, stack string) (*cdcompose.Stack, error)

DeclaredResources converts a rendered manifest to the specs it declares, without resolving what its services mount to the ids Swarm addresses them by.

It is for the caller that wants names — the sweep proving a resource was declared by one of this application's own stored revisions. That caller is asking about the past, so it must not need any of it to still exist: DesiredServices resolves every mounted config and secret against the daemon, and a revision that mounted one a previous sweep has already deleted does not convert at all (#87).

Not a substitute for DesiredServices. What it returns is what ConvertUnresolved returns, so the config and secret references in it carry a placeholder id and nothing may be applied from it.

func (*Backend) DeleteConfig

func (b *Backend) DeleteConfig(ctx context.Context, name string) error

func (*Backend) DeployStack

func (b *Backend) DeployStack(ctx context.Context, req charts.DeployRequest) error

DeployStack converges the swarm to a rendered manifest.

Order matters and is the same order `docker stack deploy` uses: the things a service can reference have to exist before the service that references them. Volumes are absent from that list on purpose — Swarm creates a named volume on the node that first needs it, so there is nothing to pre-create.

The manifest is converted twice, and the first conversion is thrown away. It is what makes that order compatible with refusing a stack whole: converting a service resolves every config and secret it mounts to the id Swarm addresses it by, so the conversion that is applied cannot run until those exist — while the guard has to read what a service mounts before anything has been created (swarmcli-cd#84 for the first, #63 for the second). cdcompose.ConvertUnresolved answers that first read without needing anything to exist, and produces the same names for the guard to compare; the second conversion is the one whose specs are applied.

Nothing is deleted. Phase 1 is explicitly no prune.

req.Files is ignored, and that is a decision rather than an oversight. A manifest reaching here can never name a file: this controller converts it in-process, with no checkout for a relative path to resolve against (cdcompose.Convert sets WorkingDir: "/"), and cdcompose.checkFileSources refuses configs.*.file, secrets.*.file and services.*.env_file outright before the loader can read one — swarmcli-cd#99, because the only filesystem those paths could name is the one holding the Docker socket, the application set and /run/secrets. The chart engine fills the map from exactly those keys, so what arrives here is always empty.

Making the field mean something is a separate change with its own threat argument, not a line added to this method: it means materialising the files to a temp directory, pointing WorkingDir at that directory, and relaxing checkFileSources from "refuse the key" to "refuse a path that escapes" — reopening half of #99's guard on a process holding the socket. That is #528's CE-side PR 4 and a follow-up issue here.

func (*Backend) DesiredServices

func (b *Backend) DesiredServices(ctx context.Context, manifest, stack string) (*cdcompose.Stack, error)

DesiredServices converts a rendered manifest to the Swarm specs it declares.

It is the same conversion DeployStack does before applying, so what drift compares against is what a sync would write, rather than a second reading of the manifest that could disagree with it.

func (*Backend) InspectConfig

func (b *Backend) InspectConfig(ctx context.Context, name string) ([]byte, error)

func (*Backend) ListConfigs

func (b *Backend) ListConfigs(ctx context.Context) ([]charts.ConfigMeta, error)

ListConfigs returns every config's name and labels, and the payload of the ones holding release history.

One ConfigList call and nothing else. A config's payload comes back in the list response, not only on inspect, so passing it through means the engine decodes release history straight from this call instead of inspecting each stored revision (Eldara-Tech/swarmcli#510). That matters here more than anywhere: this runs several times per reconcile per application, against a store that grows by one config per release revision, and a controller reconciles on a timer whether or not anything changed.

Deliberately unfiltered, and that is a conclusion rather than an omission. The engine asks this one method two different questions — which configs hold release history, and which config names exist at all — and the second is asked about a chart's external configs, which carry no swarmcli label. Filtering on the release label server-side would answer the first cheaply and make the second report every external config as absent, which is a hard error refusing the deploy. Nor is there a projection to ask for instead: GET /configs is converted by the same daemon function as GET /configs/{id}, so a name cannot be fetched without its payload — unlike a secret, whose payload the list deliberately withholds — and the accepted filters are id, name, names and label, with no negation to list "everything else" with.

What can be bounded is what is *kept*. Only a release record is ever decoded — ConfigMeta.Data is documented as optional, and allRevisions skips anything not carrying the release label before it looks — so every other config contributes its name and labels and its payload is dropped here rather than held for the length of a plan. On a swarm whose stacks mount configs of their own that is the difference between retaining the release store and retaining the whole config store, on the manager node holding the raft log.

func (*Backend) LiveConfigs

func (b *Backend) LiveConfigs(ctx context.Context, stack string) (map[string]string, error)

LiveConfigs returns the configs this controller created under this stack's namespace, by scoped name.

A release-history config is never included. It carries com.swarmcli.* labels and no namespace, so the filter above cannot see one anyway — this is the same belt and braces RemoveStack applies, and for the same reason: a future change that did put a namespace label on them must not silently turn this sweep into "delete the history too".

func (*Backend) LiveNetworkNames

func (b *Backend) LiveNetworkNames(ctx context.Context) (map[string]string, error)

LiveNetworkNames returns every network on the swarm, by id.

It belongs to the live comparison rather than to the sweep, which is why it sits here beside LiveServices and not below with LiveNetworks and the other two scoped listers: it is deliberately *not* stack-scoped, and putting it in that group would contradict the one thing that group has in common.

A spec names the networks its service is attached to by id — the daemon rewrites each Target to one as it creates or updates the service (daemon/cluster.populateNetworkID) — while a manifest names a network. So comparing attachments is a resolution step before it is a comparison, and a stack-scoped listing cannot perform it: a service may be attached to an external network or to one of the swarm's predefined ones, and neither carries this stack's namespace label. An id that could not be named would have to be treated as opaque, which is precisely the attachment worth reporting.

Id to name, the opposite direction from LiveNetworks, because these are opposite jobs: the sweep matches on a name and deletes by id, and this reads an id and reports a name.

func (*Backend) LiveNetworks

func (b *Backend) LiveNetworks(ctx context.Context, stack string) (map[string]string, error)

LiveNetworks returns the networks carrying this stack's namespace label, by scoped name.

func (*Backend) LiveSecrets

func (b *Backend) LiveSecrets(ctx context.Context, stack string) (map[string]string, error)

LiveSecrets returns the secrets this controller created under this stack's namespace, by scoped name.

func (*Backend) LiveServices

func (b *Backend) LiveServices(ctx context.Context, stack string) (map[string]swarm.Service, error)

LiveServices returns the stack's running services with their full specs, by scoped name.

Named for what it returns rather than StackServices, which is taken by the charts.Backend method above it and answers a different question.

func (*Backend) NetworkScopes

func (b *Backend) NetworkScopes(ctx context.Context) (map[string]string, error)

NetworkScopes maps every network's name to its scope, for the engine's external-network pre-flight.

func (*Backend) ReadStackServices

func (b *Backend) ReadStackServices(ctx context.Context, name string) ([]charts.ServiceState, error)

ReadStackServices is the same read with the failure kept, for the caller that has to tell "the swarm has no services under this release" from "the swarm could not be asked".

Those are one answer to StackServices and opposite findings to a health rollup, which reads an empty list as the positive assertion "deployed, but no services are present on the swarm". One slow daemon therefore flipped every release of an application from healthy to missing — the loudest state the rollup has, and the one anything alerting is watching for (#107).

It is a method beside StackServices rather than its signature because charts.Backend is CE's interface: widening it would change every implementation CE has, for a distinction only this repository's caller needs. The reconciler reaches this one through an optional-interface upgrade, the same way it reaches WithRegistryAuth, so a backend that cannot answer reads exactly as it did before.

func (*Backend) ReadStacks

func (b *Backend) ReadStacks(ctx context.Context, releases []string) (map[string][]charts.ServiceState, error)

ReadStacks answers for several releases from one look at the swarm.

It exists because the read above is not scoped to a release at all: the snapshot is the whole swarm — NodeList, ServiceList, TaskList and Info — and ServiceStatesFrom filters it afterwards. Asking per release therefore fetched the entire swarm per release and discarded all but one stack's worth of it each time, so an application declaring ten releases spent ten identical round trips on every reconcile, against the manager this controller runs on.

All-or-nothing, which is the same answer the single read gives: either the snapshot arrived and every release is in the map, or it did not and none are. An empty slice for a release therefore means the swarm really has no services under that name, which is precisely the distinction #107 turned on.

func (*Backend) RefreshSnapshot

func (b *Backend) RefreshSnapshot(context.Context) error

RefreshSnapshot is a no-op: this backend holds no cache to invalidate.

The method exists because the ambient CE backend reads through a process-wide snapshot with a 3s TTL and has to be told when it has gone stale. Here every read fetches, which is what makes one process able to serve several swarms without them evicting each other's state.

func (*Backend) RemoveConfig

func (b *Backend) RemoveConfig(ctx context.Context, id string) error

RemoveConfig deletes one config by id.

Distinct from DeleteConfig, which removes a release record by name on behalf of the chart engine. This one is the sweep's, takes an id, and tolerates a config that is already gone.

func (*Backend) RemoveNetwork

func (b *Backend) RemoveNetwork(ctx context.Context, id string) error

RemoveNetwork deletes one network by id.

Not RemoveOverlayNetwork, which resolves a name against an unfiltered list of every network on the swarm: that is the wrong cost on a path that runs every reconcile, and it reopens the window RemoveService closes by taking an id the caller has already read.

A network Swarm has already garbage-collected is not an error. The last task leaving an overlay network removes it, which routinely happens between the sweep reading the network and deciding to delete it.

Finding that out takes a second look, and classifying the error is not enough on its own — the premise RemoveStack states and answers with stackRemains, which this had no equivalent of. A swarm-scoped removal is proxied through swarmkit, and the helper that resolves the network there is the one of its five siblings that does *not* wrap "not found" in errdefs.NotFound (daemon/cluster/helpers.go:238, against :51, :87, :131, :167 and :274 which all do). An unclassified error is scored as Unknown and rendered 500 (api/server/httpstatus/status.go:16), which the client turns into ErrInternal (client/errors.go:124), so errdefs.IsNotFound is false for a network that had already gone. Reporting that as a failed prune, on the path whose whole purpose is honest reporting, is the defect.

So the state is the answer and the error is only a hint about where to look: a removal that failed is followed by a re-read, and a network that is no longer listed reached the state this was asking for whatever the call said.

func (*Backend) RemoveOverlayNetwork

func (b *Backend) RemoveOverlayNetwork(ctx context.Context, name string) error

RemoveOverlayNetwork removes a network by name, rolling back one this engine auto-created for an install whose deploy then failed. A network that is already gone is not an error: the caller is undoing, and undoing something that did not happen has succeeded.

func (*Backend) RemoveSecret

func (b *Backend) RemoveSecret(ctx context.Context, id string) error

RemoveSecret deletes one secret by id.

func (*Backend) RemoveService

func (b *Backend) RemoveService(ctx context.Context, id string) error

RemoveService deletes one service by id.

By id and not by name, because the caller has already read the service it means: resolving a name again here would open a window in which the name pointed at something else. A service already gone is not an error — a sweep that raced another deletion has still got the outcome it wanted.

It is the whole of the applier's delete surface, and deliberately narrow. RemoveStack takes a namespace and removes everything under it; this takes one service the caller has proved it may delete, and the proof lives in the reconciler rather than here.

func (*Backend) RemoveStack

func (b *Backend) RemoveStack(ctx context.Context, name string) error

RemoveStack deletes the services, networks, configs and secrets carrying the stack's namespace label — what `docker stack rm` removes, and nothing more.

Removal is idempotent, and it is decided by looking rather than by reading the error. A resource that has already gone between the list and the delete has reached the state this was asking for; a failure that leaves nothing behind is not a failure. That is not a rare race — Swarm garbage-collects an overlay network once the last task attached to it goes, which happens while the services removed a few lines above are still shutting down, so the network this listed is routinely gone before it is asked to remove it.

Classifying the error is not enough on its own. A swarm-scoped network removal is proxied through swarmkit and its "already gone" reply does not reliably arrive as a not-found the client recognises, so every failed removal is followed by a re-check of what is actually left. The state is the answer; the error is only a hint about where to look.

This is what makes the call safely repeatable, which prune's retry depends on: a pass that failed part-way is followed by another that re-lists and re-deletes, and treating the already-deleted half as an error would make that retry fail forever.

Volumes survive, as they do there: a stack's data outliving the stack is the whole point of a named volume, and charts has RemoveVolume for the caller that means it.

The engine's own release records are untouched. They are Docker configs, but they carry com.swarmcli.* labels rather than a stack namespace, so the filter below cannot see them — which is what lets a release be uninstalled and its history still be readable.

func (*Backend) RemoveVolume

func (b *Backend) RemoveVolume(ctx context.Context, name string) error

RemoveVolume deletes one volume by name.

A volume that is already gone is not an error, as it is not for the four removals in live.go. The caller retries this for as long as the volume is still held by a container that has not finished shutting down, so a volume that vanished in between — another sweep, an operator, a `docker volume prune` — would otherwise burn the whole settle budget and then fail the prune naming "still in use" for something that had reached the asked-for state on the first attempt.

The classification is reliable here in a way it is not for a network: a missing volume is answered by the local volume store, or on a manager by getVolume, and both wrap in a not-found the client recognises (daemon/cluster/helpers.go:274).

func (*Backend) SecretNames

func (b *Backend) SecretNames(ctx context.Context) (map[string]struct{}, error)

SecretNames is the set of existing secret names, for the engine's pre-flight. An external secret cannot be auto-created — its content is exactly what the manifest does not carry — so this only ever answers "is it there".

func (*Backend) StackServices

func (b *Backend) StackServices(ctx context.Context, name string) []charts.ServiceState

StackServices reads one stack's live service states.

Every rule in here belongs to the chart engine and is reached through its own exported mapping (Eldara-Tech/swarmcli#508): the running count by actual rather than desired state (#480), the target over active nodes (#481), a completed one-shot job counting toward its target instead of reading 0/N (#443, #494). A second copy would diverge silently, and both directions of that are wrong — reporting a release converged while the engine would still be waiting, or degraded on a stack that is fine.

A snapshot that cannot be read returns nil, matching the CE backend: the caller polls, so an unavailable daemon is "not converged yet" rather than a failure to report. That is true of awaitConverged and false of everything that asks once and then takes a view — see ReadStackServices.

func (*Backend) StackVolumes

func (b *Backend) StackVolumes(ctx context.Context, name string) ([]string, error)

StackVolumes names the volumes carrying this stack's namespace label **on the node this controller talks to**, which on a swarm of more than one node is not the same question as "this stack's volumes".

GET /volumes answers from the node-local volume store (api/server/router/volume/volume_routes.go:35 → volume/service/service.go:265): the daemon lists what this engine has, and only CSI *cluster* volumes are added from swarm, and only on a manager (volume_routes.go:41). An ordinary named volume is created by the engine that runs the task that mounts it, so a stack's volumes live on whichever nodes scheduled its tasks and are invisible from everywhere else.

There is no swarm-wide named-volume listing to ask instead, and swarms.Registry resolves one swarm rather than one node, so this is the whole of what the OSS build can see. SwarmNodes below is how a caller finds out whether that is everything.

Guarded like RemoveStack, and not only for symmetry: this is the list a purge deletes from, and the chart engine's own Uninstall reaches it even when the stack removal before it failed — it collects that error and carries on. A release named for the controller's stack would name the volume holding every application's git clone and chart cache, so the refusal has to be on the read that produces the list rather than only on the removal that precedes it (#102).

func (*Backend) SwarmNodes

func (b *Backend) SwarmNodes(ctx context.Context) (int, error)

SwarmNodes counts the swarm's nodes, which is the only thing that says whether StackVolumes could have seen everything: on a single-node swarm the node-local volume store *is* the swarm's, and on any larger one it is a fraction of unknown size.

A node that is not a manager cannot list nodes, and that failure is not smoothed over here — the caller decides what an unanswerable question means, and for a deletion it has to mean "assume not".

func (*Backend) WithAllowedReferences

func (b *Backend) WithAllowedReferences(allow application.Allow) charts.Backend

WithAllowedReferences returns a copy of the backend that permits one application's charts to reach what allow enumerates — host paths on the nodes, and the secrets, configs, volumes and networks of some other stack.

Per application, unlike WithForbiddenSecrets, and applied through the same optional-interface upgrade as WithRegistryAuth so the reconciler need not depend on this concrete type. It is the only thing that carries an application's identity into this package: the guards here are name comparisons, and until now every set they compared against was the controller's own, which needed no caller to say who was deploying.

A backend nobody scoped this way permits nothing beyond what the release being deployed owns. That is the safe direction and it is what the sweep in package prune resolves for itself — a sweep removes rather than deploys, so it reaches neither this value's readers, but if it ever did it would refuse rather than wave something through.

func (*Backend) WithForbiddenSecrets

func (b *Backend) WithForbiddenSecrets(names map[string]struct{}) charts.Backend

WithForbiddenSecrets returns a copy of the backend that refuses to deploy a stack mounting any of the named secrets — the controller's own credentials. Controller-wide, not per application, but applied through the same optional-interface upgrade as WithRegistryAuth so the reconciler need not depend on this concrete type.

It is the startup-derived half only. The backend adds what Swarm reports it has mounted, and the chart engine's release records, at deploy time — see rejectForbiddenResources. Nothing an operator has to wire up can therefore be the difference between the guard being on and off.

func (*Backend) WithOutOfBandNotifier

func (b *Backend) WithOutOfBandNotifier(fn func(service string)) charts.Backend

WithOutOfBandNotifier returns a copy of the backend that reports a lost compare-and-swap to fn.

It is what makes Options.OnOutOfBandChange reachable at all from a reconcile: a backend is built once per swarm, by the swarms registry, which knows nothing about applications — and only the caller knows which application's sync a losing write belongs to. Applied through the same optional-interface upgrade as WithRegistryAuth, for the same reason.

A nil fn leaves the existing notifier in place rather than removing it, so a caller that does not care cannot accidentally silence one that does.

func (*Backend) WithRegistryAuth

func (b *Backend) WithRegistryAuth(auth regauth.Resolver) charts.Backend

WithRegistryAuth returns a copy of the backend that authenticates its image pulls with auth. The copy shares the client — one swarm's connection pool is not duplicated per application — and differs only by the resolver, so the per-swarm backend stays shared while the credential stays per application.

It returns charts.Backend so the reconciler can reach it through the swarms seam (which hands back that interface) with an optional-interface upgrade, rather than depending on this concrete type.

type Options

type Options struct {
	Log               *slog.Logger
	OnOutOfBandChange func(service string)
	Now               func() time.Time
}

Options tune a Backend. Every field has a working default.

Jump to

Keyboard shortcuts

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