Skip to content

Commit 19d1412

Browse files
authored
Add portainer registry configuration 🚨 (#1125)
* Automatically configure registry in portainer Creates a single authneticated dockerhub registry that lets pulling private images (i.e. updating service's image (that is private) in portainer shall work now) * closes #1089 * Update * Imrpove wait for it installation
1 parent 1865d84 commit 19d1412

File tree

8 files changed

+179
-65
lines changed

8 files changed

+179
-65
lines changed

‎scripts/.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
wait4x

‎scripts/common.Makefile‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,13 @@ show-venv: venv ## show venv info
342342
@echo venv: $(VENV_DIR)
343343
344344
.PHONY: install
345-
install: requirements.txt venv ## install dependencies from ./requirements.txt
346-
@VIRTUAL_ENV=$(VENV_DIR) $(UV) pip install --requirement $<
345+
install: guard-optional-REQUIREMENTS_FILE venv ## install requirements.txt dependencies
346+
@if [ -z "$(REQUIREMENTS_FILE)" ]; then \
347+
REQUIREMENTS_FILE=./requirements.txt; \
348+
else \
349+
REQUIREMENTS_FILE=$(REQUIREMENTS_FILE); \
350+
fi; \
351+
VIRTUAL_ENV=$(VENV_DIR) $(UV) pip install --requirement $$REQUIREMENTS_FILE
347352
348353
# https://github.com/kolypto/j2cli?tab=readme-ov-file#customization
349354
ifeq ($(shell test -f j2cli_customization.py && echo -n yes),yes)
@@ -362,3 +367,21 @@ define jinja
362367
endef
363368
364369
endif
370+
371+
#
372+
# wait-fot-it functionality
373+
#
374+
375+
WAIT_FOR_IT := $(REPO_BASE_DIR)/scripts/wait4x
376+
377+
alias: $(WAIT_FOR_IT)
378+
379+
# https://github.com/wait4x/wait4x
380+
$(WAIT_FOR_IT): ## installs wait4x utility for WAIT_FOR_IT functionality
381+
# installing wait4x
382+
@mkdir --parents /tmp/wait4x
383+
@cd /tmp/wait4x && curl --silent --location --remote-name https://github.com/wait4x/wait4x/releases/download/v3.5.0/wait4x-linux-amd64.tar.gz
384+
@tar -xf /tmp/wait4x/wait4x-linux-amd64.tar.gz -C /tmp/wait4x
385+
@mv /tmp/wait4x/wait4x $@
386+
@rm -rf /tmp/wait4x
387+
@$@ version

‎services/monitoring/grafana/Makefile‎

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -39,29 +39,11 @@ terraform-apply: $(REPO_CONFIG_LOCATION) terraform/plan.cache $(TF_STATE_FILE) e
3939
terraform -chdir=./terraform apply plan.cache
4040

4141
.PHONY: ensure-grafana-online
42-
ensure-grafana-online:
42+
ensure-grafana-online: $(WAIT_FOR_IT)
4343
@set -o allexport; \
4444
source $(REPO_CONFIG_LOCATION); \
4545
set +o allexport; \
46-
url=$${TF_VAR_GRAFANA_URL}; \
47-
echo "Waiting for grafana at $$url to become reachable..."; \
48-
attempts=0; \
49-
max_attempts=10; \
50-
while [ $$attempts -lt $$max_attempts ]; do \
51-
status_code=$$(curl -k -o /dev/null -s -w "%{http_code}" --max-time 10 $$url); \
52-
if [ "$$status_code" -ge 200 ] && [ "$$status_code" -lt 400 ]; then \
53-
echo "Grafana is online"; \
54-
break; \
55-
else \
56-
echo "Grafana still unreachable, waiting 5s for grafana to become reachable... (Attempt $$((attempts+1)))"; \
57-
sleep 5; \
58-
attempts=$$((attempts + 1)); \
59-
fi; \
60-
done; \
61-
if [ $$attempts -eq $$max_attempts ]; then \
62-
echo "Max attempts reached, Grafana is still unreachable."; \
63-
exit 1; \
64-
fi;
46+
$(WAIT_FOR_IT) http $$TF_VAR_GRAFANA_URL --timeout=120s --interval=5s --expect-status-code 200
6547

6648
.PHONY: assets
6749
assets: ${REPO_CONFIG_LOCATION}

‎services/portainer/Makefile‎

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,50 +15,38 @@ include ${REPO_BASE_DIR}/scripts/common.Makefile
1515
.PHONY: up ## Deploys portainer stack
1616
up: .init .env secrets ${TEMP_COMPOSE}
1717
@docker stack deploy --with-registry-auth --prune --compose-file ${TEMP_COMPOSE} ${STACK_NAME}
18+
@$(MAKE) --noprint configure-portainer-registry
1819

1920
.PHONY: up-local ## Deploys portainer stack for local deployment
2021
up-local: .init .env secrets ${TEMP_COMPOSE} ${TEMP_COMPOSE}-local
2122
@docker stack deploy --with-registry-auth --prune --compose-file ${TEMP_COMPOSE}-local ${STACK_NAME}
22-
23-
.PHONY: up-letsencrypt-http ## Deploys portainer stack using let's encrypt http challenge
24-
up-letsencrypt-http: .init .env secrets ${TEMP_COMPOSE}-letsencrypt-http
25-
@docker stack deploy --with-registry-auth --prune --compose-file ${TEMP_COMPOSE}-letsencrypt-http ${STACK_NAME}
26-
27-
.PHONY: up-letsencrypt-dns ## Deploys portainer stack using let's encrypt dns challenge
28-
up-letsencrypt-dns: .init .env secrets ${TEMP_COMPOSE}-letsencrypt-dns
29-
@docker stack deploy --with-registry-auth --prune --compose-file ${TEMP_COMPOSE}-letsencrypt-dns ${STACK_NAME}
23+
@$(MAKE) --noprint configure-portainer-registry
3024

3125
.PHONY: up-dalco ## Deploys portainer stack for Dalco Cluster
3226
up-dalco: .init .env secrets ${TEMP_COMPOSE}-dalco
3327
@docker stack deploy --with-registry-auth --prune --compose-file ${TEMP_COMPOSE}-dalco ${STACK_NAME}
28+
@$(MAKE) --noprint configure-portainer-registry
3429

3530
.PHONY: up-aws ## Deploys portainer stack for AWS
3631
up-aws: .init .env secrets ${TEMP_COMPOSE}-aws
3732
@docker stack deploy --with-registry-auth --prune --compose-file ${TEMP_COMPOSE}-aws ${STACK_NAME}
33+
@$(MAKE) --noprint configure-portainer-registry
3834

3935
.PHONY: up-public ## Deploys portainer stack for public access Cluster
4036
up-public: up-dalco
4137

4238
.PHONY: up-master ## Deploys portainer stack for master Cluster
4339
up-master: .init .env secrets ${TEMP_COMPOSE}-master
4440
@docker stack deploy --with-registry-auth --prune --compose-file ${TEMP_COMPOSE}-master ${STACK_NAME}
41+
@$(MAKE) --noprint configure-portainer-registry
4542

4643

47-
.PHONY: configure-registry
48-
configure-registry: ## Add if necessary dockerhub registry configuration to portainer.
49-
@set -o allexport; \
50-
source $(REPO_CONFIG_LOCATION); \
51-
set +o allexport; \
52-
while [ "$$(curl -s -o /dev/null -I -w "%{http_code}" --max-time 10 -H "Accept: application/json" -H "Content-Type: application/json" -X GET https://"$$MONITORING_DOMAIN"/portainer/#/auth)" != 200 ]; do\
53-
echo "waiting for portainer to run...";\
54-
sleep 5s;\
55-
done;\
56-
echo "Updating docker-hub config";\
57-
authentificationToken=$$(curl -o /dev/null -X POST "https://"$$MONITORING_DOMAIN"/portainer/api/auth" -H "Content-Type: application/json" -d "{ \"Username\": \"$${PORTAINER_ADMIN_LOGIN}\", \"Password\": \"$${PORTAINER_ADMIN_PWD}\"}"); \
58-
authentificationToken=$$(echo "$$authentificationToken" | jq --raw-output '.jwt'); \
59-
update_hub=$$(curl -o /dev/null -X PUT "https://"$$MONITORING_DOMAIN"/portainer/api/dockerhub" -H "accept: application/json" -H \
60-
"Authorization: Bearer $${authentificationToken}" -H "Content-Type: application/json" \
61-
-d "{ \"Authentication\": true, \"Username\": \"$$DOCKER_HUB_LOGIN\", \"Password\": \"$$DOCKER_HUB_PASSWORD\"}"); \
44+
.PHONY: configure-portainer-registry
45+
configure-portainer-registry: venv $(VENV_BIN)/python $(WAIT_FOR_IT) ## Add registry to Portainer
46+
@$(MAKE) --no-print install REQUIREMENTS_FILE=./scripts/requirements.txt
47+
@set -o allexport; source $(REPO_CONFIG_LOCATION); set +o allexport; \
48+
$(WAIT_FOR_IT) http $$PORTAINER_URL --timeout=120s --interval=5s --expect-status-code 200 && \
49+
$(VENV_BIN)/python ./scripts/configure_portainer_registry.py
6250

6351

6452
# Helpers -------------------------------------------------
@@ -67,14 +55,6 @@ configure-registry: ## Add if necessary dockerhub registry configuration to por
6755
${TEMP_COMPOSE}: docker-compose.yml .env
6856
@${REPO_BASE_DIR}/scripts/docker-stack-config.bash -e .env $< > $@
6957

70-
.PHONY: ${TEMP_COMPOSE}-letsencrypt-http
71-
${TEMP_COMPOSE}-letsencrypt-http: docker-compose.yml docker-compose.letsencrypt.http.yml .env
72-
@${REPO_BASE_DIR}/scripts/docker-stack-config.bash -e .env $< docker-compose.letsencrypt.http.yml > $@
73-
74-
.PHONY: ${TEMP_COMPOSE}-letsencrypt-dns
75-
${TEMP_COMPOSE}-letsencrypt-dns: docker-compose.yml docker-compose.letsencrypt.dns.yml .env
76-
@${REPO_BASE_DIR}/scripts/docker-stack-config.bash -e .env $< docker-compose.letsencrypt.dns.yml > $@
77-
7858
.PHONY: ${TEMP_COMPOSE}-dalco
7959
${TEMP_COMPOSE}-dalco: docker-compose.yml docker-compose.dalco.yml .env
8060
@${REPO_BASE_DIR}/scripts/docker-stack-config.bash -e .env $< docker-compose.dalco.yml > $@

‎services/portainer/docker-compose.letsencrypt.dns.yml‎

Lines changed: 0 additions & 6 deletions
This file was deleted.

‎services/portainer/docker-compose.letsencrypt.http.yml‎

Lines changed: 0 additions & 6 deletions
This file was deleted.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import logging
2+
import os
3+
from enum import Enum
4+
from typing import TypedDict
5+
6+
import requests
7+
from tenacity import retry
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
# https://app.swaggerhub.com/apis/portainer/portainer-ce/2.27.6#/portainer.Registry
13+
class RegistryType(Enum):
14+
DOCKER_HUB = 6
15+
16+
17+
class Registry(TypedDict):
18+
Id: int
19+
Name: str
20+
URL: str
21+
Authentication: bool
22+
Username: str
23+
Type: RegistryType
24+
25+
26+
@retry
27+
def get_portainer_api_auth_token(
28+
portainer_api_url: str, portainer_username: str, portainer_password: str
29+
) -> str:
30+
# https://app.swaggerhub.com/apis/portainer/portainer-ce/2.27.6#/auth/AuthenticateUser
31+
response = requests.post(
32+
f"{portainer_api_url}/auth",
33+
# https://app.swaggerhub.com/apis/portainer/portainer-ce/2.27.6#/auth.authenticatePayload
34+
json={"Username": portainer_username, "Password": portainer_password},
35+
)
36+
37+
try:
38+
response.raise_for_status()
39+
except requests.HTTPError as e:
40+
logger.error("Failed to authenticate with Portainer API: %s", e.response.text)
41+
raise
42+
43+
return response.json()["jwt"]
44+
45+
46+
@retry
47+
def get_registries(portainer_api_url: str, auth_token: str) -> list[Registry]:
48+
# https://app.swaggerhub.com/apis/portainer/portainer-ce/2.27.6#/registries/RegistryList
49+
response = requests.get(
50+
f"{portainer_api_url}/registries",
51+
headers={"Authorization": f"Bearer {auth_token}"},
52+
)
53+
54+
try:
55+
response.raise_for_status()
56+
except requests.HTTPError as e:
57+
logger.error("Failed to fetch registries: %s", e.response.text)
58+
raise
59+
60+
return response.json()
61+
62+
63+
@retry
64+
def create_authenticated_dockerhub_registry(
65+
portainer_api_url: str,
66+
auth_token: str,
67+
dockerhub_username: str,
68+
dockerhub_password: str,
69+
registry_name: str = "IT'IS Foundation",
70+
) -> None:
71+
# https://app.swaggerhub.com/apis/portainer/portainer-ce/2.27.6#/registries/RegistryCreate
72+
response = requests.post(
73+
f"{portainer_api_url}/registries",
74+
headers={"Authorization": f"Bearer {auth_token}"},
75+
# https://app.swaggerhub.com/apis/portainer/portainer-ce/2.27.6#/registries.registryCreatePayload
76+
json={
77+
"name": registry_name,
78+
"url": "docker.io",
79+
"authentication": True,
80+
"username": dockerhub_username,
81+
"password": dockerhub_password,
82+
"type": RegistryType.DOCKER_HUB.value,
83+
},
84+
)
85+
86+
try:
87+
response.raise_for_status()
88+
except requests.HTTPError as e:
89+
logger.error(
90+
"Failed to create authenticated Docker Hub registry: %s", e.response.text
91+
)
92+
raise
93+
94+
return response.json()
95+
96+
97+
def main():
98+
logger.info("Configuring Portainer registries...")
99+
100+
portainer_username = os.environ["SERVICES_USER"]
101+
portainer_password = os.environ["SERVICES_PASSWORD"]
102+
portainer_api_url = os.environ["PORTAINER_URL"] + "/api"
103+
104+
dockerhub_username = os.environ["DOCKER_HUB_LOGIN"]
105+
dockerhub_password = os.environ["DOCKER_HUB_PASSWORD"]
106+
107+
portainer_jwt_token = get_portainer_api_auth_token(
108+
portainer_api_url, portainer_username, portainer_password
109+
)
110+
111+
registries = get_registries(portainer_api_url, portainer_jwt_token)
112+
113+
if not any(
114+
r["Type"] == RegistryType.DOCKER_HUB.value and r["Authentication"] is True
115+
for r in registries
116+
):
117+
logging.info("Creating authenticated Docker Hub registry in Portainer...")
118+
create_authenticated_dockerhub_registry(
119+
portainer_api_url,
120+
portainer_jwt_token,
121+
dockerhub_username,
122+
dockerhub_password,
123+
)
124+
else:
125+
logging.info("Portainer already has an authenticated Docker Hub registry.")
126+
127+
logging.info("Portainer registries configuration completed.")
128+
129+
130+
def configure_logging():
131+
logging.basicConfig(
132+
level=logging.INFO,
133+
)
134+
135+
136+
if __name__ == "__main__":
137+
configure_logging()
138+
main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
tenacity==9.1.2
2+
requests==2.32.4

0 commit comments

Comments
 (0)