Compare commits
30 Commits
bad31c8a5c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e885d5dc27 | |||
| 10a4c05912 | |||
| 02f73bcf27 | |||
| 7dedfdb5d7 | |||
| f234ef2015 | |||
| 8a84a70c59 | |||
| aa60dd2529 | |||
| 3b6f13abc4 | |||
| 9b5a45290a | |||
| a64fc40a16 | |||
| 5b3b02980c | |||
| bbbf4ce497 | |||
| 3b79e59076 | |||
| 91b8d35220 | |||
| 6dd7f5bff7 | |||
| 335f0c6f50 | |||
| 38f3fba33c | |||
| 400d4231d0 | |||
| 59ea60576f | |||
| 1a481776c7 | |||
| cc7d62d975 | |||
| d58f26d49c | |||
| ef7e5956ed | |||
| acd0572d6b | |||
| cd9a3ea255 | |||
| 56f303cce7 | |||
| c3e4b04f20 | |||
| c8e7674256 | |||
| dda3414428 | |||
| 1b3f577486 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
venv
|
||||||
.idea/
|
.idea/
|
||||||
|
config.py
|
||||||
|
|||||||
12
README.md
Normal file
12
README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Portainer Updater
|
||||||
|
|
||||||
|
Calls portainer's API and updates the images of all currently active stacks
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
Update configuration file with your portainer access token and the path of portainer compose directory
|
||||||
|
|
||||||
|
After restarting all container, it prunes all unused images, including non dangling ones
|
||||||
|
|
||||||
|
You can then simply execute:
|
||||||
|
|
||||||
|
python main.py
|
||||||
7
config.example.py
Normal file
7
config.example.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
portainer_url = 'https://portainer.dorfsvald.net'
|
||||||
|
portainer_access_token = 'example_access_token'
|
||||||
|
portainer_compose_dir = '/absolute/path/to/portainer-data/compose'
|
||||||
|
git_repositories = [
|
||||||
|
'path/to/a/git/repository',
|
||||||
|
'path/to/another/git/repo'
|
||||||
|
]
|
||||||
@@ -1 +0,0 @@
|
|||||||
access_token = 'ptr_2Wx6aA26hEEcsPbi2iVyRcuDt6/9tjHlkg+7z9btkjw='
|
|
||||||
@@ -5,6 +5,6 @@ set -e
|
|||||||
PROJECT_NAME=$1
|
PROJECT_NAME=$1
|
||||||
COMPOSE_FILE=$2
|
COMPOSE_FILE=$2
|
||||||
|
|
||||||
docker-compose -p $PROJECT_NAME -f $COMPOSE_FILE stop
|
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" pull
|
||||||
docker-compose -p $PROJECT_NAME -f $COMPOSE_FILE pull
|
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" down
|
||||||
docker-compose -p $PROJECT_NAME -f $COMPOSE_FILE up -d
|
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d
|
||||||
|
|||||||
171
main.py
Normal file → Executable file
171
main.py
Normal file → Executable file
@@ -1,31 +1,170 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import json
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
DOCKER_COMPOSE_PATH = config.portainer_compose_dir + '/{}/docker-compose.yml'
|
||||||
DOCKER_COMPOSE_PATH = '/srv/docker/portainer/portainer-data/compose/{}/docker-compose.yml'
|
SCRIPT_DIR = os.path.dirname(__file__)
|
||||||
CURRENT_DIR = os.path.dirname(__file__)
|
BASH_COMPANION_SCRIPT = f'{SCRIPT_DIR}/docker_updater.sh'
|
||||||
BASH_COMPANION_SCRIPT = CURRENT_DIR + '/docker_updater.sh'
|
REPO_UPDATER_SCRIPT = f'{SCRIPT_DIR}/update_repos.sh'
|
||||||
|
|
||||||
|
|
||||||
def update_stack(stack):
|
class PortainerClient:
|
||||||
|
|
||||||
|
def __init__(self, portainer_url, access_token):
|
||||||
|
self.portainer_url = portainer_url
|
||||||
|
self.access_token = access_token
|
||||||
|
|
||||||
|
self.api_url = f"{self.portainer_url}/api"
|
||||||
|
|
||||||
|
def _get_headers(self):
|
||||||
|
return {
|
||||||
|
"accept": "application/json",
|
||||||
|
"X-API-Key": self.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get(self, uri):
|
||||||
|
response = requests.get(f"{self.api_url}/{uri}", headers=self._get_headers())
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def list_endpoints(self):
|
||||||
|
return self._get("endpoints")
|
||||||
|
|
||||||
|
def list_stacks(self, endpoint_id=None):
|
||||||
|
filters = f'?filters={{"EndpointID":{endpoint_id}}}' if endpoint_id else ""
|
||||||
|
return self._get(f'stacks{filters}')
|
||||||
|
|
||||||
|
def get_stack(self, stack_id):
|
||||||
|
return self._get(f'stacks/{stack_id}')
|
||||||
|
|
||||||
|
def get_stack_file(self, stack_id):
|
||||||
|
return self._get(f'stacks/{stack_id}/file')
|
||||||
|
|
||||||
|
def _put(self, uri, payload):
|
||||||
|
response = requests.put(
|
||||||
|
f"{self.api_url}/{uri}",
|
||||||
|
headers=self._get_headers(),
|
||||||
|
json=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def upgrade_stack(self, stack):
|
||||||
|
file_response = self.get_stack_file(stack['Id'])
|
||||||
|
payload = {
|
||||||
|
"pullImage": True,
|
||||||
|
"prune": True,
|
||||||
|
"StackFileContent": file_response["StackFileContent"],
|
||||||
|
"Env": stack["Env"]
|
||||||
|
}
|
||||||
|
|
||||||
|
return self._put(f"stacks/{stack['Id']}?endpointId={stack['EndpointId']}", payload)
|
||||||
|
|
||||||
|
def list_stack_containers(self, endpoint, stack_name):
|
||||||
|
filters = '{"label":["com.docker.compose.project=' + stack_name + '"]}'
|
||||||
|
return self._get(f'endpoints/{endpoint}/docker/containers/json?all=1&filters={filters}')
|
||||||
|
|
||||||
|
def update_image(self, endpoint, image):
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.api_url}/endpoints/{endpoint}/docker/images/create?fromImage={image}",
|
||||||
|
headers=self._get_headers()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = json.loads(response.text.strip().split('\r\n')[-1])['status']
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return f"Downloaded newer image for {image}" in status
|
||||||
|
|
||||||
|
def delete_image(self, endpoint, image):
|
||||||
|
response = requests.delete(
|
||||||
|
f"{self.api_url}/endpoints/{endpoint}/docker/images/{image}?force=false",
|
||||||
|
headers=self._get_headers()
|
||||||
|
)
|
||||||
|
|
||||||
|
def ping_server(self):
|
||||||
|
try:
|
||||||
|
r = requests.get(f"{self.portainer_url}/", headers=self._get_headers())
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def update_repositories():
|
||||||
|
for repo_path in config.git_repositories:
|
||||||
|
ret = os.system(f"{REPO_UPDATER_SCRIPT} {repo_path}")
|
||||||
|
if ret > 0:
|
||||||
|
exit(ret)
|
||||||
|
|
||||||
|
|
||||||
|
def update_stack_images(endpoint, stack_name):
|
||||||
|
containers = client.list_stack_containers(endpoint, stack_name)
|
||||||
|
|
||||||
|
updated_images = []
|
||||||
|
for c in containers:
|
||||||
|
if client.update_image(endpoint, c['Image']):
|
||||||
|
print(f"{c['Image']} was updated.")
|
||||||
|
updated_images.append(c['ImageID'])
|
||||||
|
|
||||||
|
return updated_images
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_local_stack(stack):
|
||||||
project_name = stack['Name']
|
project_name = stack['Name']
|
||||||
|
|
||||||
|
print(f"Updating stack: {project_name}")
|
||||||
|
|
||||||
compose_file = DOCKER_COMPOSE_PATH.format(stack['Id'])
|
compose_file = DOCKER_COMPOSE_PATH.format(stack['Id'])
|
||||||
os.system(f"{BASH_COMPANION_SCRIPT} {project_name} {compose_file}")
|
ret = os.system(f"{BASH_COMPANION_SCRIPT} {project_name} {compose_file}")
|
||||||
|
if ret > 0:
|
||||||
|
exit(ret)
|
||||||
|
|
||||||
|
print(f"Stack {project_name} updated!\n")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_endpoint_stack(client, endpoint):
|
||||||
|
endpoint_id = endpoint['Id']
|
||||||
|
endpoint_updated_images = []
|
||||||
|
for stack in client.list_stacks(endpoint_id):
|
||||||
|
if stack['Status'] == 1:
|
||||||
|
stack_name = stack['Name']
|
||||||
|
|
||||||
|
print(f"Checking {stack_name} for updates.")
|
||||||
|
|
||||||
|
updated_images = update_stack_images(endpoint_id, stack_name)
|
||||||
|
if updated_images:
|
||||||
|
endpoint_updated_images += updated_images
|
||||||
|
print(f"{stack_name} was updated. Restarting...")
|
||||||
|
if endpoint_id == 2 and stack_name in ('portainer', 'traefik'):
|
||||||
|
upgrade_local_stack(stack)
|
||||||
|
while not client.ping_server():
|
||||||
|
print("Waiting for server to be back online...")
|
||||||
|
sleep(1)
|
||||||
|
else:
|
||||||
|
client.upgrade_stack(stack)
|
||||||
|
print(f"{stack_name} restarted.")
|
||||||
|
else:
|
||||||
|
print(f"{stack_name} is up to date. Skipping.")
|
||||||
|
|
||||||
|
if endpoint_updated_images:
|
||||||
|
print("Deleting obsolete images")
|
||||||
|
for image_id in endpoint_updated_images:
|
||||||
|
client.delete_image(endpoint_id, image_id)
|
||||||
|
else:
|
||||||
|
print("No obsolete image to delete")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
headers = {
|
update_repositories()
|
||||||
"accept": "application/json",
|
|
||||||
"X-API-Key": config.access_token
|
|
||||||
}
|
|
||||||
|
|
||||||
api_url = "https://portainer.dorfsvald.net/api/stacks"
|
client = PortainerClient(portainer_url=config.portainer_url, access_token=config.access_token)
|
||||||
response = requests.get(api_url, headers=headers)
|
|
||||||
stack_list = response.json()
|
|
||||||
|
|
||||||
for s in stack_list:
|
for e in client.list_endpoints():
|
||||||
if s['Status'] == 1:
|
upgrade_endpoint_stack(client, e)
|
||||||
update_stack(s)
|
|
||||||
|
|||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
requests
|
||||||
6
update_repos.sh
Executable file
6
update_repos.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
REPO_PATH=$1
|
||||||
|
|
||||||
|
git -C "$REPO_PATH" fetch --tags
|
||||||
|
LAST_HASH=$(git -C "$REPO_PATH" rev-list --tags --max-count=1)
|
||||||
|
LAST_TAG=$(git -C "$REPO_PATH" describe --tags "$LAST_HASH")
|
||||||
|
git -C "$REPO_PATH" checkout "$LAST_TAG"
|
||||||
Reference in New Issue
Block a user