diff --git a/app/config/settings.py b/app/config/settings.py index cf95017..c831957 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -1,18 +1,33 @@ from functools import lru_cache - -from pydantic_settings import BaseSettings, SettingsConfigDict -from pydantic import BaseModel from pathlib import Path + import yaml +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict + class AppConfig(BaseModel): host: str = "127.0.0.1" port: int = 8000 reload: bool = True + +class GitConfig(BaseModel): + directory: str = "/opt/repo/sample" + branch: str = "master" + remote: str = "origin" + + +class KeePassConfig(BaseModel): + file: str = "database.kdbx" + secret: Path | str = "/run/secrets/kp_secret" + + class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix="KARL_", env_nested_delimiter="__") app: AppConfig = AppConfig() + git: GitConfig = GitConfig() + kp: KeePassConfig = KeePassConfig() @classmethod def from_yaml(cls, path: Path | str = "config/config.yaml") -> "Settings": @@ -23,6 +38,7 @@ class Settings(BaseSettings): data = yaml.safe_load(fh) or {} return cls(**data) + @lru_cache def get_settings() -> Settings: return Settings.from_yaml() diff --git a/app/services/passwd.py b/app/model/__init__.py similarity index 100% rename from app/services/passwd.py rename to app/model/__init__.py diff --git a/app/model/containers.py b/app/model/containers.py new file mode 100644 index 0000000..2bdf6aa --- /dev/null +++ b/app/model/containers.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from docker.models.containers import Container + + +@dataclass +class SimpleContainer: + name: str + image: str + status: str + health: str + created: datetime + + @staticmethod + def from_container(container: Container): + created = datetime.strptime(container.attrs['Created'].split('.')[0], '%Y-%m-%dT%H:%M:%S') + return SimpleContainer( + name=container.name, + image=container.image.tags[0], + status=container.status, + health=container.health, + created=created + ) + + +@dataclass +class Compose: + directory: str + containers: list[SimpleContainer] = field(default_factory=list) + + @property + def last_modified(self): + return max(self.containers, key=lambda c: c.created).created + + +@dataclass +class Tree: + composes: dict[str, Compose] = field(default_factory=dict) + containers: list[SimpleContainer] = field(default_factory=list) diff --git a/app/model/passwords.py b/app/model/passwords.py new file mode 100644 index 0000000..d795d2c --- /dev/null +++ b/app/model/passwords.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass, field +from typing import Type + +# TODO: unnecessary? + +@dataclass +class PathItem: + name: str + t: Type + +@dataclass +class Path: + path: list[PathItem] = field(default_factory=list) + + def append(self, name, t): + self.path.append(PathItem(name, t)) + + def __str__(self): + return "/".join([i.name for i in self.path]) + + +@dataclass +class Group: + name: str + passwords: list["Password"] = field(default_factory=list) + parent: "Group|None" = None + + @property + def path(self): + if self.parent is None: + new_path = Path() + new_path.append(self.name, type(self)) + return new_path + return self.parent.path.append(self.name, type(self)) + + +@dataclass +class Password: + name: str + group: Group + + @property + def path(self): + return self.group.path.append(self.name, type(self)) + +class UnencryptedPassword(Password): + def __init__(self, name: str, value: str, group: Group): + super().__init__(name, group) + self.value = value diff --git a/app/services/containers.py b/app/services/containers.py index e69de29..6a4814c 100644 --- a/app/services/containers.py +++ b/app/services/containers.py @@ -0,0 +1,28 @@ +import docker +from docker.models.containers import Container + +from app.model.containers import Tree, Compose, SimpleContainer + + +class DockerService: + def __init__(self): + self._client = docker.from_env() + self._tree = self._init_tree() + + def _init_tree(self) -> Tree: + tree = Tree() + container: Container + for container in self._client.containers.list(): + labels = container.labels + working_dir = labels.get("com.docker.compose.project.working_dir") + if working_dir: + if tree.composes.get(working_dir) is None: + tree.composes[working_dir] = Compose(working_dir) + tree.composes[working_dir].containers.append(SimpleContainer.from_container(container)) + else: + tree.containers.append(SimpleContainer.from_container(container)) + return tree + + @property + def tree(self) -> Tree: + return self._tree diff --git a/app/services/passwords.py b/app/services/passwords.py new file mode 100644 index 0000000..9dca423 --- /dev/null +++ b/app/services/passwords.py @@ -0,0 +1,38 @@ +import os.path + +from pykeepass import PyKeePass, create_database, Group + + +class Passwords: + def __init__(self): + from app.config import get_settings + settings = get_settings() + + with open(settings.kp.secret, "r") as fh: + secret = fh.read() + + self._kp_org = self.__get_or_create_store(settings.kp.file, secret) + self._kp = self.__get_lock(settings.kp.file, secret) + + @staticmethod + def __get_or_create_store(path, passwd) -> PyKeePass: + if os.path.exists(path): + return PyKeePass( + path, + password=passwd, + ) + return create_database(path, passwd) + + @staticmethod + def __get_lock(path, passwd) -> PyKeePass: + lock_path = path + ".lock" + import shutil + shutil.copyfile(path, lock_path) + return Passwords.__get_or_create_store(lock_path, passwd) + + @property + def store(self): + return self._kp.root_group + + def save(self, group: Group): + pass diff --git a/app/services/system.py b/app/services/system.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/services/system.py @@ -0,0 +1 @@ + diff --git a/app/services/vcs.py b/app/services/vcs.py index e69de29..c137108 100644 --- a/app/services/vcs.py +++ b/app/services/vcs.py @@ -0,0 +1,28 @@ +from git import Repo, Remote + +from app.config import get_settings + + +class GitService: + def __init__(self): + self._settings = get_settings() + self._repo = Repo(self._settings.git.directory) + self._origin: Remote = self._repo.remotes.origin + + def get_modified_compose(self) -> str | None: + self._update() + return self._diff() + + def _update(self): + self._origin.pull() + + def _diff(self) -> str | None: + diff = self._repo.head.commit.diff("HEAD~1") + composes = [f for f in diff if f.a_path.endswith("docker-compose.yml")] + match len(composes): + case 0: + return None + case 1: + return composes[0].a_path + case _: + raise Exception("Multiple compose files modified") diff --git a/app/util/__init__.py b/app/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/util/dicts.py b/app/util/dicts.py new file mode 100644 index 0000000..faf7f65 --- /dev/null +++ b/app/util/dicts.py @@ -0,0 +1,11 @@ +from types import SimpleNamespace + + +class NestedNamespace(SimpleNamespace): + def __init__(self, dictionary, **kwargs): + super().__init__(**kwargs) + for key, value in dictionary.items(): + if isinstance(value, dict): + self.__setattr__(key, NestedNamespace(value)) + else: + self.__setattr__(key, value) diff --git a/config/config.yaml b/config/config.yaml index 8afdd9a..5b47197 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -2,3 +2,6 @@ app: host: "127.0.0.1" port: 8000 reload: true +kp: + file: "config/kp.kdbx" + secret: "config/secret.txt" diff --git a/pyproject.toml b/pyproject.toml index c7db9ab..13d3179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,9 @@ dependencies = [ "jinja2>=3.1.4", "pydantic-settings>=2.4.0", "pyyaml>=6.0.2", + "gitpython>=3.1.45", + "pykeepass>=4.1.1.post1", + "docker>=7.1.0" ] [project.optional-dependencies]