mad-cli 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mad_cli/__init__.py +3 -0
- mad_cli/__main__.py +6 -0
- mad_cli/app.py +77 -0
- mad_cli/commands/__init__.py +5 -0
- mad_cli/commands/_adapt.py +41 -0
- mad_cli/commands/_common.py +12 -0
- mad_cli/commands/config.py +94 -0
- mad_cli/commands/install.py +504 -0
- mad_cli/commands/instances.py +102 -0
- mad_cli/commands/keys.py +126 -0
- mad_cli/commands/lifecycle.py +69 -0
- mad_cli/commands/profiles.py +238 -0
- mad_cli/commands/service.py +220 -0
- mad_cli/commands/versions.py +61 -0
- mad_cli/core/__init__.py +4 -0
- mad_cli/core/claude_creds.py +31 -0
- mad_cli/core/compose.py +145 -0
- mad_cli/core/docker_check.py +89 -0
- mad_cli/core/envfile.py +140 -0
- mad_cli/core/instance.py +110 -0
- mad_cli/core/keyspec.py +98 -0
- mad_cli/core/paths.py +40 -0
- mad_cli/core/profiles.py +93 -0
- mad_cli/core/pypi.py +29 -0
- mad_cli/core/templates.py +91 -0
- mad_cli/core/usecases/__init__.py +11 -0
- mad_cli/core/usecases/adopt.py +55 -0
- mad_cli/core/usecases/configvals.py +94 -0
- mad_cli/core/usecases/errors.py +57 -0
- mad_cli/core/usecases/install.py +263 -0
- mad_cli/core/usecases/instances.py +156 -0
- mad_cli/core/usecases/keys.py +169 -0
- mad_cli/core/usecases/lifecycle.py +76 -0
- mad_cli/core/usecases/service.py +269 -0
- mad_cli/core/usecases/versions.py +126 -0
- mad_cli/py.typed +0 -0
- mad_cli/server/__init__.py +13 -0
- mad_cli/server/app.py +260 -0
- mad_cli/server/auth.py +41 -0
- mad_cli/server/models.py +156 -0
- mad_cli/templates/Dockerfile.tmpl +66 -0
- mad_cli/templates/__init__.py +6 -0
- mad_cli/templates/com.mad-core.mad-cli.plist.tmpl +28 -0
- mad_cli/templates/compose.yml.tmpl +29 -0
- mad_cli/templates/entrypoint.sh.tmpl +11 -0
- mad_cli/templates/mad-cli.service.tmpl +15 -0
- mad_cli/ui/__init__.py +5 -0
- mad_cli/ui/console.py +65 -0
- mad_cli/ui/prompts.py +83 -0
- mad_cli-0.4.0.dist-info/METADATA +167 -0
- mad_cli-0.4.0.dist-info/RECORD +54 -0
- mad_cli-0.4.0.dist-info/WHEEL +4 -0
- mad_cli-0.4.0.dist-info/entry_points.txt +2 -0
- mad_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
mad_cli/core/paths.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Config-directory resolution for mad-cli.
|
|
2
|
+
|
|
3
|
+
All paths derive from :func:`config_root`, which honours the ``MAD_CLI_CONFIG_DIR``
|
|
4
|
+
environment override and otherwise falls back to ``~/.config/mad``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_root() -> Path:
|
|
17
|
+
"""Return the mad-cli config root.
|
|
18
|
+
|
|
19
|
+
``$MAD_CLI_CONFIG_DIR`` overrides the default ``~/.config/mad``.
|
|
20
|
+
"""
|
|
21
|
+
override = os.environ.get("MAD_CLI_CONFIG_DIR")
|
|
22
|
+
if override:
|
|
23
|
+
return Path(override).expanduser()
|
|
24
|
+
return Path.home() / ".config" / "mad"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def instances_root() -> Path:
|
|
28
|
+
"""Return the directory that holds one sub-directory per instance."""
|
|
29
|
+
return config_root() / "instances"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def instance_dir(name: str) -> Path:
|
|
33
|
+
"""Return the config directory for ``name``.
|
|
34
|
+
|
|
35
|
+
The name must match ``[a-z0-9][a-z0-9-]*`` so it is safe to use as a
|
|
36
|
+
directory name, a Docker Compose project suffix and a container name.
|
|
37
|
+
"""
|
|
38
|
+
if not _NAME_RE.match(name):
|
|
39
|
+
raise ValueError(f"invalid instance name {name!r}: must match [a-z0-9][a-z0-9-]*")
|
|
40
|
+
return instances_root() / name
|
mad_cli/core/profiles.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Named environment profiles — reusable sets of ``.env`` values.
|
|
2
|
+
|
|
3
|
+
A *profile* is a named, reusable set of environment variables an operator can
|
|
4
|
+
stamp onto instances. It carries **credentials and tuning**, never an instance's
|
|
5
|
+
identity: the keys that name one specific instance (its port, data path, name,
|
|
6
|
+
uid/gid and version pin — :data:`IDENTITY_KEYS`) are deliberately excluded, so a
|
|
7
|
+
profile can be applied across many instances without collision.
|
|
8
|
+
|
|
9
|
+
Profiles are stored one file per profile at ``config_root()/profiles/<name>.env``
|
|
10
|
+
in the same :class:`~mad_cli.core.envfile.EnvFile` format as an instance's
|
|
11
|
+
``.env``, ``chmod 600`` because they may hold secrets. The name follows the same
|
|
12
|
+
rule as an instance name (``[a-z0-9][a-z0-9-]*``) so it is safe as a filename.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from mad_cli.core.envfile import EnvFile
|
|
21
|
+
from mad_cli.core.paths import config_root
|
|
22
|
+
|
|
23
|
+
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
24
|
+
|
|
25
|
+
# Instance-identity keys a profile must never carry: each names one specific
|
|
26
|
+
# instance, not the reusable credentials/tuning a profile is for. Excluded when
|
|
27
|
+
# seeding a profile from an instance (`mad profiles create --from-instance`).
|
|
28
|
+
IDENTITY_KEYS: tuple[str, ...] = (
|
|
29
|
+
"MAD_INSTANCE",
|
|
30
|
+
"MAD_HOST_PORT",
|
|
31
|
+
"PUID",
|
|
32
|
+
"PGID",
|
|
33
|
+
"MAD_DATA_PATH",
|
|
34
|
+
"MAD_VERSION",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ProfileNotFoundError(Exception):
|
|
39
|
+
"""Raised when a named profile does not exist under the config root."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def profiles_root() -> Path:
|
|
43
|
+
"""Return the directory that holds one ``<name>.env`` file per profile."""
|
|
44
|
+
return config_root() / "profiles"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validate_name(name: str) -> str:
|
|
48
|
+
"""Return ``name`` if it is a valid profile name, else raise ``ValueError``."""
|
|
49
|
+
if not _NAME_RE.match(name):
|
|
50
|
+
raise ValueError(f"invalid profile name {name!r}: must match [a-z0-9][a-z0-9-]*")
|
|
51
|
+
return name
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def profile_path(name: str) -> Path:
|
|
55
|
+
"""Return the storage path for profile ``name`` (validates the name)."""
|
|
56
|
+
return profiles_root() / f"{_validate_name(name)}.env"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_profiles() -> list[str]:
|
|
60
|
+
"""Return every stored profile name, sorted (empty when none exist)."""
|
|
61
|
+
root = profiles_root()
|
|
62
|
+
if not root.is_dir():
|
|
63
|
+
return []
|
|
64
|
+
return sorted(p.stem for p in root.iterdir() if p.is_file() and p.suffix == ".env")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_profile(name: str) -> EnvFile:
|
|
68
|
+
"""Load profile ``name`` or raise :class:`ProfileNotFoundError`."""
|
|
69
|
+
path = profile_path(name)
|
|
70
|
+
if not path.is_file():
|
|
71
|
+
raise ProfileNotFoundError(name)
|
|
72
|
+
return EnvFile.load(path)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_profile(name: str, env: EnvFile) -> Path:
|
|
76
|
+
"""Write ``env`` to profile ``name`` (``chmod 600``); return its path.
|
|
77
|
+
|
|
78
|
+
The ``profiles/`` directory is created if needed. The file may hold secrets,
|
|
79
|
+
so it is written with owner-only permissions.
|
|
80
|
+
"""
|
|
81
|
+
path = profile_path(name)
|
|
82
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
env.save(path)
|
|
84
|
+
path.chmod(0o600)
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def delete_profile(name: str) -> None:
|
|
89
|
+
"""Delete profile ``name`` or raise :class:`ProfileNotFoundError`."""
|
|
90
|
+
path = profile_path(name)
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
raise ProfileNotFoundError(name)
|
|
93
|
+
path.unlink()
|
mad_cli/core/pypi.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Latest-version lookup via the PyPI JSON API (urllib, stdlib only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
_PYPI_JSON = "https://pypi.org/pypi/{package}/json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def latest_version(package: str, timeout_s: float = 5.0) -> str | None:
|
|
12
|
+
"""Return the latest published version of ``package`` on PyPI, or ``None``.
|
|
13
|
+
|
|
14
|
+
Any failure — unknown package (404), network error, timeout or malformed
|
|
15
|
+
payload — yields ``None`` rather than raising, so callers can degrade
|
|
16
|
+
gracefully when offline.
|
|
17
|
+
"""
|
|
18
|
+
url = _PYPI_JSON.format(package=package)
|
|
19
|
+
try:
|
|
20
|
+
with urllib.request.urlopen(url, timeout=timeout_s) as response:
|
|
21
|
+
raw = response.read()
|
|
22
|
+
data = json.loads(raw)
|
|
23
|
+
except (OSError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
info = data.get("info") if isinstance(data, dict) else None
|
|
26
|
+
if not isinstance(info, dict):
|
|
27
|
+
return None
|
|
28
|
+
version = info.get("version")
|
|
29
|
+
return version if isinstance(version, str) else None
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Instance file rendering (Dockerfile / compose.yml / entrypoint.sh / .env).
|
|
2
|
+
|
|
3
|
+
Ports the ``write_dockerfile`` / ``write_compose`` / ``write_entrypoint`` heredocs
|
|
4
|
+
from the reference ``configure.sh`` to packaged ``string.Template`` files. The one
|
|
5
|
+
deliberate change: the pip package is parameterised via
|
|
6
|
+
:attr:`RenderContext.edge_package` (default ``"mad-edge"``), and because
|
|
7
|
+
``string.Template`` has no conditionals the container's start binary is resolved
|
|
8
|
+
here in Python — ``mad-edge`` normally, but ``mad`` for the ``mad-bros`` package,
|
|
9
|
+
which still installs the ``mad`` console script.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import string
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from mad_cli.core.envfile import EnvFile
|
|
20
|
+
|
|
21
|
+
EDGE_PACKAGE: str = "mad-edge"
|
|
22
|
+
|
|
23
|
+
# Rendered output name -> packaged template name.
|
|
24
|
+
_TEMPLATES: dict[str, str] = {
|
|
25
|
+
"Dockerfile": "Dockerfile.tmpl",
|
|
26
|
+
"compose.yml": "compose.yml.tmpl",
|
|
27
|
+
"entrypoint.sh": "entrypoint.sh.tmpl",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class RenderContext:
|
|
33
|
+
instance: str
|
|
34
|
+
host_port: int
|
|
35
|
+
data_path: Path
|
|
36
|
+
timeout_s: int
|
|
37
|
+
puid: int
|
|
38
|
+
pgid: int
|
|
39
|
+
edge_package: str = EDGE_PACKAGE
|
|
40
|
+
edge_version: str = "" # '' = latest
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _entrypoint_binary(edge_package: str) -> str:
|
|
44
|
+
"""Console script that starts the server for ``edge_package``.
|
|
45
|
+
|
|
46
|
+
``mad-bros`` ships its server as the ``mad`` script; every other package
|
|
47
|
+
(``mad-edge`` and forks) exposes ``mad-edge``.
|
|
48
|
+
"""
|
|
49
|
+
return "mad" if edge_package == "mad-bros" else "mad-edge"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _load_template(template_name: str) -> string.Template:
|
|
53
|
+
text = resources.files("mad_cli.templates").joinpath(template_name).read_text(encoding="utf-8")
|
|
54
|
+
return string.Template(text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_all(ctx: RenderContext) -> dict[str, str]:
|
|
58
|
+
"""Render the three instance files from the packaged templates."""
|
|
59
|
+
version_spec = f"=={ctx.edge_version}" if ctx.edge_version else ""
|
|
60
|
+
mapping = {
|
|
61
|
+
"instance": ctx.instance,
|
|
62
|
+
"host_port": str(ctx.host_port),
|
|
63
|
+
"data_path": str(ctx.data_path),
|
|
64
|
+
"timeout_s": str(ctx.timeout_s),
|
|
65
|
+
"puid": str(ctx.puid),
|
|
66
|
+
"pgid": str(ctx.pgid),
|
|
67
|
+
"edge_package": ctx.edge_package,
|
|
68
|
+
"edge_version_spec": version_spec,
|
|
69
|
+
"image_tag": ctx.edge_version or "latest",
|
|
70
|
+
"edge_entrypoint": _entrypoint_binary(ctx.edge_package),
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
out_name: _load_template(tmpl_name).substitute(mapping)
|
|
74
|
+
for out_name, tmpl_name in _TEMPLATES.items()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_instance_files(target: Path, ctx: RenderContext, env: EnvFile) -> None:
|
|
79
|
+
"""Render into ``target`` and save ``env`` as ``target/.env``.
|
|
80
|
+
|
|
81
|
+
``entrypoint.sh`` is made executable (mode ``0o755``); the ``.env`` is written
|
|
82
|
+
through :meth:`EnvFile.save` so its comments and ordering survive.
|
|
83
|
+
"""
|
|
84
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
rendered = render_all(ctx)
|
|
86
|
+
(target / "Dockerfile").write_text(rendered["Dockerfile"], encoding="utf-8")
|
|
87
|
+
(target / "compose.yml").write_text(rendered["compose.yml"], encoding="utf-8")
|
|
88
|
+
entrypoint = target / "entrypoint.sh"
|
|
89
|
+
entrypoint.write_text(rendered["entrypoint.sh"], encoding="utf-8")
|
|
90
|
+
entrypoint.chmod(0o755)
|
|
91
|
+
env.save(target / ".env")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Framework-free use-case layer — the shared core of the CLI and the HTTP API.
|
|
2
|
+
|
|
3
|
+
Every operator capability lives here as a plain function that takes primitive
|
|
4
|
+
inputs, drives :mod:`mad_cli.core`, and returns dataclasses (never ``typer``,
|
|
5
|
+
``rich`` or ``fastapi`` objects). The Typer commands and the FastAPI routes are
|
|
6
|
+
thin adapters over these functions, so the two surfaces can never drift.
|
|
7
|
+
|
|
8
|
+
Expected, user-facing failures are raised as :class:`~mad_cli.core.usecases.errors.UseCaseError`
|
|
9
|
+
subclasses; each adapter maps them to its own idiom (``typer.Exit(1)`` / an HTTP
|
|
10
|
+
status code).
|
|
11
|
+
"""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Adopt use case: migrate the legacy single-instance layout into ``instances/``.
|
|
2
|
+
|
|
3
|
+
The old layout kept ``compose.yml`` / ``.env`` / ``Dockerfile`` / ``entrypoint.sh``
|
|
4
|
+
directly under the config root; the modern layout stores them per instance. Only
|
|
5
|
+
those config files move — the instance's data (``MAD_DATA_PATH``) stays put.
|
|
6
|
+
|
|
7
|
+
Planning and applying are split so the CLI can show the plan and ask for
|
|
8
|
+
confirmation before anything moves; the API applies directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from mad_cli.core.instance import discover_instances
|
|
17
|
+
from mad_cli.core.paths import instance_dir
|
|
18
|
+
from mad_cli.core.usecases.errors import ValidationError
|
|
19
|
+
|
|
20
|
+
# Config files that make up a legacy single-instance layout and move together.
|
|
21
|
+
ADOPT_FILES = ("compose.yml", ".env", "Dockerfile", "entrypoint.sh")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class AdoptPlan:
|
|
26
|
+
name: str
|
|
27
|
+
source: Path
|
|
28
|
+
target: Path
|
|
29
|
+
movable: list[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def plan_adopt() -> AdoptPlan | None:
|
|
33
|
+
"""Return the pending adoption, or ``None`` when there is no legacy layout.
|
|
34
|
+
|
|
35
|
+
Raises :class:`ValidationError` if the legacy instance name cannot be used as
|
|
36
|
+
a target directory.
|
|
37
|
+
"""
|
|
38
|
+
legacy = next((i for i in discover_instances() if getattr(i, "legacy", False)), None)
|
|
39
|
+
if legacy is None:
|
|
40
|
+
return None
|
|
41
|
+
source = legacy.config_dir
|
|
42
|
+
try:
|
|
43
|
+
target = instance_dir(legacy.name)
|
|
44
|
+
except ValueError as exc:
|
|
45
|
+
raise ValidationError(f"cannot adopt {legacy.name!r}: {exc}") from exc
|
|
46
|
+
movable = [name for name in ADOPT_FILES if (source / name).exists()]
|
|
47
|
+
return AdoptPlan(name=legacy.name, source=source, target=target, movable=movable)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def apply_adopt(plan: AdoptPlan) -> AdoptPlan:
|
|
51
|
+
"""Move the planned config files into ``instances/<name>/``; return the plan."""
|
|
52
|
+
plan.target.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
for name in plan.movable:
|
|
54
|
+
(plan.source / name).rename(plan.target / name)
|
|
55
|
+
return plan
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``.env`` editing use cases: get / set / unset with validation and masking.
|
|
2
|
+
|
|
3
|
+
The general-purpose ``.env`` editor behind ``mad config`` and the
|
|
4
|
+
``/v1/instances/{name}/config`` routes. Each takes an already-resolved
|
|
5
|
+
:class:`Instance`. A handful of known keys are validated on write; compose-baked
|
|
6
|
+
keys (host port / data bind) still write to ``.env`` but the adapter warns that a
|
|
7
|
+
regenerate is required. Secret-looking values are masked on read via
|
|
8
|
+
:class:`~mad_cli.core.usecases.instances.EnvItem`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
from mad_cli.core.instance import Instance
|
|
16
|
+
from mad_cli.core.keyspec import is_secret_key
|
|
17
|
+
from mad_cli.core.usecases.errors import NotFoundError, ValidationError
|
|
18
|
+
from mad_cli.core.usecases.instances import EnvItem
|
|
19
|
+
|
|
20
|
+
# Keys whose value was rendered into compose.yml at install time.
|
|
21
|
+
COMPOSE_KEYS: tuple[str, ...] = ("MAD_HOST_PORT", "MAD_DATA_PATH")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _validate_port(value: str) -> str:
|
|
25
|
+
try:
|
|
26
|
+
port = int(value)
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise ValidationError(f"invalid MAD_HOST_PORT {value!r}: must be an integer") from None
|
|
29
|
+
if not 1 <= port <= 65535:
|
|
30
|
+
raise ValidationError(f"invalid MAD_HOST_PORT {value!r}: must be between 1 and 65535")
|
|
31
|
+
return str(port)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_timeout(value: str) -> str:
|
|
35
|
+
try:
|
|
36
|
+
seconds = int(value)
|
|
37
|
+
except ValueError:
|
|
38
|
+
raise ValidationError(
|
|
39
|
+
f"invalid MAD_AGENT_TIMEOUT_S {value!r}: must be an integer"
|
|
40
|
+
) from None
|
|
41
|
+
if seconds <= 0:
|
|
42
|
+
raise ValidationError(f"invalid MAD_AGENT_TIMEOUT_S {value!r}: must be a positive integer")
|
|
43
|
+
return str(seconds)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_VALIDATORS: dict[str, Callable[[str], str]] = {
|
|
47
|
+
"MAD_HOST_PORT": _validate_port,
|
|
48
|
+
"MAD_AGENT_TIMEOUT_S": _validate_timeout,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _item(key: str, value: str) -> EnvItem:
|
|
53
|
+
return EnvItem(key=key, value=value, secret=is_secret_key(key))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def list_config(instance: Instance) -> list[EnvItem]:
|
|
57
|
+
"""Return all ``.env`` items (values masked on display)."""
|
|
58
|
+
env = instance.env
|
|
59
|
+
return [_item(key, env.get(key) or "") for key in env.keys()] # noqa: SIM118
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_config(instance: Instance, key: str) -> EnvItem:
|
|
63
|
+
"""Return one ``.env`` item. Raises :class:`NotFoundError` when unset."""
|
|
64
|
+
value = instance.env.get(key)
|
|
65
|
+
if value is None:
|
|
66
|
+
raise NotFoundError(f"{key} is not set on {instance.name}")
|
|
67
|
+
return _item(key, value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def set_config(instance: Instance, key: str, value: str) -> tuple[EnvItem, bool]:
|
|
71
|
+
"""Validate and write a ``.env`` value.
|
|
72
|
+
|
|
73
|
+
Returns ``(item, compose_baked)`` where ``compose_baked`` is True for keys
|
|
74
|
+
rendered into compose.yml at install time. Raises :class:`ValidationError` for
|
|
75
|
+
a bad known value.
|
|
76
|
+
"""
|
|
77
|
+
validator = _VALIDATORS.get(key)
|
|
78
|
+
if validator is not None:
|
|
79
|
+
value = validator(value)
|
|
80
|
+
instance.env.set(key, value)
|
|
81
|
+
instance.env.save()
|
|
82
|
+
return _item(key, value), key in COMPOSE_KEYS
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def unset_config(instance: Instance, key: str) -> bool:
|
|
86
|
+
"""Remove ``key`` from the instance's ``.env``.
|
|
87
|
+
|
|
88
|
+
Returns whether the key existed; False is a no-op the adapter reports.
|
|
89
|
+
"""
|
|
90
|
+
if instance.env.get(key) is None:
|
|
91
|
+
return False
|
|
92
|
+
instance.env.unset(key)
|
|
93
|
+
instance.env.save()
|
|
94
|
+
return True
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Expected, user-facing failures raised by the use-case layer.
|
|
2
|
+
|
|
3
|
+
These are the vocabulary the adapters translate. The CLI turns any of them into
|
|
4
|
+
an ``error(...)`` line plus ``typer.Exit(1)``; the HTTP API maps each subclass to
|
|
5
|
+
a status code (see :data:`HTTP_STATUS`). They carry a human-readable message and
|
|
6
|
+
never wrap a stack trace the operator should not see.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UseCaseError(Exception):
|
|
13
|
+
"""Base class for expected failures surfaced to the operator."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValidationError(UseCaseError, ValueError):
|
|
17
|
+
"""An input was malformed or out of range (bad port, unknown key id, …).
|
|
18
|
+
|
|
19
|
+
Also a :class:`ValueError` so it satisfies the ``ui.prompts.ask`` validator
|
|
20
|
+
contract (raise ``ValueError`` to re-prompt): the same validators back the
|
|
21
|
+
interactive prompts and the non-interactive / HTTP paths.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFoundError(UseCaseError):
|
|
26
|
+
"""A referenced instance, key or value does not exist."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConflictError(UseCaseError):
|
|
30
|
+
"""The requested state clashes with what already exists (instance exists)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AmbiguousInstanceError(UseCaseError):
|
|
34
|
+
"""No instance was named and the target could not be inferred (0 or >1)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PreconditionError(UseCaseError):
|
|
38
|
+
"""A precondition is unmet (Docker missing, no data path, health failed)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Adapter hint: use-case error -> HTTP status. The CLI ignores this and exits 1.
|
|
42
|
+
HTTP_STATUS: dict[type[UseCaseError], int] = {
|
|
43
|
+
ValidationError: 400,
|
|
44
|
+
NotFoundError: 404,
|
|
45
|
+
ConflictError: 409,
|
|
46
|
+
AmbiguousInstanceError: 409,
|
|
47
|
+
PreconditionError: 412,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def http_status_for(exc: UseCaseError) -> int:
|
|
52
|
+
"""Return the HTTP status code for ``exc`` (500 if unmapped)."""
|
|
53
|
+
for cls in type(exc).__mro__:
|
|
54
|
+
status = HTTP_STATUS.get(cls)
|
|
55
|
+
if status is not None:
|
|
56
|
+
return status
|
|
57
|
+
return 500
|