diff --git a/goosebit/db.py b/goosebit/db.py index bc64e592..8e05863a 100644 --- a/goosebit/db.py +++ b/goosebit/db.py @@ -2,10 +2,10 @@ from tortoise import Tortoise, run_async from goosebit.models import Firmware -from goosebit.settings import DB_MIGRATIONS_LOC, DB_URI +from goosebit.settings import DB_MIGRATIONS_LOC, config TORTOISE_CONF = { - "connections": {"default": DB_URI}, + "connections": {"default": config.db_uri}, "apps": { "models": { "models": ["goosebit.models", "aerich.models"], diff --git a/goosebit/permissions.py b/goosebit/permissions.py index 12c875d7..0b70ef35 100644 --- a/goosebit/permissions.py +++ b/goosebit/permissions.py @@ -1,5 +1,7 @@ from enum import Enum -from typing import TypeVar, cast +from typing import ClassVar, TypeVar, cast + +from pydantic import BaseModel T = TypeVar("T", bound="PermissionsBase") @@ -38,11 +40,11 @@ class HomePermissions(PermissionsBase): READ = "home.read" -class Permissions: - HOME = HomePermissions - FIRMWARE = FirmwarePermissions - DEVICE = DevicePermissions - ROLLOUT = RolloutPermissions +class Permissions(BaseModel): + HOME: ClassVar = HomePermissions + FIRMWARE: ClassVar = FirmwarePermissions + DEVICE: ClassVar = DevicePermissions + ROLLOUT: ClassVar = RolloutPermissions @classmethod def full(cls) -> set[T]: diff --git a/goosebit/settings.py b/goosebit/settings.py deleted file mode 100644 index 70bb9f92..00000000 --- a/goosebit/settings.py +++ /dev/null @@ -1,62 +0,0 @@ -import secrets -from dataclasses import dataclass -from pathlib import Path - -import yaml -from argon2 import PasswordHasher -from joserfc.jwk import OctKey - -from goosebit.permissions import Permissions - -BASE_DIR = Path(__file__).resolve().parent.parent -DB_MIGRATIONS_LOC = BASE_DIR.joinpath("migrations") - -SECRET = OctKey.import_key(secrets.token_hex(16)) -PWD_CXT = PasswordHasher() - -with open(BASE_DIR.joinpath("settings.yaml"), "r") as f: - config = yaml.safe_load(f.read()) - -ARTIFACTS_DIR = Path(config.get("artifacts_dir", BASE_DIR.joinpath("artifacts"))) - -LOGGING = config.get("logging", {}) - -TENANT = config.get("tenant", "DEFAULT") - -POLL_TIME = config.get("poll_time_default", "00:01:00") -POLL_TIME_UPDATING = config.get("poll_time_updating", "00:00:05") -POLL_TIME_REGISTRATION = config.get("poll_time_registration", "00:00:10") - -DB_URI = config.get("db_uri", f"sqlite:///{BASE_DIR.joinpath('db.sqlite3')}") - - -@dataclass -class User: - username: str - hashed_pwd: str - permissions: set - - def get_json_permissions(self): - return [str(p) for p in self.permissions] - - -users: dict[str, User] = {} - - -def add_user(u: User): - users[u.username] = u - - -for user in config.get("users", []): - permissions = set() - for p in user["permissions"]: - permissions.update(Permissions.from_str(p)) - add_user( - User( - username=user["email"], - hashed_pwd=PWD_CXT.hash(user["password"]), - permissions=permissions, - ) - ) - -USERS = users diff --git a/goosebit/settings/__init__.py b/goosebit/settings/__init__.py new file mode 100644 index 00000000..d11e6f93 --- /dev/null +++ b/goosebit/settings/__init__.py @@ -0,0 +1,6 @@ +from .const import BASE_DIR, DB_MIGRATIONS_LOC, PWD_CXT, SECRET # noqa: F401 +from .schema import GooseBitSettings + +config = GooseBitSettings() + +USERS = {u.username: u for u in config.users} diff --git a/goosebit/settings/const.py b/goosebit/settings/const.py new file mode 100644 index 00000000..ea7065f3 --- /dev/null +++ b/goosebit/settings/const.py @@ -0,0 +1,23 @@ +import secrets +from pathlib import Path + +from argon2 import PasswordHasher +from joserfc.jwk import OctKey + +BASE_DIR = Path(__file__).resolve().parent.parent +DB_MIGRATIONS_LOC = BASE_DIR.joinpath("migrations") + +SECRET = OctKey.import_key(secrets.token_hex(16)) +PWD_CXT = PasswordHasher() + +LOGGING_DEFAULT = { + "version": 1, + "formatters": {"simple": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"}}, + "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "simple", "level": "DEBUG"}}, + "loggers": { + "tortoise": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + "aiosqlite": {"handlers": ["console"], "level": "WARNING", "propagate": True}, + "multipart": {"handlers": ["console"], "level": "INFO", "propagate": True}, + }, + "root": {"level": "INFO", "handlers": ["console"]}, +} diff --git a/goosebit/settings/schema.py b/goosebit/settings/schema.py new file mode 100644 index 00000000..7728bdd6 --- /dev/null +++ b/goosebit/settings/schema.py @@ -0,0 +1,75 @@ +from pathlib import Path +from typing import Annotated, Iterable + +from pydantic import BaseModel, BeforeValidator, Field +from pydantic_settings import ( + BaseSettings, + PydanticBaseSettingsSource, + SettingsConfigDict, + YamlConfigSettingsSource, +) + +from goosebit.permissions import Permissions, PermissionsBase + +from .const import BASE_DIR, LOGGING_DEFAULT, PWD_CXT + + +def parse_permissions(items: Iterable[str]): + permissions = set() + for p in items: + permissions.update(Permissions.from_str(p)) + return permissions + + +class User(BaseModel): + username: str + hashed_pwd: Annotated[str, BeforeValidator(PWD_CXT.hash)] = Field(validation_alias="password") + permissions: Annotated[set[PermissionsBase], BeforeValidator(parse_permissions)] + + def get_json_permissions(self): + return [str(p) for p in self.permissions] + + +class PrometheusSettings(BaseModel): + enable: bool = False + port: int = 9090 + + +class MetricsSettings(BaseModel): + prometheus: PrometheusSettings = PrometheusSettings() + + +class GooseBitSettings(BaseSettings): + model_config = SettingsConfigDict(env_prefix="GOOSEBIT_") + + artifacts_dir: Path = BASE_DIR.joinpath("artifacts") + + tenant: str = "DEFAULT" + + poll_time_default: str = "00:01:00" + poll_time_updating: str = "00:00:05" + poll_time_registration: str = "00:00:10" + + users: list[User] = [] + + db_uri: str = f"sqlite:///{BASE_DIR.joinpath('db.sqlite3')}" + + metrics: MetricsSettings = MetricsSettings() + + logging: dict = LOGGING_DEFAULT + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + return ( + init_settings, + YamlConfigSettingsSource(settings_cls, BASE_DIR.parent.joinpath("settings.yaml")), + env_settings, + file_secret_settings, + ) diff --git a/goosebit/telemetry/prometheus.py b/goosebit/telemetry/prometheus.py index 9f452cfb..a41c88a4 100644 --- a/goosebit/telemetry/prometheus.py +++ b/goosebit/telemetry/prometheus.py @@ -1,10 +1,9 @@ from opentelemetry.exporter.prometheus import PrometheusMetricReader from prometheus_client import start_http_server -from goosebit import settings - -PROMETHEUS_PORT = settings.config.get("metrics", {}).get("prometheus", {}).get("port", 9090) +from goosebit.settings import config # separate file to enable it as a feature later. reader = PrometheusMetricReader() -start_http_server(port=PROMETHEUS_PORT, addr="0.0.0.0") +if config.metrics.prometheus.enable: + start_http_server(port=config.metrics.prometheus.port, addr="0.0.0.0") diff --git a/goosebit/ui/routes.py b/goosebit/ui/routes.py index 35afec11..8232c701 100644 --- a/goosebit/ui/routes.py +++ b/goosebit/ui/routes.py @@ -7,7 +7,7 @@ from goosebit.auth import authenticate_session, validate_user_permissions from goosebit.models import Firmware, Rollout from goosebit.permissions import Permissions -from goosebit.settings import ARTIFACTS_DIR +from goosebit.settings import config from goosebit.ui.nav import nav from goosebit.ui.templates import templates from goosebit.updates import create_firmware_update @@ -51,7 +51,7 @@ async def upload_update_local( done: bool = Form(...), filename: str = Form(...), ): - file = ARTIFACTS_DIR.joinpath(filename) + file = config.artifacts_dir.joinpath(filename) temp_file = file.with_suffix(".tmp") if init: diff --git a/goosebit/updater/controller/v1/routes.py b/goosebit/updater/controller/v1/routes.py index b847dafb..e44ddf71 100644 --- a/goosebit/updater/controller/v1/routes.py +++ b/goosebit/updater/controller/v1/routes.py @@ -5,7 +5,7 @@ from fastapi.requests import Request from goosebit.models import Firmware, UpdateStateEnum -from goosebit.settings import POLL_TIME_REGISTRATION +from goosebit.settings import config from goosebit.updater.manager import HandlingType, UpdateManager, get_update_manager from goosebit.updates import generate_chunk @@ -28,7 +28,7 @@ async def polling( if device.last_state == UpdateStateEnum.UNKNOWN: # device registration - sleep = POLL_TIME_REGISTRATION + sleep = config.poll_time_registration links["configData"] = { "href": str( request.url_for( diff --git a/goosebit/updater/manager.py b/goosebit/updater/manager.py index d49440a5..e412a467 100644 --- a/goosebit/updater/manager.py +++ b/goosebit/updater/manager.py @@ -18,7 +18,7 @@ UpdateModeEnum, UpdateStateEnum, ) -from goosebit.settings import POLL_TIME, POLL_TIME_UPDATING +from goosebit.settings import config caches.set_config( { @@ -111,11 +111,11 @@ def log_subscribers(self, value: list): @property def poll_time(self): - return UpdateManager.device_poll_time.get(self.dev_id, POLL_TIME) + return UpdateManager.device_poll_time.get(self.dev_id, config.poll_time_default) @poll_time.setter def poll_time(self, value: str): - if not value == POLL_TIME: + if not value == config.poll_time_default: UpdateManager.device_poll_time[self.dev_id] = value return if self.dev_id in UpdateManager.device_poll_time: @@ -135,7 +135,7 @@ async def update_log(self, log_data: str) -> None: ... class UnknownUpdateManager(UpdateManager): def __init__(self, dev_id: str): super().__init__(dev_id) - self.poll_time = POLL_TIME_UPDATING + self.poll_time = config.poll_time_updating async def _get_firmware(self) -> Firmware: return await Firmware.latest(await self.get_device()) @@ -282,19 +282,19 @@ async def get_update(self) -> tuple[HandlingType, Firmware]: if firmware is None: handling_type = HandlingType.SKIP - self.poll_time = POLL_TIME + self.poll_time = config.poll_time_default elif firmware.version == device.fw_version and not device.force_update: handling_type = HandlingType.SKIP - self.poll_time = POLL_TIME + self.poll_time = config.poll_time_default elif device.last_state == UpdateStateEnum.ERROR and not device.force_update: handling_type = HandlingType.SKIP - self.poll_time = POLL_TIME + self.poll_time = config.poll_time_default else: handling_type = HandlingType.FORCED - self.poll_time = POLL_TIME_UPDATING + self.poll_time = config.poll_time_updating if device.log_complete: await self.update_log_complete(False) diff --git a/goosebit/updater/routes.py b/goosebit/updater/routes.py index 9d8eca20..284bfc84 100644 --- a/goosebit/updater/routes.py +++ b/goosebit/updater/routes.py @@ -3,14 +3,14 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.requests import Request -from goosebit.settings import TENANT +from goosebit.settings import config from . import controller from .manager import get_update_manager async def verify_tenant(tenant: str): - if not tenant == TENANT: + if not tenant == config.tenant: raise HTTPException(404) return tenant diff --git a/main.py b/main.py index 9ce10d23..83ab6d7c 100644 --- a/main.py +++ b/main.py @@ -2,9 +2,10 @@ import uvicorn -from goosebit import app, settings +from goosebit import app +from goosebit.settings import config -logging.config.dictConfig(settings.LOGGING) +logging.config.dictConfig(config.logging) uvicorn_args = {"port": 80} diff --git a/pyproject.toml b/pyproject.toml index b561a16b..f5bdf848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ opentelemetry-instrumentation-fastapi = "^0.47b0" opentelemetry-exporter-prometheus = "^0.47b0" aiocache = "^0.12.2" httpx = "^0.27.0" +pydantic-settings = "^2.4.0" asyncpg = { version = "^0.29.0", optional = true } diff --git a/settings.yaml b/settings.yaml index 6f16318b..61673567 100644 --- a/settings.yaml +++ b/settings.yaml @@ -18,12 +18,13 @@ poll_time_updating: 00:00:05 # "device.read", "device.write", "device.delete" # "rollout.read", "rollout.write", "rollout.delete" # "home.read" + users: - - email: admin@goosebit.local + - username: admin@goosebit.local password: admin permissions: - "*" - - email: ops@goosebit.local + - username: ops@goosebit.local password: ops permissions: - "home.read" @@ -33,6 +34,7 @@ poll_time_registration: 00:00:10 tenant: DEFAULT metrics: prometheus: + enable: false port: 9090 logging: version: 1