diff --git a/portainer.py b/portainer.py index 5b2f7ce..b26d09f 100644 --- a/portainer.py +++ b/portainer.py @@ -10,17 +10,26 @@ parser.add_argument("--user", "-u", default=os.getenv("PORTAINER_USER"), help="Portainer username (ENV: PORTAINER_USER)") parser.add_argument("--password", "-p", default=os.getenv("PORTAINER_PASS"), help="Portainer password (ENV: PORTAINER_PASS)") -parser.add_argument("--endpoint-id", "-e", type=int, help="Endpoint ID to limit stack operations") +parser.add_argument("--endpoint-id", "-e", type=str, help="Endpoint ID to limit stack operations") parser.add_argument("--list-endpoints", action="store_true", help="List endpoints") -parser.add_argument("--list-stacks", action="store_true", help="List stacks") +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", action='store_true') +parser.add_argument("--create-stack","-c", 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("--token-only", action="store_true", help="Print auth token and exit") parser.add_argument("--timeout", type=int, default=10, help="Request timeout seconds") 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 get_portainer_token(base_url, username=None, password=None, timeout=10): """ Authenticate to Portainer and return a JWT token. @@ -44,18 +53,29 @@ def api_get(base_url, path, token, timeout=10): """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.get(url, headers=headers, timeout=timeout) resp.raise_for_status() return resp.json() -def api_post(base_url, path, token, data, timeout=20): +def api_post(base_url, path, token, data, 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, json=data, 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.""" + 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) + resp.raise_for_status() + print(resp.status_code) + return resp.status_code def get_stacks(base_url, token, endpoint_id=None, timeout=10): """ @@ -91,7 +111,7 @@ def get_stack(base_url, identifier, token, endpoint_id=None, timeout=10): raise ValueError(f"Stack not found: {identifier}") -def create_stack(base_url, token, endpoint_id=None, data={}, timeout=10): +def create_stack(base_url, token, endpoint_id=None, data={}, timeout=120): """ Return a list of stacks. If endpoint_id is provided, it will be added as a query param. """ @@ -101,7 +121,7 @@ def create_stack(base_url, token, endpoint_id=None, data={}, timeout=10): 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.") return [] @@ -109,43 +129,133 @@ def create_stack(base_url, token, endpoint_id=None, data={}, timeout=10): return [] return stacks +def delete_stack(base_url, token, endpoint_id=None, stack=None, timeout=30): + """ + Return a list of stacks. If endpoint_id is provided, it will be added as a query param. + """ + + if stack == "all": + stacks = get_stacks(base_url, token, endpoint_id=endpoint_id, timeout=timeout) + for s in stacks: + #print(s['EndpointId'], endpoint_id) + if int(s['EndpointId']) != int(endpoint_id): + continue + print("Deleting stack:", s['Name']) + path = f"/api/stacks/{s['Id']}" + if endpoint_id is not None: + path += f"?endpointId={endpoint_id}&removeVolumes=true" + #print(path) + out = api_delete(base_url, path, token, timeout=timeout) + return "Done" + else: + path = f"/api/stacks/{stack}" + try: + #print(path) + stacks = api_delete(base_url, path, token, 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(f"Error deleting stack: {e}") + print(stacks) + return [] + if stacks is None: + return [] + + return stacks +def print_stacks(base, token): + stacks = get_stacks(base, token) + for stack in stacks: + try: + print(f"Stack ID: {stack['Id']}, Name: {stack['Name']}, EndpointName: {eps[stack['EndpointId']]}") + except KeyError as e: + print(f"Stack ID: {stack['Id']}, Name: {stack['Name']}, EndpointName: ?") 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") - token = get_portainer_token(base,"admin","l4c1j4yd33Du5lo") # or get_portainer_token(base, "admin", "secret") + #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 + + if args.list_stacks: + print_stacks(base, token) - stacks = get_stacks(base, token) - for stack in stacks: - print(f"Stack ID: {stack['Id']}, Name: {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)) - - uid = str(uuid.uuid4()) - try: - stck["AutoUpdate"]["Webhook"] = uid - except: - stck["AutoUpdate"] = {} - try: - req = { + #print(json.dumps(req, indent=2)) + if args.create_stack: + stck = get_stack(base, args.stack_id, 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']}") + HWS = ["HW_MODE","HW_MODE1","HW_MODE2"] + if e['name'] in HWS: + print("Found HW_MODE env var.") + if args.gpu: + e['value'] = "hw" + else: + e['value'] = "cpu" + if e['name'] == "LOGGING": + print("Found LOGGING env var.") + if args.gpu: + e['value'] = "journald" + else: + e['value'] = "syslog" + #print(json.dumps(stck, indent=2)) + uid = str(uuid.uuid4()) + try: + stck["AutoUpdate"]["Webhook"] = uid + except: + stck["AutoUpdate"] = None + try: + req = { + "Name": stck["Name"], + "Env": stck["Env"], + "AdditionalFiles": stck["AdditionalFiles"], + "AutoUpdate": stck["AutoUpdate"], + "repositoryURL": stck["GitConfig"]["URL"], + "ReferenceName": "refs/heads/main", + "composeFile": f"{stck['Name']}/docker-compose.yml", + "ConfigFilePath": f"{stck['Name']}/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/stacks", + "RegistryID": 4 + } + except: + req = { "Name": stck["Name"], "Env": stck["Env"], "AdditionalFiles": stck["AdditionalFiles"], - "AutoUpdate": stck["AutoUpdate"], - "repositoryURL": stck["GitConfig"]["URL"], + "AutoUpdate": None, + "repositoryURL": "https://gitlab.sectorq.eu/home/docker-compose.git", "ReferenceName": "refs/heads/main", "composeFile": f"{stck['Name']}/docker-compose.yml", "ConfigFilePath": f"{stck['Name']}/docker-compose.yml", @@ -157,42 +267,103 @@ if __name__ == "__main__": "supportRelativePath": True, "repositoryAuthentication": True, "fromAppTemplate": False, - "registries": [6], + "registries": [6,3], "FromAppTemplate": False, "Namespace": "", "CreatedByUserId": "", "Webhook": "", - "filesystemPath": "/tmp", - "RegistryID": 4 + "filesystemPath": "/share/docker_data/portainer/portainer-data/stacks", + "RegistryID": 6 } - except: - req = { - "Name": stck["Name"], - "Env": stck["Env"], - "AdditionalFiles": stck["AdditionalFiles"], - "AutoUpdate": None, - "repositoryURL": "https://gitlab.sectorq.eu/home/docker-compose.git", - "ReferenceName": "refs/heads/main", - "composeFile": f"{stck['Name']}/docker-compose.yml", - "ConfigFilePath": f"{stck['Name']}/docker-compose.yml", - "repositoryAuthentication": True, - "repositoryUsername": "jaydee", - "repositoryPassword": "glpat-uj-n-eEfTY398PE4vKSS", - "AuthorizationType": 0, - "TLSSkipVerify": False, - "supportRelativePath": True, - "repositoryAuthentication": True, - "fromAppTemplate": False, - "registries": [6], - "FromAppTemplate": False, - "Namespace": "", - "CreatedByUserId": "", - "Webhook": "", - "filesystemPath": "/tmp", - "RegistryID": 6 - } - print(json.dumps(req, indent=2)) - if args.create_stack: - create_stack(base, token, 41, data=req) + print(json.dumps(req, indent=2)) + create_stack(base, token, install_endpoint_id, data=req) else: - print("Not creating stack, --create-stack not provided.") \ No newline at end of file + print("Not creating stack, --create-stack not provided.") + if args.create_stacks: + for ns in nas_stacks: + 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']}") + HWS = ["HW_MODE","HW_MODE1","HW_MODE2"] + if e['name'] in HWS: + print("Found HW_MODE env var.") + if args.gpu: + e['value'] = "hw" + else: + e['value'] = "cpu" + if e['name'] == "LOGGING": + print("Found LOGGING env var.") + if args.gpu: + e['value'] = "journald" + else: + e['value'] = "syslog" + #print(stck["Env"]) + uid = str(uuid.uuid4()) + try: + stck["AutoUpdate"]["Webhook"] = uid + except: + stck["AutoUpdate"] = {} + try: + req = { + "Name": stck["Name"], + "Env": stck["Env"], + "AdditionalFiles": stck["AdditionalFiles"], + "AutoUpdate": stck["AutoUpdate"], + "repositoryURL": stck["GitConfig"]["URL"], + "ReferenceName": "refs/heads/main", + "composeFile": f"{stck['Name']}/docker-compose.yml", + "ConfigFilePath": f"{stck['Name']}/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/stacks", + "RegistryID": 4 + } + except: + req = { + "Name": stck["Name"], + "Env": stck["Env"], + "AdditionalFiles": stck["AdditionalFiles"], + "AutoUpdate": None, + "repositoryURL": "https://gitlab.sectorq.eu/home/docker-compose.git", + "ReferenceName": "refs/heads/main", + "composeFile": f"{stck['Name']}/docker-compose.yml", + "ConfigFilePath": f"{stck['Name']}/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/stacks", + "RegistryID": 6 + } + print(f"Creating stack: {ns}") + create_stack(base, token, install_endpoint_id, data=req) + else: + print("Not creating stack, --create-stack not provided.") + + if args.delete_stack: + 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