Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DM-38339: Update to latest Safir, use Settings for config #213

Merged
merged 7 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/mobu/business/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import asyncio
from asyncio import Queue, QueueEmpty
from collections.abc import AsyncIterable, AsyncIterator
from datetime import datetime, timezone
from enum import Enum
from typing import TypeVar

from safir.datetime import current_datetime
from structlog import BoundLogger

from ..models.business import BusinessConfig, BusinessData
Expand Down Expand Up @@ -181,9 +181,9 @@ async def iter_with_timeout(
async def iter_next() -> T:
return await iterator.__anext__()

start = datetime.now(tz=timezone.utc)
start = current_datetime(microseconds=True)
while True:
now = datetime.now(tz=timezone.utc)
now = current_datetime(microseconds=True)
remaining = timeout - (now - start).total_seconds()
if remaining < 0:
break
Expand Down
13 changes: 7 additions & 6 deletions src/mobu/business/jupyterloginloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@

import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime
from typing import Optional

from aiohttp import ClientError, ClientResponseError
from safir.datetime import current_datetime, format_datetime_for_logging
from structlog import BoundLogger

from ..constants import DATE_FORMAT
from ..exceptions import (
JupyterResponseError,
JupyterSpawnError,
Expand All @@ -37,12 +37,13 @@ class ProgressLogMessage:
"""The message."""

timestamp: datetime = field(
default_factory=lambda: datetime.now(tz=timezone.utc)
default_factory=lambda: current_datetime(microseconds=True)
)
"""When the event was received."""

def __str__(self) -> str:
return f"{self.timestamp.strftime(DATE_FORMAT)} - {self.message}"
timestamp = format_datetime_for_logging(self.timestamp)
return f"{timestamp} - {self.message}"


class JupyterLoginLoop(Business):
Expand Down Expand Up @@ -181,9 +182,9 @@ async def delete_lab(self) -> None:
if self.stopping:
return
timeout = self.config.delete_timeout
start = datetime.now(tz=timezone.utc)
start = current_datetime(microseconds=True)
while not await self._client.is_lab_stopped():
now = datetime.now(tz=timezone.utc)
now = current_datetime(microseconds=True)
elapsed = round((now - start).total_seconds())
if elapsed > timeout:
if not await self._client.is_lab_stopped(final=True):
Expand Down
4 changes: 3 additions & 1 deletion src/mobu/business/tapqueryrunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ def __init__(

@staticmethod
def _make_client(token: str) -> pyvo.dal.TAPService:
tap_url = config.environment_url + "/api/tap"
if not config.environment_url:
raise RuntimeError("environment_url not set")
tap_url = str(config.environment_url).rstrip("/") + "/api/tap"

s = requests.Session()
s.headers["Authorization"] = "Bearer " + token
Expand Down
6 changes: 4 additions & 2 deletions src/mobu/cachemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ def __init__(
self._session = session
self._token = token
self._username = username
if not config.environment_url:
raise RuntimeError("environment_url not set")
self._url = (
config.environment_url
str(config.environment_url).rstrip("/")
+ "/cachemachine/jupyter/"
+ config.cachemachine_image_policy
+ config.cachemachine_image_policy.value
)

async def get_latest_weekly(self) -> JupyterImage:
Expand Down
147 changes: 86 additions & 61 deletions src/mobu/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,83 +2,108 @@

from __future__ import annotations

import os
from dataclasses import dataclass
from enum import Enum
from pathlib import Path

__all__ = ["Configuration", "config"]
from pydantic import BaseSettings, Field, HttpUrl
from safir.logging import LogLevel, Profile

__all__ = [
"CachemachinePolicy",
"Configuration",
"config",
]

@dataclass
class Configuration:
"""Configuration for mobu."""

alert_hook: str | None = os.getenv("ALERT_HOOK")
"""The slack webhook used for alerting exceptions to slack.

Set with the ``ALERT_HOOK`` environment variable.
This is an https URL which should be considered secret.
If not set or set to "None", this feature will be disabled.
"""

autostart: str | None = os.getenv("AUTOSTART")
"""The path to a YAML file defining what flocks to automatically start.
class CachemachinePolicy(Enum):
"""Policy for what eligible images to retrieve from cachemachine."""

The YAML file should, if given, be a list of flock specifications. All
flocks specified there will be automatically started when mobu starts.
"""
available = "available"
desired = "desired"

environment_url: str = os.getenv("ENVIRONMENT_URL", "")
"""The URL of the environment to run tests against.

This is used for creating URLs to services, such as JupyterHub.
mobu will not work if this is not set.

Set with the ``ENVIRONMENT_URL`` environment variable.
"""
class Configuration(BaseSettings):
"""Configuration for mobu."""

cachemachine_image_policy: str = os.getenv(
"CACHEMACHINE_IMAGE_POLICY", "available"
alert_hook: HttpUrl | None = Field(
None,
title="Slack webhook URL used for sending alerts",
description=(
"An https URL, which should be considered secret. If not set or"
" set to `None`, this feature will be disabled."
),
env="ALERT_HOOK",
example="https://slack.example.com/ADFAW1452DAF41/",
)
"""Whether to use the images available on all nodes, or the images
desired by cachemachine. In instances where image streaming is enabled,
and therefore pulls are fast, ``desired`` is preferred. The default is
``available``.

Set with the ``CACHEMACHINE_IMAGE_POLICY`` environment variable.
"""

gafaelfawr_token: str | None = os.getenv("GAFAELFAWR_TOKEN")
"""The Gafaelfawr admin token to use to create user tokens.

This token is used to make an admin API call to Gafaelfawr to get a token
for the user. mobu will not work if this is not set.

Set with the ``GAFAELFAWR_TOKEN`` environment variable.
"""

name: str = os.getenv("SAFIR_NAME", "mobu")
"""The application's name, which doubles as the root HTTP endpoint path.

Set with the ``SAFIR_NAME`` environment variable.
"""
autostart: Path | None = Field(
None,
title="Path to YAML file defining flocks to automatically start",
description=(
"If given, the YAML file must contain a list of flock"
" specifications. All flocks given there will be automatically"
" started when mobu starts."
),
env="AUTOSTART",
example="/etc/mobu/autostart.yaml",
)

profile: str = os.getenv("SAFIR_PROFILE", "development")
"""Application run profile: "development" or "production".
environment_url: HttpUrl | None = Field(
None,
title="Base URL of the Science Platform environment",
description=(
"Used to create URLs to other services, such as Gafaelfawr and"
" JupyterHub. This is only optional to make writing the test"
" suite easier. If it is not set to a valid URL, mobu will abort"
" during startup."
),
env="ENVIRONMENT_URL",
example="https://data.example.org/",
)

Set with the ``SAFIR_PROFILE`` environment variable.
"""
cachemachine_image_policy: CachemachinePolicy = Field(
CachemachinePolicy.available,
field="Class of cachemachine images to use",
description=(
"Whether to use the images available on all nodes, or the images"
" desired by cachemachine. In instances where image streaming is"
" enabled and therefore pulls are fast, ``desired`` is preferred."
" The default is ``available``."
),
env="CACHEMACHINE_IMAGE_POLICY",
example=CachemachinePolicy.desired,
)

logger_name: str = os.getenv("SAFIR_LOGGER", "mobu")
"""The root name of the application's logger.
gafaelfawr_token: str | None = Field(
None,
field="Gafaelfawr admin token used to create user tokens",
description=(
"This token is used to make an admin API call to Gafaelfawr to"
" 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."
),
env="GAFAELFAWR_TOKEN",
example="gt-vilSCi1ifK_MyuaQgMD2dQ.d6SIJhowv5Hs3GvujOyUig",
)

Set with the ``SAFIR_LOGGER`` environment variable.
"""
name: str = Field(
"mobu",
title="Name of application",
description="Doubles as the root HTTP endpoint path.",
env="SAFIR_NAME",
)

log_level: str = os.getenv("SAFIR_LOG_LEVEL", "INFO")
"""The log level of the application's logger.
profile: Profile = Field(
Profile.development,
title="Application logging profile",
env="SAFIR_PROFILE",
)

Set with the ``SAFIR_LOG_LEVEL`` environment variable.
"""
log_level: LogLevel = Field(
LogLevel.INFO,
title="Log level of the application's logger",
env="SAFIR_LOG_LEVEL",
)


config = Configuration()
Expand Down
3 changes: 0 additions & 3 deletions src/mobu/constants.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
"""Global constants for mobu."""

DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
"""Date format to use for dates in Slack alerts."""

NOTEBOOK_REPO_URL = "https://github.com/lsst-sqre/notebook-demo.git"
"""Default notebook repository for NotebookRunner."""

Expand Down
15 changes: 8 additions & 7 deletions src/mobu/flock.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

import asyncio
import math
from datetime import datetime, timezone
from typing import Dict, List, Optional
from datetime import datetime
from typing import Optional

from aiohttp import ClientSession
from aiojobs import Scheduler
from safir.datetime import current_datetime

from .business.base import Business
from .business.jupyterjitterloginloop import JupyterJitterLoginLoop
Expand Down Expand Up @@ -46,7 +47,7 @@ def __init__(
self._config = flock_config
self._scheduler = scheduler
self._session = session
self._monkeys: Dict[str, Monkey] = {}
self._monkeys: dict[str, Monkey] = {}
self._start_time: Optional[datetime] = None
try:
self._business_type = _BUSINESS_CLASS[self._config.business]
Expand All @@ -68,7 +69,7 @@ def get_monkey(self, name: str) -> Monkey:
raise MonkeyNotFoundException(name)
return monkey

def list_monkeys(self) -> List[str]:
def list_monkeys(self) -> list[str]:
"""List the names of the monkeys."""
return sorted(self._monkeys.keys())

Expand Down Expand Up @@ -97,7 +98,7 @@ async def start(self) -> None:
monkey = self._create_monkey(user)
self._monkeys[user.username] = monkey
await monkey.start(self._scheduler)
self._start_time = datetime.now(tz=timezone.utc)
self._start_time = current_datetime(microseconds=True)

async def stop(self) -> None:
"""Stop all the monkeys.
Expand All @@ -114,7 +115,7 @@ def _create_monkey(self, user: AuthenticatedUser) -> Monkey:
config = self._config.monkey_config(user.username)
return Monkey(config, self._business_type, user, self._session)

async def _create_users(self) -> List[AuthenticatedUser]:
async def _create_users(self) -> list[AuthenticatedUser]:
"""Create the authenticated users the monkeys will run as."""
users = self._config.users
if not users:
Expand All @@ -127,7 +128,7 @@ async def _create_users(self) -> List[AuthenticatedUser]:
for u in users
]

def _users_from_spec(self, spec: UserSpec, count: int) -> List[User]:
def _users_from_spec(self, spec: UserSpec, count: int) -> list[User]:
"""Generate count Users from the provided spec."""
padding = int(math.log10(count) + 1)
users = []
Expand Down
Loading