"""Deploy a Marketplace app on a cluster (replicated across its nodes). Resolves the application by name, picks an app version (the latest one by default, or a specific one by --version), and creates a Marketplace stack on the target cluster. Usage: python deploy_marketplace_cluster.py --app python deploy_marketplace_cluster.py --app --version --apply Without --apply, this only prints the deployment plan (read-only). With --apply, it creates the stack. By default every service is deployed with the values published in the marketplace app version's template (ports/volumes/env). Any of these can be overridden with a `.json` file, or a `.env`-style file (any other extension) with one `KEY=value` per line — only the keys present are changed, everything else keeps the template's default: --compose-config-file ports_volumes.json | ports_volumes.txt JSON: [{"name": "grafana", "ports": {"GRAFANA_PORT": "13001"}, "volumes": {"DATA_DIR": "/mnt/data"}}] .env: GRAFANA_PORT=13001 DATA_DIR=/mnt/data (single-service apps only — for multi-service apps, prefix each section with "#service_name", see --app-secrets-file below) --app-secrets-file secrets.json | secrets.txt JSON: [{"name": "grafana", "env": {"GRAFANA_USER": "admin"}}] .env, single service: GRAFANA_USER=admin .env, multi-service (one "#service_name" section per service): #connector_opcua_pro OPCUA_MQTT_USER=admin #connector_opcua_ui OPCUA_WEBUI_USER=admin --app-config-file config.json | config.txt / --app-config-id Sets the workload's own App Config (mutually exclusive): a JSON object or flat `.env` KEY=value file to send inline, or the id of an existing App Config. Cluster-only (a single node can't be "replicated" or "placed" among nodes it isn't part of): --deployment service:mode[:replicas] (repeatable, one per service) --deployment grafana:replicated:3 --deployment worker:global ``mode`` is one of replicated/global/replicated-job/global-job and becomes immutable after the stack's first deployment; ``replicas`` is required (and only used) for the replicated/replicated-job modes. --placement service:tag=value (repeatable — multiple tags per service accumulate) --placement grafana:zone=eu-west --placement grafana:gpu=true Pins a service to nodes carrying matching tags. 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 base64 import json import os import re import sys from typing import Any, Dict, List, Optional from barbara import BarbaraApiError, BarbaraAuthError, BarbaraClient from barbara.models import Application, Cluster def _decode(value: str) -> str: return base64.b64decode(value).decode("utf-8") if value else "" INTERNAL_ID_RE = re.compile(r"^[0-9a-f]{24}$") 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_cluster(client: BarbaraClient, identifier: str) -> Cluster: if INTERNAL_ID_RE.match(identifier): return client.clusters.get(identifier) return client.clusters.resolve(identifier) def resolve_application(client: BarbaraClient, name: str) -> Application: matches = client.applications.list(search=name) exact = [app for app in matches if app.name == name] if exact: return exact[0] if matches: return matches[0] raise SystemExit(f"No marketplace application found matching '{name}'") _VERSIONS_PAGE_SIZE = 50 def resolve_app_version( client: BarbaraClient, application: Application, version_name: Optional[str] ) -> Dict[str, Any]: if version_name is None: # ``list_versions`` returns a single page (server default size=10) — # ask the server to sort by created date and hand back just the top # one, instead of paging through everything ourselves. versions = client.applications.list_versions( application.id, size=1, sort_column="created", sort_order="desc" ) if not versions: raise SystemExit(f"Application '{application.name}' has no published versions") return versions[0] from_ = 0 while True: page = client.applications.list_versions( application.id, size=_VERSIONS_PAGE_SIZE, from_=from_ ) if not page: break for version in page: if version.get("name") == version_name: return version if len(page) < _VERSIONS_PAGE_SIZE: break from_ += _VERSIONS_PAGE_SIZE raise SystemExit( f"No version '{version_name}' found for application '{application.name}'." ) def parse_env_sections(text: str) -> Dict[Optional[str], Dict[str, str]]: """Parses a ``.env``-style file with optional ``#service_name`` section headers (for multi-service apps) — everything before the first section header belongs to a ``None`` (implicit, single-service) section: #connector_opcua_pro OPCUA_MQTT_USER=bbruser #connector_opcua_ui OPCUA_WEBUI_USER=bbruser Plain ``# comment`` lines (i.e. not immediately followed by ``KEY=`` material recognizable as a service section) aren't distinguished from section headers — every ``#...`` line starts a new section, so this format has no room for regular comments. """ sections: Dict[Optional[str], Dict[str, str]] = {} current: Optional[str] = None sections[current] = {} for line in text.splitlines(): line = line.strip() if not line: continue if line.startswith("#"): current = line[1:].strip() sections.setdefault(current, {}) continue if "=" not in line: continue key, _, value = line.partition("=") sections[current][key.strip()] = value.strip() return sections _DEPLOYMENT_MODES = {"replicated", "global", "replicated-job", "global-job"} def _check_known_service(name: str, known_names: List[str], *, flag: str, raw: str) -> None: """Services in --deployment/--placement are merged into the wire body by name (see ``_build_stack_services`` in the SDK) — a name that doesn't match the app version's template silently adds an *extra* service instead of erroring, and the API then rejects the whole request with a length-mismatch 400. Catch it here with a clear message instead: the name must be the marketplace template's service name (e.g. "socat"), not the deployed stack/workload's own name. """ if name not in known_names: raise SystemExit( f"{flag} '{raw}': '{name}' is not a service in the app version's " f"template. Available: {', '.join(known_names)}" ) def parse_deployment(values: List[str], known_names: List[str]) -> List[Dict[str, Any]]: """Parses repeated ``--deployment service:mode[:replicas]`` into the SDK's ``deployment`` shape (one entry per service). ``replicas`` is required for the replicated/replicated-job modes, and rejected otherwise — matching the API's own rule (see the module docstring). """ entries = [] for value in values: parts = value.split(":") if len(parts) not in (2, 3): raise SystemExit( f"--deployment '{value}' must be 'service:mode' or 'service:mode:replicas'" ) name, mode, *rest = parts _check_known_service(name, known_names, flag="--deployment", raw=value) if mode not in _DEPLOYMENT_MODES: raise SystemExit( f"--deployment '{value}': mode must be one of {', '.join(sorted(_DEPLOYMENT_MODES))}" ) replicated = mode in ("replicated", "replicated-job") if replicated and not rest: raise SystemExit(f"--deployment '{value}': mode '{mode}' requires ':replicas'") if not replicated and rest: raise SystemExit(f"--deployment '{value}': mode '{mode}' does not take replicas") entry: Dict[str, Any] = {"name": name, "mode": mode} if rest: try: entry["replicas"] = int(rest[0]) except ValueError: raise SystemExit(f"--deployment '{value}': replicas must be an integer") entries.append(entry) return entries def parse_placement(values: List[str], known_names: List[str]) -> List[Dict[str, Any]]: """Parses repeated ``--placement service:tag=value`` into the SDK's ``placement_constraints`` shape, grouping multiple tags for the same service into one ``constraints`` dict. """ by_name: Dict[str, Dict[str, Any]] = {} for value in values: service_part, sep, tag_part = value.partition(":") if not sep or "=" not in tag_part: raise SystemExit(f"--placement '{value}' must be 'service:tag=value'") _check_known_service(service_part, known_names, flag="--placement", raw=value) tag, _, tag_value = tag_part.partition("=") by_name.setdefault(service_part, {"name": service_part, "constraints": {}}) by_name[service_part]["constraints"][tag.strip()] = tag_value.strip() return list(by_name.values()) def load_overrides_file(path: str) -> Any: """Loads --compose-config-file / --app-secrets-file / --app-config-file content. A ``.json`` extension is parsed as JSON; anything else is parsed as a ``.env``-style file with optional ``#service_name`` sections — see ``parse_env_sections``. """ with open(path, encoding="utf-8") as fh: text = fh.read() if path.endswith(".json"): return json.loads(text) return parse_env_sections(text) def _resolve_service_name( section: Optional[str], services: List[Dict[str, Any]], *, path: str ) -> str: if section is not None: return section if len(services) != 1: raise SystemExit( f"'{path}' has no '#service_name' section headers, but the app has " f"{len(services)} services ({', '.join(s['name'] for s in services)}) — " "add a '#service_name' header per section." ) return services[0]["name"] def apply_compose_env_overrides( compose_config: list, sections: Dict[Optional[str], Dict[str, str]] ) -> None: """Routes each ``KEY=value`` pair from a parsed .env file (given via --compose-config-file) to the matching service's ports or volumes, based on where that key already exists in the app version's template. Mutates ``compose_config`` in place. """ by_name = {item["name"]: item for item in compose_config} for section, pairs in sections.items(): name = _resolve_service_name(section, list(compose_config), path="") if name not in by_name: raise SystemExit( f"'#{name}' does not match any service in the app version's template. " f"Available: {', '.join(by_name)}" ) service = by_name[name] for key, value in pairs.items(): if key in service["ports"]: service["ports"][key] = value elif key in service["volumes"]: service["volumes"][key] = value else: raise SystemExit( f"'{key}' (under '#{name}') is not a port or volume in service " f"'{name}''s template." ) def apply_secrets_env_overrides( app_secrets: list, sections: Dict[Optional[str], Dict[str, str]] ) -> None: """Same as ``apply_compose_env_overrides``, but for --app-secrets-file (env vars only). Mutates ``app_secrets`` in place. """ by_name = {item["name"]: item for item in app_secrets} for section, pairs in sections.items(): name = _resolve_service_name(section, list(app_secrets), path="") if name not in by_name: raise SystemExit( f"'#{name}' does not match any service in the app version's template. " f"Available: {', '.join(by_name)}" ) secrets = by_name[name] for key, value in pairs.items(): if key not in secrets["env"]: raise SystemExit( f"'{key}' (under '#{name}') is not an env var in service " f"'{name}''s template." ) secrets["env"][key] = value def apply_json_overrides(base: list, overrides: list, *, field: str) -> list: """Merges ``overrides`` (a --compose-config-file/--app-secrets-file JSON array) into ``base`` (from ``default_services()``) by service name — only the services/keys present in ``overrides`` are changed, everything else keeps its template default. """ by_name = {item["name"]: item for item in base} for override in overrides: name = override["name"] if name not in by_name: raise SystemExit( f"--{field.replace('_', '-')}-file references unknown service '{name}'. " f"Available: {', '.join(by_name)}" ) by_name[name][field] = {**by_name[name].get(field, {}), **override.get(field, {})} return list(by_name.values()) def build_plan( cluster: Cluster, application: Application, app_version: Dict[str, Any], name: str ) -> Dict[str, Any]: return { "cluster": cluster.id, "cluster_name": cluster.name, "application_id": application.id, "application_name": application.name, "app_version_id": app_version["_id"], "app_version_name": app_version.get("name"), "workload_name": name, "services": [_decode(s["name"]) for s in app_version.get("services", [])], } def main() -> None: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("cluster", help="Target cluster name or internal _id") parser.add_argument("--app", required=True, help="Marketplace application name") parser.add_argument( "--version", default=None, help="App version name (default: the latest published version)" ) parser.add_argument( "--name", default=None, help="Workload name (default: the application name)" ) parser.add_argument( "--apply", action="store_true", help="Actually create the stack on the cluster" ) parser.add_argument( "--compose-config-file", default=None, help="Overrides ports/volumes for one or more services — a .json array " "or a .env-style KEY=value file (with '#service_name' sections for " "multi-service apps). See the module docstring for the format.", ) parser.add_argument( "--app-secrets-file", default=None, help="Overrides env vars for one or more services — a .json array or " "a .env-style KEY=value file (with '#service_name' sections for " "multi-service apps). See the module docstring for the format.", ) app_config_group = parser.add_mutually_exclusive_group() app_config_group.add_argument( "--app-config-file", default=None, help="The workload's App Config — a .json object or a flat .env " "KEY=value file", ) app_config_group.add_argument( "--app-config-id", default=None, help="Id of an existing App Config to reuse" ) parser.add_argument( "--deployment", action="append", default=[], metavar="service:mode[:replicas]", help="Deployment mode/replicas for one service, repeatable " "(e.g. --deployment grafana:replicated:3). mode is one of " "replicated/global/replicated-job/global-job; replicas is required " "for the replicated* modes and rejected otherwise.", ) parser.add_argument( "--placement", action="append", default=[], metavar="service:tag=value", help="Placement constraint tag for one service, repeatable — " "multiple tags for the same service accumulate " "(e.g. --placement grafana:zone=eu-west --placement grafana:gpu=true).", ) 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.") cluster = resolve_cluster(client, args.cluster) application = resolve_application(client, args.app) app_version = resolve_app_version(client, application, args.version) workload_name = args.name or application.name plan = build_plan(cluster, application, app_version, workload_name) compose_config, app_secrets = client.applications.default_services(app_version) if args.compose_config_file: overrides = load_overrides_file(args.compose_config_file) if args.compose_config_file.endswith(".json"): compose_config = apply_json_overrides(compose_config, overrides, field="ports") compose_config = apply_json_overrides(compose_config, overrides, field="volumes") else: apply_compose_env_overrides(compose_config, overrides) if args.app_secrets_file: overrides = load_overrides_file(args.app_secrets_file) if args.app_secrets_file.endswith(".json"): app_secrets = apply_json_overrides(app_secrets, overrides, field="env") else: apply_secrets_env_overrides(app_secrets, overrides) app_config: Optional[Dict[str, Any]] = None if args.app_config_file: loaded = load_overrides_file(args.app_config_file) app_config = loaded if args.app_config_file.endswith(".json") else loaded[None] known_names = [s["name"] for s in compose_config] deployment = parse_deployment(args.deployment, known_names) if args.deployment else None placement_constraints = ( parse_placement(args.placement, known_names) if args.placement else None ) plan["compose_config"] = compose_config plan["app_secrets"] = app_secrets if app_config is not None: plan["app_config"] = app_config if args.app_config_id: plan["app_config_id"] = args.app_config_id if deployment is not None: plan["deployment"] = deployment if placement_constraints is not None: plan["placement_constraints"] = placement_constraints if not args.apply: print(json.dumps(plan, indent=2)) return try: client.clusters.workloads.create_marketplace_workload( cluster.id, app_version_id=app_version["_id"], application_id=application.id, name=workload_name, compose_config=compose_config, app_secrets=app_secrets, config=app_config, config_id=args.app_config_id, deployment=deployment, placement_constraints=placement_constraints, ) except BarbaraApiError as exc: print( json.dumps( {**plan, "status": "error", "error": str(exc), "detail": exc.body}, indent=2, ) ) sys.exit(1) print(json.dumps({**plan, "status": "created"}, indent=2)) if __name__ == "__main__": main()