"""Clone a node's workloads (Docker, Marketplace, Model), their App Config, the node's Global Config, and its named docker volumes onto another node. Usage: python clone_node.py python clone_node.py --apply Without --apply, this only prints the clone plan (read-only). With --apply, it first deletes every workload currently on the target node (so re-running a clone doesn't produce duplicates), then creates the cloned workloads and volumes. Secret values and docker credential passwords can't be read back through the API — their names are listed in the plan so you know what to recreate by hand. For a full backup/restore/clean workflow (including network configuration), see clone_tool.py. 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 Node, Workload from barbara.utils import from_base64 INTERNAL_ID_RE = re.compile(r"^[0-9a-f]{24}$") # deviceSpace.type, per the Barbara API OpenAPI spec. SPACE_DOCKER = 0 SPACE_MARKETPLACE = 1 SPACE_MODEL = 2 def load_dotenv(path: str) -> None: """Minimal .env loader (no extra dependency). Variables already set in the environment always take precedence over the file. """ 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 _oid(value: Any) -> Any: """A referenced id may come back as a raw id string, or as a populated object with that id under ``_id`` — normalize to the raw id either way. """ return value.get("_id") if isinstance(value, dict) else value def _app_reference(workload: Workload, kind: Any) -> tuple[Optional[str], Optional[str], Optional[str]]: """Marketplace/Model workloads reference the application/version under different field names than Docker ones. Returns ``(application_id, app_version_id, application_name)``. """ 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 read_workloads(client: BarbaraClient, node_id: str) -> list[dict[str, Any]]: """Reads every workload on ``node_id``, with the configuration each type actually has: App Config for Docker; Compose Config for Model; App Config, Compose Config, and App Secrets for Marketplace. """ 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] workload_plan: 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): workload_plan["compose_config"] = client.nodes.workloads.get_compose_config( node_id, workload_id ) if kind in (SPACE_DOCKER, SPACE_MARKETPLACE): workload_plan["config"] = read_app_config(workload.raw) if kind == SPACE_MARKETPLACE: workload_plan["app_secrets"] = client.nodes.workloads.get_app_secrets( node_id, workload_id ) workloads.append(workload_plan) return workloads ZERO_OBJECT_ID = "000000000000000000000000" def _extract_app_config(app_config_block: dict[str, Any]) -> Optional[dict[str, Any]]: """Extracts ``{"config_id": ...}`` or ``{"config": {...}}`` from an already-unwrapped ``{"current": {"config": {...}}}`` app config object (the shape shared by a workload's ``appConfig`` field and ``client.nodes.get_global_config``), ready to pass to ``create_docker_workload``/``create_marketplace_workload``/ ``set_global_config`` — or ``None`` if no app config is actually set. Barbara uses the all-zero id as a "not set" sentinel for ``appConfigId`` rather than omitting the field. """ 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 read_app_config(workload_raw: dict[str, Any]) -> Optional[dict[str, Any]]: return _extract_app_config(workload_raw.get("appConfig") or {}) def read_global_config(client: BarbaraClient, node_id: str) -> Optional[dict[str, Any]]: try: config_block = client.nodes.get_global_config(node_id) except BarbaraApiError as exc: warn(f"Could not read global config for {node_id}: {exc}") return None return _extract_app_config(config_block) def read_global_secret_names(client: BarbaraClient, node_id: str) -> list[str]: """Names only — secret values are write-only and can't be read back.""" 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 read_docker_credentials(client: BarbaraClient, node_id: str) -> list[dict[str, Any]]: """Server + user only — the password is never returned by the API.""" try: return [ {"server": cred.server, "user": cred.user} for cred in client.nodes.list_docker_credentials(node_id) ] except BarbaraApiError as exc: warn(f"Could not read docker credentials for {node_id}: {exc}") return [] def read_docker_volumes(client: BarbaraClient, node_id: str) -> list[dict[str, Any]]: """Only the name is cloned — a volume's data is not.""" 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 warn(message: str) -> None: print(f"Warning: {message}", file=sys.stderr) def apply_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 "cloned-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 "cloned-workload", compose_config=workload.get("compose_config") or [], **common, ) else: client.nodes.workloads.create_docker_workload( target_id, **(workload.get("config") or {}), **common ) def clean_target(client: BarbaraClient, target_id: str) -> list[str]: """Deletes every workload currently on the target node, so applying the clone plan can't produce duplicates. Returns the ids that were deleted. """ node = client.nodes.get(target_id) workload_ids = [_oid(space) for space in node.raw.get("spaces") or []] for workload_id in workload_ids: client.nodes.workloads.delete(target_id, workload_id) return workload_ids def apply_docker_volumes( client: BarbaraClient, target_id: str, source_volumes: list[dict[str, Any]] ) -> list[dict[str, Any]]: """Creates each of the source's named volumes on the target, empty. A pre-existing target volume is never deleted or overwritten — it may hold real data this example has no way to restore. """ existing = {v.get("name") for v in client.nodes.list_docker_volumes(target_id)} results = [] for volume in source_volumes: name = volume.get("name") if not name: continue if name in existing: results.append({"name": name, "status": "skipped (already exists on target)"}) continue try: client.nodes.create_docker_volume(target_id, name) results.append({"name": name, "status": "created"}) except BarbaraApiError as exc: results.append({"name": name, "status": "error", "error": str(exc)}) return results def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("source", help="Source node name (deviceName) or internal _id") parser.add_argument("target", help="Target node name (deviceName) or internal _id") parser.add_argument( "--apply", action="store_true", help="Create the workloads on the target node" ) parser.add_argument( "--env-file", default=".env", help="Path to a .env file with Barbara API Credentials" ) 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.") source = resolve_node(client, args.source) target = resolve_node(client, args.target) workloads = read_workloads(client, source.id) global_config = read_global_config(client, source.id) docker_volumes = read_docker_volumes(client, source.id) global_secret_names = read_global_secret_names(client, source.id) docker_credentials = read_docker_credentials(client, source.id) plan = { "source": source.id, "target": target.id, "docker_volumes": docker_volumes, "workloads": workloads, "global_config": global_config, "global_secret_names": global_secret_names, "docker_credentials_to_recreate": docker_credentials, } if not args.apply: print(json.dumps(plan, indent=2)) return cleaned = clean_target(client, target.id) volume_results = apply_docker_volumes(client, target.id, docker_volumes) results = [] for workload in workloads: try: apply_workload(client, target.id, workload) results.append({**workload, "status": "created"}) except BarbaraApiError as exc: results.append( {**workload, "status": "error", "error": str(exc), "detail": exc.body} ) if global_config is not None: try: client.nodes.set_global_config(target.id, **global_config) global_config_status = "applied" except BarbaraApiError as exc: global_config_status = f"error: {exc}" else: global_config_status = "none" if global_secret_names: warn( f"{len(global_secret_names)} secret(s) on the source node were NOT cloned " f"(values are write-only): {', '.join(global_secret_names)}. Recreate them on " "the target with client.nodes.create_global_secrets(...) if needed." ) if docker_credentials: warn( f"{len(docker_credentials)} docker credential(s) on the source node were NOT " "cloned (passwords are never returned by the API): " f"{', '.join(c['server'] for c in docker_credentials if c.get('server'))}. " "Recreate them on the target with client.nodes.create_docker_credentials(...) " "if needed." ) print( json.dumps( { "target": target.id, "cleaned_workloads": cleaned, "docker_volumes": volume_results, "results": results, "global_config_status": global_config_status, "secrets_to_recreate": global_secret_names, "docker_credentials_to_recreate": docker_credentials, }, indent=2, ) ) if __name__ == "__main__": main()