Skip to main content

Clusters & cluster workloads

This article refers to SDK version v0.5.0. The current SDK version is N/A.

Overview

A cluster groups nodes so they can be configured and deployed to together, without repeating the same call once per node. This is the same Docker Swarm cluster documented from the Panel side in Clusters; see High Availability for the concept end to end. A cluster workload is the cluster-level equivalent of a node workload: deploy an application once and have it land on every eligible member. Barbara's product docs use "Workload" at both scopes, there's no separate "stack" concept in Panel, and the SDK's client.clusters.workloads resource matches that.

This guide walks through the same fields as the Add Cluster wizard, since client.clusters.create accepts the raw per-node networking dicts the wizard's two steps build for you visually. Read Cluster networking first if the difference between the nodes' physical network and the Swarm overlay network isn't already clear, since several fields below only make sense once that distinction is.

Concept

Cluster. A group of nodes an operator manages and deploys to as one unit in Barbara Panel, giving High Availability instead of a single point of failure.

Learn more

Create a cluster

client.clusters.create(
"floor-2-cluster",
primary_node={
"nodeId": "<node-id>",
"labels": "eyJ6b25lIjogImZsb29yLTIifQ==", # base64 JSON: {"zone": "floor-2"}
"restrictSwarmTrafficToInterface": False,
"advertiseAddr": "10.0.0.5",
},
enable_cluster_volumes=True,
)

create(name, primary_node, *, secondary_nodes=None, enable_cluster_volumes=False, virtual_ip_enabled=False, id_vrr=None, password=None, virtual_ip=None, sync_time=None, multicast=False, default_addr_pool=None, subnet_size=None) on client.clusters:

ParameterTypeWizard equivalent
namestrCluster name, up to 32 characters
primary_nodeDict[str, Any]The first node tab in Step 2 — Node details
secondary_nodesOptional[List[Dict[str, Any]]]Any additional node tabs added with + in Step 2
enable_cluster_volumesboolEnable Cluster Volumes, default True in the wizard but False here, set it explicitly
virtual_ip_enabledboolEnable Virtual IP, default False
id_vrrOptional[int]The VRRP router id KeepAlived uses internally; leave unset unless you have a specific reason to pin it
passwordOptional[str]The VRRP password, only meaningful when virtual_ip_enabled=True; base64-encoded internally by the SDK
virtual_ipOptional[str]Virtual IP, a free address on the same network as the nodes (see the warning below), only used when virtual_ip_enabled=True
sync_timeOptional[float]Synctime, the interval in seconds between status synchronizations across nodes; must be greater than 0.005
multicastboolWhether KeepAlived uses multicast instead of unicast for its heartbeats; default False, matching the wizard's default of configuring Unicast IP per node
default_addr_poolOptional[List[str]]Default address Pool under Advanced Settings, an [ip, netmask]-style pair, for example ["10.0.0.0", "255.0.0.0"]; the default when unset matches the wizard's own default
subnet_sizeOptional[int]Subnet Size under Advanced Settings, an integer between 0 and 32; the wizard defaults this to 24
Three nodes minimum for HA

For High Availability to work correctly, a cluster needs a minimum of three nodes, matching the wizard's own warning. Fewer nodes still form a working Swarm, but the cluster loses quorum as soon as one node goes down. Include every node you intend to run in primary_node/secondary_nodes at creation time: growing a cluster afterwards means calling join_node per additional node, which works but is considerably more involved than listing them all up front.

primary_node (and each item of secondary_nodes) is the raw per-node VRRP/Swarm config dict the API expects; this method doesn't reshape it, since cluster networking config is inherently node-specific. Each dict needs:

KeyTypeWhat it is
nodeIdstrThe node's internal id, from client.nodes.resolve(node_name)
labelsstrA base64-encoded JSON object of {key: value} node labels; see the tip below for building this without hand-rolling base64
restrictSwarmTrafficToInterfaceboolPins Swarm traffic to a single network interface when True, mirroring Restrict Swarm Traffic to Interface; default False in the wizard
advertiseAddrstrThe node's own fixed IP address on the physical network, which Swarm advertises to the other members, mirrors Advertised address

When virtual_ip_enabled=True, add the Virtual IP Settings keys to each node dict as well: virtualIpInterface (the interface name to announce the virtual IP on), unicastIp (the node's own address for KeepAlived heartbeats, normally equal to advertiseAddr), priority (an integer 1254, higher wins the election), and trackInterfaces (a list of interface names to monitor).

Build labels without hand-rolling base64

labels expects base64-encoded JSON, not a plain dict, because that's the wire format the underlying cluster-membership endpoint uses. (join_node and update_node_config below accept a plain dict instead; only the raw create payload needs it pre-encoded.) Build it yourself with:

import base64
import json

labels = base64.b64encode(json.dumps({"zone": "floor-2"}).encode()).decode()
Keep the virtual IP off the address pool

The Virtual IP does not belong to the Default address Pool range. The address pool is the internal network Swarm hands out to containers; the virtual IP lives on the nodes' own network. An address from the wrong network is accepted without any warning and fails silently later, exactly as documented in Cluster networking. The SDK performs no validation here either, so check the address ranges yourself before calling create.

Manage membership

client.clusters.join_node(
"<cluster-id>", "<node-id>",
labels={"zone": "floor-2"},
restrict_swarm_traffic_to_interface=False,
advertise_addr="10.0.0.6",
)

client.clusters.pause_node("<cluster-id>", "<node-id>")
client.clusters.drain_node("<cluster-id>", "<node-id>")
client.clusters.set_node_active("<cluster-id>", "<node-id>")
client.clusters.leave_node("<cluster-id>", "<node-id>")

join_node(cluster_id, node_id, *, labels, restrict_swarm_traffic_to_interface, advertise_addr, iface=None, unicast_ip=None, swarm_iface=None, priority=None, track_interfaces=None) adds one more node to an existing cluster, matching the effect of the Add to cluster shortcut in the Nodes List batch toolbar. Unlike the raw dicts inside create, labels here is a plain Dict[str, str]: the SDK JSON+base64-encodes it for you, matching the convention documented on the endpoint. iface, unicast_ip, swarm_iface, priority, and track_interfaces are the same Virtual IP Settings fields as in create's per-node dicts, only relevant if the cluster has the virtual IP enabled.

pause_node(cluster_id, node_id) and drain_node(cluster_id, node_id) take a node out of scheduling temporarily without removing it from the cluster, mirroring the Pause / Drain actions in the Cluster Info card; set_node_active(cluster_id, node_id) brings a Paused or Drained node back to Active, mirroring Force Activate. leave_node(cluster_id, node_id, *, force=False) removes it entirely, mirroring Remove node; set force=True to detach a node that's in an unusual state, the SDK equivalent of Panel's Force Drain/Force Pause variants applied to removal.

To relabel a node already in the cluster without removing and re-adding it:

client.clusters.update_node_config("<cluster-id>", "<node-id>", labels={"zone": "floor-3"})

update_node_config(cluster_id, node_id, labels) replaces the node's label set, the same plain Dict[str, str] shape as join_node's labels. This is what backs the Node Labels chips you'd otherwise click in the wizard's Step 2.

Deploy a cluster workload

client.clusters.workloads.create_docker_workload(
"<cluster-id>",
app_version_id="<app-version-id>",
application_id="<application-id>",
)

create_docker_workload(cluster_id, *, app_version_id, application_id, force_pull=False, enable_logs=True, config=None, config_id=None) on client.clusters.workloads deploys a Docker application across the cluster; the parameters mean exactly what they mean for create_docker_workload on client.nodes.workloads, just without a run_docker toggle, since a cluster workload starts on every eligible node according to its deployment mode rather than a single immediate/not choice. Marketplace and Model workloads follow the same compose_config/app_secrets-template rules as their node-workload counterparts, see Marketplace workload deployment and Model deployment.

Configure deployment mode and placement

client.clusters.workloads.create_marketplace_workload(
"<cluster-id>",
app_version_id="<app-version-id>",
application_id="<application-id>",
name="my-mqtt-broker",
compose_config=[{"name": "broker", "ports": {"PORT_NUMBER": "1883"}}],
deployment=[{"name": "broker", "mode": "replicated", "replicas": 3}],
placement_constraints=[{"name": "broker", "constraints": ["node.role==worker"]}],
)

deployment and placement_constraints are cluster-only parameters, present on create_marketplace_workload/update_marketplace_workload/create_model_workload/update_model_workload at cluster scope, with no node-level equivalent. Each is a list of one dict per service: deployment takes {"name", "mode", "replicas"}, mirroring the Deployment mode / replicas step of the Adding applications wizard; placement_constraints takes {"name", "constraints"}, mirroring the wizard's Placement constraints step. This is the same setting reachable on an already-deployed workload via the Deployment and Replicas / Placement Constraints popups on the workload's Config segment.

No get() to read back cluster workload state

Cluster-level workloads have no get() to read back their current state, unlike node-level workloads. Giving only some of compose_config/app_secrets/deployment/placement_constraints to update_marketplace_workload (or the model equivalent) replaces the whole services array and clears whichever axes you didn't pass, so track and pass the full desired state yourself if that matters. The narrower update_marketplace_workload_compose_config and update_marketplace_workload_app_secrets calls have the same replace-the-whole-array behavior at cluster scope, since there's no current state to read back and merge with first the way the node-level equivalents do.

Manage the cluster workload

client.clusters.workloads.get_logs("<cluster-id>", "<workload-id>", results=100)
client.clusters.workloads.delete("<cluster-id>", "<workload-id>")

get_logs(cluster_id, workload_id, *, from_=None, to=None, results=100, search=None) takes the same parameters as client.nodes.workloads.get_logs, just scoped to the whole cluster workload rather than a single node's copy of it. See Workload lifecycle, the same operations exist on client.clusters.workloads with a cluster_id and workload_id in place of a node_id and workload_id, including get_urls.

Reading back the live container state differs slightly from node scope, since client.clusters.get(cluster_id) embeds every stack's full current state rather than exposing a per-workload get():

services = client.clusters.workloads.get_running_services("<cluster-id>", "<workload-id>")

get_running_services(cluster_id, workload_id) reads the live Docker Swarm service state backing this stack, stacks[].clusterInfo.services[] on the raw cluster document, one entry per Swarm service with its own tasks[] for per-node running/shutdown/failed/orphaned state. It's returned as-is, unlike the node-level get_running_services(), which is a lightly-normalized subset of Docker container info.

Manage cluster-wide volumes

client.clusters.create_swarm_volume("<cluster-id>", "shared-cache")
client.clusters.delete_swarm_volume("<cluster-id>", "<volume-id>")
client.clusters.delete_all_swarm_volumes("<cluster-id>")

create_swarm_volume(cluster_id, volume_name) creates a cluster-wide external volume, the same + action on the cluster's Volumes card; volume_name is base64-encoded internally, same convention as everywhere else in the SDK. delete_swarm_volume(cluster_id, volume_id) takes the volume's internal _id, not its name, the same distinction covered for node-level volumes in Docker credentials & volumes. Since create_swarm_volume doesn't return an id either, look it up first via the cluster's raw node/volume data or Panel.

Shared volume stays pending on a single node

On a single-node cluster with enable_cluster_volumes=True, the shared volume is not created yet. It stays pending until a second node joins, matching the wizard's own warning: creating shared volumes before that fails because the shared storage does not exist.

Clean up Swarm-native configs and secrets

Beyond Barbara's own Global Config and Global Secrets, a cluster app can declare its own Docker Swarm configs and secrets directly in its docker-compose.yaml (configs:/secrets: sections). These are Docker-native objects, not Barbara-managed ones: Panel surfaces them read-only on the Swarm Config and Swarm Secrets cards, letting you audit what apps have declared and clean up ones no longer in use. The SDK mirrors that: cleanup only, no create, since these objects are declared by the app itself, not by your script.

client.clusters.delete_swarm_config("<cluster-id>", "<swarm-config-id>")
client.clusters.delete_all_swarm_configs("<cluster-id>")

client.clusters.delete_swarm_secret("<cluster-id>", "<swarm-secret-id>")
client.clusters.delete_all_swarm_secrets("<cluster-id>")

delete_swarm_config(cluster_id, config_id) and delete_all_swarm_configs(cluster_id) mirror the trash icon on the Swarm Config card; delete_swarm_secret and delete_all_swarm_secrets do the same for Swarm Secrets. Both cards, and both sets of SDK methods, only let you remove an object not currently referenced by a running service: an attempt against one still in use is rejected, so uninstall the app that declared it first. See Node & cluster secrets for how these differ from Barbara's own Global Secrets.

Summary

You created a cluster, moved nodes through membership states, and deployed a workload across it, the same application concepts as node workloads, just scoped to every member at once, plus the Swarm-native config/secret cleanup that's specific to cluster scope.

For the full method list, see client.clusters and client.clusters.workloads in the Reference.