Source code for qcfractal.config

"""
The global qcfractal config file specification.
"""

import logging
import os
import secrets
import tempfile
from typing import Any, Annotated

import yaml
from psycopg2.extensions import make_dsn, parse_dsn
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError, ConfigDict, StringConstraints
from pydantic_settings import BaseSettings, SettingsConfigDict, PydanticBaseSettingsSource
from sqlalchemy.engine.url import URL, make_url

from qcfractal.port_util import find_open_port
from qcportal.utils import duration_to_seconds, update_nested_dict


def _make_abs_path(path: str | None, base_folder: str, default_filename: str | None) -> str | None:
    # No path specified, no default
    if path is None and default_filename is None:
        return None

    # Path isn't specified, but default is given
    if path is None:
        path = default_filename

    path = os.path.expanduser(path)
    if os.path.isabs(path):
        return path
    else:
        path = os.path.join(base_folder, path)
        return os.path.abspath(path)


def make_uri_string(
    host: str | None,
    port: int | str | None,
    username: str | None,
    password: str | None,
    dbname: str | None,
    query: dict[str, str] | None,
) -> str:
    username = username if username is not None else ""
    password = ":" + password if password is not None else ""
    sep = "@" if username != "" or password != "" else ""
    query_str = "" if query is None else "&".join(f"{k}={v}" for k, v in query.items())

    # If this is a socket file, move the host to the query params
    if host.startswith("/"):
        query_str = "&" + query_str if query_str != "" else ""
        return f"postgresql://{username}{password}{sep}:{port}/{dbname}?host={host}{query_str}"
    else:
        query_str = "?" + query_str if query_str != "" else ""
        return f"postgresql://{username}{password}{sep}{host}:{port}/{dbname}{query_str}"


class QCFConfigBase(BaseModel):
    model_config = ConfigDict(extra="forbid", use_attribute_docstrings=True)


[docs] class DatabaseConfig(QCFConfigBase): """ Settings for the database used by QCFractal """ base_folder: str """The base folder to use as the default for some options (logs, etc). Default is the location of the config file. """ full_uri: str | None = None """Full connection URI. This overrides host,username,password,port, etc""" host: str = "localhost" """The hostname or ip address the database is running on. If own = True, this must be localhost. May also be a path to a directory containing the database socket file """ port: int = 5432 """The port the database is running on. If own = True, a database will be started, binding to this port""" database_name: str = "qcfractal_default" """The database name to connect to""" username: str """The database username to connect with""" password: str """The database password to connect with""" query: dict[str, str | int] = {} """Extra connection query parameters at the end of the URL string""" own: bool = True """If True, QCFractal will control the database instance. If False, you must start and manage the database yourself """ data_directory: str | None = None """Location to place the database if own == True. Default is [base_folder]/database if we own the database""" logfile: str | None = None """Path to a file to use as the database logfile (if own == True). Default is [base_folder]/qcfractal_database.log """ echo_sql: bool = False """[ADVANCED] output raw SQL queries being run""" pg_tool_dir: str | None = None """Directory containing Postgres tools such as psql and pg_ctl (ie, /usr/bin, or /usr/lib/postgresql/13/bin). If not specified, an attempt to find them will be made. This field is only required if autodetection fails and own == True """ pool_size: int = 5 """[ADVANCED] set the size of the connection pool to use in SQLAlchemy. Set to zero to disable pooling""" maintenance_db: str = "postgres" """[ADVANCED] An existing database (not the one you want to use/create). This is used for database management""" @model_validator(mode="after") def _check_paths(self): if self.own: self.data_directory = _make_abs_path(self.data_directory, self.base_folder, "postgres") self.logfile = _make_abs_path(self.logfile, self.base_folder, "qcfractal_database.log") return self @property def database_uri(self) -> str: """ Returns the real database URI as a string It does not hide the password, so is not suitable for logging """ if self.full_uri is not None: return self.full_uri else: return make_uri_string( host=self.host, port=self.port, username=self.username, password=self.password, dbname=self.database_name, query=self.query, ) @property def sqlalchemy_url(self) -> URL: """Returns the SQLAlchemy URL for this database""" url = make_url(self.database_uri) return url.set(drivername="postgresql+psycopg2") @property def psycopg2_dsn(self) -> str: """ Returns a string suitable for use as a psycopg2 connection string """ dsn_dict = parse_dsn(self.database_uri) return make_dsn(**dsn_dict) @property def psycopg2_maintenance_dsn(self) -> str: dsn_dict = parse_dsn(self.database_uri) dsn_dict["dbname"] = self.maintenance_db return make_dsn(**dsn_dict) @property def safe_uri(self) -> str: """ Returns a user-readable version of the URI for logging, etc. """ dsn = parse_dsn(self.database_uri) host = dsn.pop("host") port = dsn.pop("port", None) user = dsn.pop("user", None) password = dsn.pop("password", None) dbname = dsn.pop("dbname") # SQLAlchemy render_string has some problems sometimes, so use our own return make_uri_string( host=host, port=port, username=user, password="********" if password else None, dbname=dbname, query=dsn ) # everything left over
[docs] class AutoResetConfig(QCFConfigBase): """ How many times the server will automatically retry a failed computation """ enabled: bool = False """Whether to automatically reset errored records at all. With this disabled, every errored record waits for someone to reset it by hand. """ unknown_error: int = 2 """Maximum automatic restarts for errors that could not be classified""" compute_lost: int = 5 """Maximum automatic restarts for computations whose compute resource disappeared. This covers the ordinary ways a batch job ends without reporting back - a walltime kill, a preempted node, a manager killed mid-task. These failures say nothing about whether the computation itself is sound, which is why the default is more generous than for ``unknown_error``. """ random_error: int = 5 """Maximum automatic restarts for errors the server recognises as intermittent"""
[docs] class APILimitConfig(QCFConfigBase): """ Limits on the number of records returned per query. This can be specified per object (molecule, etc) """ get_records: int = 1000 """Number of calculation records that can be retrieve in a single request""" add_records: int = 500 """Number of calculation records that can be added in a single request""" get_dataset_entries: int = 2000 """Number of dataset entries that can be retrieved in a single request""" get_molecules: int = 1000 """Number of molecules that can be retrieved in a single request""" add_molecules: int = 1000 """Number of molecules that can be added in a single request""" get_managers: int = 1000 """Number of manager records to return""" manager_tasks_claim: int = 200 """Number of tasks a single manager can pull down""" manager_tasks_return: int = 10 """Number of tasks a single manager can return at once""" get_access_logs: int = 1000 """Number of access log records to return""" get_error_logs: int = 100 """Number of error log records to return""" get_internal_jobs: int = 1000 """Number of internal jobs to return"""
[docs] class WebAPIConfig(QCFConfigBase): """ Settings for the Web API (api) interface """ num_threads_per_worker: int = 4 """Number of threads per worker""" worker_timeout: int = 120 """If the master process does not hear from a worker for the given amount of time (in seconds),kill it. This effectively limits the time a worker has to respond to a request """ host: str = "localhost" """The IP address or hostname to bind to""" port: int = 7777 """The port on which to run the REST interface""" secret_key: str """Secret key for flask api. See documentation""" jwt_secret_key: str """Secret key for web tokens. See documentation""" jwt_access_token_expires: int = 15 * 60 """The time (in seconds) an access token is valid for. Default is 15 minutes""" jwt_refresh_token_expires: int = 60 * 60 * 24 """The time (in seconds) a refresh token is valid for. Default is 1 day""" user_session_max_age: int = 60 * 60 * 24 """The time (in seconds) that a user session can be idle (for browser-based sessions)""" user_session_cookie_name: str = "qcf_session" """Name to use for a session cookie (for browser-based sessions)""" user_session_cookie_domain: str | None = None """Domain to use for the user-session cookie (for browser-based sessions)""" user_session_cookie_samesite: str | None = None """Set the SameSite flag for the user-session cookie (for browser-based sessions)""" user_session_cookie_partitioned: bool = False """Use the Partitioned flag for the user-session cookie (for browser-based sessions)""" user_session_cookie_secure: bool = False """Use Secure flag for the user-session cookie (for browser-based sessions)""" user_session_cookie_httponly: bool = False """Use Secure flag for the user-session cookie (for browser-based sessions)""" extra_flask_options: dict[str, Any] | None = None """Any additional options to pass directly to flask""" extra_waitress_options: dict[str, Any] | None = None """Any additional options to pass directly to the waitress serve function""" @field_validator( "jwt_access_token_expires", "jwt_refresh_token_expires", "user_session_max_age", mode="before", ) @classmethod def _convert_durations(cls, v): return duration_to_seconds(v)
# S3 bucket names are all lowercase characters and numbers S3BucketName = Annotated[str, StringConstraints(min_length=3, max_length=63, pattern=r"^[a-z0-9\-]+[a-z0-9]$")]
[docs] class S3BucketMap(QCFConfigBase): dataset_attachment: S3BucketName = "dataset-attachments" """Bucket to hold dataset views""" project_attachment: S3BucketName = "project-attachments" """Bucket to hold project attachments"""
[docs] class S3Config(QCFConfigBase): """ Settings for using external files with S3 """ enabled: bool = False """Whether to store large external files (dataset views, attachments) in S3. When enabled, ``endpoint_url``, ``access_key_id`` and ``secret_access_key`` are all required; the server refuses to start otherwise. """ verify: bool = True """Verify TLS certificates when connecting to S3""" passthrough: bool = False """Whether clients may download directly from the S3 endpoint. With this off, file contents are proxied through the server, so clients never need to reach S3 themselves. """ endpoint_url: str | None = None """S3 endpoint URL""" access_key_id: str | None = None """AWS/S3 access key""" secret_access_key: str | None = None """AWS/S3 secret key""" auto_create_buckets: bool = False """Create the buckets named in ``bucket_map`` at startup if they do not already exist""" bucket_map: S3BucketMap = Field(default_factory=S3BucketMap) """Configuration for where to store various files""" @model_validator(mode="after") def _check_enabled(self): if self.enabled: for key in ["endpoint_url", "access_key_id", "secret_access_key"]: if getattr(self, key) is None: raise ValueError(f"S3 enabled but {key} not set") return self
[docs] class CORSconfig(QCFConfigBase): """ Settings for using CORS """ enabled: bool = False """Whether to send CORS headers at all. With this off the other options here do nothing.""" origins: list[str] = [] """Origins permitted to make cross-origin requests. Use ``["*"]`` to allow any origin.""" supports_credentials: bool = False """Whether cross-origin requests may carry credentials (cookies, authorization headers)""" headers: list[str] = [] """Request headers a cross-origin request is allowed to set, such as ``Authorization``""" methods: list[str] = [] """HTTP methods permitted for cross-origin requests. Empty means the CORS default."""
[docs] class FractalConfig(BaseSettings): """ Fractal Server settings """ base_folder: str """The base directory to use as the default for some options (logs, etc). Default is the location of the config file. """ temporary_dir: str | None = None """Temporary directory to use for things such as view creation. If None, uses system default. This may require a lot of space! """ # Info for the REST interface name: str = "QCFractal Server" """The QCFractal server name""" enable_security: bool = True """Enable user authentication and authorization""" allow_unauthenticated_read: bool = True """Allows unauthenticated read access to this instance. This does not extend to sensitive tables (such as user information) """ strict_compute_tags: bool = False """If True, disables wildcard behavior for compute tags. This disables managers from claiming all tags if they specify a wildcard ('*') tag. Managers will still be able to claim tasks with an explicit '*' tag if they specify the '*' queue tag in their config """ # Logging and profiling logfile: str | None = None """Path to a file to use for server logging. If not specified, logs will be printed to standard output""" loglevel: str = "INFO" """Level of logging to enable (debug, info, warning, error, critical). Case insensitive""" hide_internal_errors: bool = True """If True, internal errors will only be reported as an error number to the user. If False, the entire error/backtrace will be sent (which could rarely contain sensitive info). In either case, errors will be stored in the database """ # Periodics service_frequency: int = 60 """The frequency at which to update services (in seconds)""" max_active_services: int = 20 """The maximum number of concurrent active services""" heartbeat_frequency: int = Field(1800, gt=0) """The frequency (in seconds) to check the heartbeat of compute managers""" heartbeat_frequency_jitter: float = Field(0.1, ge=0) """Jitter fraction to be applied to the heartbeat frequency""" heartbeat_max_missed: int = Field(5, ge=0) """The maximum number of heartbeats that a compute manager can miss. If more are missed, the worker is considered dead """ # Access logging log_access: bool = False """Store API access in the database""" access_log_keep: int = 0 """How far back to keep access logs (in days or as a duration string). 0 means keep all""" maxmind_license_key: str | None = None """License key for MaxMind GeoIP2 service. If provided, the GeoIP2 database will be downloaded and updated automatically """ geoip2_dir: str | None = None """Directory containing the Maxmind GeoIP2 Cities file (GeoLite2-City.mmdb) Defaults to [base_folder]/geoip2. This directory will be created if needed. """ geoip2_filename: str = "GeoLite2-City.mmdb" """Filename of the Maxmind GeoIP2 Cities file (GeoLite2-City.mmdb)""" # Internal jobs internal_job_processes: int = 1 """Number of processes for processing internal jobs and async requests""" internal_job_keep: int = 0 """How far back to keep finished internal jobs (in days or as a duration string). 0 means keep all""" # Homepage settings homepage_redirect_url: str | None = None """Redirect to this URL when going to the root path""" homepage_directory: str | None = None """Use this directory to serve the homepage""" # File uploads upload_directory: str | None = None """Directory to store user-uploaded files for processing""" # Other settings blocks database: DatabaseConfig """Configuration of the settings for the database""" api: WebAPIConfig """Configuration of the REST interface""" s3: S3Config = Field(default_factory=S3Config) """Configuration of the S3 file storage (optional)""" api_limits: APILimitConfig = Field(default_factory=APILimitConfig) """Configuration of the limits to the api""" cors: CORSconfig = Field(default_factory=CORSconfig) """Configuration Cross Origin Resource sharing (advanced)""" auto_reset: AutoResetConfig = Field(default_factory=AutoResetConfig) """Configuration for automatic resetting of tasks""" @field_validator("loglevel", mode="after") @classmethod def _check_loglevel(cls, v): v = v.upper() if v not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: raise ValidationError(f"{v} is not a valid loglevel. Must be DEBUG, INFO, WARNING, ERROR, or CRITICAL") return v @field_validator("service_frequency", "heartbeat_frequency", mode="before") @classmethod def _convert_durations(cls, v): return duration_to_seconds(v) @field_validator("access_log_keep", "internal_job_keep", mode="before") def _convert_durations_days(cls, v): if isinstance(v, int) or (isinstance(v, str) and v.isdigit()): return int(v) * 86400 return duration_to_seconds(v) @model_validator(mode="before") @classmethod def _propagate_base_folder(cls, values): if isinstance(values, dict) and "base_folder" in values: values.setdefault("database", {}) values["database"]["base_folder"] = values["base_folder"] return values @model_validator(mode="before") @classmethod def _detect_deprecated_fields(cls, values): if isinstance(values, dict): if "strict_queue_tags" in values: logging.getLogger(__name__).warning("strict_queue_tags is deprecated. Use strict_compute_tags instead") values["strict_compute_tags"] = values.pop("strict_queue_tags") return values @model_validator(mode="after") def _check_paths(self): self.homepage_directory = _make_abs_path(self.homepage_directory, self.base_folder, None) self.upload_directory = _make_abs_path(self.upload_directory, self.base_folder, None) self.logfile = _make_abs_path(self.logfile, self.base_folder, None) self.geoip2_dir = _make_abs_path(self.geoip2_dir, self.base_folder, "geoip2") if self.temporary_dir is None: self.temporary_dir = tempfile.gettempdir() self.temporary_dir = _make_abs_path("qcf_tmp", self.base_folder, tempfile.gettempdir()) else: self.temporary_dir = _make_abs_path(self.temporary_dir, self.base_folder, None) os.makedirs(self.temporary_dir, exist_ok=True) return self model_config = SettingsConfigDict( extra="forbid", case_sensitive=False, env_prefix="QCF_", env_nested_delimiter="__", use_attribute_docstrings=True, ) # Since we manually read the yaml files, the values passed into the init # come from files and should be lower priority that env settings @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 env_settings, dotenv_settings, init_settings, file_secret_settings
# Environment variable prefixes that were removed when the configuration moved from one # BaseSettings per section (each with its own flat prefix) to a single BaseSettings at # the top level with env_nested_delimiter="__". Maps the old prefix to its replacement. _DEPRECATED_ENV_PREFIXES: list[tuple[str, str]] = [ ("QCF_DB_", "QCF_DATABASE__"), ("QCF_APILIMIT_", "QCF_API_LIMITS__"), ("QCF_AUTORESET_", "QCF_AUTO_RESET__"), ("QCF_S3_", "QCF_S3__"), ("QCF_API_", "QCF_API__"), ] def check_deprecated_env_vars() -> None: """ Raise if the environment contains one of the removed per-section variable prefixes These no longer have any effect, so without this check a stale ``QCF_DB_HOST`` would leave the setting at its default with no indication that anything was wrong. Raises ------ RuntimeError If a deprecated environment variable is set. The message names the replacement. """ # Checked first: a valid variable can begin with a deprecated prefix. QCF_API_LIMITS__ # starts with QCF_API_, and used to be rejected as if it were the old QCF_API_ prefix, # which made api_limits impossible to set from the environment at all. current_prefixes = tuple( f"QCF_{name.upper()}__" for name, field in FractalConfig.model_fields.items() if isinstance(field.annotation, type) and issubclass(field.annotation, BaseModel) ) for key in os.environ: upper_key = key.upper() if upper_key.startswith(current_prefixes): continue for old_prefix, new_prefix in _DEPRECATED_ENV_PREFIXES: if not upper_key.startswith(old_prefix): continue # A further underscore means a nested-style name that simply does not exist # (eg QCF_DB__HOST); leave it to pydantic rather than suggesting a mangled # replacement. if upper_key.startswith(old_prefix + "_"): continue new_key = new_prefix + upper_key[len(old_prefix) :] raise RuntimeError(f"Environment variable {key} is deprecated. Use {new_key} instead.") def read_configuration(file_paths: list[str], extra_config: dict[str, Any] | None = None) -> FractalConfig: """ Reads QCFractal configuration from YAML files """ logger = logging.getLogger(__name__) config_data: dict[str, Any] = {} # Read all the files, in order for path in file_paths: with open(path, "r") as yf: logger.info(f"Reading configuration data from {path}") file_data = yaml.safe_load(yf) update_nested_dict(config_data, file_data) if extra_config: update_nested_dict(config_data, extra_config) # Find the base folder # 1. If specified in the environment, use that # 2. Use any specified in a config file # 3. Use the path of the last (highest-priority) file given if "QCF_BASE_FOLDER" in os.environ: base_dir = os.getenv("QCF_BASE_FOLDER") elif config_data.get("base_folder") is not None: base_dir = config_data["base_folder"] elif len(file_paths) > 0: # use the location of the last file as the base directory base_dir = os.path.dirname(file_paths[-1]) else: raise RuntimeError("Base folder must be specified somehow. Maybe set QCF_BASE_FOLDER in the environment?") config_data["base_folder"] = os.path.abspath(base_dir) check_deprecated_env_vars() # Pydantic will handle reading from environment variables # See if it can assemble a config. If there was a problem, and no # config files specified, mention that try: return FractalConfig(**config_data) except Exception as e: if len(file_paths) == 0: raise RuntimeError(f"Could not assemble a working configuration from environment variables:\n{str(e)}") raise def write_initial_configuration(file_path: str, full_config: bool = True): base_folder = os.path.dirname(file_path) # Generate two secret keys for flask/jwt secret_key = secrets.token_urlsafe(32) jwt_secret_key = secrets.token_urlsafe(32) db_config = { "username": "qcfractal", "password": secrets.token_urlsafe(32), } default_config = FractalConfig( base_folder=base_folder, api={"secret_key": secret_key, "jwt_secret_key": jwt_secret_key}, database=db_config ) default_config.database.port = find_open_port(starting_port=5432) default_config.api.port = find_open_port(starting_port=7777) include = None if not full_config: include = { "name": True, "enable_security": True, "log_access": True, "allow_unauthenticated_read": True, "logfile": True, "loglevel": True, "service_frequency": True, "max_active_services": True, "heartbeat_frequency": True, "database": {"own", "host", "port", "database_name", "base_folder", "username", "password"}, "api": {"secret_key", "jwt_secret_key", "host", "port"}, } with open(file_path, "x") as f: yaml.dump(default_config.model_dump(include=include), f, sort_keys=False)