Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
bb258efa39
fix: mo url typo 2026-05-08 06:03:41 +02:00
667d7f476d
feat!: Multiple services support (beta?) 2026-05-08 04:10:31 +02:00
ce77c0cf7d
fix: Changed docker#reload to restart and not remove containers
* Possibly fixes issues with fail2ban
2026-05-08 04:00:07 +02:00
0445a4cef8
fix: Disable timestamp in console handler -> provided by journalctl 2026-05-08 03:53:14 +02:00
976d739b18
fix: Detach composes 2026-04-14 03:46:05 +02:00
ce74860ca3
fix: Another path fix 2026-04-14 03:44:13 +02:00
3224f8cfca
fix: Absolute path fix 2026-04-14 03:41:44 +02:00
bc592a2c6c
fix: Just another verbosity improvement 2026-04-14 03:38:18 +02:00
64726c56f3
fix: Just another verbosity improvement 2026-04-14 03:37:16 +02:00
88aa334846
fix: repr WoodpeckerEvent?!?! 2026-04-14 03:32:50 +02:00
d5f94e26c6
fix: repr WoodpeckerEvent??!! 2026-04-14 03:29:06 +02:00
53bbf001a0
fix: repr WoodpeckerEvent?? 2026-04-14 03:27:44 +02:00
239e51f0f8
fix: repr WoodpeckerEvent?! 2026-04-14 03:26:17 +02:00
7234560d16
fix: repr WoodpeckerEvent! 2026-04-14 03:24:04 +02:00
8da4bc4aaf
fix: repr WoodpeckerEvent? 2026-04-14 03:21:49 +02:00
7fa243a8d4
fix: repr WoodpeckerEvent 2026-04-14 03:19:07 +02:00
9a8bc44e09
fix: verbosity in ReloadService 2026-04-14 03:16:26 +02:00
4b8ea49c0c
fix: force .mo. reload in reload 2026-04-14 03:12:31 +02:00
08972266db Merge pull request 'feat: reload' (#32) from reload into develop
Reviewed-on: https://hattori.ztsh.eu/iac/karl/pulls/32
2026-04-14 02:50:29 +02:00
da1292a45f
fix: detached head check 2026-04-14 02:46:15 +02:00
a50df466d0
fix: moved services initialization to main 2026-04-14 02:27:31 +02:00
59c5844f5b
fix: api annotations again 2026-04-14 02:12:22 +02:00
cd62e256cf
fix: api annotations 2026-04-14 02:11:12 +02:00
dcba20ee94
fix: fixed import in injects 2026-04-14 02:07:16 +02:00
05931329a3
fix: moved ReloadService to core, fixed import 2026-04-14 02:06:23 +02:00
6ee6341a5e
fix: annotations 2026-04-14 02:03:31 +02:00
8f4dc486ac
fix: inject ReloadService to API class 2026-04-14 01:54:43 +02:00
604381348a
fix: logging in reload 2026-04-14 01:48:42 +02:00
0e19df5c3e
fix: settings location 2026-04-14 01:40:34 +02:00
e0bc04770b
feat: manual reload 2026-04-14 01:27:56 +02:00
13 changed files with 125 additions and 33 deletions

2
.gitignore vendored
View file

@ -14,6 +14,6 @@ uv.lock
**/*.kdbx*
.compose_repository
__pycache__/
deployment/
**/dist/
**/*.log

View file

@ -8,8 +8,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__)
@ -21,7 +20,6 @@ async def root():
@cbv(router)
class APIv1:
woodpecker: Woodpecker = Depends(AutowireSupport.woodpecker)
bus: EventBus = Depends(AutowireSupport.bus)
def __init__(self):
@ -39,3 +37,10 @@ class APIv1:
async def ci(self, request: Request):
await self.bus.dispatch(mapper.map(request))
return Response(status_code=201)
@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))
return Response(status_code=201)

View file

@ -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")

View file

@ -1,6 +1,7 @@
from bubus import EventBus
from injectable import inject
from karl.core.reload import ReloadService
from karl.core.woodpecker import Woodpecker
@ -10,6 +11,10 @@ class AutowireSupport:
def woodpecker():
return inject(Woodpecker)
@staticmethod
def reload():
return inject(ReloadService)
@staticmethod
def bus():
return inject(EventBus)

48
src/karl/core/reload.py Normal file
View file

@ -0,0 +1,48 @@
import logging
from datetime import datetime
from pathlib import Path
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
logger = logging.getLogger(__name__)
@injectable(singleton=True)
class ReloadService:
@autowired
def __init__(self, bus: Annotated[EventBus, Autowired]):
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(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())])}")
mos = list(file_path.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).replace(str(self.root_path), '') for pp in mos]
)
logger.debug(f"Sending <WoodpeckerEvent commit={we.commit} ref={we.ref} message={we.message} started={we.started} files={we.files}")
await self._bus.dispatch(we)
except Exception as e:
logger.error(f"Reload error: {e}", exc_info=True)

View file

@ -43,16 +43,20 @@ 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)
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())
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:
@ -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)

View file

@ -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():

7
src/karl/model/vcs.py Normal file
View file

@ -0,0 +1,7 @@
from dataclasses import dataclass
@dataclass
class Head:
sha: str
branch: str

View file

@ -10,3 +10,6 @@ class WoodpeckerEvent(BaseEvent):
message: str
started: int
files: List[str]
class ReloadEvent(BaseEvent):
service: str

View file

@ -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)
self._client.compose.up()
# 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)

View file

@ -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,11 @@ class GitService:
def checkout(self, sha: str):
self._origin.fetch()
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
)

View file

@ -32,14 +32,18 @@ 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}"]
@ -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

View file

@ -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']