Skip to content

Commit

Permalink
Config from a single YAML file
Browse files Browse the repository at this point in the history
  • Loading branch information
fajpunk committed Nov 8, 2024
1 parent 126c1ca commit f7d59ff
Show file tree
Hide file tree
Showing 31 changed files with 501 additions and 389 deletions.
216 changes: 174 additions & 42 deletions src/mobu/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,159 @@
from __future__ import annotations

from pathlib import Path
from textwrap import dedent
from typing import Self

from pydantic import Field, HttpUrl
from pydantic_settings import BaseSettings
import yaml
from pydantic import AliasChoices, Field, HttpUrl
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings, SettingsConfigDict
from safir.logging import LogLevel, Profile
from safir.metrics import MetricsConfiguration, metrics_configuration_factory

from mobu.models.flock import FlockConfig

from .models.user import User

__all__ = [
"Configuration",
"config",
"GitHubCiAppConfig",
"GitHubRefreshAppConfig",
]


class Configuration(BaseSettings):
"""Configuration for mobu."""
class GitHubCiAppConfig(BaseSettings):
"""Configuration for GitHub CI app functionality if it is enabled."""

alert_hook: HttpUrl | None = Field(
None,
title="Slack webhook URL used for sending alerts",
model_config = SettingsConfigDict(
alias_generator=to_camel, extra="forbid", populate_by_name=True
)

id: int = Field(
...,
title="Github CI app id",
description=(
"An https URL, which should be considered secret. If not set or"
" set to `None`, this feature will be disabled."
"Found on the GitHub app's settings page (NOT the installation"
" configuration page). For example:"
" https://github.com/organizations/lsst-sqre/settings/apps/mobu-ci-data-dev-lsst-cloud"
),
validation_alias="MOBU_ALERT_HOOK",
examples=["https://slack.example.com/ADFAW1452DAF41/"],
examples=[123456],
validation_alias=AliasChoices("MOBU_GITHUB_CI_APP_ID", "id"),
)

autostart: Path | None = Field(
None,
title="Path to YAML file defining flocks to automatically start",
private_key: str = Field(
...,
title="Github CI app private key",
description=(
"If given, the YAML file must contain a list of flock"
" specifications. All flocks given there will be automatically"
" started when mobu starts."
"Generated when the GitHub app was set up. This should NOT be"
" base64 enocded, and will contain newlines. You can find this"
" in 1Password; check the Phalanx mobu values for more details."
),
examples=[
dedent("""
-----BEGIN RSA PRIVATE KEY-----
abc123MeowMeow456abc123MeowMeow456abc123MeowMeow456abc123MeowMeo
abc123MeowMeow456abc123MeowMeow456abc123MeowMeow456abc123MeowMeo
abc123MeowMeow456abc123MeowMeow456abc123MeowMeow456abc123MeowMeo
etc, etc
-----END RSA PRIVATE KEY-----
""")
],
validation_alias=AliasChoices(
"MOBU_GITHUB_CI_APP_PRIVATE_KEY", "privateKey"
),
validation_alias="MOBU_AUTOSTART_PATH",
examples=["/etc/mobu/autostart.yaml"],
)

github_ci_app_config_path: Path | None = Field(
None,
title="GitHub CI app config path",
webhook_secret: str = Field(
...,
title="Github CI app webhook secret",
description=(
"Generated when the GitHub app was set up. You can find this"
" in 1Password; check the Phalanx mobu values for more details."
),
validation_alias=AliasChoices(
"MOBU_GITHUB_CI_APP_WEBHOOK_SECRET", "webhookSecret"
),
)

users: list[User] = Field(
...,
title="Environment users for CI jobs to run as.",
description=(
"Must be prefixed with 'bot-', like all mobu users. In "
" environments without Firestore, users have to be provisioned"
" by environment admins, and their usernames, uids, and guids must"
" be specified here. In environments with firestore, only "
" usernames need to be specified, but you still need to explicitly"
" specify as many users as needed to get the amount of concurrency"
" that you want."
),
)

scopes: list[str] = Field(
...,
title="Gafaelfawr Scopes",
description=(
"A list of Gafaelfawr scopes that will be granted to the"
" user when running notebooks for a GitHub CI app check."
),
)

accepted_github_orgs: list[str] = Field(
[],
title="Allowed GitHub organizations.",
description=(
"Any webhook payload request from a repo in an organization not in"
" this list will get a 403 response."
),
)


class GitHubRefreshAppConfig(BaseSettings):
"""Configuration for GitHub refresh app functionality."""

model_config = SettingsConfigDict(
alias_generator=to_camel, extra="forbid", populate_by_name=True
)

webhook_secret: str = Field(
...,
title="Github refresh app webhook secret",
description=(
"Path to YAML file defining settings for GitHub CI app"
" integration"
"Generated when the GitHub app was set up. You can find this"
" in 1Password; check the Phalanx mobu values for more details."
),
validation_alias=AliasChoices(
"MOBU_GITHUB_REFRESH_APP_WEBHOOK_SECRET", "webhookSecret"
),
validation_alias="MOBU_GITHUB_CI_APP_CONFIG_PATH",
examples=["/etc/mobu/github-ci-app.yaml"],
)

github_refresh_app_config_path: Path | None = Field(
accepted_github_orgs: list[str] = Field(
[],
title="Allowed GitHub organizations.",
description=(
"Any webhook payload request from a repo in an organization not in"
" this list will get a 403 response."
),
)


class Configuration(BaseSettings):
"""Configuration for mobu."""

model_config = SettingsConfigDict(
alias_generator=to_camel, extra="forbid", populate_by_name=True
)

alert_hook: HttpUrl | None = Field(
None,
title="GitHub refresh app config path",
title="Slack webhook URL used for sending alerts",
description=(
"Path to YAML file defining settings for GitHub refresh app"
" integration"
"An https URL, which should be considered secret. If not set or"
" set to `None`, this feature will be disabled."
),
validation_alias="MOBU_GITHUB_REFRESH_APP_CONFIG_PATH",
examples=["/etc/mobu/github-refresh-app.yaml"],
examples=["https://slack.example.com/ADFAW1452DAF41/"],
validation_alias=AliasChoices("MOBU_ALERT_HOOK", "alertHook"),
)

environment_url: HttpUrl | None = Field(
Expand All @@ -71,7 +167,6 @@ class Configuration(BaseSettings):
" suite easier. If it is not set to a valid URL, mobu will abort"
" during startup."
),
validation_alias="MOBU_ENVIRONMENT_URL",
examples=["https://data.example.org/"],
)

Expand All @@ -83,8 +178,10 @@ class Configuration(BaseSettings):
" get a token for the user. This is only optional to make writing"
" tests easier. mobu will abort during startup if it is not set."
),
validation_alias="MOBU_GAFAELFAWR_TOKEN",
examples=["gt-vilSCi1ifK_MyuaQgMD2dQ.d6SIJhowv5Hs3GvujOyUig"],
validation_alias=AliasChoices(
"MOBU_GAFAELFAWR_TOKEN", "gafaelfawrToken"
),
)

available_services: set[str] = Field(
Expand All @@ -96,35 +193,70 @@ class Configuration(BaseSettings):
" When we have a service discovery mechanism in place, it should"
" be used here."
),
validation_alias="MOBU_AVAILABLE_SERVICES",
examples=[{"tap", "ssotap", "butler"}],
)

name: str = Field(
"mobu",
title="Name of application",
description="Doubles as the root HTTP endpoint path.",
validation_alias="MOBU_NAME",
)

autostart: list[FlockConfig] = Field(
default=[],
title="Autostart config",
description=(
"Configuration of flocks of monkeys that will run businesses"
" repeatedly as long as Mobu is running."
),
)

path_prefix: str = Field(
"/mobu",
title="URL prefix for application API",
validation_alias="MOBU_PATH_PREFIX",
)

profile: Profile = Field(
Profile.development,
title="Application logging profile",
validation_alias="MOBU_LOGGING_PROFILE",
)

log_level: LogLevel = Field(
LogLevel.INFO,
title="Log level of the application's logger",
validation_alias="MOBU_LOG_LEVEL",
)

github_ci_app: GitHubCiAppConfig | None = Field(
None,
title="GitHub CI app config",
description=("Configuration for GitHub CI app functionality"),
)

github_refresh_app: GitHubRefreshAppConfig | None = Field(
None,
title="GitHub refresh app config",
description=("Configuration for GitHub refresh app functionality"),
)

metrics: MetricsConfiguration = Field(
default_factory=metrics_configuration_factory,
title="Metrics configuration",
description="Config for emitting app metrics.",
)

@classmethod
def from_file(cls, path: Path) -> Self:
"""Construct a Configuration object from a configuration file.
Parameters
----------
path
Path to the configuration file in YAML.
config = Configuration()
"""Configuration for mobu."""
Returns
-------
Config
The corresponding `Configuration` object.
"""
with path.open("r") as f:
return cls.model_validate(yaml.safe_load(f))
3 changes: 3 additions & 0 deletions src/mobu/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path

__all__ = [
"CONFIGURATION_PATH",
"GITHUB_REPO_CONFIG_PATH",
"GITHUB_WEBHOOK_WAIT_SECONDS",
"NOTEBOOK_REPO_BRANCH",
Expand All @@ -15,6 +16,8 @@
"WEBSOCKET_OPEN_TIMEOUT",
]

CONFIGURATION_PATH = Path("/etc/mobu/config.yaml")
"""Default path to configuration."""

GITHUB_REPO_CONFIG_PATH = Path("mobu.yaml")
"""The path to a config file with repo-specific configuration."""
Expand Down
64 changes: 64 additions & 0 deletions src/mobu/dependencies/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Config dependency."""

import os
from pathlib import Path

from ..config import Configuration
from ..constants import CONFIGURATION_PATH

__all__ = [
"ConfigDependency",
"config_dependency",
]


class ConfigDependency:
"""Dependency to manage a cached Mobu configuration.
The controller configuration is read on first request, cached, and
returned to all dependency callers unless `~ConfigDependency.set_path` is
called to change the configuration.
Parameters
----------
path
Path to the Nublado mobu configuration.
"""

def __init__(self, path: Path = CONFIGURATION_PATH) -> None:
# This is needed for unit tests to specify an alternate config file
# when mobu is started in a separate process from the tests.
if test_path := os.environ.get("MOBU_UNIT_TEST_PATH"):
path = Path(test_path)
self._path = path
self._config: Configuration | None = None

async def __call__(self) -> Configuration:
return self.config

@property
def config(self) -> Configuration:
"""Load configuration if needed and return it."""
if self._config is None:
self._config = Configuration.from_file(self._path)
return self._config

@property
def is_initialized(self) -> bool:
"""Whether the configuration has been initialized."""
return self._config is not None

def set_path(self, path: Path) -> None:
"""Change the configuration path and reload.
Parameters
----------
path
New configuration path.
"""
self._path = path
self._config = Configuration.from_file(path)


config_dependency = ConfigDependency()
"""The dependency that will return the global configuration."""
Loading

0 comments on commit f7d59ff

Please sign in to comment.