Compare commits
50 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01d6f1f049 | |||
| 612afbc7e0 | |||
| e904f3a05b | |||
| 910735def7 | |||
| 4834a98466 | |||
| a4b1c6a53f | |||
| d61602cb78 | |||
| 86e7d4b8c3 | |||
| 24e17a862e | |||
| a1c39df952 | |||
| 8baddb8b86 | |||
| 56254a4032 | |||
| a5b275fa12 | |||
| 56aec8982c | |||
| 2acfcc871f | |||
| 3ec1942b4e | |||
| 6f14bd7db1 | |||
| c7f52e8b04 | |||
| 0ced5212e9 | |||
| d220ac1963 | |||
| bb258efa39 | |||
| 667d7f476d | |||
| ce77c0cf7d | |||
| 0445a4cef8 | |||
| 976d739b18 | |||
| ce74860ca3 | |||
| 3224f8cfca | |||
| bc592a2c6c | |||
| 64726c56f3 | |||
| 88aa334846 | |||
| d5f94e26c6 | |||
| 53bbf001a0 | |||
| 239e51f0f8 | |||
| 7234560d16 | |||
| 8da4bc4aaf | |||
| 7fa243a8d4 | |||
| 9a8bc44e09 | |||
| 4b8ea49c0c | |||
| 08972266db | |||
| da1292a45f | |||
| a50df466d0 | |||
| 59c5844f5b | |||
| cd62e256cf | |||
| dcba20ee94 | |||
| 05931329a3 | |||
| 6ee6341a5e | |||
| 8f4dc486ac | |||
| 604381348a | |||
| 0e19df5c3e | |||
| e0bc04770b |
21 changed files with 269 additions and 43 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -10,10 +10,9 @@ wheels/
|
||||||
.venv
|
.venv
|
||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
uv.lock
|
**/*.lock
|
||||||
**/*.kdbx*
|
|
||||||
.compose_repository
|
.compose_repository
|
||||||
|
|
||||||
__pycache__/
|
deployment/
|
||||||
**/dist/
|
**/dist/
|
||||||
**/*.log
|
**/*.log
|
||||||
|
|
|
||||||
|
|
@ -13,3 +13,5 @@ steps:
|
||||||
- event: pull_request
|
- event: pull_request
|
||||||
evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "ci-ready"'
|
evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "ci-ready"'
|
||||||
- event: manual
|
- event: manual
|
||||||
|
depends_on:
|
||||||
|
- name: python
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,5 @@ steps:
|
||||||
when:
|
when:
|
||||||
- event: [ tag, push, manual ]
|
- event: [ tag, push, manual ]
|
||||||
branch: master
|
branch: master
|
||||||
|
depends_on:
|
||||||
|
- name: python
|
||||||
|
|
|
||||||
24
.woodpecker/python.yaml
Normal file
24
.woodpecker/python.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
steps:
|
||||||
|
- 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
|
||||||
|
- uv run 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"
|
||||||
|
if echo "$line" | grep -q "Uvicorn running on"; then
|
||||||
|
pkill -f "uvicorn"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
when:
|
||||||
|
- event: [ push, manual ]
|
||||||
BIN
config/kp.kdbx
Normal file
BIN
config/kp.kdbx
Normal file
Binary file not shown.
|
|
@ -8,8 +8,7 @@ from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
from karl.api.models import Request
|
from karl.api.models import Request
|
||||||
from karl.core.injects import AutowireSupport
|
from karl.core.injects import AutowireSupport
|
||||||
from karl.core.woodpecker import Woodpecker
|
from karl.model.webhook import WoodpeckerEvent, ReloadEvent
|
||||||
from karl.model.webhook import WoodpeckerEvent
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -21,7 +20,6 @@ async def root():
|
||||||
|
|
||||||
@cbv(router)
|
@cbv(router)
|
||||||
class APIv1:
|
class APIv1:
|
||||||
woodpecker: Woodpecker = Depends(AutowireSupport.woodpecker)
|
|
||||||
bus: EventBus = Depends(AutowireSupport.bus)
|
bus: EventBus = Depends(AutowireSupport.bus)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|
@ -39,3 +37,10 @@ class APIv1:
|
||||||
async def ci(self, request: Request):
|
async def ci(self, request: Request):
|
||||||
await self.bus.dispatch(mapper.map(request))
|
await self.bus.dispatch(mapper.map(request))
|
||||||
return Response(status_code=201)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,8 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
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")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from bubus import EventBus
|
from bubus import EventBus
|
||||||
from injectable import inject
|
from injectable import inject
|
||||||
|
|
||||||
|
from karl.core.reload import ReloadService
|
||||||
from karl.core.woodpecker import Woodpecker
|
from karl.core.woodpecker import Woodpecker
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -10,6 +11,10 @@ class AutowireSupport:
|
||||||
def woodpecker():
|
def woodpecker():
|
||||||
return inject(Woodpecker)
|
return inject(Woodpecker)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def reload():
|
||||||
|
return inject(ReloadService)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def bus():
|
def bus():
|
||||||
return inject(EventBus)
|
return inject(EventBus)
|
||||||
|
|
|
||||||
48
src/karl/core/reload.py
Normal file
48
src/karl/core/reload.py
Normal 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)
|
||||||
|
|
@ -2,7 +2,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import RLock, Thread
|
from threading import RLock, Thread, current_thread
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from bubus import EventBus, BaseEvent
|
from bubus import EventBus, BaseEvent
|
||||||
|
|
@ -32,6 +32,7 @@ class WoodpeckerRunner(Thread):
|
||||||
self._bus = bus
|
self._bus = bus
|
||||||
self._event: WoodpeckerEvent | None = None
|
self._event: WoodpeckerEvent | None = None
|
||||||
self._root = get_settings().git.path
|
self._root = get_settings().git.path
|
||||||
|
self.thread_logger = logging.getLogger(current_thread().name)
|
||||||
|
|
||||||
def process_event(self, event: WoodpeckerEvent):
|
def process_event(self, event: WoodpeckerEvent):
|
||||||
self._event = event
|
self._event = event
|
||||||
|
|
@ -42,18 +43,26 @@ class WoodpeckerRunner(Thread):
|
||||||
await self._bus.dispatch(r)
|
await self._bus.dispatch(r)
|
||||||
|
|
||||||
result = RunnerResult()
|
result = RunnerResult()
|
||||||
|
self.thread_logger.info("Runner started")
|
||||||
try:
|
try:
|
||||||
service = self.get_service(self._event.files)
|
services = self.get_service(self._event.files)
|
||||||
if service is None:
|
if len(services) == 0:
|
||||||
logger.info("No service found.")
|
self.thread_logger.info("No service found.")
|
||||||
result.success = True
|
result.success = True
|
||||||
else:
|
else:
|
||||||
service_path = f"{self._root}/compose/{service}/docker-compose.yml"
|
|
||||||
self._git.checkout(self._event.commit)
|
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"
|
||||||
for file in self._event.files:
|
for file in self._event.files:
|
||||||
if file.__contains__('.mo.'):
|
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)
|
||||||
|
self.thread_logger.info(f"{len(paths)} paths set")
|
||||||
|
for service_path in paths:
|
||||||
self._docker.reload(Path(service_path).absolute())
|
self._docker.reload(Path(service_path).absolute())
|
||||||
|
self.thread_logger.info("Reloaded all services")
|
||||||
result.success = True
|
result.success = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
result.throwable = e
|
result.throwable = e
|
||||||
|
|
@ -61,20 +70,14 @@ class WoodpeckerRunner(Thread):
|
||||||
asyncio.run(dispatch(result))
|
asyncio.run(dispatch(result))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_service(files: list[str]) -> str | None:
|
def get_service(files: list[str]) -> list[str]:
|
||||||
supported_files = []
|
supported_files = []
|
||||||
for f in files:
|
for f in files:
|
||||||
f_parts = f.split("/")
|
f_parts = f.split("/")
|
||||||
if f_parts[0] in ["compose", "files"]:
|
if f_parts[0] in ["compose", "files"]:
|
||||||
supported_files.append(f_parts[1])
|
supported_files.append(f_parts[1])
|
||||||
match len(set(supported_files)):
|
# TODO: check service priorities (NGINX last)
|
||||||
case 0:
|
return list(dict.fromkeys(supported_files))
|
||||||
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.")
|
|
||||||
|
|
||||||
|
|
||||||
@injectable(singleton=True)
|
@injectable(singleton=True)
|
||||||
|
|
@ -94,7 +97,7 @@ class Woodpecker:
|
||||||
logger.info("Woodpecker initialized.")
|
logger.info("Woodpecker initialized.")
|
||||||
|
|
||||||
async def on_ci_event(self, event: WoodpeckerEvent):
|
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:
|
with self._lock:
|
||||||
logger.debug("Lock acquired [on-ci-event]")
|
logger.debug("Lock acquired [on-ci-event]")
|
||||||
if len(self._pending) > 0 or self._runner is not None:
|
if len(self._pending) > 0 or self._runner is not None:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import logging
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from injectable import load_injection_container
|
from injectable import load_injection_container
|
||||||
|
|
||||||
|
from core.injects import AutowireSupport
|
||||||
from karl.config import get_settings
|
from karl.config import get_settings
|
||||||
from karl.util.logging import HandlerFactory
|
from karl.util.logging import HandlerFactory
|
||||||
|
|
||||||
|
|
@ -15,7 +16,7 @@ class KarlApplication:
|
||||||
_app = FastAPI(title="Karl", version="0.1.0")
|
_app = FastAPI(title="Karl", version="0.1.0")
|
||||||
self._set_middlewares(_app)
|
self._set_middlewares(_app)
|
||||||
self._set_routes(_app)
|
self._set_routes(_app)
|
||||||
self._set_events(_app)
|
self._init_services(_app)
|
||||||
|
|
||||||
self._app = _app
|
self._app = _app
|
||||||
|
|
||||||
|
|
@ -55,8 +56,9 @@ class KarlApplication:
|
||||||
app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
|
app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _set_events(self, app: FastAPI):
|
def _init_services(self, app: FastAPI):
|
||||||
pass
|
AutowireSupport.reload()
|
||||||
|
AutowireSupport.woodpecker()
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
|
|
|
||||||
7
src/karl/model/vcs.py
Normal file
7
src/karl/model/vcs.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Head:
|
||||||
|
sha: str
|
||||||
|
branch: str
|
||||||
|
|
@ -10,3 +10,6 @@ class WoodpeckerEvent(BaseEvent):
|
||||||
message: str
|
message: str
|
||||||
started: int
|
started: int
|
||||||
files: List[str]
|
files: List[str]
|
||||||
|
|
||||||
|
class ReloadEvent(BaseEvent):
|
||||||
|
service: str
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ class DockerService:
|
||||||
|
|
||||||
def reload(self, compose_path: Path):
|
def reload(self, compose_path: Path):
|
||||||
os.chdir(compose_path.parent)
|
os.chdir(compose_path.parent)
|
||||||
self._client.compose.ps()
|
# TODO: Check if container needs to be reloaded from scratch
|
||||||
self._client.compose.down(remove_orphans=True)
|
# if len(self._client.compose.ps()) > 0:
|
||||||
self._client.compose.up()
|
# self._client.compose.restart()
|
||||||
|
# return
|
||||||
|
self._client.compose.up(detach=True)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from karl.services import Passwords
|
||||||
|
|
||||||
class ValueTemplate(Template):
|
class ValueTemplate(Template):
|
||||||
# Pozwala na kropki i ukośniki w nazwach placeholderów, np. ${user.name/first}
|
# 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
|
@injectable
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ import keyring
|
||||||
from injectable import injectable
|
from injectable import injectable
|
||||||
from pykeepass import PyKeePass, create_database
|
from pykeepass import PyKeePass, create_database
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class KeyRequest:
|
class KeyRequest:
|
||||||
def __init__(self, prompt: str):
|
def __init__(self, prompt: str):
|
||||||
self.field_name = None
|
self.field_name = None
|
||||||
|
|
@ -14,7 +18,8 @@ class KeyRequest:
|
||||||
self._parse_prompt(prompt)
|
self._parse_prompt(prompt)
|
||||||
|
|
||||||
def _parse_prompt(self, prompt: str):
|
def _parse_prompt(self, prompt: str):
|
||||||
prompt_parts = prompt.split(".")
|
logger.debug(f"Got prompt: {prompt}")
|
||||||
|
prompt_parts = prompt.replace('\n', '').split(".")
|
||||||
key = None
|
key = None
|
||||||
match len(prompt_parts):
|
match len(prompt_parts):
|
||||||
case 1:
|
case 1:
|
||||||
|
|
@ -26,6 +31,7 @@ class KeyRequest:
|
||||||
case _:
|
case _:
|
||||||
key = None
|
key = None
|
||||||
if key is None:
|
if key is None:
|
||||||
|
logger.warning(f"Prompt {prompt} cannot be parsed")
|
||||||
return
|
return
|
||||||
key_parts = key.split("/")
|
key_parts = key.split("/")
|
||||||
self.path = key_parts[:] if len(key_parts) > 1 else None
|
self.path = key_parts[:] if len(key_parts) > 1 else None
|
||||||
|
|
@ -39,9 +45,13 @@ class Passwords:
|
||||||
import keyring_backend
|
import keyring_backend
|
||||||
keyring.set_keyring(keyring=keyring_backend.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:
|
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
|
self._path = settings.kp.file
|
||||||
|
if not os.path.exists(self._path):
|
||||||
|
logger.warning(f"Database file {self._path} does not exist")
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def open(self, mode: str = "r") -> Generator[PyKeePass | Any, Any, None]:
|
def open(self, mode: str = "r") -> Generator[PyKeePass | Any, Any, None]:
|
||||||
|
|
@ -57,6 +67,8 @@ class Passwords:
|
||||||
request = KeyRequest(k)
|
request = KeyRequest(k)
|
||||||
with self.open() as kp:
|
with self.open() as kp:
|
||||||
kp_entry = kp.find_entries(path=request.path, first=True, title=request.entry_name)
|
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)
|
output[k] = self._get_field_value(kp_entry, request.field_name)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,25 @@
|
||||||
|
try:
|
||||||
from git import Repo, Remote
|
from git import Repo, Remote
|
||||||
|
except ImportError:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
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 injectable import injectable
|
||||||
|
|
||||||
from karl.config import GitConfig, get_settings
|
from karl.config import GitConfig, get_settings
|
||||||
|
from karl.model.vcs import Head
|
||||||
|
|
||||||
|
|
||||||
@injectable(singleton=True)
|
@injectable(singleton=True)
|
||||||
|
|
@ -27,3 +45,11 @@ class GitService:
|
||||||
def checkout(self, sha: str):
|
def checkout(self, sha: str):
|
||||||
self._origin.fetch()
|
self._origin.fetch()
|
||||||
self._repo.git.checkout(sha)
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,14 +32,18 @@ class NamingCache:
|
||||||
|
|
||||||
|
|
||||||
class ApplicationFormatter(Formatter):
|
class ApplicationFormatter(Formatter):
|
||||||
def __init__(self, handler_prefix: str = ''):
|
def __init__(self, handler_prefix: str = '', include_timestamp: bool = True):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._logger_names = NamingCache()
|
self._logger_names = NamingCache()
|
||||||
self._handler_prefix = handler_prefix
|
self._handler_prefix = handler_prefix
|
||||||
|
self._include_timestamp = include_timestamp
|
||||||
|
|
||||||
def format(self, record):
|
def format(self, record):
|
||||||
from datetime import datetime
|
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)
|
level = record.levelname.replace('WARNING', 'WARN').rjust(5)
|
||||||
thread_name = record.threadName.replace(' (', '#').replace(')', '').rjust(16)[-16:] # TODO: NamingCache?
|
thread_name = record.threadName.replace(' (', '#').replace(')', '').rjust(16)[-16:] # TODO: NamingCache?
|
||||||
logger_name = self._logger_names[f"{self._handler_prefix}{record.name}"]
|
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 create(target: Target, handler_prefix: str = '', file_path: Path = None) -> List[Handler]:
|
||||||
def console_handler(prefix: str = ''):
|
def console_handler(prefix: str = ''):
|
||||||
handler = StreamHandler()
|
handler = StreamHandler()
|
||||||
handler.setFormatter(ApplicationFormatter(prefix))
|
handler.setFormatter(ApplicationFormatter(prefix, include_timestamp=False))
|
||||||
handler.setLevel('INFO')
|
handler.setLevel('INFO')
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
|
|
|
||||||
10
tests/files/test1/test.mo.env
Normal file
10
tests/files/test1/test.mo.env
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
value=${sample}
|
||||||
|
nested=${some/nested/value}
|
||||||
|
mixed=${some/nested/value.username}
|
||||||
|
custom=${custom.field}
|
||||||
|
|
||||||
|
uname=${sample.username}
|
||||||
|
invalid=${double.dot.example}
|
||||||
|
|
||||||
|
#some comment
|
||||||
|
dashed=${some/dashed}
|
||||||
63
tests/test_mo_env.py
Normal file
63
tests/test_mo_env.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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')
|
||||||
|
# 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('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 and not line.startswith("#"):
|
||||||
|
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_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'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
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 = [".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",
|
"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/conf.d/mqtt.conf", "files/nginx/conf.d/nextcloud.conf", "files/nginx/conf.d/zitadel.conf",
|
||||||
"files/nginx/nginx.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)
|
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']
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue