This commit is contained in:
2025-12-04 13:21:20 +01:00
parent 48e8b36646
commit 5fb1e8ade4
2 changed files with 170 additions and 111 deletions

271
port.py
View File

@@ -1,5 +1,5 @@
import os import os
import requests from concurrent.futures import ThreadPoolExecutor
import json import json
import uuid import uuid
import shutil import shutil
@@ -8,7 +8,8 @@ import logging
import base64 import base64
import tabulate import tabulate
from git import Repo from git import Repo
from concurrent.futures import ThreadPoolExecutor import requests
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -105,7 +106,7 @@ class Portainer:
self.get_stacks() self.get_stacks()
self.get_containers() self.get_containers()
def is_number(self, s): def _is_number(self, s):
"""Check if the input string is a number.""" """Check if the input string is a number."""
try: try:
float(s) float(s)
@@ -113,14 +114,14 @@ class Portainer:
except ValueError: except ValueError:
return False return False
def api_get(self, path, timeout=120): def _api_get(self, path, timeout=120):
url = f"{self.base_url.rstrip('/')}{path}" url = f"{self.base_url.rstrip('/')}{path}"
headers = {"X-API-Key": f"{self.token}"} headers = {"X-API-Key": f"{self.token}"}
resp = requests.get(url, headers=headers, timeout=timeout) resp = requests.get(url, headers=headers, timeout=timeout)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
def api_post(self, path, json="", timeout=120): def _api_post(self, path, json="", timeout=120):
url = f"{self.base_url.rstrip('/')}{path}" url = f"{self.base_url.rstrip('/')}{path}"
headers = {"X-API-Key": f"{self.token}"} headers = {"X-API-Key": f"{self.token}"}
# print(url) # print(url)
@@ -128,7 +129,7 @@ class Portainer:
resp = requests.post(url, headers=headers, json=json, timeout=timeout) resp = requests.post(url, headers=headers, json=json, timeout=timeout)
return resp.text return resp.text
def api_post_file(self, path, endpoint_id, name, envs, file, timeout=120): def _api_post_file(self, path, endpoint_id, name, envs, file, timeout=120):
# input("API POST2 called. Press Enter to continue.") # input("API POST2 called. Press Enter to continue.")
"""Example authenticated GET request to Portainer API.""" """Example authenticated GET request to Portainer API."""
url = f"{self.base_url.rstrip('/')}{path}" url = f"{self.base_url.rstrip('/')}{path}"
@@ -141,7 +142,7 @@ class Portainer:
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
def api_post_no_body(self, path, timeout=120): def _api_post_no_body(self, path, timeout=120):
"""Example authenticated GET request to Portainer API.""" """Example authenticated GET request to Portainer API."""
url = f"{self.base_url.rstrip('/')}{path}" url = f"{self.base_url.rstrip('/')}{path}"
# print(url) # print(url)
@@ -149,7 +150,7 @@ class Portainer:
resp = requests.post(url, headers=headers, timeout=timeout) resp = requests.post(url, headers=headers, timeout=timeout)
return resp.text return resp.text
def api_delete(self, path, timeout=120): def _api_delete(self, path, timeout=120):
"""Example authenticated DELETE request to Portainer API.""" """Example authenticated DELETE request to Portainer API."""
url = f"{self.base_url.rstrip('/')}{path}" url = f"{self.base_url.rstrip('/')}{path}"
headers = {"X-API-Key": f"{self.token}"} headers = {"X-API-Key": f"{self.token}"}
@@ -170,7 +171,7 @@ class Portainer:
endpoint_id = self.get_endpoint_id(endpoint_id) endpoint_id = self.get_endpoint_id(endpoint_id)
path = "/stacks" path = "/stacks"
stcks = [] stcks = []
stacks = self.api_get(path, timeout=timeout) stacks = self._api_get(path, timeout=timeout)
self.stacks_all = {} self.stacks_all = {}
fail_endponts = [20, 39, 41] fail_endponts = [20, 39, 41]
# print(json.dumps(stacks,indent=2)) # print(json.dumps(stacks,indent=2))
@@ -231,17 +232,14 @@ class Portainer:
# input(json.dumps(self.stacks_all,indent=2)) # input(json.dumps(self.stacks_all,indent=2))
return stcks return stcks
def get_stack_id(self, endpoint, stack):
pass
def update_status(self, endpoint, stack): def update_status(self, endpoint, stack):
path = f"/stacks/{self.all_data['stacks'][endpoint]['by_name'][stack]}/images_status?refresh=true" path = f"/stacks/{self.all_data['stacks'][endpoint]['by_name'][stack]}/images_status?refresh=true"
# input(path) # input(path)
stats = self.api_get(path) stats = self._api_get(path)
print(stats) print(stats)
def get_endpoint_id(self, endpoint): def get_endpoint_id(self, endpoint):
if self.is_number(endpoint): if self._is_number(endpoint):
self.endpoint_id = endpoint self.endpoint_id = endpoint
self.endpoint_name = self.endpoints["by_id"][endpoint] self.endpoint_name = self.endpoints["by_id"][endpoint]
return endpoint return endpoint
@@ -251,7 +249,7 @@ class Portainer:
return self.endpoints["by_name"][endpoint] return self.endpoints["by_name"][endpoint]
def get_endpoint_name(self, endpoint): def get_endpoint_name(self, endpoint):
if self.is_number(endpoint): if self._is_number(endpoint):
self.endpoint_id = endpoint self.endpoint_id = endpoint
self.endpoint_name = self.endpoints["by_id"][endpoint] self.endpoint_name = self.endpoints["by_id"][endpoint]
return self.endpoints["by_id"][endpoint] return self.endpoints["by_id"][endpoint]
@@ -282,7 +280,7 @@ class Portainer:
) )
logging.info(f"request : {path}") logging.info(f"request : {path}")
try: try:
containers = self.api_get(path) containers = self._api_get(path)
except Exception as e: except Exception as e:
print(f"failed to get containers from {path}: {e}") print(f"failed to get containers from {path}: {e}")
continue continue
@@ -318,14 +316,14 @@ class Portainer:
def stop(c): def stop(c):
print(f" > Stopping {c}") print(f" > Stopping {c}")
self.api_post_no_body(f"/endpoints/{ep_id}/docker/containers/{c}/stop") self._api_post_no_body(f"/endpoints/{ep_id}/docker/containers/{c}/stop")
# print(f"✔") # print(f"✔")
with ThreadPoolExecutor(max_workers=10) as exe: with ThreadPoolExecutor(max_workers=10) as exe:
exe.map(stop, containers) exe.map(stop, containers)
# for c in containers: # for c in containers:
# print(f" > Stopping {c}") # print(f" > Stopping {c}")
# self.api_post_no_body(f"/endpoints/{self.endpoints["by_name"][endpoint]}/docker/containers/{c}/stop") # self._api_post_no_body(f"/endpoints/{self.endpoints["by_name"][endpoint]}/docker/containers/{c}/stop")
# return 0 # return 0
def start_containers(self, endpoint, containers, timeout=130): def start_containers(self, endpoint, containers, timeout=130):
@@ -333,7 +331,7 @@ class Portainer:
def stop(c): def stop(c):
print(f" > Starting {c}") print(f" > Starting {c}")
self.api_post_no_body(f"/endpoints/{ep_id}/docker/containers/{c}/start") self._api_post_no_body(f"/endpoints/{ep_id}/docker/containers/{c}/start")
with ThreadPoolExecutor(max_workers=10) as exe: with ThreadPoolExecutor(max_workers=10) as exe:
exe.map(stop, containers) exe.map(stop, containers)
@@ -349,7 +347,7 @@ class Portainer:
# input(stcs) # input(stcs)
def update(c): def update(c):
print(f" > Updating {c[0]} on {endpoint}") print(f" > Updating {c[0]} on {endpoint}")
ans = self.api_post_no_body(f"/stacks/webhooks/{c[1]}") ans = self._api_post_no_body(f"/stacks/webhooks/{c[1]}")
logger.debug( logger.debug(
f"Update response for stack {c[0]} on endpoint {endpoint}: {ans}" f"Update response for stack {c[0]} on endpoint {endpoint}: {ans}"
) )
@@ -373,7 +371,7 @@ class Portainer:
self.stop_containers(endpoint, cont) self.stop_containers(endpoint, cont)
def get_endpoints(self, timeout=10): def get_endpoints(self, timeout=10):
endpoints = self.api_get("/endpoints") endpoints = self._api_get("/endpoints")
eps = {"by_id": {}, "by_name": {}} eps = {"by_id": {}, "by_name": {}}
eps_stats = {} eps_stats = {}
for ep in endpoints: for ep in endpoints:
@@ -391,7 +389,7 @@ class Portainer:
def get_endpoint(self, endpoint_id=None, timeout=30): def get_endpoint(self, endpoint_id=None, timeout=30):
self.get_endpoints() self.get_endpoints()
# print(self.endpoints) # print(self.endpoints)
if self.is_number(endpoint_id): if self._is_number(endpoint_id):
self.endpoint_name = self.endpoints["by_id"][endpoint_id] self.endpoint_name = self.endpoints["by_id"][endpoint_id]
self.endpoint_id = endpoint_id self.endpoint_id = endpoint_id
else: else:
@@ -402,12 +400,12 @@ class Portainer:
def get_swarm_id(self, endpoint): def get_swarm_id(self, endpoint):
ep_id = self.endpoints["by_name"][endpoint] ep_id = self.endpoints["by_name"][endpoint]
path = f"/endpoints/{ep_id}/docker/info" path = f"/endpoints/{ep_id}/docker/info"
stats = self.api_get(path) stats = self._api_get(path)
return stats["Swarm"]["Cluster"]["ID"] return stats["Swarm"]["Cluster"]["ID"]
def get_stack(self, stack=None, endpoint_id=None, timeout=None): def get_stack(self, stack=None, endpoint_id=None, timeout=None):
self.get_stacks(endpoint_id) self.get_stacks(endpoint_id)
if not self.is_number(endpoint_id): if not self._is_number(endpoint_id):
endpoint_id = int(self.endpoints["by_name"][endpoint_id]) endpoint_id = int(self.endpoints["by_name"][endpoint_id])
self.stack_id = [] self.stack_id = []
if stack == "all": if stack == "all":
@@ -446,7 +444,6 @@ class Portainer:
mode="git", mode="git",
autostart=False, autostart=False,
swarm=False, swarm=False,
timeout=None,
): ):
if swarm: if swarm:
swarm_id = self.get_swarm_id(endpoint) swarm_id = self.get_swarm_id(endpoint)
@@ -564,7 +561,7 @@ class Portainer:
req["ConfigFilePath"] = f"__swarm/{stack}/{stack}-swarm.yml" req["ConfigFilePath"] = f"__swarm/{stack}/{stack}-swarm.yml"
print(json.dumps(req)) print(json.dumps(req))
res = self.api_post(path, req) res = self._api_post(path, req)
if "Id" in res: if "Id" in res:
# print("Deploy request OK") # print("Deploy request OK")
pass pass
@@ -658,9 +655,10 @@ class Portainer:
open(f"/tmp/docker-compose/{stack}/docker-compose.yml", "rb"), open(f"/tmp/docker-compose/{stack}/docker-compose.yml", "rb"),
), ),
} }
self.api_post_file(path, self.endpoint_id, stack, envs, file) self._api_post_file(path, self.endpoint_id, stack, envs, file)
def print_stacks(self, endpoint="all"): def print_stacks(self, endpoint="all"):
'''Print a table of stacks, optionally filtered by endpoint.'''
stacks = self.get_stacks() stacks = self.get_stacks()
count = 0 count = 0
data = [] data = []
@@ -682,7 +680,7 @@ class Portainer:
except KeyError as e: except KeyError as e:
data.append([stack["Id"], stack["Name"], "?"]) data.append([stack["Id"], stack["Name"], "?"])
logger.debug( logger.debug(
f"KeyError getting endpoint name for stack {stack['Name']}: {e}" "KeyError getting endpoint name for stack %s : %s", stack['Name'],e
) )
count += 1 count += 1
@@ -691,31 +689,33 @@ class Portainer:
print(f"Total stacks: {count}") print(f"Total stacks: {count}")
def start_stack(self, stack=None, endpoint_id=None): def start_stack(self, stack=None, endpoint_id=None):
'''Start one stack or all stacks on an endpoint.'''
if endpoint_id is not None: if endpoint_id is not None:
print("Getting endpoint") print("Getting endpoint")
self.get_endpoint(endpoint_id) self.get_endpoint(endpoint_id)
if stack is not None: if stack is not None:
self.get_stack(stack, endpoint_id) self.get_stack(stack, endpoint_id)
for stack in self.stack_ids: for stck in self.stack_ids:
path = f"/stacks/{stack}/start" path = f"/stacks/{stck}/start"
if self.endpoint_id is not None: if self.endpoint_id is not None:
path += f"?endpointId={self.endpoint_id}" path += f"?endpointId={self.endpoint_id}"
try: try:
resp = self.api_post_no_body(path, timeout=20) resp = self._api_post_no_body(path, timeout=20)
except Exception as e: except ValueError as e:
print(f"Error stoping stack: {e}") print(f"Error stoping stack: {e}")
return [] return []
if "Id" in json.loads(resp): if "Id" in json.loads(resp):
print( print(
f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stack]} : started" f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stck]} : started"
) )
else: else:
print( print(
f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stack]} : {json.loads(resp)['message']}" f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stck]} : {json.loads(resp)['message']}"
) )
return True return True
def stop_stack(self, stack, endpoint_id): def stop_stack(self, stack, endpoint_id):
'''Stop one stack or all stacks on an endpoint.'''
print(f"Stopping stack {stack}") print(f"Stopping stack {stack}")
if endpoint_id is not None: if endpoint_id is not None:
self.get_endpoint(endpoint_id) self.get_endpoint(endpoint_id)
@@ -724,100 +724,169 @@ class Portainer:
else: else:
if stack is not None: if stack is not None:
self.stack_ids = [self.get_stack(stack, endpoint_id)["Id"]] self.stack_ids = [self.get_stack(stack, endpoint_id)["Id"]]
for stack in self.stack_ids: for stck in self.stack_ids:
path = f"/stacks/{stack}/stop" path = f"/stacks/{stack}/stop"
if self.endpoint_id is not None: if self.endpoint_id is not None:
path += f"?endpointId={self.endpoint_id}" path += f"?endpointId={self.endpoint_id}"
try: try:
resp = self.api_post_no_body(path, timeout=120) resp = self._api_post_no_body(path, timeout=120)
except NameError as e: except NameError as e:
print(f"Error stopping stack: {e}") print(f"Error stopping stack: {e}")
return [] return []
if "Id" in json.loads(resp): if "Id" in json.loads(resp):
print( print(
f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stack]} : stopped" f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stck]} : stopped"
) )
else: else:
print( print(
f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stack]} : {json.loads(resp)['message']}" f"Stack {self.stacks_all[self.endpoint_id]['by_id'][stck]} : {json.loads(resp)['message']}"
) )
return True return True
def delete_stack(self, endpoint_id=None, stack=None, timeout=None):
""" def _resolve_endpoint(self, endpoint_id):
Return a list of stacks. If endpoint_id is provided, it will be added as a query param.
"""
self.get_endpoints() self.get_endpoints()
if self.is_number(endpoint_id):
self.endpoint_name = self.endpoints["by_id"][endpoint_id] if self._is_number(endpoint_id):
self.endpoint_id = endpoint_id self.endpoint_id = int(endpoint_id)
self.endpoint_name = self.endpoints["by_id"][self.endpoint_id]
else: else:
self.endpoint_name = endpoint_id self.endpoint_name = endpoint_id
self.endpoint_id = self.endpoints["by_name"][endpoint_id] self.endpoint_id = int(self.endpoints["by_name"][endpoint_id])
if not self.is_number(endpoint_id): def _resolve_stack_id(self, stack, endpoint_id):
endpoint_id = int(self.endpoints["by_name"][endpoint_id])
if not self.is_number(stack) and stack != "all":
# print(stack)
# print(self.endpoint_id)
stack = self.get_stack(stack, self.endpoint_id)["Id"]
if stack == "all": if stack == "all":
stacks = self.get_stacks(self.endpoint_id) return "all"
paths = []
for s in stacks:
# print(f"Delete stack {s['Name']}")
# print(s['EndpointId'], endpoint_id)
if int(s["EndpointId"]) != int(endpoint_id):
continue
# print("Deleting stack:", s['Name'])
path = f"/stacks/{s['Id']}"
if endpoint_id is not None:
path += f"?endpointId={endpoint_id}&removeVolumes=true"
paths.append([self.get_endpoint_name(endpoint_id), s["Name"], path])
# input(paths)
def delete(c): if not self._is_number(stack):
print(f"Delete stack {c[1]} from {c[0]} ") result = self.get_stack(stack, endpoint_id)
out = self.api_delete(c[2]) return result["Id"]
logger.debug(f"Deleted stack {c[1]} from {c[0]}: {out}")
with ThreadPoolExecutor(max_workers=10) as exe: return int(stack)
exe.map(delete, paths) def _delete_all_stacks(self, endpoint_id):
return "Done" stacks = self.get_stacks(endpoint_id)
else: paths = []
path = f"/stacks/{stack}"
if endpoint_id is not None: for s in stacks:
path += f"?endpointId={endpoint_id}&removeVolumes=true" if int(s["EndpointId"]) != int(endpoint_id):
# print(path) continue
try:
# print(path)
# print(base_url)
# print(token)
stacks = self.api_delete(path)
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 path = f"/stacks/{s['Id']}?endpointId={endpoint_id}&removeVolumes=true"
paths.append([
self.get_endpoint_name(endpoint_id),
s["Name"],
path
])
def refresh_status(self, stack, timeout=None): def delete_item(item):
pass print(f"Delete stack {item[1]} from {item[0]}")
out = self._api_delete(item[2])
logger.debug("Deleted stack %s from %s: %s", item[1], item[0], out)
def __repr__(self): with ThreadPoolExecutor(max_workers=10) as exe:
pass exe.map(delete_item, paths)
return "Done"
def _delete_single_stack(self, stack_id, endpoint_id):
path = f"/stacks/{stack_id}?endpointId={endpoint_id}&removeVolumes=true"
try:
out = self._api_delete(path)
except ValueError as e:
msg = str(e)
if "Conflict for url" in msg:
print("Stack with this name may already exist.")
else:
print(f"Error deleting stack: {e}")
return []
return out or []
def delete_stack(self, endpoint_id=None, stack=None):
"""Delete one stack or all stacks on an endpoint."""
self._resolve_endpoint(endpoint_id)
endpoint_id = self.endpoint_id
stack_id = self._resolve_stack_id(stack, endpoint_id)
if stack == "all":
return self._delete_all_stacks(endpoint_id)
return self._delete_single_stack(stack_id, endpoint_id)
# def delete_stack(self, endpoint_id=None, stack=None):
# """
# Return a list of stacks. If endpoint_id is provided, it will be added as a query param.
# """
# self.get_endpoints()
# if self._is_number(endpoint_id):
# self.endpoint_name = self.endpoints["by_id"][endpoint_id]
# self.endpoint_id = endpoint_id
# else:
# self.endpoint_name = endpoint_id
# self.endpoint_id = self.endpoints["by_name"][endpoint_id]
# if not self._is_number(endpoint_id):
# endpoint_id = int(self.endpoints["by_name"][endpoint_id])
# if not self._is_number(stack) and stack != "all":
# # print(stack)
# # print(self.endpoint_id)
# stack = self.get_stack(stack, self.endpoint_id)["Id"]
# if stack == "all":
# stacks = self.get_stacks(self.endpoint_id)
# paths = []
# for s in stacks:
# # print(f"Delete stack {s['Name']}")
# # print(s['EndpointId'], endpoint_id)
# if int(s["EndpointId"]) != int(endpoint_id):
# continue
# # print("Deleting stack:", s['Name'])
# path = f"/stacks/{s['Id']}"
# if endpoint_id is not None:
# path += f"?endpointId={endpoint_id}&removeVolumes=true"
# paths.append([self.get_endpoint_name(endpoint_id), s["Name"], path])
# # input(paths)
# def delete(c):
# print(f"Delete stack {c[1]} from {c[0]} ")
# out = self._api_delete(c[2])
# logger.debug(f"Deleted stack {c[1]} from {c[0]}: {out}")
# with ThreadPoolExecutor(max_workers=10) as exe:
# exe.map(delete, paths)
# return "Done"
# else:
# path = f"/stacks/{stack}"
# if endpoint_id is not None:
# path += f"?endpointId={endpoint_id}&removeVolumes=true"
# # print(path)
# try:
# # print(path)
# # print(base_url)
# # print(token)
# stacks = self._api_delete(path)
# 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 create_secret(self, name, value, endpoint_id=None, timeout=None): def create_secret(self, name, value, endpoint_id=None, timeout=None):
'''Create a Docker secret on the specified endpoint.'''
endpoint_id = int(self.endpoints["by_name"][endpoint_id]) endpoint_id = int(self.endpoints["by_name"][endpoint_id])
path = f"/endpoints/{endpoint_id}/docker/secrets/create" path = f"/endpoints/{endpoint_id}/docker/secrets/create"
encoded = base64.b64encode(value.encode()).decode() encoded = base64.b64encode(value.encode()).decode()
data = {"Name": name, "Data": encoded} data = {"Name": name, "Data": encoded}
return self.api_post(path, data, timeout=timeout) return self._api_post(path, data, timeout=timeout)

View File

@@ -141,16 +141,6 @@ def wl(msg):
if args.debug: if args.debug:
print(msg) print(msg)
def is_number(s):
"""Check if the input string is a number."""
try:
float(s)
return True
except ValueError:
return False
def prompt_missing_args(args_in, defaults_in, fields): def prompt_missing_args(args_in, defaults_in, fields):
""" """
fields = [("arg_name", "Prompt text")] fields = [("arg_name", "Prompt text")]