diff --git a/portainer.py b/portainer.py index b26d09f..07109ae 100644 --- a/portainer.py +++ b/portainer.py @@ -1,8 +1,15 @@ import os +import sys import requests import json import uuid import argparse +import shutil +import time +from tabulate import tabulate +from git import Repo # pip install gitpython +from port import Portainer + parser = argparse.ArgumentParser(description="Portainer helper - use env vars or pass credentials.") parser.add_argument("--base", "-b", default=os.getenv("PORTAINER_URL", "https://portainer.example.com"), help="Base URL for Portainer (ENV: PORTAINER_URL)") @@ -15,19 +22,36 @@ parser.add_argument("--list-endpoints", action="store_true", help="List endpoint parser.add_argument("--list-stacks", "-l", action="store_true", help="List stacks") parser.add_argument("--delete-stack", "-d", action="store_true", help="Delete stack") parser.add_argument("--get-stack", metavar="NAME_OR_ID", help="Get stack by name or numeric id") -parser.add_argument("--create-stack","-c", action='store_true') +parser.add_argument("--autostart", "-a", action="store_true", help="Auto-start created stacks") +parser.add_argument("--start-stack", "-x", action='store_true') +parser.add_argument("--stop-stack", "-o", action='store_true') +parser.add_argument("--debug", "-D", action='store_true') +parser.add_argument("--create-stack1","-c", action='store_true') +parser.add_argument("--create-stack","-n", action='store_true') +parser.add_argument("--create-stack_new2","-N", action='store_true') parser.add_argument("--gpu","-g", action='store_true') parser.add_argument("--create-stacks","-C", action='store_true') -parser.add_argument("--stack-id", "-s", type=str, help="Stack ID for operations") +parser.add_argument("--refresh-status","-r", action='store_true') +parser.add_argument("--stack", "-s", type=str, help="Stack ID for operations") parser.add_argument("--token-only", action="store_true", help="Print auth token and exit") parser.add_argument("--timeout", type=int, default=10, help="Request timeout seconds") +parser.add_argument("--deploy-mode","-m", type=str, default="git", help="Deploy mode") args = parser.parse_args() -nas_stacks = ["pihole","authentik","bitwarden","bookstack","dockermon","gitea","gitlab","grafana","home-assistant","homepage","immich","jupiter","kestra","mailu3","mealie","mediacenter"] - portainer_api_key = "ptr_QoHE/e6kfqIirE3fhzYMRFRxK7eL42wtCxo5P18i1zQ=" +def wl(msg): + if args.debug: + print(msg) +def is_number(s): + """Check if the input string is a number.""" + try: + float(s) + return True + except ValueError: + return False + def get_portainer_token(base_url, username=None, password=None, timeout=10): @@ -58,7 +82,7 @@ def api_get(base_url, path, token, timeout=10): resp.raise_for_status() return resp.json() -def api_post(base_url, path, token, data, timeout=120): +def api_post(base_url, path, token, data, timeout=240): """Example authenticated GET request to Portainer API.""" url = f"{base_url.rstrip('/')}{path}" headers = {"Authorization": f"Bearer {token}"} @@ -66,70 +90,164 @@ def api_post(base_url, path, token, data, timeout=120): resp = requests.post(url, headers=headers, json=data, timeout=timeout) resp.raise_for_status() return resp.json() +def api_post2(base_url, path, token, endpoint_id, name, envs, file, timeout=240): + #input("API POST2 called. Press Enter to continue.") + """Example authenticated GET request to Portainer API.""" + url = f"{base_url.rstrip('/')}{path}" + headers = {"Authorization": f"Bearer {token}"} + headers = {"X-API-Key": f"{token}"} + data = { + "EndpointId": endpoint_id, + "Name": name, + "Env": json.dumps(envs) + } + print(data) + resp = requests.post(url, headers=headers, files=file, data=data, timeout=timeout) + resp.raise_for_status() + return resp.json() +def api_post_no_body(base_url, path, token, timeout=120): + """Example authenticated GET request to Portainer API.""" + url = f"{base_url.rstrip('/')}{path}" + headers = {"Authorization": f"Bearer {token}"} + headers = {"X-API-Key": f"{token}"} + resp = requests.post(url, headers=headers, timeout=timeout) + resp.raise_for_status() + return resp.json() def api_delete(base_url, path, token, timeout=20): """Example authenticated DELETE request to Portainer API.""" + wl("Deleting stack via API...") url = f"{base_url.rstrip('/')}{path}" headers = {"Authorization": f"Bearer {token}"} headers = {"X-API-Key": f"{token}"} resp = requests.delete(url, headers=headers, timeout=timeout) + wl(resp) resp.raise_for_status() - print(resp.status_code) + wl(resp.status_code) return resp.status_code +def refresh_status(base_url, token, stack, timeout=20): + path = f"/api/stacks/{stack}/images_status?refresh=true" + wl(path) + stacks = api_get(base_url, path, token, timeout=timeout) + wl(json.dumps(stacks, indent=2)) + if stcks is None: + return [] + return stcks + def get_stacks(base_url, token, endpoint_id=None, timeout=10): """ Return a list of stacks. If endpoint_id is provided, it will be added as a query param. """ path = "/api/stacks" - if endpoint_id is not None: - path += f"?endpointId={endpoint_id}" + stcks = [] stacks = api_get(base_url, path, token, timeout=timeout) - if stacks is None: + for s in stacks: + if s['EndpointId'] == endpoint_id: + stcks.append(s) + wl(json.dumps(stacks, indent=2)) + if stcks is None: return [] - return stacks + return stcks def get_stack(base_url, identifier, token, endpoint_id=None, timeout=10): + wl("get_stack") """ Retrieve a single stack by numeric Id or by Name. Identifier may be an int (Id) or a string (Name). Raises ValueError if not found. """ + #print(endpoint_id) stacks = get_stacks(base_url, token, endpoint_id=endpoint_id, timeout=timeout) # Normalize identifier + #input(stacks) + + ident_id = None + #print(identifier) try: ident_id = int(identifier) except (TypeError, ValueError): pass - + #wl(stacks) for s in stacks: # Many Portainer responses use 'Id' and 'Name' keys - if ident_id is not None and s.get("Id") == ident_id: + if ident_id is not None and s.get("Id") == ident_id and endpoint_id == s.get("EndpointId"): return s - if str(s.get("Name")) == str(identifier): + if str(s.get("Name")) == str(identifier) and endpoint_id == s.get("EndpointId"): + return s - + raise ValueError(f"Stack not found: {identifier}") -def create_stack(base_url, token, endpoint_id=None, data={}, timeout=120): +def create_stack(base_url, token, endpoint_id=None, data={}, timeout=300): """ Return a list of stacks. If endpoint_id is provided, it will be added as a query param. """ path = "/api/stacks/create/standalone/repository" if endpoint_id is not None: path += f"?endpointId={endpoint_id}" + wl(path) try: stacks = api_post(base_url, path, token, data, timeout=timeout) except Exception as e: - print(f"Error creating stack: {e}") + + #print(f"Error creating stack: {e}") if "Conflict for url" in str(e): print("Stack with this name may already exist.") + else: + print(f"Error creating stack: {e}") + wl(json.dumps(data, indent=2)) return [] if stacks is None: return [] return stacks - -def delete_stack(base_url, token, endpoint_id=None, stack=None, timeout=30): +def create_stack2(base_url, token, endpoint_id=None, name="", file="", envs=[], timeout=300): + #input("Creating stack from file... Press Enter to continue.") + """ + Return a list of stacks. If endpoint_id is provided, it will be added as a query param. + """ + path = "/api/stacks/create/standalone/file" + if endpoint_id is not None: + path += f"?endpointId={endpoint_id}&name={name}" + wl(path) + try: + stacks = api_post2(base_url, path, token, endpoint_id, name=name, envs=envs, file=file, timeout=timeout) + except Exception as e: + + print(f"Error creating stack: {e}") + if "Conflict for url" in str(e): + print("Stack with this name may already exist.") + else: + print(json.dumps(envs, indent=2)) + return [] + if stacks is None: + return [] + return stacks +def stop_stack(base_url, token, endpoint_id=None, stack=None, timeout=120): + path = f"/api/stacks/{stack}/stop" + if endpoint_id is not None: + path += f"?endpointId={endpoint_id}" + try: + api_post_no_body(base_url, path, token, timeout=timeout) + except Exception as e: + + print(f"Error stoping stack: {e}") + return [] + return True +def start_stack(base_url, token, endpoint_id=None, stack=None, timeout=120): + path = f"/api/stacks/{stack}/start" + if endpoint_id is not None: + path += f"?endpointId={endpoint_id}" + try: + stacks = api_post_no_body(base_url, path, token, timeout=timeout) + except Exception as e: + + print(f"Error starting stack: {e}") + return [] + if stacks is None: + return [] + return stacks +def delete_stack(base_url, token, endpoint_id=None, stack=None, timeout=120): """ Return a list of stacks. If endpoint_id is provided, it will be added as a query param. """ @@ -144,14 +262,21 @@ def delete_stack(base_url, token, endpoint_id=None, stack=None, timeout=30): path = f"/api/stacks/{s['Id']}" if endpoint_id is not None: path += f"?endpointId={endpoint_id}&removeVolumes=true" - #print(path) + if args.debug: + print(path) out = api_delete(base_url, path, token, timeout=timeout) return "Done" else: + path = f"/api/stacks/{stack}" + if endpoint_id is not None: + path += f"?endpointId={endpoint_id}&removeVolumes=true" + # print(path) try: - #print(path) + # print(path) + # print(base_url) + # print(token) stacks = api_delete(base_url, path, token, timeout=timeout) except Exception as e: #print(f"Error creating stack: {e}") @@ -159,45 +284,316 @@ def delete_stack(base_url, token, endpoint_id=None, stack=None, timeout=30): print("Stack with this name may already exist.") else: print(f"Error deleting stack: {e}") - print(stacks) + #print(stacks) return [] if stacks is None: return [] return stacks -def print_stacks(base, token): - stacks = get_stacks(base, token) - for stack in stacks: +def print_stacks(base, token, endpoint=None,endpoints={}): + stacks = get_stacks(base, token, endpoint) + count = 0 + lst = [] + data = [] + #print(stacks) + for stack in stacks: + #print(endpoint) + #print(stack['EndpointId']) + #print(stack) + if endpoint != None: + if not stack['EndpointId'] in endpoints['by_id']: + continue + if endpoint != stack['EndpointId']: + continue + if not stack['Name'] in basic_stacks: + lst.append(stack['Name']) try: - print(f"Stack ID: {stack['Id']}, Name: {stack['Name']}, EndpointName: {eps[stack['EndpointId']]}") + #print(f"Stack ID: {stack['Id']}, Name: {stack['Name']}, EndpointName: {eps[stack['EndpointId']]}") + data.append([stack['Id'], stack['Name'], endpoints['by_id'][stack['EndpointId']]]) except KeyError as e: - print(f"Stack ID: {stack['Id']}, Name: {stack['Name']}, EndpointName: ?") + #print(f"Stack ID: {stack['Id']}, Name: {stack['Name']}, EndpointName: ?") + data.append([stack['Id'], stack['Name'], "?"]) + count += 1 + headers = ["StackID", "Name", "Endpoint"] + print(tabulate(data, headers=headers, tablefmt="github")) + print(f"Total stacks: {count}") + +def resolve_endpoins(base,token): + base = os.getenv("PORTAINER_URL", "https://portainer.sectorq.eu") + endpoints = api_get(base, "/api/endpoints", token) + eps = {"by_id":{}, "by_name":{}} + for ep in endpoints: + eps['by_id'][ep['Id']] = ep['Name'] + eps['by_name'][ep['Name']] = ep['Id'] + return eps + + if __name__ == "__main__": # Example usage: set PORTAINER_USER and PORTAINER_PASS in env, or pass literals below. - base = os.getenv("PORTAINER_URL", "https://portainer.sectorq.eu") + base = os.getenv("PORTAINER_URL", "https://portainer.sectorq.eu/api") #token = get_portainer_token(base,"admin","l4c1j4yd33Du5lo") # or get_portainer_token(base, "admin", "secret") token = portainer_api_key # Example: list endpoints - endpoints = api_get(base, "/api/endpoints", token) - #print(endpoints) - eps = {} - install_endpoint_id = None - for ep in endpoints: - eps[ep['Id']] = ep['Name'] - print(f"Endpoint ID: {ep['Id']}, Name: {ep['Name']}") - if args.endpoint_id == str(ep['Name']): - install_endpoint_id = ep['Id'] - if install_endpoint_id == None: - install_endpoint_id = args.endpoint_id + por = Portainer(base, token) + if args.delete_stack: + por.delete_stack(args.endpoint_id,args.stack,) + sys.exit() + if args.create_stack: + por.create_stack(args.endpoint_id,args.stack, args.deploy_mode, args.autostart) + sys.exit() + + #print(por.base_url) + + if args.stop_stack: + por.stop_stack(args.stack,args.endpoint_id) + sys.exit() + + #print(por.base_url) + if args.start_stack: + por.start_stack(args.stack,args.endpoint_id) + sys.exit() + + #print(por.base_url) + + + endpoints = resolve_endpoins(base, token) + wl(endpoints) + if is_number(args.endpoint_id): + install_endpoint_id = int(args.endpoint_id) + install_endpoint_name = endpoints['by_id'][install_endpoint_id] + else: + install_endpoint_id = endpoints['by_name'][args.endpoint_id] + install_endpoint_name = args.endpoint_id + wl(install_endpoint_name) + wl(install_endpoint_id) + if args.list_stacks: - print_stacks(base, token) + print_stacks(base, token, install_endpoint_id,endpoints) + + if args.create_stack_new21: + print("Creating new stack from git repo...") + if not args.stack_id: + args.stack_id = input("Stack name? : ") + + if install_endpoint_id == None: + install_endpoint_id = endpoints['by_id'][input("Endpoint name? : ")] + if is_number(install_endpoint_id): + install_endpoint_id = int(install_endpoint_id) + else: + install_endpoint_id = endpoints['by_name'][install_endpoint_id] + + + git_url = "https://gitlab.sectorq.eu/home/docker-compose.git" + git_url = "git@gitlab.sectorq.eu:home/docker-compose.git" + repo_dir = "/tmp/docker-compose" + # Check if folder exists + if os.path.exists(repo_dir): + shutil.rmtree(repo_dir) + print(f"Folder '{repo_dir}' has been removed.") + else: + print(f"Folder '{repo_dir}' does not exist.") + Repo.clone_from(git_url, repo_dir) + + wl(args.stack_id) + wl(install_endpoint_id) + if args.stack_id == "all": + wl("All stacks selected") + if install_endpoint_name == "nas": + args.stack_id = nas_stacks + elif install_endpoint_name == "rpi5": + print("RPI5 stacks selected") + args.stack_id = rpi5_stacks + elif install_endpoint_name == "rack": + args.stack_id = rack_stacks + else: + args.stack_id = [args.stack_id] + for s in args.stack_id: + print(f"Processing stack: {s}") + + if os.path.exists(f"{repo_dir}/{s}/.env"): + f = open(f"{repo_dir}/{s}/.env","r") + + env_vars = f.read().splitlines() + envs = [] + for ev in env_vars: + if ev.startswith("#") or ev.strip() == "": + continue + if "=" in ev: + name, value = ev.split("=",1) + envs.append({"name": name, "value": value}) + f.close() + #wl(envs) + for e in envs: + wl(f"Env: {e['name']} = {e['value']}") + HWS = ["HW_MODE","HW_MODE1","HW_MODE2"] + if e['name'] == "RESTART" and args.endpoint_id == "m-server": + e['value'] = "always" + if e['name'] in HWS: + wl("Found HW_MODE env var.") + if args.gpu: + e['value'] = "hw" + else: + e['value'] = "cpu" + if e['name'] == "LOGGING": + wl("Found LOGGING env var.") + if args.gpu: + e['value'] = "journald" + else: + e['value'] = "syslog" + req = { + "Name": s, + "Env": envs, + "AdditionalFiles": [], + "AutoUpdate": None, + "repositoryURL": "https://gitlab.sectorq.eu/home/docker-compose.git", + "ReferenceName": "refs/heads/main", + "composeFile": f"{s}/docker-compose.yml", + "ConfigFilePath": f"{s}/docker-compose.yml", + "repositoryAuthentication": True, + "repositoryUsername": "jaydee", + "repositoryPassword": "glpat-uj-n-eEfTY398PE4vKSS", + "AuthorizationType": 0, + "TLSSkipVerify": False, + "supportRelativePath": True, + "repositoryAuthentication": True, + "fromAppTemplate": False, + "registries": [6,3], + "FromAppTemplate": False, + "Namespace": "", + "CreatedByUserId": "", + "Webhook": "", + "filesystemPath": "/share/docker_data/portainer/portainer-data/", + "RegistryID": 4, + "isDetachedFromGit": True + } + #wl(json.dumps(req, indent=2)) + create_stack(base, token, install_endpoint_id, data=req) + if not args.autostart and s != "pihole": + tries = 0 + while True: + try: + stck2 = get_stack(base, s, token, endpoint_id=install_endpoint_id) + break + except Exception as e: + print(f"Waiting for stack {s} to be created...") + time.sleep(10) + tries += 1 + if tries > 20: + print(f"Error retrieving stack {s} after creation: {e}") + break + + + try: + print(f"Stopping stack: ID {stck2['Id']}, Name: {stck2['Name']}") + stop_stack(base, token, install_endpoint_id, stck2['Id']) + except Exception as e: + print(f"Error stopping stack {s}: {e}") + + if args.create_stack_new2: + print("Creating new stack from file...") + if not args.stack_id: + args.stack_id = input("Stack name? : ") + if install_endpoint_id == None: + install_endpoint_id = endpoints['by_id'][input("Endpoint name? : ")] + if is_number(install_endpoint_id): + install_endpoint_id = int(install_endpoint_id) + else: + install_endpoint_id = endpoints['by_name'][install_endpoint_id] + git_url = "https://gitlab.sectorq.eu/home/docker-compose.git" + git_url = "git@gitlab.sectorq.eu:home/docker-compose.git" + repo_dir = "/tmp/docker-compose" + + # Check if folder exists + if os.path.exists(repo_dir): + shutil.rmtree(repo_dir) + print(f"Folder '{repo_dir}' has been removed.") + else: + print(f"Folder '{repo_dir}' does not exist.") + Repo.clone_from(git_url, repo_dir) + + + wl(args.stack_id) + wl(install_endpoint_id) + if args.stack_id == "all": + wl("All stacks selected") + if install_endpoint_name == "nas": + args.stack_id = nas_stacks + elif install_endpoint_name == "rpi5": + print("RPI5 stacks selected") + args.stack_id = rpi5_stacks + elif install_endpoint_name == "rack": + args.stack_id = rack_stacks + else: + args.stack_id = [args.stack_id] + for s in args.stack_id: + # file = f"/tmp/docker-compose/{s}/docker-compose.yml" + print(f"Processing stack: {s}") + + file = { + # ("filename", file_object) + "file": ("docker-compose.yml", open(f"/tmp/docker-compose/{s}/docker-compose.yml", "rb")), + } + print(file) + if os.path.exists(f"{repo_dir}/{s}/.env"): + f = open(f"{repo_dir}/{s}/.env","r") + + env_vars = f.read().splitlines() + envs = [] + for ev in env_vars: + if ev.startswith("#") or ev.strip() == "": + continue + if "=" in ev: + name, value = ev.split("=",1) + envs.append({"name": name, "value": value}) + f.close() + #wl(envs) + for e in envs: + wl(f"Env: {e['name']} = {e['value']}") + HWS = ["HW_MODE","HW_MODE1","HW_MODE2"] + if e['name'] in HWS: + wl("Found HW_MODE env var.") + if args.gpu: + e['value'] = "hw" + else: + e['value'] = "cpu" + if e['name'] == "LOGGING": + wl("Found LOGGING env var.") + if args.gpu: + e['value'] = "journald" + else: + e['value'] = "syslog" + + create_stack2(base, token, install_endpoint_id, name=s, file=file, envs=envs) + if not args.autostart and s != "pihole": + tries = 0 + while True: + try: + stck2 = get_stack(base, s, token, endpoint_id=install_endpoint_id) + break + except Exception as e: + print(f"Waiting for stack {s} to be created...") + time.sleep(2) + tries += 1 + if tries > 5: + print(f"Error retrieving stack {s} after creation: {e}") + break + + + try: + print(f"Stopping stack: ID {stck2['Id']}, Name: {stck2['Name']}") + stop_stack(base, token, install_endpoint_id, stck2['Id']) + except Exception as e: + print(f"Error stopping stack {s}: {e}") + #print(json.dumps(req, indent=2)) if args.create_stack: + if not args.stack_id: + input("Stack name?") stck = get_stack(base, args.stack_id, token) print(f"Found stack: ID {stck['Id']}, Name: {stck['Name']}") print(json.dumps(stck, indent=2)) @@ -277,15 +673,24 @@ if __name__ == "__main__": } print(json.dumps(req, indent=2)) create_stack(base, token, install_endpoint_id, data=req) - else: - print("Not creating stack, --create-stack not provided.") + if args.create_stacks: - for ns in nas_stacks: + if args.endpoint_id == "nas": + s = nas_stacks + elif args.endpoint_id == "rpi5": + s = rpi5_stacks + elif args.endpoint_id == "rack": + s = rack_stacks + wl(s) + for ns in s: + wl(f"Processing stack: {ns}") stck = get_stack(base, ns, token) print(f"Found stack: ID {stck['Id']}, Name: {stck['Name']}") #print(json.dumps(stck, indent=2)) for e in stck["Env"]: #print(f"Env: {e['name']} = {e['value']}") + if e['name'] == "RESTART" and stck["Env"] == install_endpoint_id: + e['value'] = "always" HWS = ["HW_MODE","HW_MODE1","HW_MODE2"] if e['name'] in HWS: print("Found HW_MODE env var.") @@ -304,7 +709,7 @@ if __name__ == "__main__": try: stck["AutoUpdate"]["Webhook"] = uid except: - stck["AutoUpdate"] = {} + stck["AutoUpdate"] = None try: req = { "Name": stck["Name"], @@ -359,11 +764,48 @@ if __name__ == "__main__": } print(f"Creating stack: {ns}") create_stack(base, token, install_endpoint_id, data=req) - else: - print("Not creating stack, --create-stack not provided.") + if not args.autostart: + stck2 = get_stack(base, ns, token, endpoint_id=install_endpoint_id) + print(print_stacks(base, token, install_endpoint_id,endpoints)) + print(f"Stopping stack: ID {stck2['Id']}, Name: {stck2['Name']}") + stop_stack(base, token, install_endpoint_id, stck2['Id']) - if args.delete_stack: + if args.delete_stack22: + print(f"Delete stack {args.stack_id}") + if not is_number(args.stack_id) and args.stack_id != "all": + args.stack_id = get_stack(base, args.stack_id, token, install_endpoint_id)['Id'] + + #print(args.stack_id) + #print(install_endpoint_id) + delete_stack(base, token, install_endpoint_id, args.stack_id) - else: - print("Not deleting stack, --create-stack not provided.") - \ No newline at end of file + + if args.stop_stack: + if args.stack_id == "all": + print("Stopping all stacks...") + stcks = get_stacks(base, token, endpoint_id=install_endpoint_id) + # stcks = get_stack(base, sta, token, endpoint_id=install_endpoint_id) + else: + stcks = [get_stack(base, args.stack_id, token, endpoint_id=install_endpoint_id)] + for stck in stcks: + print(f"Stopping stack {stck['Name']}") + stop_stack(base, token, install_endpoint_id, stck['Id']) + + if args.start_stack: + if args.stack_id == "all": + print("Starting all stacks...") + stcks = get_stacks(base, token, endpoint_id=install_endpoint_id) + # stcks = get_stack(base, sta, token, endpoint_id=install_endpoint_id) + else: + stcks = [get_stack(base, args.stack_id, token, endpoint_id=install_endpoint_id)] + for stck in stcks: + print(f"Starting stack {stck['Name']}") + start_stack(base, token, install_endpoint_id, stck['Id']) + if args.refresh_status: + if args.stack_id == "all": + print("Stopping all stacks...") + stcks = get_stacks(base, token, endpoint_id=install_endpoint_id) + # stcks = get_stack(base, sta, token, endpoint_id=install_endpoint_id) + else: + refresh_status(base, args.stack_id, token) +