From e0bc04770b3433b8eb5074e9af29cf6a8bbef25f Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 01:27:56 +0200 Subject: [PATCH 01/49] feat: manual reload --- src/karl/api/v1.py | 9 ++++++++- src/karl/model/vcs.py | 7 +++++++ src/karl/model/webhook.py | 3 +++ src/karl/services/reload.py | 29 +++++++++++++++++++++++++++++ src/karl/services/vcs.py | 7 +++++++ 5 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/karl/model/vcs.py create mode 100644 src/karl/services/reload.py diff --git a/src/karl/api/v1.py b/src/karl/api/v1.py index de85719..2a88c18 100644 --- a/src/karl/api/v1.py +++ b/src/karl/api/v1.py @@ -9,7 +9,7 @@ from starlette.responses import JSONResponse, Response from karl.api.models import Request from karl.core.injects import AutowireSupport from karl.core.woodpecker import Woodpecker -from karl.model.webhook import WoodpeckerEvent +from karl.model.webhook import WoodpeckerEvent, ReloadEvent router = APIRouter() logger = logging.getLogger(__name__) @@ -39,3 +39,10 @@ class APIv1: async def ci(self, request: Request): await self.bus.dispatch(mapper.map(request)) return Response(status_code=201) + + @router.post("/reload", summary="Manual service reload") + async def reload(self, service: str = None): + if service is None: + return Response(status_code=400) + await self.bus.dispatch(ReloadEvent(service=service)) + return Response(status_code=201) diff --git a/src/karl/model/vcs.py b/src/karl/model/vcs.py new file mode 100644 index 0000000..80800bc --- /dev/null +++ b/src/karl/model/vcs.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class Head: + sha: str + branch: str diff --git a/src/karl/model/webhook.py b/src/karl/model/webhook.py index 6c941ae..5b44d46 100644 --- a/src/karl/model/webhook.py +++ b/src/karl/model/webhook.py @@ -10,3 +10,6 @@ class WoodpeckerEvent(BaseEvent): message: str started: int files: List[str] + +class ReloadEvent(BaseEvent): + service: str diff --git a/src/karl/services/reload.py b/src/karl/services/reload.py new file mode 100644 index 0000000..28997aa --- /dev/null +++ b/src/karl/services/reload.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import Annotated + +from bubus import EventBus +from injectable import injectable, autowired, Autowired + +from model.webhook import ReloadEvent, WoodpeckerEvent +from services import GitService + + +@injectable(singleton=True) +class ReloadService: + + @autowired + def __init__(self, bus: Annotated[EventBus, Autowired]): + self._bus = bus + self._bus.on(ReloadEvent, self.on_reload) + self._git = GitService() + + def on_reload(self, event: ReloadEvent): + head = self._git.get_head() + self._bus.dispatch(WoodpeckerEvent( + _id=-1, + commit=head.sha, + ref=head.branch, + message=f"Manual reload of {event.service}", + started=int(datetime.now().timestamp()), + files=[f"compose/{event.service}/docker-compose.yml"] + )) diff --git a/src/karl/services/vcs.py b/src/karl/services/vcs.py index e89836d..8f63c19 100644 --- a/src/karl/services/vcs.py +++ b/src/karl/services/vcs.py @@ -2,6 +2,7 @@ from git import Repo, Remote from injectable import injectable from karl.config import GitConfig, get_settings +from model.vcs import Head @injectable(singleton=True) @@ -27,3 +28,9 @@ class GitService: def checkout(self, sha: str): self._origin.fetch() self._repo.git.checkout(sha) + + def get_head(self) -> Head: + return Head( + self._repo.active_branch.commit.hexsha, + self._repo.active_branch.name + ) From 0e19df5c3e2763b13c37ee327b8e88f457f69244 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 01:40:34 +0200 Subject: [PATCH 02/49] fix: settings location --- .gitignore | 2 +- src/karl/config/settings.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 52364cd..06c7873 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,6 @@ uv.lock **/*.kdbx* .compose_repository -__pycache__/ +deployment/ **/dist/ **/*.log diff --git a/src/karl/config/settings.py b/src/karl/config/settings.py index 79e66c9..8e20b51 100644 --- a/src/karl/config/settings.py +++ b/src/karl/config/settings.py @@ -51,4 +51,8 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: - return Settings.from_yaml() + paths = ['deployment/config.yaml', 'config/config.yaml'] + for path in paths: + if Path(path).exists(): + return Settings.from_yaml(path) + raise Exception("Config file not found") From 604381348abc23d2498cb7f813cc941ae539dc4b Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 01:48:42 +0200 Subject: [PATCH 03/49] fix: logging in reload --- src/karl/services/reload.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/karl/services/reload.py b/src/karl/services/reload.py index 28997aa..c28b8be 100644 --- a/src/karl/services/reload.py +++ b/src/karl/services/reload.py @@ -1,3 +1,4 @@ +import logging from datetime import datetime from typing import Annotated @@ -7,23 +8,26 @@ from injectable import injectable, autowired, Autowired from model.webhook import ReloadEvent, WoodpeckerEvent from services import GitService +logger = logging.getLogger(__name__) + @injectable(singleton=True) class ReloadService: - @autowired - def __init__(self, bus: Annotated[EventBus, Autowired]): - self._bus = bus - self._bus.on(ReloadEvent, self.on_reload) - self._git = GitService() + @autowired + def __init__(self, bus: Annotated[EventBus, Autowired]): + self._bus = bus + self._bus.on(ReloadEvent, self.on_reload) + self._git = GitService() - def on_reload(self, event: ReloadEvent): - head = self._git.get_head() - self._bus.dispatch(WoodpeckerEvent( - _id=-1, - commit=head.sha, - ref=head.branch, - message=f"Manual reload of {event.service}", - started=int(datetime.now().timestamp()), - files=[f"compose/{event.service}/docker-compose.yml"] - )) + async def on_reload(self, event: ReloadEvent): + logger.info(f"Received ReloadEvent: {event.service}") + head = self._git.get_head() + await self._bus.dispatch(WoodpeckerEvent( + _id=-1, + commit=head.sha, + ref=head.branch, + message=f"Manual reload of {event.service}", + started=int(datetime.now().timestamp()), + files=[f"compose/{event.service}/docker-compose.yml"] + )) From 8f4dc486ac469c067a58f62e104b5b572d4fdb18 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 01:54:43 +0200 Subject: [PATCH 04/49] fix: inject ReloadService to API class --- src/karl/api/v1.py | 2 ++ src/karl/core/injects.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/karl/api/v1.py b/src/karl/api/v1.py index 2a88c18..60e9f1c 100644 --- a/src/karl/api/v1.py +++ b/src/karl/api/v1.py @@ -10,6 +10,7 @@ from karl.api.models import Request from karl.core.injects import AutowireSupport from karl.core.woodpecker import Woodpecker from karl.model.webhook import WoodpeckerEvent, ReloadEvent +from services.reload import ReloadService router = APIRouter() logger = logging.getLogger(__name__) @@ -21,6 +22,7 @@ async def root(): @cbv(router) class APIv1: + reload: ReloadService = Depends(AutowireSupport.reload) woodpecker: Woodpecker = Depends(AutowireSupport.woodpecker) bus: EventBus = Depends(AutowireSupport.bus) diff --git a/src/karl/core/injects.py b/src/karl/core/injects.py index 34a6280..dd23516 100644 --- a/src/karl/core/injects.py +++ b/src/karl/core/injects.py @@ -2,6 +2,7 @@ from bubus import EventBus from injectable import inject from karl.core.woodpecker import Woodpecker +from services.reload import ReloadService class AutowireSupport: @@ -10,6 +11,10 @@ class AutowireSupport: def woodpecker(): return inject(Woodpecker) + @staticmethod + def reload(): + return inject(ReloadService) + @staticmethod def bus(): return inject(EventBus) From 6ee6341a5e2539555b4f21e5bb3c25017f027221 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 02:03:31 +0200 Subject: [PATCH 05/49] fix: annotations --- src/karl/api/v1.py | 4 ++-- src/karl/services/reload.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/karl/api/v1.py b/src/karl/api/v1.py index 60e9f1c..28d8515 100644 --- a/src/karl/api/v1.py +++ b/src/karl/api/v1.py @@ -42,8 +42,8 @@ class APIv1: await self.bus.dispatch(mapper.map(request)) return Response(status_code=201) - @router.post("/reload", summary="Manual service reload") - async def reload(self, service: str = None): + @router.get("/reload", summary="Manual service reload") + async def reload(self, service: str = None) -> Response: if service is None: return Response(status_code=400) await self.bus.dispatch(ReloadEvent(service=service)) diff --git a/src/karl/services/reload.py b/src/karl/services/reload.py index c28b8be..6c3b28d 100644 --- a/src/karl/services/reload.py +++ b/src/karl/services/reload.py @@ -17,8 +17,8 @@ class ReloadService: @autowired def __init__(self, bus: Annotated[EventBus, Autowired]): self._bus = bus - self._bus.on(ReloadEvent, self.on_reload) self._git = GitService() + bus.on(ReloadEvent, self.on_reload) async def on_reload(self, event: ReloadEvent): logger.info(f"Received ReloadEvent: {event.service}") From 05931329a30a360edeb743e45e2631840bd40d27 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 02:06:23 +0200 Subject: [PATCH 06/49] fix: moved ReloadService to core, fixed import --- src/karl/api/v1.py | 4 ++-- src/karl/{services => core}/reload.py | 0 2 files changed, 2 insertions(+), 2 deletions(-) rename src/karl/{services => core}/reload.py (100%) diff --git a/src/karl/api/v1.py b/src/karl/api/v1.py index 28d8515..e32db0b 100644 --- a/src/karl/api/v1.py +++ b/src/karl/api/v1.py @@ -8,9 +8,9 @@ from starlette.responses import JSONResponse, Response from karl.api.models import Request from karl.core.injects import AutowireSupport +from karl.core.reload import ReloadService from karl.core.woodpecker import Woodpecker from karl.model.webhook import WoodpeckerEvent, ReloadEvent -from services.reload import ReloadService router = APIRouter() logger = logging.getLogger(__name__) @@ -22,8 +22,8 @@ async def root(): @cbv(router) class APIv1: - reload: ReloadService = Depends(AutowireSupport.reload) woodpecker: Woodpecker = Depends(AutowireSupport.woodpecker) + reload: ReloadService = Depends(AutowireSupport.reload) bus: EventBus = Depends(AutowireSupport.bus) def __init__(self): diff --git a/src/karl/services/reload.py b/src/karl/core/reload.py similarity index 100% rename from src/karl/services/reload.py rename to src/karl/core/reload.py From dcba20ee944933e47a8f264b29033b19b7de179e Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 02:07:16 +0200 Subject: [PATCH 07/49] fix: fixed import in injects --- src/karl/core/injects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/core/injects.py b/src/karl/core/injects.py index dd23516..b52451b 100644 --- a/src/karl/core/injects.py +++ b/src/karl/core/injects.py @@ -1,8 +1,8 @@ from bubus import EventBus from injectable import inject +from karl.core.reload import ReloadService from karl.core.woodpecker import Woodpecker -from services.reload import ReloadService class AutowireSupport: From cd62e256cfb93007cdf57099606cfc9a950badd2 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 02:11:12 +0200 Subject: [PATCH 08/49] fix: api annotations --- src/karl/api/v1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/api/v1.py b/src/karl/api/v1.py index e32db0b..65139ff 100644 --- a/src/karl/api/v1.py +++ b/src/karl/api/v1.py @@ -42,7 +42,7 @@ class APIv1: await self.bus.dispatch(mapper.map(request)) return Response(status_code=201) - @router.get("/reload", summary="Manual service reload") + @router.get("/reload", summary="Manual service reload", response_model=Response) async def reload(self, service: str = None) -> Response: if service is None: return Response(status_code=400) From 59c5844f5b61eeef5adb5b30a689535abba37c1a Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 02:12:22 +0200 Subject: [PATCH 09/49] fix: api annotations again --- src/karl/api/v1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/api/v1.py b/src/karl/api/v1.py index 65139ff..39123de 100644 --- a/src/karl/api/v1.py +++ b/src/karl/api/v1.py @@ -42,7 +42,7 @@ class APIv1: await self.bus.dispatch(mapper.map(request)) return Response(status_code=201) - @router.get("/reload", summary="Manual service reload", response_model=Response) + @router.get("/reload", summary="Manual service reload", response_model=None) async def reload(self, service: str = None) -> Response: if service is None: return Response(status_code=400) From a50df466d01f53e4892bf4795f3228447e973576 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 02:27:31 +0200 Subject: [PATCH 10/49] fix: moved services initialization to main --- src/karl/api/v1.py | 6 +----- src/karl/core/reload.py | 1 + src/karl/main.py | 8 +++++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/karl/api/v1.py b/src/karl/api/v1.py index 39123de..8a529ab 100644 --- a/src/karl/api/v1.py +++ b/src/karl/api/v1.py @@ -8,8 +8,6 @@ from starlette.responses import JSONResponse, Response from karl.api.models import Request from karl.core.injects import AutowireSupport -from karl.core.reload import ReloadService -from karl.core.woodpecker import Woodpecker from karl.model.webhook import WoodpeckerEvent, ReloadEvent router = APIRouter() @@ -22,8 +20,6 @@ async def root(): @cbv(router) class APIv1: - woodpecker: Woodpecker = Depends(AutowireSupport.woodpecker) - reload: ReloadService = Depends(AutowireSupport.reload) bus: EventBus = Depends(AutowireSupport.bus) def __init__(self): @@ -42,7 +38,7 @@ class APIv1: await self.bus.dispatch(mapper.map(request)) return Response(status_code=201) - @router.get("/reload", summary="Manual service reload", response_model=None) + @router.get("/reload", summary="Manual service reload") async def reload(self, service: str = None) -> Response: if service is None: return Response(status_code=400) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 6c3b28d..4a8bcbd 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -19,6 +19,7 @@ class ReloadService: self._bus = bus self._git = GitService() bus.on(ReloadEvent, self.on_reload) + logger.info("ReloadService initialized.") async def on_reload(self, event: ReloadEvent): logger.info(f"Received ReloadEvent: {event.service}") diff --git a/src/karl/main.py b/src/karl/main.py index fc75ec3..25029ac 100644 --- a/src/karl/main.py +++ b/src/karl/main.py @@ -3,6 +3,7 @@ import logging from fastapi import FastAPI from injectable import load_injection_container +from core.injects import AutowireSupport from karl.config import get_settings from karl.util.logging import HandlerFactory @@ -15,7 +16,7 @@ class KarlApplication: _app = FastAPI(title="Karl", version="0.1.0") self._set_middlewares(_app) self._set_routes(_app) - self._set_events(_app) + self._init_services(_app) self._app = _app @@ -55,8 +56,9 @@ class KarlApplication: app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"]) pass - def _set_events(self, app: FastAPI): - pass + def _init_services(self, app: FastAPI): + AutowireSupport.reload() + AutowireSupport.woodpecker() def run(): From da1292a45f9346c5bd164c177a69b392b9d94dc9 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 02:46:15 +0200 Subject: [PATCH 11/49] fix: detached head check --- src/karl/services/vcs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/karl/services/vcs.py b/src/karl/services/vcs.py index 8f63c19..0d70139 100644 --- a/src/karl/services/vcs.py +++ b/src/karl/services/vcs.py @@ -30,6 +30,8 @@ class GitService: self._repo.git.checkout(sha) def get_head(self) -> Head: + if self._repo.head.is_detached: + return Head(self._repo.head.object.hexsha, "detached") return Head( self._repo.active_branch.commit.hexsha, self._repo.active_branch.name From 4b8ea49c0ca7292fd4a97e885302cadadca9c48d Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:12:31 +0200 Subject: [PATCH 12/49] fix: force .mo. reload in reload --- src/karl/core/reload.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 4a8bcbd..5af7151 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from pathlib import Path from typing import Annotated from bubus import EventBus @@ -24,11 +25,12 @@ class ReloadService: async def on_reload(self, event: ReloadEvent): logger.info(f"Received ReloadEvent: {event.service}") head = self._git.get_head() + mos = Path('files/forge').glob('*.mo.*') await self._bus.dispatch(WoodpeckerEvent( _id=-1, commit=head.sha, ref=head.branch, message=f"Manual reload of {event.service}", started=int(datetime.now().timestamp()), - files=[f"compose/{event.service}/docker-compose.yml"] + files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] )) From 9a8bc44e0996aba065ad01dd230a3b79da3ebf14 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:16:26 +0200 Subject: [PATCH 13/49] fix: verbosity in ReloadService --- src/karl/core/reload.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 5af7151..c8c9785 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -25,12 +25,14 @@ class ReloadService: async def on_reload(self, event: ReloadEvent): logger.info(f"Received ReloadEvent: {event.service}") head = self._git.get_head() - mos = Path('files/forge').glob('*.mo.*') - await self._bus.dispatch(WoodpeckerEvent( + mos = Path(f"files/{event.service}").glob('*.mo.*') + event = WoodpeckerEvent( _id=-1, commit=head.sha, ref=head.branch, message=f"Manual reload of {event.service}", started=int(datetime.now().timestamp()), files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] - )) + ) + logger.trace(f"Sending WoodpeckerEvent: {event}") + await self._bus.dispatch(event) From 7fa243a8d4276595572fa546690c1179805669e9 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:19:07 +0200 Subject: [PATCH 14/49] fix: repr WoodpeckerEvent --- src/karl/core/reload.py | 4 ++-- src/karl/model/webhook.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index c8c9785..58e119a 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -26,7 +26,7 @@ class ReloadService: logger.info(f"Received ReloadEvent: {event.service}") head = self._git.get_head() mos = Path(f"files/{event.service}").glob('*.mo.*') - event = WoodpeckerEvent( + we = WoodpeckerEvent( _id=-1, commit=head.sha, ref=head.branch, @@ -34,5 +34,5 @@ class ReloadService: started=int(datetime.now().timestamp()), files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] ) - logger.trace(f"Sending WoodpeckerEvent: {event}") + logger.trace(f"Sending WoodpeckerEvent: {we}") await self._bus.dispatch(event) diff --git a/src/karl/model/webhook.py b/src/karl/model/webhook.py index 5b44d46..abfea20 100644 --- a/src/karl/model/webhook.py +++ b/src/karl/model/webhook.py @@ -11,5 +11,8 @@ class WoodpeckerEvent(BaseEvent): started: int files: List[str] + def __repr__(self): + return f"" + class ReloadEvent(BaseEvent): service: str From 8da4bc4aaf44c586e4515e10897ba3bd51aff89e Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:21:49 +0200 Subject: [PATCH 15/49] fix: repr WoodpeckerEvent? --- src/karl/core/reload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 58e119a..43628be 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -34,5 +34,5 @@ class ReloadService: started=int(datetime.now().timestamp()), files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] ) - logger.trace(f"Sending WoodpeckerEvent: {we}") + logger.trace(f"Sending WoodpeckerEvent: {str(we)}") await self._bus.dispatch(event) From 7234560d1639b57158f91927cfeeeffe08a2ee44 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:24:04 +0200 Subject: [PATCH 16/49] fix: repr WoodpeckerEvent! --- src/karl/core/reload.py | 4 ++-- src/karl/model/webhook.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 43628be..e14e2ea 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -34,5 +34,5 @@ class ReloadService: started=int(datetime.now().timestamp()), files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] ) - logger.trace(f"Sending WoodpeckerEvent: {str(we)}") - await self._bus.dispatch(event) + logger.trace(f"Sending " - class ReloadEvent(BaseEvent): service: str From 239e51f0f8c099d7f3f1d4cf87ad30c8452b4668 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:26:17 +0200 Subject: [PATCH 17/49] fix: repr WoodpeckerEvent?! --- src/karl/core/reload.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index e14e2ea..9880bad 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -23,16 +23,19 @@ class ReloadService: logger.info("ReloadService initialized.") async def on_reload(self, event: ReloadEvent): - logger.info(f"Received ReloadEvent: {event.service}") - head = self._git.get_head() - mos = Path(f"files/{event.service}").glob('*.mo.*') - we = WoodpeckerEvent( - _id=-1, - commit=head.sha, - ref=head.branch, - message=f"Manual reload of {event.service}", - started=int(datetime.now().timestamp()), - files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] - ) - logger.trace(f"Sending Date: Tue, 14 Apr 2026 03:27:44 +0200 Subject: [PATCH 18/49] fix: repr WoodpeckerEvent?? --- src/karl/core/reload.py | 2 +- src/karl/model/webhook.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 9880bad..4b5b047 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -35,7 +35,7 @@ class ReloadService: started=int(datetime.now().timestamp()), files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] ) - logger.debug(f"Sending Date: Tue, 14 Apr 2026 03:29:06 +0200 Subject: [PATCH 19/49] fix: repr WoodpeckerEvent??!! --- src/karl/core/reload.py | 2 +- src/karl/model/webhook.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 4b5b047..ea6b32a 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -35,7 +35,7 @@ class ReloadService: started=int(datetime.now().timestamp()), files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] ) - logger.debug(f"Sending Date: Tue, 14 Apr 2026 03:32:50 +0200 Subject: [PATCH 20/49] fix: repr WoodpeckerEvent?!?! --- src/karl/core/reload.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index ea6b32a..5e0e36c 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -26,14 +26,15 @@ class ReloadService: try: logger.info(f"Received ReloadEvent: {event.service}") head = self._git.get_head() - mos = Path(f"files/{event.service}").glob('*.mo.*') + mos = list(Path(f"files/{event.service}").glob('*.mo.*')) + logger.debug(f"Found {len(mos)} .mo files") we = WoodpeckerEvent( _id=-1, commit=head.sha, ref=head.branch, message=f"Manual reload of {event.service}", started=int(datetime.now().timestamp()), - files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in list(mos)] + files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in mos] ) logger.debug(f"Sending Date: Tue, 14 Apr 2026 03:37:16 +0200 Subject: [PATCH 21/49] fix: Just another verbosity improvement --- src/karl/core/reload.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 5e0e36c..19d87cf 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -26,7 +26,11 @@ class ReloadService: try: logger.info(f"Received ReloadEvent: {event.service}") head = self._git.get_head() - mos = list(Path(f"files/{event.service}").glob('*.mo.*')) + file_path = Path(f"files/{event.service}") + if not file_path.exists(): + raise Exception(f"Service {event.service} not found.") + logger.debug(f"Found service files at {file_path}: {', '.join([str(f) for f in list(file_path.iterdir())])}") + mos = list(file_path.glob('*.mo.*')) logger.debug(f"Found {len(mos)} .mo files") we = WoodpeckerEvent( _id=-1, From bc592a2c6c73312fce2bf2a79c95ff18b6c1c4fe Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:38:18 +0200 Subject: [PATCH 22/49] fix: Just another verbosity improvement --- src/karl/core/reload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 19d87cf..a8c0838 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -28,7 +28,7 @@ class ReloadService: head = self._git.get_head() file_path = Path(f"files/{event.service}") if not file_path.exists(): - raise Exception(f"Service {event.service} not found.") + raise Exception(f"Service {event.service} not found: {file_path.absolute()} does not exist.") logger.debug(f"Found service files at {file_path}: {', '.join([str(f) for f in list(file_path.iterdir())])}") mos = list(file_path.glob('*.mo.*')) logger.debug(f"Found {len(mos)} .mo files") From 3224f8cfca61046bdab8f106071cb7d018a04671 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:41:44 +0200 Subject: [PATCH 23/49] fix: Absolute path fix --- src/karl/core/reload.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index a8c0838..9478150 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -6,6 +6,7 @@ from typing import Annotated from bubus import EventBus from injectable import injectable, autowired, Autowired +from karl import get_settings from model.webhook import ReloadEvent, WoodpeckerEvent from services import GitService @@ -20,13 +21,14 @@ class ReloadService: self._bus = bus self._git = GitService() bus.on(ReloadEvent, self.on_reload) + self.root_path = get_settings().git.path logger.info("ReloadService initialized.") async def on_reload(self, event: ReloadEvent): try: logger.info(f"Received ReloadEvent: {event.service}") head = self._git.get_head() - file_path = Path(f"files/{event.service}") + file_path = Path(self.root_path) / f"files/{event.service}" if not file_path.exists(): raise Exception(f"Service {event.service} not found: {file_path.absolute()} does not exist.") logger.debug(f"Found service files at {file_path}: {', '.join([str(f) for f in list(file_path.iterdir())])}") From ce74860ca37b5fb95609e7940d2764cdf357c857 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 14 Apr 2026 03:44:13 +0200 Subject: [PATCH 24/49] fix: Another path fix --- src/karl/core/reload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/core/reload.py b/src/karl/core/reload.py index 9478150..f3975c4 100644 --- a/src/karl/core/reload.py +++ b/src/karl/core/reload.py @@ -40,7 +40,7 @@ class ReloadService: ref=head.branch, message=f"Manual reload of {event.service}", started=int(datetime.now().timestamp()), - files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp) for pp in mos] + files=[f"compose/{event.service}/docker-compose.yml"] + [str(pp).replace(str(self.root_path), '') for pp in mos] ) logger.debug(f"Sending Date: Tue, 14 Apr 2026 03:46:05 +0200 Subject: [PATCH 25/49] fix: Detach composes --- src/karl/services/containers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/services/containers.py b/src/karl/services/containers.py index cc7cf56..96b2c45 100644 --- a/src/karl/services/containers.py +++ b/src/karl/services/containers.py @@ -17,4 +17,4 @@ class DockerService: os.chdir(compose_path.parent) self._client.compose.ps() self._client.compose.down(remove_orphans=True) - self._client.compose.up() + self._client.compose.up(detach=True) From 0445a4cef8452d915515d6b6ce452262318c403f Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Fri, 8 May 2026 03:53:14 +0200 Subject: [PATCH 26/49] fix: Disable timestamp in console handler -> provided by journalctl --- src/karl/util/logging.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/karl/util/logging.py b/src/karl/util/logging.py index f25ed4e..27a2423 100644 --- a/src/karl/util/logging.py +++ b/src/karl/util/logging.py @@ -32,19 +32,23 @@ class NamingCache: class ApplicationFormatter(Formatter): - def __init__(self, handler_prefix: str = ''): + def __init__(self, handler_prefix: str = '', include_timestamp: bool = True): super().__init__() self._logger_names = NamingCache() self._handler_prefix = handler_prefix + self._include_timestamp = include_timestamp def format(self, record): from datetime import datetime - timestamp = datetime.fromtimestamp(record.created).isoformat(sep=' ', timespec='milliseconds') + if self._include_timestamp: + timestamp = datetime.fromtimestamp(record.created).isoformat(sep=' ', timespec='milliseconds') + ' ' + else: + timestamp = '' level = record.levelname.replace('WARNING', 'WARN').rjust(5) thread_name = record.threadName.replace(' (', '#').replace(')', '').rjust(16)[-16:] # TODO: NamingCache? logger_name = self._logger_names[f"{self._handler_prefix}{record.name}"] message = record.getMessage() - formatted = f"{timestamp} {level} [{thread_name}] {logger_name} : {message}" + formatted = f"{timestamp}{level} [{thread_name}] {logger_name} : {message}" if record.exc_info: formatted += "\n" + self.formatException(record.exc_info) @@ -62,7 +66,7 @@ class HandlerFactory: def create(target: Target, handler_prefix: str = '', file_path: Path = None) -> List[Handler]: def console_handler(prefix: str = ''): handler = StreamHandler() - handler.setFormatter(ApplicationFormatter(prefix)) + handler.setFormatter(ApplicationFormatter(prefix, include_timestamp=False)) handler.setLevel('INFO') return handler From ce77c0cf7da9fc7dd00c5595208ebc09c52a684e Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Fri, 8 May 2026 03:58:48 +0200 Subject: [PATCH 27/49] fix: Changed docker#reload to restart and not remove containers * Possibly fixes issues with fail2ban --- src/karl/services/containers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/karl/services/containers.py b/src/karl/services/containers.py index 96b2c45..dce9c81 100644 --- a/src/karl/services/containers.py +++ b/src/karl/services/containers.py @@ -15,6 +15,8 @@ class DockerService: def reload(self, compose_path: Path): os.chdir(compose_path.parent) - self._client.compose.ps() - self._client.compose.down(remove_orphans=True) + # TODO: Check if container needs to be reloaded from scratch + # if len(self._client.compose.ps()) > 0: + # self._client.compose.restart() + # return self._client.compose.up(detach=True) From 667d7f476df75f1f8bbfcf952fedf856e85c8a32 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Fri, 8 May 2026 04:10:31 +0200 Subject: [PATCH 28/49] feat!: Multiple services support (beta?) --- src/karl/core/woodpecker.py | 30 ++++++++++++++---------------- tests/test_woodpecker.py | 9 +++++++-- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/karl/core/woodpecker.py b/src/karl/core/woodpecker.py index 3318291..80f133e 100644 --- a/src/karl/core/woodpecker.py +++ b/src/karl/core/woodpecker.py @@ -43,17 +43,21 @@ class WoodpeckerRunner(Thread): result = RunnerResult() try: - service = self.get_service(self._event.files) - if service is None: + services = self.get_service(self._event.files) + if len(services) == 0: logger.info("No service found.") result.success = True else: - service_path = f"{self._root}/compose/{service}/docker-compose.yml" self._git.checkout(self._event.commit) - for file in self._event.files: - if file.__contains__('.mo.'): - self._mo.process(Path(f"{self._root}{file}").absolute()) - self._docker.reload(Path(service_path).absolute()) + paths = [] + for service in services: + service_path = f"{self._root}/compose/{service}/docker-compose.yml" + for file in self._event.files: + if file.__contains__('.mo.'): + self._mo.process(Path(f"{self._root}{file}").absolute()) + paths.append(service_path) + for service_path in paths: + self._docker.reload(Path(service_path).absolute()) result.success = True except Exception as e: result.throwable = e @@ -61,20 +65,14 @@ class WoodpeckerRunner(Thread): asyncio.run(dispatch(result)) @staticmethod - def get_service(files: list[str]) -> str | None: + def get_service(files: list[str]) -> list[str]: supported_files = [] for f in files: f_parts = f.split("/") if f_parts[0] in ["compose", "files"]: supported_files.append(f_parts[1]) - match len(set(supported_files)): - case 0: - return None - case 1: - return supported_files[0] - case _: - logger.error(f"Multiple services were provided: {', '.join(supported_files)}") - raise Exception("Multiple services are not supported.") + # TODO: check service priorities (NGINX last) + return list(dict.fromkeys(supported_files)) @injectable(singleton=True) diff --git a/tests/test_woodpecker.py b/tests/test_woodpecker.py index 7521d88..9bb4616 100644 --- a/tests/test_woodpecker.py +++ b/tests/test_woodpecker.py @@ -4,8 +4,13 @@ files = [".gitignore", "compose/nginx/docker-compose.yaml", "config/heimdall.kdb "files/nginx/cert/key.mo.pem", "files/nginx/conf.d/default.conf", "files/nginx/conf.d/forge.conf", "files/nginx/conf.d/mqtt.conf", "files/nginx/conf.d/nextcloud.conf", "files/nginx/conf.d/zitadel.conf", "files/nginx/nginx.conf"] +files2 = [".gitignore", "compose/nginx/docker-compose.yaml", "compose/nginx2/docker-compose.yaml"] -def test_get_service(): +def test_get_service_single(): service = woodpecker.WoodpeckerRunner.get_service(files) - assert service == 'nginx' + assert service == ['nginx'] + +def test_get_service_multi(): + services = woodpecker.WoodpeckerRunner.get_service(files2) + assert services == ['nginx', 'nginx2'] From bb258efa3977461e37973011963123a8ef5da8a8 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Fri, 8 May 2026 06:03:41 +0200 Subject: [PATCH 29/49] fix: mo url typo --- src/karl/core/woodpecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/core/woodpecker.py b/src/karl/core/woodpecker.py index 80f133e..3dc751e 100644 --- a/src/karl/core/woodpecker.py +++ b/src/karl/core/woodpecker.py @@ -54,7 +54,7 @@ class WoodpeckerRunner(Thread): service_path = f"{self._root}/compose/{service}/docker-compose.yml" for file in self._event.files: if file.__contains__('.mo.'): - self._mo.process(Path(f"{self._root}{file}").absolute()) + self._mo.process(Path(f"{self._root}/{file}").absolute()) paths.append(service_path) for service_path in paths: self._docker.reload(Path(service_path).absolute()) From d220ac196396aad33c7b0def6a0ddf8062eebb6d Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Thu, 11 Jun 2026 14:11:15 +0200 Subject: [PATCH 30/49] chore: WoodpeckerRunner logs --- src/karl/core/woodpecker.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/karl/core/woodpecker.py b/src/karl/core/woodpecker.py index 3dc751e..83416a3 100644 --- a/src/karl/core/woodpecker.py +++ b/src/karl/core/woodpecker.py @@ -32,6 +32,7 @@ class WoodpeckerRunner(Thread): self._bus = bus self._event: WoodpeckerEvent | None = None self._root = get_settings().git.path + self.thread_logger = logging.getLogger(self.__class__.name) def process_event(self, event: WoodpeckerEvent): self._event = event @@ -42,13 +43,15 @@ class WoodpeckerRunner(Thread): await self._bus.dispatch(r) result = RunnerResult() + self.thread_logger.info("Runner started") try: services = self.get_service(self._event.files) if len(services) == 0: - logger.info("No service found.") + self.thread_logger.info("No service found.") result.success = True else: self._git.checkout(self._event.commit) + self.thread_logger.info(f"Checked out successfully at {self._event.commit}") paths = [] for service in services: service_path = f"{self._root}/compose/{service}/docker-compose.yml" @@ -56,8 +59,10 @@ class WoodpeckerRunner(Thread): if file.__contains__('.mo.'): self._mo.process(Path(f"{self._root}/{file}").absolute()) paths.append(service_path) + self.thread_logger.info(f"{len(paths)} paths set") for service_path in paths: self._docker.reload(Path(service_path).absolute()) + self.thread_logger.info("Reloaded all services") result.success = True except Exception as e: result.throwable = e @@ -92,7 +97,7 @@ class Woodpecker: logger.info("Woodpecker initialized.") async def on_ci_event(self, event: WoodpeckerEvent): - logger.debug(f"Received WoodpeckerEvent: {event.event_id}") + logger.info(f"Received WoodpeckerEvent: {event.event_id}") with self._lock: logger.debug("Lock acquired [on-ci-event]") if len(self._pending) > 0 or self._runner is not None: From 0ced5212e9d4741065c527e7633b3965fd5eb0cd Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Thu, 11 Jun 2026 14:14:07 +0200 Subject: [PATCH 31/49] fix: thread logger name --- src/karl/core/woodpecker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/karl/core/woodpecker.py b/src/karl/core/woodpecker.py index 83416a3..41ca7e6 100644 --- a/src/karl/core/woodpecker.py +++ b/src/karl/core/woodpecker.py @@ -2,7 +2,7 @@ import asyncio import logging from collections import deque from pathlib import Path -from threading import RLock, Thread +from threading import RLock, Thread, current_thread from typing import Annotated from bubus import EventBus, BaseEvent @@ -32,7 +32,7 @@ class WoodpeckerRunner(Thread): self._bus = bus self._event: WoodpeckerEvent | None = None self._root = get_settings().git.path - self.thread_logger = logging.getLogger(self.__class__.name) + self.thread_logger = logging.getLogger(current_thread().ident) def process_event(self, event: WoodpeckerEvent): self._event = event From c7f52e8b045dc22352b7a532a884621d649e1f57 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Thu, 11 Jun 2026 14:18:17 +0200 Subject: [PATCH 32/49] fix: thread logger name2 --- src/karl/core/woodpecker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/core/woodpecker.py b/src/karl/core/woodpecker.py index 41ca7e6..21360c6 100644 --- a/src/karl/core/woodpecker.py +++ b/src/karl/core/woodpecker.py @@ -32,7 +32,7 @@ class WoodpeckerRunner(Thread): self._bus = bus self._event: WoodpeckerEvent | None = None self._root = get_settings().git.path - self.thread_logger = logging.getLogger(current_thread().ident) + self.thread_logger = logging.getLogger(current_thread().name) def process_event(self, event: WoodpeckerEvent): self._event = event From 6f14bd7db16fa61edd9f96e210c9c539ca0f1c42 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Thu, 11 Jun 2026 14:48:34 +0200 Subject: [PATCH 33/49] test: env variant --- tests/files/test1/test.mo.env | 7 ++++ tests/test_mo_env.py | 64 +++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/files/test1/test.mo.env create mode 100644 tests/test_mo_env.py diff --git a/tests/files/test1/test.mo.env b/tests/files/test1/test.mo.env new file mode 100644 index 0000000..e3b4cb0 --- /dev/null +++ b/tests/files/test1/test.mo.env @@ -0,0 +1,7 @@ +value=${sample} +nested=${some/nested/value} +mixed=${some/nested/value.username} +custom=${custom.field} + +uname=${sample.username} +invalid=${double.dot.example} diff --git a/tests/test_mo_env.py b/tests/test_mo_env.py new file mode 100644 index 0000000..bbbf0da --- /dev/null +++ b/tests/test_mo_env.py @@ -0,0 +1,64 @@ +from pathlib import Path + +import pytest +import yaml + +from karl.services import Passwords +from karl.services.mo import Mo + + +@pytest.fixture(scope='class') +def target_path(): + # p = Path('tests/files/test1/test.env') + p = Path('files/test1/test.env') + # posprzątaj przed testem, gdyby plik istniał z poprzednich uruchomień + if p.exists(): + p.unlink() + yield p + # sprzątanie po teście + if p.exists(): + p.unlink() + + +@pytest.fixture(scope='class') +def test1_content(target_path: Path): + mo = Mo(Passwords()) + mo.process(Path('files/test1/test.mo.env').absolute()) + # mo.process(Path('tests/files/test1/test.mo.env').absolute()) + + assert target_path.absolute().exists() + + content = target_path.read_text() + assert '${' not in content + + + props = {} + prop_lines = content.split('\n') + for line in prop_lines: + if len(line)> 1: + splitted = line.split('=') + props[splitted[0]] = splitted[1] + # props = {prop[0]:prop[1] for prop in content.split('\n')} + print(props) + yield props + + +class TestEnvParsing: + + def test_simple(self, test1_content: dict): + assert test1_content['value'] == 'some_pass' + + def test_nested(self, test1_content: dict): + assert test1_content['nested'] == 'nested_pass' + + def test_mixed(self, test1_content: dict): + assert test1_content['mixed'] == 'nested_username' + + def test_custom_field(self, test1_content: dict): + assert test1_content['custom'] == 'custom_content' + + def test_username_field(self, test1_content: dict): + assert test1_content['uname'] == 'sample_username' + + def test_invalid_key(self, test1_content: dict): + assert test1_content.get('invalid') == 'None' From 3ec1942b4e4b4b385b285c7c470230fbb1bbe247 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Thu, 11 Jun 2026 14:58:17 +0200 Subject: [PATCH 34/49] chore: passwords logging --- src/karl/services/passwords.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/karl/services/passwords.py b/src/karl/services/passwords.py index bf2cda0..9f662b0 100644 --- a/src/karl/services/passwords.py +++ b/src/karl/services/passwords.py @@ -6,6 +6,10 @@ import keyring from injectable import injectable from pykeepass import PyKeePass, create_database +import logging + +logger = logging.getLogger(__name__) + class KeyRequest: def __init__(self, prompt: str): self.field_name = None @@ -14,7 +18,8 @@ class KeyRequest: self._parse_prompt(prompt) def _parse_prompt(self, prompt: str): - prompt_parts = prompt.split(".") + logger.debug(f"Got prompt: {prompt}") + prompt_parts = prompt.replace('\n', '').split(".") key = None match len(prompt_parts): case 1: @@ -26,6 +31,7 @@ class KeyRequest: case _: key = None if key is None: + logger.warning(f"Prompt {prompt} cannot be parsed") return key_parts = key.split("/") self.path = key_parts[:] if len(key_parts) > 1 else None @@ -57,6 +63,8 @@ class Passwords: request = KeyRequest(k) with self.open() as kp: kp_entry = kp.find_entries(path=request.path, first=True, title=request.entry_name) + if kp_entry is None: + logger.warning(f"No value found for key {k}") output[k] = self._get_field_value(kp_entry, request.field_name) return output From 2acfcc871fbf877dc18140510c8b66e88c56a483 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Thu, 11 Jun 2026 16:50:48 +0200 Subject: [PATCH 35/49] fix: dash in identifiers --- src/karl/services/mo.py | 2 +- tests/files/test1/test.mo.env | 3 +++ tests/test_mo_env.py | 7 ++++--- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/karl/services/mo.py b/src/karl/services/mo.py index c063d62..e0b1388 100644 --- a/src/karl/services/mo.py +++ b/src/karl/services/mo.py @@ -9,7 +9,7 @@ from karl.services import Passwords class ValueTemplate(Template): # Pozwala na kropki i ukośniki w nazwach placeholderów, np. ${user.name/first} - idpattern = r'[_a-zA-Z][_a-zA-Z0-9.\/]*' + idpattern = r'[_a-zA-Z][_a-zA-Z0-9.\/\-]*' @injectable diff --git a/tests/files/test1/test.mo.env b/tests/files/test1/test.mo.env index e3b4cb0..cbdde2f 100644 --- a/tests/files/test1/test.mo.env +++ b/tests/files/test1/test.mo.env @@ -5,3 +5,6 @@ custom=${custom.field} uname=${sample.username} invalid=${double.dot.example} + +#some comment +dashed=${some/dashed} diff --git a/tests/test_mo_env.py b/tests/test_mo_env.py index bbbf0da..b5e26a7 100644 --- a/tests/test_mo_env.py +++ b/tests/test_mo_env.py @@ -1,7 +1,6 @@ from pathlib import Path import pytest -import yaml from karl.services import Passwords from karl.services.mo import Mo @@ -31,11 +30,10 @@ def test1_content(target_path: Path): content = target_path.read_text() assert '${' not in content - props = {} prop_lines = content.split('\n') for line in prop_lines: - if len(line)> 1: + if len(line) > 1 and not line.startswith("#"): splitted = line.split('=') props[splitted[0]] = splitted[1] # props = {prop[0]:prop[1] for prop in content.split('\n')} @@ -54,6 +52,9 @@ class TestEnvParsing: def test_mixed(self, test1_content: dict): assert test1_content['mixed'] == 'nested_username' + def test_dashed(self, test1_content: dict): + assert test1_content['dashed'] == 'dd-value' + def test_custom_field(self, test1_content: dict): assert test1_content['custom'] == 'custom_content' From 56aec8982c2c55d85221f75038ba158c027b7ad4 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 22:33:29 +0200 Subject: [PATCH 36/49] test: path fixes --- src/karl/services/vcs.py | 2 +- tests/test_mo_env.py | 6 ++---- tests/test_woodpecker.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/karl/services/vcs.py b/src/karl/services/vcs.py index 0d70139..50be523 100644 --- a/src/karl/services/vcs.py +++ b/src/karl/services/vcs.py @@ -2,7 +2,7 @@ from git import Repo, Remote from injectable import injectable from karl.config import GitConfig, get_settings -from model.vcs import Head +from karl.model.vcs import Head @injectable(singleton=True) diff --git a/tests/test_mo_env.py b/tests/test_mo_env.py index b5e26a7..5e245b9 100644 --- a/tests/test_mo_env.py +++ b/tests/test_mo_env.py @@ -8,8 +8,7 @@ from karl.services.mo import Mo @pytest.fixture(scope='class') def target_path(): - # p = Path('tests/files/test1/test.env') - p = Path('files/test1/test.env') + p = Path('tests/files/test1/test.env') # posprzątaj przed testem, gdyby plik istniał z poprzednich uruchomień if p.exists(): p.unlink() @@ -22,8 +21,7 @@ def target_path(): @pytest.fixture(scope='class') def test1_content(target_path: Path): mo = Mo(Passwords()) - mo.process(Path('files/test1/test.mo.env').absolute()) - # mo.process(Path('tests/files/test1/test.mo.env').absolute()) + mo.process(Path('tests/files/test1/test.mo.env').absolute()) assert target_path.absolute().exists() diff --git a/tests/test_woodpecker.py b/tests/test_woodpecker.py index 9bb4616..f4104db 100644 --- a/tests/test_woodpecker.py +++ b/tests/test_woodpecker.py @@ -1,4 +1,4 @@ -from core import woodpecker +from karl.core import woodpecker files = [".gitignore", "compose/nginx/docker-compose.yaml", "config/heimdall.kdbx", "files/nginx/cert/fullchain.mo.pem", "files/nginx/cert/key.mo.pem", "files/nginx/conf.d/default.conf", "files/nginx/conf.d/forge.conf", From a5b275fa1255417675fdaaeed87417623ae512d4 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 22:47:44 +0200 Subject: [PATCH 37/49] ci: python pipeline --- .woodpecker/dev.yaml | 2 ++ .woodpecker/latest.yaml | 2 ++ .woodpecker/python.yaml | 16 ++++++++++++++++ 3 files changed, 20 insertions(+) create mode 100644 .woodpecker/python.yaml diff --git a/.woodpecker/dev.yaml b/.woodpecker/dev.yaml index f837559..dbe313d 100644 --- a/.woodpecker/dev.yaml +++ b/.woodpecker/dev.yaml @@ -13,3 +13,5 @@ steps: - event: pull_request evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "ci-ready"' - event: manual + depends_on: + - name: python diff --git a/.woodpecker/latest.yaml b/.woodpecker/latest.yaml index 8309154..1ce29a7 100644 --- a/.woodpecker/latest.yaml +++ b/.woodpecker/latest.yaml @@ -12,3 +12,5 @@ steps: when: - event: [ tag, push, manual ] branch: master + depends_on: + - name: python diff --git a/.woodpecker/python.yaml b/.woodpecker/python.yaml new file mode 100644 index 0000000..fa5c5a3 --- /dev/null +++ b/.woodpecker/python.yaml @@ -0,0 +1,16 @@ +steps: + - name: test + image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine + commands: + - uv sync + - pytest + - | + python src/karl/__init__.py 2>&1 | while IFS= read -r line; do + echo "$line" + if echo "$line" | grep -q "Uvicorn running on"; then + pkill -f "uvicorn" + break + fi + done + when: + - event: push From 56254a4032828b3a2ca5cafb48d3643910e9f2bc Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 22:48:51 +0200 Subject: [PATCH 38/49] ci: python pipeline events fix --- .woodpecker/python.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/python.yaml b/.woodpecker/python.yaml index fa5c5a3..37b22b5 100644 --- a/.woodpecker/python.yaml +++ b/.woodpecker/python.yaml @@ -13,4 +13,4 @@ steps: fi done when: - - event: push + - event: [ push, manual ] From 8baddb8b86321599abdae2c53b3271057e20d7e5 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 22:51:39 +0200 Subject: [PATCH 39/49] ci: dependencies fix --- .woodpecker/dev.yaml | 4 ++-- .woodpecker/latest.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.woodpecker/dev.yaml b/.woodpecker/dev.yaml index dbe313d..5f86119 100644 --- a/.woodpecker/dev.yaml +++ b/.woodpecker/dev.yaml @@ -13,5 +13,5 @@ steps: - event: pull_request evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "ci-ready"' - event: manual - depends_on: - - name: python +depends_on: + - name: python diff --git a/.woodpecker/latest.yaml b/.woodpecker/latest.yaml index 1ce29a7..d43e184 100644 --- a/.woodpecker/latest.yaml +++ b/.woodpecker/latest.yaml @@ -12,5 +12,5 @@ steps: when: - event: [ tag, push, manual ] branch: master - depends_on: - - name: python +depends_on: + - name: python From a1c39df952bade1fe5d7667fa1831255202884e5 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 22:53:39 +0200 Subject: [PATCH 40/49] ci: GCC in python pipeline --- .woodpecker/python.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.woodpecker/python.yaml b/.woodpecker/python.yaml index 37b22b5..2064317 100644 --- a/.woodpecker/python.yaml +++ b/.woodpecker/python.yaml @@ -2,6 +2,7 @@ steps: - name: test image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine commands: + - apk add --no-cache gcc - uv sync - pytest - | From 24e17a862e94a5d39b4e3a9eee27afa286655eea Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 22:55:11 +0200 Subject: [PATCH 41/49] ci: more dependencies --- .woodpecker/python.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/python.yaml b/.woodpecker/python.yaml index 2064317..c550a14 100644 --- a/.woodpecker/python.yaml +++ b/.woodpecker/python.yaml @@ -2,7 +2,7 @@ steps: - name: test image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine commands: - - apk add --no-cache gcc + - apk add --no-cache gcc python3-dev musl-dev linux-headers - uv sync - pytest - | From 86e7d4b8c36cd13d2d542bb5bbc47002e8df25cc Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 22:58:36 +0200 Subject: [PATCH 42/49] ci: pytest exec fix? --- .woodpecker/python.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/python.yaml b/.woodpecker/python.yaml index c550a14..46fe6fa 100644 --- a/.woodpecker/python.yaml +++ b/.woodpecker/python.yaml @@ -4,7 +4,7 @@ steps: commands: - apk add --no-cache gcc python3-dev musl-dev linux-headers - uv sync - - pytest + - python -m pytest - | python src/karl/__init__.py 2>&1 | while IFS= read -r line; do echo "$line" From d61602cb783ad8486ea8865419a2f341f2eb98a6 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 23:02:18 +0200 Subject: [PATCH 43/49] ci: python pipeline splitted --- .woodpecker/python.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.woodpecker/python.yaml b/.woodpecker/python.yaml index 46fe6fa..00fd85e 100644 --- a/.woodpecker/python.yaml +++ b/.woodpecker/python.yaml @@ -1,10 +1,17 @@ steps: - - name: test + - name: prepare image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine commands: - apk add --no-cache gcc python3-dev musl-dev linux-headers - uv sync + - name: test + image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine + commands: + - uv sync --group dev - python -m pytest + - name: test-run + image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine + commands: - | python src/karl/__init__.py 2>&1 | while IFS= read -r line; do echo "$line" @@ -13,5 +20,5 @@ steps: break fi done - when: - - event: [ push, manual ] +when: + - event: [ push, manual ] From a4b1c6a53fc594e30d778c5c236818b2560b8f79 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 23:08:48 +0200 Subject: [PATCH 44/49] ci: pytest exec fix? --- .woodpecker/python.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker/python.yaml b/.woodpecker/python.yaml index 00fd85e..ed0d43d 100644 --- a/.woodpecker/python.yaml +++ b/.woodpecker/python.yaml @@ -8,7 +8,7 @@ steps: image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine commands: - uv sync --group dev - - python -m pytest + - uv run pytest - name: test-run image: ghcr.io/astral-sh/uv:0.9-python3.12-alpine commands: From 4834a984664a46f9815e7ffd8805b037acb76a1e Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 23:18:54 +0200 Subject: [PATCH 45/49] ci: pytest dependencies --- src/karl/services/vcs.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/karl/services/vcs.py b/src/karl/services/vcs.py index 50be523..b7ac62f 100644 --- a/src/karl/services/vcs.py +++ b/src/karl/services/vcs.py @@ -1,4 +1,11 @@ -from git import Repo, Remote +try: + from git import Repo, Remote +except ImportError: + import sys + + sys.stderr.write("gitpython is not installed, skipping git service\n") + sys.stderr.flush() + from injectable import injectable from karl.config import GitConfig, get_settings From 910735def764d2ccc6eaf24c16000ca12a370cb2 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 23:23:51 +0200 Subject: [PATCH 46/49] ci: vcs imports for pytest --- src/karl/services/vcs.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/karl/services/vcs.py b/src/karl/services/vcs.py index b7ac62f..c47a7ce 100644 --- a/src/karl/services/vcs.py +++ b/src/karl/services/vcs.py @@ -3,9 +3,19 @@ try: except ImportError: import sys - sys.stderr.write("gitpython is not installed, skipping git service\n") + sys.stderr.write("Cannot import Git\n") sys.stderr.flush() + + class Repo: + @staticmethod + def clone_from(*args): + raise RuntimeError("git lib not imported") + + + class Remote: + pass + from injectable import injectable from karl.config import GitConfig, get_settings From e904f3a05b8c0a20288e5b5d4d23369cf719e703 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 23:39:22 +0200 Subject: [PATCH 47/49] chore: more verbosity in passwords --- src/karl/services/passwords.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/karl/services/passwords.py b/src/karl/services/passwords.py index 9f662b0..981828e 100644 --- a/src/karl/services/passwords.py +++ b/src/karl/services/passwords.py @@ -45,9 +45,13 @@ class Passwords: import keyring_backend keyring.set_keyring(keyring=keyring_backend.Backend()) + logger.info(f"Reading secret file at {settings.kp.secret}") with open(settings.kp.secret, "r") as fh: keyring.set_password("karl", "kp", fh.read().splitlines()[0]) + logger.info(f"Database file: {settings.kp.file}") self._path = settings.kp.file + if not os.path.exists(self._path): + logger.warning(f"Database file {self._path} does not exist") @contextmanager def open(self, mode: str = "r") -> Generator[PyKeePass | Any, Any, None]: From 612afbc7e0c506cc7a78df07b2ad507db0679f8c Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Sun, 12 Jul 2026 23:49:56 +0200 Subject: [PATCH 48/49] chore: Sample kdbx file --- .gitignore | 3 +-- config/kp.kdbx | Bin 0 -> 2851 bytes 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 config/kp.kdbx diff --git a/.gitignore b/.gitignore index 06c7873..c6460ce 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,7 @@ wheels/ .venv .idea *.iml -uv.lock -**/*.kdbx* +**/*.lock .compose_repository deployment/ diff --git a/config/kp.kdbx b/config/kp.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..1734db9982723bdc0335e69b3d23c0f415ab6442 GIT binary patch literal 2851 zcmV+;3*7Vr*`k_f`%AR|00aO65C8xGF~RcYzi~rQzE}kzYW!ON0|Wp70096100bZa z003Fjzmu=~zLFIFB6h0&PWAx>BS`A`zC1VVf(3y$vG)fM0002eUz4TPPH;*FU(k*K z!c%DrivR!s00BY;0000aRaHqu5C8xG?_+J>j44D*k@u;j1LFz|1pxp607(b{000gE z00000000F60000@2mk;800004000001OWg508j(~00062002S(0000}AOHXWe8Bl{ zy1xAA$zf51WjURW8H?I0)#5(X`2zvW5`A9L9VE~{B000t&IhCcQ zL4?Jb!nf%;&0v7zu&LF|jAVI%Z_=gS0#ism&3R`Hk9ta?K|C6oPfs_(ZjUTz#P`5Z zbz2R_&1wci*PI%C5xzFgF39fg%wP4ZZW3C3*e;Iv_BQZ&59W2-1KV_KQPc)yGNG3# zEwi`~gl4+4B1yq?f0luI2KEN6ok>ik#ETY8^vHtUip8x6VeKB}(GeIU%BkvCv`rzJ zIv}jJlQ%NN)>q|R-)P|=))O~l=zrMKore)a=0=mKOYT&xo#HIHNEMZGO{~-0)oQr3?D>a6Y8_$SdRsZF`Ul*{{ zO3PP42#adbC2Bv;xIQ6h6h5>(TV&4i2NOB#8Cf#?_(0q-zXTdru-4-K@M{-aPt2h( z&dGI#z>rD?O=X@Y>aN61r=MW#;2bppXGlJ_NE}Y1HfMOHTWo90SZX@LnG&*W#gmc4 zlPKG!D4&xgTnc!ao!7~-WIXu`b!VsZyBueH70cV2N&{;+qGcTsk(`#&8Ial8}*_AzAP(SZTt^PH(buy0PC*M^Cu zuD|_y!rHxjAiOi}tuF+(I{`U_s)*Z>uUXhXwq|3%^;hSDGwOX&z7ftY#u(tQzEX4L z={<(>;a>yd?rtgy5F0%@;Vx~_5MPS|d~)h3i4O4m`tYfcAYG>^Kj@)!ELI@nHe!F> zL>cZxFq{$jjp4oNF#8zj->JhAKFle$65MtW*D=4z_vs=%G|w%-H^O16R^VQygAq|g zfyL|}ppqGM#bCV~Z~0X(oMSYf=q6`9@6^U8MoGT=Uj!OZ(_+VNS}$qPiumG0w_n5>hSM3&5Tsm6g-3Um2RDaFF-rSae>@{@M>X8;Grk)gxS~nD8lg-nEL=jpMsDg`S? zMfNpvvI)$>reAFC!51`BxGbyFc1eBXE5X^~U6^}3JPd`{k*wlvDDqCy)|@t4VDMcy8Z#^1C4L5)bosMD~26gdLwG?D7Op0zELpc;-wK!{1E@? z>5{dG@gUl7$riPN%~-ug(}ICnHW`-VC~~Y$c*RWI^0c3{VyLS@i0Ca$uIXs#HIRMZ zA~YF+Qy}DDv+-$^hRx&kdQk(j`8}MR=IFO6+{|58T&A1ZM#ghi6%x3hkvqg*y*_9c z@}r>X-$%;{jtwu%RZJBHU9`>3xFc25n9x|zcXPaFT0^Vt=!ahBN4_t$; zn%ce}kEkF#y}YKtv9DQ3R%KDeN!zS2g=-tXem-PP_`8)_mb3Q>PC%&jq37@9PZ zXg|h<@!@@V*L}xB(tuMd#qTo0he=3qhZ?#x7idE)+VjDztlClDFLuGtxm)Sv|9;18 zfhtYa>8XtAZb#!$h(UlK;?A85*s4tof7Bhu7n$k8O~Fly54*CI%Fkdd*J^&0aMs;u z`G!1?+Qc1ZJj%ElGqdSmL}bghfo&W}QoR7;k=4pcN#6o_@npInyWJ$2%rCkNEQo&*KmT3~sz{dJ z3jx^;eLLec zkhj`SvbTTE)+B(D%gAX4m>%7b-5uhZH{C{MNjUlQ4DPjP$!M4rl*c|JMD0L9S!3J= zte)p3;aIog*mQv5ckbO4HZ5aFw{v_T2~HiUI5l z8S_)k)=PRL=*84~L;wy+$mH4H!w|FpDB_G-nCc^mD|=O^{|%2CXX#d=`smSFb5Kcf zi6zcR{DL-HT;I_75c|mOQoXS4+LXnY%>d^`{4-bq+uf)%z0hs|ZGTdjQ&<||4c7lF zicTGEy0-h!oSHE%tgqI-GhG+ww-^%@H*v*h)PCs*KSCw%z$EmV78)B7a#dOMVm{NF zo*I|_?N8x`=-CcJ%9{B$vr{P_(*v;)tYb0l-l}?m8ZR*U_Z#)qH;6|l@oL{->p5Lf ze5(`4PlM-vrAte}JgpX3)XA!Duz0$RP)ZEUn_)nt3uE6=U%vX5IV&F=m+Ta`S%3YR zPJ0@Wi4Ax{4d#{ogLFk0DQ6c`mGJt6J)nzn-Z?aeGx;%tXD?SPl#o=zE}tt5Dm{&b zkM`^YzolAx$ZlYNRs?w6QOB4#=CBRvE1u8gCD`d1m= Date: Sun, 12 Jul 2026 23:57:07 +0200 Subject: [PATCH 49/49] test: password file read fix? --- src/karl/services/passwords.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/karl/services/passwords.py b/src/karl/services/passwords.py index 981828e..aafcf52 100644 --- a/src/karl/services/passwords.py +++ b/src/karl/services/passwords.py @@ -47,7 +47,7 @@ class Passwords: logger.info(f"Reading secret file at {settings.kp.secret}") with open(settings.kp.secret, "r") as fh: - keyring.set_password("karl", "kp", fh.read().splitlines()[0]) + keyring.set_password("karl", "kp", fh.read().splitlines()[0].replace('\r', '').strip()) logger.info(f"Database file: {settings.kp.file}") self._path = settings.kp.file if not os.path.exists(self._path):