"""Back up a node's configuration to a JSON file and reapply it (restore) onto one or more other nodes, or wipe a node's contents (clean). A fuller counterpart to clone_node.py: it saves the snapshot to disk, restores by section, restores onto several targets in one run, and can also touch network configuration (off by default — see "network" below). Subcommands: login Show the token's role and what it can do. backup -o FILE Write a snapshot of to FILE. restore Apply a snapshot's sections to one or more target nodes (dry-run unless --apply). clean Delete a node's workloads/volumes/secrets/ docker credentials (dry-run unless --apply). What a snapshot captures, from the API's read endpoints: Section Source Notes workloads GET .../devices/{id} + .../workloads/{wid} Docker, Marketplace, Model — App Config, Compose Config, App Secrets as each type has them. global_config GET .../devices/{id}/appconfig The node's own Global Config. volumes GET .../devices/{id} (deviceData.docker.volumes) Names only — volume data is not captured. secrets GET .../devices/{id}/secrets Names only — values are write-only. docker_creds GET .../devices/{id}/dockercredentials Server + user only — the API never returns the password, under any role. network GET .../devices/{id}/networks Interfaces, VLANs, Proxy, NTP servers. Only captured with --sections network. What restore actually applies: Section Action volumes Creates each named volume, empty (created before workloads, so the workloads that mount them can find them). workloads Recreates each captured workload with its application version and configuration. global_config Applies the node's Global Config. network Reapplies interfaces/VLANs/Proxy/NTP servers — see the warning below. secrets, docker_credentials are captured for visibility but never restored automatically: there is nothing to restore (values/passwords were never captured), only names — recreate them by hand with client.nodes.create_global_secrets(...)/create_docker_credentials(...). By default, restore applies volumes,workloads,global_config. "network" is never included unless you pass it explicitly with --sections — reapplying interface settings (especially a static IP) to a *different* node can cut its connectivity, and this tool has no way to know whether that's safe for your fleet. Restoring onto several targets in one run: pass multiple target nodes, space- or comma-separated. Each one gets the same snapshot and options applied in order, with a per-target result and a batch summary. Pass --stop-on-error to stop at the first target that fails, leaving the rest untouched. clean deletes a node's workloads, named docker volumes, Global Secrets, and docker credentials — either everything (default), or only what a given snapshot contains (--from-backup FILE, useful for reverting a restore). Requires typing "delete" to confirm when run with --apply, unless --yes is given. Login and role: on startup, every subcommand decodes the access token (a JWT) to show which of Barbara's four role levels (Viewer/Editor/Supervisor/ Administrator) the account holds, purely as a heads-up — the API's own 401/403 response is always the real authority, this is not enforced by the tool itself. `login` alone just shows this and exits. Credentials are read from environment variables (BBR_API_CLIENT_ID / BBR_API_CLIENT_SECRET / BBR_API_USERNAME / BBR_API_PASSWORD), optionally loaded from a .env file first — see load_dotenv() below and README.md. """ from __future__ import annotations import argparse import json import os import re import sys from typing import Any, Optional from barbara import BarbaraApiError, BarbaraAuthError, BarbaraClient from barbara.models import Group, Node, Workload from barbara.permissions import decode_jwt_claims from barbara.utils import from_base64 INTERNAL_ID_RE = re.compile(r"^[0-9a-f]{24}$") ZERO_OBJECT_ID = "000000000000000000000000" SPACE_DOCKER = 0 SPACE_MARKETPLACE = 1 SPACE_MODEL = 2 KIND_BY_KEY = {"docker": SPACE_DOCKER, "marketplace": SPACE_MARKETPLACE, "model": SPACE_MODEL} ALL_SECTIONS = ("workloads", "global_config", "volumes", "network") DEFAULT_RESTORE_SECTIONS = ("volumes", "workloads", "global_config") DEFAULT_CLEAN_SECTIONS = ("workloads", "volumes", "secrets", "docker_credentials") def load_dotenv(path: str) -> None: if not os.path.isfile(path): return with open(path, encoding="utf-8") as fh: for line in fh: line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, value = line.partition("=") os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) def resolve_node(client: BarbaraClient, identifier: str) -> Node: if INTERNAL_ID_RE.match(identifier): return client.nodes.get(identifier) return client.nodes.resolve(identifier) def resolve_group(client: BarbaraClient, identifier: str) -> Group: if INTERNAL_ID_RE.match(identifier): return client.groups.get(identifier) for group in client.groups.list(): if group.name == identifier: return group raise SystemExit(f"No group found matching '{identifier}'") def resolve_targets( client: BarbaraClient, target_identifiers: list[str], group_identifiers: list[str], tags: list[str], ) -> list[Node]: """The target set for a restore: the union of explicit nodes (by Barbara ID or internal _id), every node belonging to the given group(s), and every node carrying at least one of the given tags — a node reachable through more than one of the three is only restored to once. At least one of the three must be given. """ if not target_identifiers and not group_identifiers and not tags: raise SystemExit("Give at least one target node (positional), --group, or --tags.") nodes: dict[str, Node] = {} for identifier in target_identifiers: node = resolve_node(client, identifier) nodes[node.id] = node for identifier in group_identifiers: group = resolve_group(client, identifier) for node_id in group.node_ids: if node_id not in nodes: nodes[node_id] = client.nodes.get(node_id) if tags: for node in client.nodes.list_by_tags(tags): nodes.setdefault(node.id, node) return list(nodes.values()) def _oid(value: Any) -> Any: return value.get("_id") if isinstance(value, dict) else value def warn(message: str) -> None: print(f"Warning: {message}", file=sys.stderr) # -- Role check --------------------------------------------------------------- ROLE_SUFFIXES_BY_LABEL = { "read": "Viewer", "edit": "Editor", "edit_plus": "Supervisor", "admin": "Administrator", } def print_role_summary(client: BarbaraClient) -> None: token = client._get_token() claims = decode_jwt_claims(token) suffixes = {entry.rsplit("/", 1)[-1] for entry in claims.get("groupMembership") or []} labels = [ROLE_SUFFIXES_BY_LABEL[s] for s in suffixes if s in ROLE_SUFFIXES_BY_LABEL] companies = claims.get("companies") or [] company_names = ", ".join(c.get("name", "") for c in companies if isinstance(c, dict)) print(f"Company: {company_names or '(unknown)'}") print(f"Roles: {', '.join(sorted(labels)) or '(none found in token)'}") if "Supervisor" not in labels and "Administrator" not in labels: print( "Note: without Supervisor/Administrator, restoring workload configuration or " "network settings may be rejected by the API." ) # -- Workloads ------------------------------------------------------------ def _app_reference(workload: Workload, kind: Any) -> tuple[Optional[str], Optional[str], Optional[str]]: current = workload.raw.get("current") or {} if kind == SPACE_MARKETPLACE: application, app_version = current.get("marketApplicationId"), current.get("marketAppVersionId") elif kind == SPACE_MODEL: application, app_version = current.get("modelApplicationId"), current.get("modelAppVersionId") else: application, app_version = current.get("applicationId"), current.get("appVersionId") name = application.get("name") if isinstance(application, dict) else current.get("applicationName") return _oid(application), _oid(app_version), name def _extract_app_config(app_config_block: dict[str, Any]) -> Optional[dict[str, Any]]: config_block = (app_config_block.get("current") or {}).get("config") or {} config_id = config_block.get("appConfigId") if config_id and config_id != ZERO_OBJECT_ID: return {"config_id": config_id} encoded_config = config_block.get("appConfig") if encoded_config: decoded = json.loads(from_base64(encoded_config)) if decoded: return {"config": decoded} return None def backup_workloads(client: BarbaraClient, node_id: str) -> list[dict[str, Any]]: node = client.nodes.get(node_id) workloads: list[dict[str, Any]] = [] for space in node.raw.get("spaces") or []: workload_id = _oid(space) workload = client.nodes.workloads.get(node_id, workload_id) kind = workload.raw.get("type") application_id, app_version_id, name = _app_reference(workload, kind) if kind not in (SPACE_DOCKER, SPACE_MARKETPLACE, SPACE_MODEL) or not application_id or not app_version_id: continue kind_name = {SPACE_DOCKER: "docker", SPACE_MARKETPLACE: "marketplace", SPACE_MODEL: "model"}[kind] entry: dict[str, Any] = { "kind": kind_name, "application_id": application_id, "app_version_id": app_version_id, "name": name, } if kind in (SPACE_MARKETPLACE, SPACE_MODEL): entry["compose_config"] = client.nodes.workloads.get_compose_config(node_id, workload_id) if kind in (SPACE_DOCKER, SPACE_MARKETPLACE): entry["config"] = _extract_app_config(workload.raw.get("appConfig") or {}) if kind == SPACE_MARKETPLACE: entry["app_secrets"] = client.nodes.workloads.get_app_secrets(node_id, workload_id) workloads.append(entry) return workloads def restore_workload(client: BarbaraClient, target_id: str, workload: dict[str, Any]) -> None: kind = workload["kind"] common = { "app_version_id": workload["app_version_id"], "application_id": workload["application_id"], } if kind == "marketplace": client.nodes.workloads.create_marketplace_workload( target_id, name=workload.get("name") or "restored-workload", compose_config=workload.get("compose_config") or [], app_secrets=workload.get("app_secrets") or [], **(workload.get("config") or {}), **common, ) elif kind == "model": client.nodes.workloads.create_model_workload( target_id, name=workload.get("name") or "restored-workload", compose_config=workload.get("compose_config") or [], **common, ) else: client.nodes.workloads.create_docker_workload( target_id, **(workload.get("config") or {}), **common ) # -- Global Config, volumes, secrets, docker credentials ----------------- def backup_global_config(client: BarbaraClient, node_id: str) -> Optional[dict[str, Any]]: try: return _extract_app_config(client.nodes.get_global_config(node_id)) except BarbaraApiError as exc: warn(f"Could not read global config for {node_id}: {exc}") return None def backup_volumes(client: BarbaraClient, node_id: str) -> list[dict[str, Any]]: try: return [{"name": v.get("name")} for v in client.nodes.list_docker_volumes(node_id) if v.get("name")] except BarbaraApiError as exc: warn(f"Could not read docker volumes for {node_id}: {exc}") return [] def backup_secret_names(client: BarbaraClient, node_id: str) -> list[str]: try: return [secret.name for secret in client.nodes.list_global_secrets(node_id)] except BarbaraApiError as exc: warn(f"Could not read secrets for {node_id}: {exc}") return [] def backup_docker_credentials(client: BarbaraClient, node_id: str) -> list[dict[str, Any]]: try: return [ {"server": c.server, "user": c.user} for c in client.nodes.list_docker_credentials(node_id) ] except BarbaraApiError as exc: warn(f"Could not read docker credentials for {node_id}: {exc}") return [] # -- Network (only captured/restored when explicitly requested) ---------- def backup_network(client: BarbaraClient, node_id: str) -> dict[str, Any]: network = client.nodes.network.get(node_id) interfaces = [] for iface in network.interfaces: config = ((iface.raw.get("current") or {}).get("update") or {}).get("config") or {} interfaces.append({"name": iface.name, "type": iface.type, "config": config}) ntp_servers = [ from_base64(entry["current"]["update"]["config"]["server"]) for entry in client.nodes.get(node_id).raw.get("deviceConfig", {}).get("ntpServers", []) if not entry.get("systemServer") ] return {"interfaces": interfaces, "proxy": network.proxy, "ntp_servers": ntp_servers} def restore_network(client: BarbaraClient, target_id: str, network: dict[str, Any]) -> list[dict[str, Any]]: """Reapplies Ethernet/WiFi/Mobile interface settings by name — the target must have an interface with the same name (e.g. "eno1") as the source for that entry to apply. VLANs and Proxy/NTP servers are additive and don't depend on matching interface names. """ results = [] for iface in network["interfaces"]: name, iface_type, config = iface["name"], iface["type"], iface["config"] try: if iface_type == "ethernet": client.nodes.network.update_ethernet_interface( target_id, name, dhcp=config.get("dhcp"), ip=config.get("ip"), gateway=config.get("gateway"), auto_dns=config.get("autoDNS"), dns=config.get("dns"), ip_aliases=config.get("ipAliases"), dns_aliases=config.get("dnsAliases"), metric=config.get("metric"), ) elif iface_type == "mobile": client.nodes.network.update_mobile_interface( target_id, name, apn=config.get("apn"), metric=config.get("metric") ) else: continue # WiFi/VLAN need the PSK/full config this snapshot doesn't decode. results.append({"interface": name, "status": "applied"}) except BarbaraApiError as exc: results.append({"interface": name, "status": "error", "error": str(exc)}) for server in network.get("ntp_servers") or []: try: client.nodes.network.create_ntp_server(target_id, server) results.append({"ntp_server": server, "status": "created"}) except BarbaraApiError as exc: results.append({"ntp_server": server, "status": "error", "error": str(exc)}) return results # -- Backup ---------------------------------------------------------------- def build_snapshot(client: BarbaraClient, node: Node, sections: set[str]) -> dict[str, Any]: snapshot: dict[str, Any] = {"node_id": node.id, "node_name": node.node_name, "sections": sorted(sections)} if "workloads" in sections: snapshot["workloads"] = backup_workloads(client, node.id) if "global_config" in sections: snapshot["global_config"] = backup_global_config(client, node.id) if "volumes" in sections: snapshot["volumes"] = backup_volumes(client, node.id) snapshot["global_secret_names"] = backup_secret_names(client, node.id) snapshot["docker_credentials"] = backup_docker_credentials(client, node.id) if "network" in sections: snapshot["network"] = backup_network(client, node.id) return snapshot def cmd_backup(client: BarbaraClient, args: argparse.Namespace) -> None: node = resolve_node(client, args.node) sections = set(args.sections.split(",")) if args.sections else set(ALL_SECTIONS) - {"network"} snapshot = build_snapshot(client, node, sections) with open(args.output, "w", encoding="utf-8") as fh: json.dump(snapshot, fh, indent=2) print(f"Wrote snapshot of {node.node_name} ({node.id}) to {args.output}.") if snapshot["global_secret_names"]: warn(f"{len(snapshot['global_secret_names'])} secret name(s) captured, values were not.") if snapshot["docker_credentials"]: warn(f"{len(snapshot['docker_credentials'])} docker credential(s) captured, passwords were not.") # -- Restore ----------------------------------------------------------------- def restore_to_target( client: BarbaraClient, snapshot: dict[str, Any], target_id: str, sections: set[str], apply: bool ) -> dict[str, Any]: plan: dict[str, Any] = {"target": target_id, "sections": sorted(sections)} if not apply: plan["dry_run"] = {s: snapshot.get(s) for s in sections if s in snapshot} return plan if "volumes" in sections: existing = {v.get("name") for v in client.nodes.list_docker_volumes(target_id)} volume_results = [] for volume in snapshot.get("volumes") or []: name = volume.get("name") if not name or name in existing: volume_results.append({"name": name, "status": "skipped (already exists)"}) continue try: client.nodes.create_docker_volume(target_id, name) volume_results.append({"name": name, "status": "created"}) except BarbaraApiError as exc: volume_results.append({"name": name, "status": "error", "error": str(exc)}) plan["volumes"] = volume_results if "workloads" in sections: workload_results = [] for workload in snapshot.get("workloads") or []: try: restore_workload(client, target_id, workload) workload_results.append({**workload, "status": "created"}) except BarbaraApiError as exc: workload_results.append({**workload, "status": "error", "error": str(exc)}) plan["workloads"] = workload_results if "global_config" in sections and snapshot.get("global_config") is not None: try: client.nodes.set_global_config(target_id, **snapshot["global_config"]) plan["global_config"] = "applied" except BarbaraApiError as exc: plan["global_config"] = f"error: {exc}" if "network" in sections and snapshot.get("network"): plan["network"] = restore_network(client, target_id, snapshot["network"]) return plan def cmd_restore(client: BarbaraClient, args: argparse.Namespace) -> None: with open(args.backup_file, encoding="utf-8") as fh: snapshot = json.load(fh) requested = set(args.sections.split(",")) if args.sections else set(DEFAULT_RESTORE_SECTIONS) if "network" in requested and not args.sections: requested.discard("network") # never included implicitly, only via explicit --sections unavailable = requested - set(snapshot.get("sections", ALL_SECTIONS)) if unavailable: warn(f"Snapshot doesn't contain section(s) {sorted(unavailable)}, skipping them.") sections = requested & set(snapshot.get("sections", ALL_SECTIONS)) target_identifiers = [t for chunk in args.targets for t in chunk.split(",") if t] group_identifiers = [g for chunk in args.group for g in chunk.split(",") if g] tags = [t for chunk in args.tags for t in chunk.split(",") if t] targets = resolve_targets(client, target_identifiers, group_identifiers, tags) batch_results = [] for target in targets: result = restore_to_target(client, snapshot, target.id, sections, args.apply) result["target_name"] = target.node_name batch_results.append(result) has_error = any( isinstance(item, dict) and item.get("status", "").startswith("error") for key in ("volumes", "workloads", "network") for item in result.get(key) or [] ) if args.apply and args.stop_on_error and has_error: warn(f"Stopping: errors while restoring to {target.node_name} ({target.id}).") break print(json.dumps({"apply": args.apply, "results": batch_results}, indent=2)) if snapshot.get("global_secret_names"): warn( f"{len(snapshot['global_secret_names'])} secret(s) were never restored (values weren't " f"captured): {', '.join(snapshot['global_secret_names'])}. Recreate them with " "client.nodes.create_global_secrets(...) if needed." ) if snapshot.get("docker_credentials"): warn( f"{len(snapshot['docker_credentials'])} docker credential(s) were never restored " "(passwords weren't captured). Recreate them with " "client.nodes.create_docker_credentials(...) if needed." ) # -- Clean ------------------------------------------------------------------- def _workload_app_version_key(client: BarbaraClient, node_id: str, workload_id: str) -> tuple[Any, Any]: current = client.nodes.workloads.get(node_id, workload_id).raw.get("current") or {} return ( _oid(current.get("applicationId") or current.get("marketApplicationId") or current.get("modelApplicationId")), _oid(current.get("appVersionId") or current.get("marketAppVersionId") or current.get("modelAppVersionId")), ) def cmd_clean(client: BarbaraClient, args: argparse.Namespace) -> None: node = resolve_node(client, args.node) sections = set(args.sections.split(",")) if args.sections else set(DEFAULT_CLEAN_SECTIONS) from_backup = None if args.from_backup: with open(args.from_backup, encoding="utf-8") as fh: from_backup = json.load(fh) plan: dict[str, Any] = {"node": node.id, "sections": sorted(sections)} workload_ids = [_oid(space) for space in node.raw.get("spaces") or []] volume_names = {v.get("name") for v in client.nodes.list_docker_volumes(node.id) if v.get("name")} if from_backup is not None: backup_app_versions = {(w["application_id"], w["app_version_id"]) for w in from_backup.get("workloads") or []} volume_names &= {v.get("name") for v in from_backup.get("volumes") or []} workload_ids = [ wid for wid in workload_ids if _workload_app_version_key(client, node.id, wid) in backup_app_versions ] else: backup_app_versions = None if not args.apply: plan["would_delete_workloads"] = workload_ids plan["would_delete_volumes"] = sorted(volume_names) if "secrets" in sections: plan["would_delete_secrets"] = len(client.nodes.list_global_secrets(node.id)) if "docker_credentials" in sections: plan["would_delete_docker_credentials"] = len(client.nodes.list_docker_credentials(node.id)) print(json.dumps(plan, indent=2)) return if not args.yes: confirmation = input(f"Type 'delete' to confirm cleaning {node.node_name} ({node.id}): ") if confirmation.strip() != "delete": raise SystemExit("Aborted.") deleted_workloads = [] if "workloads" in sections: for workload_id in workload_ids: client.nodes.workloads.delete(node.id, workload_id) deleted_workloads.append(workload_id) plan["deleted_workloads"] = deleted_workloads deleted_volumes = [] if "volumes" in sections: for volume in client.nodes.list_docker_volumes(node.id): if volume.get("name") in volume_names: client.nodes.delete_docker_volume(node.id, volume["_id"]) deleted_volumes.append(volume.get("name")) plan["deleted_volumes"] = deleted_volumes if "secrets" in sections: client.nodes.delete_all_global_secrets(node.id) plan["secrets"] = "deleted" if "docker_credentials" in sections: client.nodes.delete_all_docker_credentials(node.id) plan["docker_credentials"] = "deleted" print(json.dumps(plan, indent=2)) # -- CLI ---------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "--env-file", default=".env", help="Path to a .env file with Barbara API Credentials" ) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("login", help="Show the token's role and what it can do") backup_parser = subparsers.add_parser("backup", help="Write a node's snapshot to a JSON file") backup_parser.add_argument("node", help="Node name (deviceName) or internal _id") backup_parser.add_argument("-o", "--output", required=True, help="Path to write the snapshot to") backup_parser.add_argument( "--sections", help=f"Comma-separated sections to capture (default: {','.join(set(ALL_SECTIONS) - {'network'})})", ) restore_parser = subparsers.add_parser("restore", help="Apply a snapshot to a set of target nodes") restore_parser.add_argument("backup_file", help="Snapshot file written by 'backup'") restore_parser.add_argument( "targets", nargs="*", default=[], help="Target node name(s)/id(s), space- or comma-separated. Combined with --group/--tags as a " "union (a node reachable through more than one of the three is only restored to once).", ) restore_parser.add_argument( "--group", action="append", default=[], metavar="GROUP", help="Also target every node in this group (name or id) — repeatable, or comma-separated.", ) restore_parser.add_argument( "--tags", action="append", default=[], metavar="TAG", help="Also target every node carrying at least one of these tags — repeatable, or " "comma-separated. At least one of a positional target, --group, or --tags is required.", ) restore_parser.add_argument("--apply", action="store_true", help="Actually restore (default: dry-run)") restore_parser.add_argument( "--sections", help=f"Comma-separated sections to restore (default: {','.join(DEFAULT_RESTORE_SECTIONS)})" ) restore_parser.add_argument( "--stop-on-error", action="store_true", help="Stop the batch at the first target that errors" ) clean_parser = subparsers.add_parser("clean", help="Delete a node's workloads/volumes/secrets/credentials") clean_parser.add_argument("node", help="Node name (deviceName) or internal _id") clean_parser.add_argument("--apply", action="store_true", help="Actually delete (default: dry-run)") clean_parser.add_argument("--yes", action="store_true", help="Skip the typed confirmation prompt") clean_parser.add_argument( "--from-backup", help="Only delete what this snapshot file contains, instead of everything" ) clean_parser.add_argument( "--sections", help=f"Comma-separated sections to delete (default: {','.join(DEFAULT_CLEAN_SECTIONS)})" ) args = parser.parse_args() load_dotenv(args.env_file) with BarbaraClient.from_env() as client: try: client.api_version() except BarbaraAuthError as exc: raise SystemExit(f"Login failed — check your Barbara API Credentials: {exc}") print("Login OK.") print_role_summary(client) if args.command == "login": return if args.command == "backup": cmd_backup(client, args) elif args.command == "restore": cmd_restore(client, args) elif args.command == "clean": cmd_clean(client, args) if __name__ == "__main__": main()