Merge pull request 'CI: docker image & woodpecker' (#13) from dockerify into develop

Reviewed-on: https://hattori.ztsh.eu/iac/karl/pulls/13
This commit is contained in:
Piotr Dec 2025-12-10 21:28:21 +01:00
commit 846f22b8e1
40 changed files with 220 additions and 41 deletions

62
.dockerignore Normal file
View file

@ -0,0 +1,62 @@
# JB Toolkit
.idea
# Git
.git
.gitignore
.gitattributes
# CI
.woodpecker/
# Virtual env
.venv/
# Docker
docker-compose.yml
Dockerfile
.docker
.dockerignore
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
target/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# Logs
**/*.log
# Documentation
docs/_build/
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# Project specific
.compose_repository/
config/
tests/

View file

@ -8,3 +8,6 @@ trim_trailing_whitespace = true
[*.py]
indent_size = 2
[*.yaml]
indent_size = 2

10
.gitignore vendored
View file

@ -1,3 +1,13 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.idea
*.iml
uv.lock

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

14
.woodpecker/dev.yaml Normal file
View file

@ -0,0 +1,14 @@
steps:
- name: build
image: woodpeckerci/plugin-docker-buildx:5.2.2
settings:
platforms: linux/amd64
repo: hattori.ztsh.eu/iac/karl
registry: hattori.ztsh.eu
tags: dev-${CI_PIPELINE_NUMBER}
username: stawros
password:
from_secret: hattori-packages
when:
- event: pull_request
evaluate: 'CI_COMMIT_PULL_REQUEST_LABELS contains "ci-ready"'

14
.woodpecker/latest.yaml Normal file
View file

@ -0,0 +1,14 @@
steps:
- name: build
image: woodpeckerci/plugin-docker-buildx:5.2.2
settings:
platforms: linux/amd64
repo: hattori.ztsh.eu/iac/karl
registry: hattori.ztsh.eu
tags: latest
username: stawros
password:
from_secret: hattori-packages
when:
- event: [ tag, push, manual ]
branch: master

33
Dockerfile Normal file
View file

@ -0,0 +1,33 @@
FROM ghcr.io/astral-sh/uv:0.9-python3.12-alpine AS builder
WORKDIR /app
RUN apk update \
&& apk add gcc python3-dev musl-dev linux-headers
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --no-install-workspace
ADD . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
FROM python:3.12-alpine3.22
RUN apk update --no-cache \
&& apk add --no-cache git
COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/src /app/src
ENV PYTHONPATH="/app"
EXPOSE 8081
WORKDIR /app
ENTRYPOINT ["/app/.venv/bin/python"]
CMD ["/app/src/karl/__init__.py"]

View file

@ -1,6 +1,6 @@
logging:
level: "TRACE"
path: "logs/karl.log"
path: "../../logs/karl.log"
app:
host: "127.0.0.1"
port: 8081

22
docker-compose.yaml Normal file
View file

@ -0,0 +1,22 @@
services:
karl:
image: hattori.ztsh.eu/iac/karl:dev-36
container_name: karl
restart: always
networks:
- web
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/run/docker.sock:/var/run/docker.sock
- ./data/config:/app/config
- ./data/logs:/app/logs
secrets:
- kp_secret
secrets:
kp_secret:
file: ./data/kp_secret
networks:
web:
external: true

View file

@ -5,6 +5,7 @@ description = "Because name 'Jenkins' was already taken. Greatest composer ever.
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "Piotr Dec" }]
dependencies = [
"fastapi>=0.119.0",
"uvicorn[standard]>=0.30.0",
@ -18,6 +19,7 @@ dependencies = [
"py-automapper>=2.2.0",
"fastapi-utils>=0.8.0",
"keyring>=25.6.0",
"keyring-backend>=0.1.0",
]
[dependency-groups]
@ -31,11 +33,11 @@ dev = [
]
[project.scripts]
app = "app.main:run"
karl = "karl.main:run"
[tool.uv]
# uv automatycznie wykrywa dependencies z [project]
# Możesz dodać tu własne ustawienia cache/mirrors, jeśli potrzebne.
[build-system]
requires = ["uv_build>=0.8.23,<0.9.0"]
build-backend = "uv_build"
[tool.ruff]
line-length = 120

19
src/karl/__init__.py Normal file
View file

@ -0,0 +1,19 @@
from config import get_settings
def main() -> None:
import uvicorn
settings = get_settings()
uvicorn.run(
"karl.main:run",
factory=True,
host=settings.app.host,
port=settings.app.port,
reload=settings.app.reload,
log_config=None,
)
if __name__ == "__main__":
main()

View file

@ -3,10 +3,10 @@ from fastapi import APIRouter, Depends
from fastapi_utils.cbv import cbv
from starlette.responses import JSONResponse, Response
from app.api.models import Request
from app.core.injects import AutowireSupport
from app.core.woodpecker import Woodpecker
from app.model.webhook import WoodpeckerEvent
from api.models import Request
from core.injects import AutowireSupport
from core.woodpecker import Woodpecker
from model.webhook import WoodpeckerEvent
router = APIRouter()

View file

@ -8,13 +8,13 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class LoggingConfig(BaseModel):
level: str = "INFO"
path: Path = Path("logs/karl.log")
path: Path | None = None
class AppConfig(BaseModel):
host: str = "127.0.0.1"
port: int = 8081
reload: bool = True
reload: bool = False
class GitConfig(BaseModel):
@ -43,6 +43,9 @@ class Settings(BaseSettings):
if p.exists():
with p.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
else:
import sys, os
sys.stderr.write(f"Warning: Config file {os.path.realpath(p)} not found.\n")
return cls(**data)

View file

@ -1,6 +1,6 @@
from injectable import inject
from app.core.woodpecker import Woodpecker
from core.woodpecker import Woodpecker
class AutowireSupport:

View file

@ -6,10 +6,10 @@ from typing import Annotated
from injectable import injectable, Autowired, autowired
from app.config import get_settings
from app.model.webhook import WoodpeckerEvent
from app.services import GitService, DockerService
from app.services.mo import Mo
from config import get_settings
from model.webhook import WoodpeckerEvent
from services import GitService, DockerService
from services.mo import Mo
logger = logging.getLogger(__name__)

View file

@ -3,8 +3,8 @@ import logging
from fastapi import FastAPI
from injectable import load_injection_container
from app.config import get_settings
from app.util.logging import HandlerFactory
from config import get_settings
from util.logging import HandlerFactory
class KarlApplication:
@ -45,13 +45,13 @@ class KarlApplication:
logging_logger.propagate = False
def _set_middlewares(self, app: FastAPI):
from app.web.middlewares import LoggingMiddleware
from web.middlewares import LoggingMiddleware
app.add_middleware(LoggingMiddleware)
def _set_routes(self, app: FastAPI):
from app.core.router import router as core_router
from core.router import router as core_router
app.include_router(core_router)
from app.api.v1 import router as api_v1_router
from api.v1 import router as api_v1_router
app.include_router(api_v1_router, prefix="/api/v1", tags=["v1"])
pass
@ -61,17 +61,3 @@ class KarlApplication:
def run():
return KarlApplication()
if __name__ == "__main__":
import uvicorn
settings = get_settings()
uvicorn.run(
"app.main:run",
factory=True,
host=settings.app.host,
port=settings.app.port,
reload=settings.app.reload,
log_config=None,
)

View file

@ -5,7 +5,7 @@ import docker
from docker.models.containers import Container
from injectable import injectable
from app.model.containers import Tree, Compose, SimpleContainer
from model.containers import Tree, Compose, SimpleContainer
logger = logging.getLogger(__name__)
@ -36,6 +36,7 @@ class DockerService:
return self._tree
def reload(self, compose_path: Path):
# TODO: Won't work in docker container
cmd = ["sudo", "docker", "compose", "-f", str(compose_path), "up", "-d"]
import subprocess
try:

View file

@ -4,7 +4,7 @@ from typing import Annotated
from injectable import injectable, autowired, Autowired
from app.services import Passwords
from services import Passwords
class ValueTemplate(Template):

View file

@ -34,8 +34,10 @@ class KeyRequest:
@injectable(singleton=True)
class Passwords:
def __init__(self):
from app.config import get_settings
from config import get_settings
settings = get_settings()
import keyring_backend
keyring.set_keyring(keyring=keyring_backend.Backend())
with open(settings.kp.secret, "r") as fh:
keyring.set_password("karl", "kp", fh.read().splitlines()[0])

View file

@ -1,7 +1,7 @@
from git import Repo, Remote
from injectable import injectable
from app.config import GitConfig, get_settings
from config import GitConfig, get_settings
@injectable(singleton=True)

View file

@ -67,6 +67,12 @@ class HandlerFactory:
return handler
def file_handler(prefix: str = ''):
if not file_path:
import sys
sys.stderr.write("No file path specified, skipping file logging...\n")
return None
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.touch(exist_ok=True)
handler = TimedRotatingFileHandler(file_path, when='midnight', backupCount=30)
handler.setFormatter(ApplicationFormatter(prefix))
handler.setLevel('TRACE')
@ -83,4 +89,5 @@ class HandlerFactory:
handlers.append(console_handler(handler_prefix))
case _:
raise ValueError(f"Unknown target: {target}")
handlers = [h for h in handlers if h is not None]
return handlers

0
src/karl/web/__init__.py Normal file
View file

View file

@ -3,8 +3,8 @@ from pathlib import Path
import pytest
import yaml
from app.services import Passwords
from app.services.mo import Mo
from karl.services import Passwords
from karl.services.mo import Mo
@pytest.fixture(scope='class')