Docs / docs (push) Successful in 49s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m11s
Playwright Tests / test-playwright (2, 2) (push) Failing after 23s
pre-commit / pre-commit (push) Successful in 3m2s
Test Backend / test-backend (push) Successful in 2m22s
Compose Smoke Test / test-compose (push) Failing after 22s
Playwright Tests / merge-reports (push) Canceled after 1s
The gates have never gone green on the new runners. Three separate reasons: - backend/Dockerfile shipped Python 3.10 while the code imports typing.Self and datetime.UTC, so the container exited on import and the suite could not even load its conftest. The image moves to 3.13 and the packages declare >=3.12, which is the floor the tests actually pass on; ruff's target follows and rewrites timezone.utc and asyncio.TimeoutError accordingly. Relocking drops the 3.10 branch, which bumps FastAPI and so regenerates the SDK. - frontend/README.md had no trailing newline and two dashboard widgets used arbitrary text-[…] sizes. Both are em-relative on purpose, so they move to the inline style the neighbouring ramp already uses. - Every commit left its own run queued: without a concurrency group a runner that was offline for a while works through a backlog nobody reads. A stack that fails to come up now prints its logs before the teardown removes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
219 lines
8.5 KiB
Python
219 lines
8.5 KiB
Python
import os
|
|
import secrets
|
|
import warnings
|
|
from pathlib import Path
|
|
from typing import Annotated, Any, Literal, Self
|
|
|
|
from pydantic import (
|
|
AnyUrl,
|
|
BeforeValidator,
|
|
EmailStr,
|
|
HttpUrl,
|
|
computed_field,
|
|
model_validator,
|
|
)
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
#: Everything the engine keeps on disk, relative to :attr:`Settings.DATA_DIR`.
|
|
#: One setting to move the lot; each still overridable on its own, which is
|
|
#: what the container images do.
|
|
DERIVED_PATHS = {
|
|
"FLOWS_DIR": "flows",
|
|
"SECRETS_FILE": "secrets.enc",
|
|
"ALERTS_FILE": "alerts.json",
|
|
"PANELS_FILE": "panels.json",
|
|
"OAUTH_PRIVATE_KEY_FILE": "oauth-key.pem",
|
|
"CLOUD_CONFIG_FILE": "cloud.json",
|
|
}
|
|
|
|
|
|
def parse_cors(v: Any) -> list[str] | str:
|
|
if isinstance(v, str) and not v.startswith("["):
|
|
return [i.strip() for i in v.split(",") if i.strip()]
|
|
elif isinstance(v, list | str):
|
|
return v
|
|
raise ValueError(v)
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(
|
|
# The stack's own file, one level above ./backend/. An installed
|
|
# `fluksio` has no such tree, so its CLI points this at the data
|
|
# directory instead — and at nothing it might find in the cwd.
|
|
env_file=os.environ.get("FLUKSIO_ENV_FILE", "../.env"),
|
|
env_ignore_empty=True,
|
|
extra="ignore",
|
|
)
|
|
API_V1_STR: str = "/api/v1"
|
|
SECRET_KEY: str = secrets.token_urlsafe(32)
|
|
# 60 minutes * 24 hours * 8 days = 8 days
|
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
|
FRONTEND_HOST: str = "http://localhost:5173"
|
|
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
|
|
|
|
#: Everything this installation keeps: the database, the flow repository,
|
|
#: secrets, artifacts and the user venv. The paths below derive from it
|
|
#: unless they are set explicitly.
|
|
DATA_DIR: Path = Path("flow-data")
|
|
#: Any SQLAlchemy URL. The default puts SQLite in the data directory, which
|
|
#: is what makes `fluksio serve` need no infrastructure at all.
|
|
DATABASE_URL: str | None = None
|
|
|
|
# Flows live on disk as a git repository; secrets stay outside it.
|
|
FLOWS_DIR: Path = Path("flow-data/flows")
|
|
SECRETS_FILE: Path = Path("flow-data/secrets.enc")
|
|
# Which failures reach which channel. Beside the flows, not in them:
|
|
# alerting is the deployment's concern, not any one flow's.
|
|
ALERTS_FILE: Path = Path("flow-data/alerts.json")
|
|
# Which dashboards each device shows. Beside the flows for the same reason
|
|
# alerting is: where a screen hangs is the deployment's concern rather than
|
|
# any one dashboard's.
|
|
PANELS_FILE: Path = Path("flow-data/panels.json")
|
|
# Which interpreter node code runs on. "auto" adopts the venv the engine
|
|
# was installed into, when it was installed into one and there is no venv
|
|
# of its own to lose — which is the `pip install fluksio` beside your own
|
|
# packages case. "managed" always builds a separate one, which is what a
|
|
# container wants. A path names an interpreter outright.
|
|
NODE_VENV: str = "auto"
|
|
# The MCP endpoint, and the OAuth server agents authenticate against. Off
|
|
# until someone asks for it: it opens client registration to the network.
|
|
MCP_ENABLED: bool = False
|
|
# Unauthenticated test-only endpoints (user seeding). Requires an explicit
|
|
# opt-in on top of ENVIRONMENT=local, so a deployment that merely kept the
|
|
# default environment never exposes them.
|
|
PRIVATE_API_ENABLED: bool = False
|
|
DOMAIN: str = "localhost"
|
|
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
|
|
# Written only when someone enrols this installation with a portal.
|
|
# Its absence is what keeps remote access off.
|
|
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
|
|
OAUTH_CODE_EXPIRE_SECONDS: int = 60
|
|
# Short, because an agent's token is a bearer secret held by a program
|
|
# rather than a person, and it can refresh unattended.
|
|
MCP_TOKEN_EXPIRE_MINUTES: int = 60
|
|
MCP_REFRESH_EXPIRE_DAYS: int = 30
|
|
FLOW_MAX_WORKERS: int = 4
|
|
# How long a python node may run before its worker is killed, unless the
|
|
# node sets its own. Long enough for a slow HTTP call, short enough that a
|
|
# runaway loop is not a wedged flow.
|
|
FLOW_NODE_TIMEOUT: float = 30.0
|
|
# How long the engine's own metrics, events and run records are kept.
|
|
OBS_RETENTION_DAYS: int = 30
|
|
# Without a Redis host the engine keeps its state in memory.
|
|
REDIS_HOST: str | None = None
|
|
REDIS_PORT: int = 6379
|
|
|
|
BACKEND_CORS_ORIGINS: Annotated[
|
|
list[AnyUrl] | str, BeforeValidator(parse_cors)
|
|
] = []
|
|
|
|
@model_validator(mode="before")
|
|
@classmethod
|
|
def _derive_data_paths(cls, data: Any) -> Any:
|
|
"""Put every stored thing under ``DATA_DIR`` unless it was named.
|
|
|
|
``setdefault``, so the container images keep their explicit ``/data``
|
|
paths and a checkout keeps ``flow-data/``.
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return data
|
|
base = Path(str(data.get("DATA_DIR", "flow-data"))).expanduser()
|
|
data["DATA_DIR"] = base
|
|
for key, name in DERIVED_PATHS.items():
|
|
data.setdefault(key, base / name)
|
|
return data
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
@property
|
|
def oauth_issuer(self) -> str:
|
|
"""Who issues MCP tokens — this app, on its API host.
|
|
|
|
Kept separate from the app's own URL because a hosted deployment can
|
|
later point agents at a different issuer without the resource server
|
|
changing: it validates whatever issuer it is configured to trust.
|
|
"""
|
|
scheme = "http" if self.ENVIRONMENT == "local" else "https"
|
|
return f"{scheme}://api.{self.DOMAIN}"
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
@property
|
|
def mcp_resource(self) -> str:
|
|
"""The resource an MCP token is issued for (RFC 8707)."""
|
|
return f"{self.oauth_issuer}/mcp"
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
@property
|
|
def all_cors_origins(self) -> list[str]:
|
|
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
|
|
self.FRONTEND_HOST
|
|
]
|
|
|
|
PROJECT_NAME: str = "Fluksio"
|
|
SENTRY_DSN: HttpUrl | None = None
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
@property
|
|
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
|
"""SQLite in the data directory, unless a URL says otherwise.
|
|
|
|
One engine process owns this database — the same reason the image runs
|
|
a single uvicorn worker — so a file beside the flows is the honest
|
|
shape for it, and needs nothing running to be one.
|
|
"""
|
|
if self.DATABASE_URL:
|
|
return self.DATABASE_URL
|
|
return f"sqlite:///{(self.DATA_DIR / 'fluksio.db').expanduser().resolve()}"
|
|
|
|
SMTP_TLS: bool = True
|
|
SMTP_SSL: bool = False
|
|
SMTP_PORT: int = 587
|
|
SMTP_HOST: str | None = None
|
|
SMTP_USER: str | None = None
|
|
SMTP_PASSWORD: str | None = None
|
|
EMAILS_FROM_EMAIL: EmailStr | None = None
|
|
EMAILS_FROM_NAME: str | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def _set_default_emails_from(self) -> Self:
|
|
if not self.EMAILS_FROM_NAME:
|
|
self.EMAILS_FROM_NAME = self.PROJECT_NAME
|
|
return self
|
|
|
|
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
|
|
|
|
@computed_field # type: ignore[prop-decorator]
|
|
@property
|
|
def emails_enabled(self) -> bool:
|
|
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
|
|
|
|
EMAIL_TEST_USER: EmailStr = "test@example.com"
|
|
# Absent means "the CLI will make one on first run" — a pip install is not
|
|
# asked for two environment variables before it can start.
|
|
FIRST_SUPERUSER: EmailStr | None = None
|
|
FIRST_SUPERUSER_PASSWORD: str | None = None
|
|
|
|
def _check_default_secret(self, var_name: str, value: str | None) -> None:
|
|
if value == "changethis":
|
|
message = (
|
|
f'The value of {var_name} is "changethis", '
|
|
"for security, please change it, at least for deployments."
|
|
)
|
|
if self.ENVIRONMENT == "local":
|
|
warnings.warn(message, stacklevel=1)
|
|
else:
|
|
raise ValueError(message)
|
|
|
|
@model_validator(mode="after")
|
|
def _enforce_non_default_secrets(self) -> Self:
|
|
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
|
|
self._check_default_secret(
|
|
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
|
|
)
|
|
|
|
return self
|
|
|
|
|
|
# No arguments and no required environment: a fresh install boots on the
|
|
# defaults above, into a data directory of its own.
|
|
settings = Settings()
|