151 lines
4.0 KiB
Python
151 lines
4.0 KiB
Python
from os.path import expandvars
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
try:
|
|
import tomllib as tomli # py311+
|
|
except ImportError: # pragma: no cover
|
|
import tomli
|
|
import yaml
|
|
from pydantic import BaseModel, Field
|
|
from pydantic.functional_validators import AfterValidator
|
|
|
|
from .identity import generate
|
|
from .slugify import slugify
|
|
|
|
ExpandVarsStr = Annotated[str, AfterValidator(expandvars)]
|
|
|
|
|
|
class App(BaseModel):
|
|
"""Defines an application."""
|
|
|
|
name: str
|
|
slug: str = ""
|
|
path: ExpandVarsStr = "./"
|
|
color: str = ""
|
|
command: ExpandVarsStr = ""
|
|
terminal: bool = False
|
|
|
|
|
|
class Config(BaseModel):
|
|
"""Root configuration model."""
|
|
|
|
apps: list[App] = Field(default_factory=list)
|
|
landing: list[App] = Field(default_factory=list)
|
|
|
|
|
|
def default_config() -> Config:
|
|
"""Get a default empty configuration.
|
|
|
|
Returns:
|
|
Configuration object.
|
|
"""
|
|
return Config()
|
|
|
|
|
|
def load_config(config_path: Path) -> Config:
|
|
"""Load config from a path.
|
|
|
|
Args:
|
|
config_path: Path to TOML configuration.
|
|
|
|
Returns:
|
|
Config object.
|
|
"""
|
|
with Path(config_path).open("rb") as config_file:
|
|
config_data = tomli.load(config_file)
|
|
|
|
def make_app(name, data: dict[str, object], terminal: bool = False) -> App:
|
|
data["name"] = name
|
|
data["terminal"] = terminal
|
|
if terminal:
|
|
data["slug"] = generate().lower()
|
|
elif not data.get("slug", ""):
|
|
data["slug"] = slugify(name)
|
|
|
|
return App(**data)
|
|
|
|
apps = [make_app(name, app) for name, app in config_data.get("app", {}).items()]
|
|
|
|
apps += [
|
|
make_app(name, app, terminal=True) for name, app in config_data.get("terminal", {}).items()
|
|
]
|
|
|
|
config = Config(apps=apps)
|
|
|
|
return config
|
|
|
|
|
|
def load_landing_yaml(manifest_path: Path) -> list[App]:
|
|
"""Load landing apps from YAML manifest.
|
|
|
|
Expected schema: list of {name, slug, command, color?, path?, terminal?}
|
|
"""
|
|
with manifest_path.open("r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or []
|
|
apps: list[App] = []
|
|
for entry in data:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
name = entry.get("name")
|
|
command = entry.get("command")
|
|
if not name or not command:
|
|
continue
|
|
slug = entry.get("slug") or slugify(name)
|
|
apps.append(
|
|
App(
|
|
name=name,
|
|
slug=slug,
|
|
command=command,
|
|
path=entry.get("path", "./"),
|
|
color=entry.get("color", ""),
|
|
terminal=bool(entry.get("terminal", True)),
|
|
)
|
|
)
|
|
return apps
|
|
|
|
|
|
def _extract_label(labels: object, key: str) -> str | None:
|
|
"""Extract a label value from either dict or list[str] forms."""
|
|
if isinstance(labels, dict):
|
|
value = labels.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
return None
|
|
if isinstance(labels, list):
|
|
for item in labels:
|
|
if not isinstance(item, str):
|
|
continue
|
|
if "=" in item:
|
|
k, v = item.split("=", 1)
|
|
if k == key:
|
|
return v
|
|
return None
|
|
|
|
|
|
def load_compose_manifest(manifest_path: Path) -> list[App]:
|
|
"""Load landing apps from a docker-compose YAML file using label `webterm-command`."""
|
|
with manifest_path.open("r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
services = data.get("services", {}) if isinstance(data, dict) else {}
|
|
apps: list[App] = []
|
|
for name, service in services.items():
|
|
if not isinstance(service, dict):
|
|
continue
|
|
labels = service.get("labels", {})
|
|
command = _extract_label(labels, "webterm-command")
|
|
if not command:
|
|
continue
|
|
slug = slugify(name)
|
|
apps.append(
|
|
App(
|
|
name=name,
|
|
slug=slug,
|
|
command=command,
|
|
path=service.get("working_dir", "./"),
|
|
color="",
|
|
terminal=True,
|
|
)
|
|
)
|
|
return apps
|