Skip to content
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
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,17 @@ otherwise clobber each other.
`PyroLabConfiguration` validates **cross-references** on load: every service's `daemon`/`nameservers`, every
daemon's `nameservers`, and every `autolaunch` entry must name something defined (a service with no `daemon:`
refers to one called `default`). `describe_config_error()` turns a load error into readable lines for the CLI.
Anything that writes a config (e.g. `pyrolab rename`) must re-validate before saving.
Anything that writes a config must re-validate before saving; `rename_entity()` renames an entity together with
every reference to it.

The models derive from `_ConfigModel` (plain pydantic `BaseModel`, `extra = "forbid"`), **not** `BaseSettings`:
settings models fill unset fields from same-named environment variables (`PORT`, `HOST`, ...), which silently
changed configurations. Unknown keys are errors. `update_pyro_config()` warns about any field it can't apply
unless the model lists it in `NOT_PYRO_OPTIONS`. Daemon placement (`host`, `port`, `unixsocket`, `nathost`,
`natport`) goes to the daemon's constructor, since Pyro5 has no global `PORT`/`UNIXSOCKET` options.
Nameservers get their names (used for storage paths) whenever a `PyroLabConfiguration` is constructed.

Every config/lockfile write goes through `utils.atomic_write_text()` (temp file + fsync + `os.replace`).

`GlobalConfiguration` is a singleton and **must only be touched from the main process**. Child processes read
the frozen `RUNTIME_CONFIG` file instead, which is why config changes require `pyrolab reload` rather than
Expand All @@ -109,6 +119,8 @@ a hung instrument kills only its own process. Runners are controlled by sentinel
- **Restart**: `checkup()` runs every second; a crash is restarted after `RESTART_BACKOFF` (5, 15, 60, 300s),
and after `MAX_CONSECUTIVE_FAILURES` (5) the entity is `failed` until started by hand or `reload`. Running
`STABLE_UPTIME` (10 min) before dying resets the count. `ps` shows status plus `LAST ERROR`.
- **Reload**: stops everything, then starts what was running (if still configured) plus every `autolaunch`
entry — nameservers first, each awaited — and returns True only if all of it came back.
- **Ready**: runners publish `ready`/`error` in a shared dict. `launch_*(wait=True)` (used by `pyrolab start`
and `reload`) returns `{"status", "error"}`; autolaunch and automatic restarts don't wait.
- **Registration** (`Registrations` class, child side) is non-fatal and retried every `REGISTRATION_RETRY` (30s)
Expand Down
127 changes: 64 additions & 63 deletions pyrolab/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import fileinput
import platform
import re
import shutil
import subprocess
import sys
import textwrap
Expand Down Expand Up @@ -45,14 +44,16 @@
)
from pyrolab.api import Proxy
from pyrolab.configure import (
SECTIONS,
PyroLabConfiguration,
describe_config_error,
export_config,
rename_entity,
reset_config,
update_config,
)
from pyrolab.pyrolabd import PyroLabDaemon, read_lockfile
from pyrolab.utils import pid_is_running
from pyrolab.utils import atomic_write_text, pid_is_running


def _print_config_problems(source, exc: Exception) -> None:
Expand Down Expand Up @@ -303,7 +304,8 @@ def reload():
"""
Reload the PyroLab daemon using the latest configuration file.

Useful if the configuration file has been updated.
Restarts everything that was running (if still configured) and starts every
autolaunch entry.
"""
daemon = get_daemon(suppress_reload_message=True)
if not USER_CONFIG_FILE.exists():
Expand Down Expand Up @@ -376,7 +378,7 @@ def config_reset():
def config_export(filename: str):
"""Export the configuration file"""
if USER_CONFIG_FILE.exists():
shutil.copy(USER_CONFIG_FILE, filename)
atomic_write_text(filename, USER_CONFIG_FILE.read_text())
else:
typer.secho("No configuration file found.", fg=typer.colors.RED)
raise typer.Abort()
Expand Down Expand Up @@ -597,89 +599,88 @@ def logs_export(filename: str):
###############################################################################


def _save_renamed(config: PyroLabConfiguration, old_name: str) -> None:
# Renaming only moves the entry; anything that still refers to the old
# name would leave an invalid configuration (which the daemon refuses to
# start with), so check before writing.
try:
PyroLabConfiguration.parse_obj(config.dict())
except ValueError as e:
def _rename(kind: str, old_name: str, new_name: str, force: bool) -> None:
"""Rename an entity in the user configuration, with every reference."""
if not USER_CONFIG_FILE.exists():
typer.secho("No user configuration file found.", fg=typer.colors.RED)
raise typer.Exit(1)
config = _load_config_or_exit(USER_CONFIG_FILE)
entities = getattr(config, SECTIONS[kind])
if old_name not in entities:
typer.secho(f"{kind.capitalize()} '{old_name}' not found.", fg=typer.colors.RED)
raise typer.Exit(1)
if new_name == old_name:
typer.secho(f"{kind.capitalize()} '{old_name}' already has that name.")
return
if new_name in entities and not force:
typer.secho(
f"Can't rename '{old_name}': other entries still refer to it.",
f"A {kind} named '{new_name}' already exists. Use --force to replace "
"it (and point its references at the renamed one).",
fg=typer.colors.RED,
)
for line in describe_config_error(e):
typer.secho(f" - {line}", fg=typer.colors.RED)
typer.secho("Update those references first. Nothing was changed.")
raise typer.Exit(1)
export_config(config, USER_CONFIG_FILE)

renamed, changes = rename_entity(config, kind, old_name, new_name)
export_config(renamed, USER_CONFIG_FILE)

typer.secho(f"Renamed {kind} '{old_name}' to '{new_name}'.", fg=typer.colors.GREEN)
if changes:
typer.echo("Updated references in: " + ", ".join(changes) + ".")
if kind == "nameserver":
before = config.nameservers[old_name]
if before.storage in ("sql", "dbm"):
after = renamed.nameservers[new_name]
typer.secho(
f"Note: its registrations are stored in "
f"{before.get_storage_location().split(':', 1)[1]}; under the new "
f"name it will use {after.get_storage_location().split(':', 1)[1]}. "
"Move the file before reloading to keep them.",
fg=typer.colors.YELLOW,
)
if kind == "service" or new_name in getattr(renamed.autolaunch, SECTIONS[kind]):
# reload restarts the service's daemon, and starts autolaunch entries.
typer.echo("Run 'pyrolab reload' to apply the new name.")
else:
# reload restarts what was running under names still configured; it
# can't tell the renamed entity is the same one.
typer.echo(
f"If '{old_name}' is running, 'pyrolab reload' will stop it; then "
f"start it under its new name with 'pyrolab start {kind} {new_name}'."
)


rename_app = typer.Typer()
app.add_typer(rename_app, name="rename", help="Rename a nameserver, daemon or service.")

_FORCE = typer.Option(
False, "--force", help="Replace an existing entity that already has the new name."
)


@rename_app.command("nameserver")
def rename_nameserver(
old_name: str,
new_name: str,
):
def rename_nameserver(old_name: str, new_name: str, force: bool = _FORCE):
"""
Rename a nameserver.
Rename a nameserver, updating every service, daemon, and autolaunch entry
that refers to it.
"""
if USER_CONFIG_FILE.exists():
config = PyroLabConfiguration.from_file(USER_CONFIG_FILE)
if old_name in config.nameservers:
config.nameservers[new_name] = config.nameservers.pop(old_name)
_save_renamed(config, old_name)
else:
typer.secho("Nameserver not found.", fg=typer.colors.RED)
raise typer.Exit()
else:
typer.secho("No user configuration file found.", fg=typer.colors.RED)
raise typer.Exit()
_rename("nameserver", old_name, new_name, force)


@rename_app.command("daemon")
def rename_daemon(
old_name: str,
new_name: str,
):
def rename_daemon(old_name: str, new_name: str, force: bool = _FORCE):
"""
Rename a daemon.
Rename a daemon, updating every service and autolaunch entry that refers
to it.
"""
if USER_CONFIG_FILE.exists():
config = PyroLabConfiguration.from_file(USER_CONFIG_FILE)
if old_name in config.daemons:
config.daemons[new_name] = config.daemons.pop(old_name)
_save_renamed(config, old_name)
else:
typer.secho("Nameserver not found.", fg=typer.colors.RED)
raise typer.Exit()
else:
typer.secho("No user configuration file found.", fg=typer.colors.RED)
raise typer.Exit()
_rename("daemon", old_name, new_name, force)


@rename_app.command("service")
def rename_service(
old_name: str,
new_name: str,
):
def rename_service(old_name: str, new_name: str, force: bool = _FORCE):
"""
Rename a service.
"""
if USER_CONFIG_FILE.exists():
config = PyroLabConfiguration.from_file(USER_CONFIG_FILE)
if old_name in config.services:
config.services[new_name] = config.services.pop(old_name)
_save_renamed(config, old_name)
else:
typer.secho("Nameserver not found.", fg=typer.colors.RED)
raise typer.Exit()
else:
typer.secho("No user configuration file found.", fg=typer.colors.RED)
raise typer.Exit()
_rename("service", old_name, new_name, force)


###############################################################################
Expand Down
Loading
Loading