From 5bfaa07c2babde4ad955f6bb077d8d73785f31f2 Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 21 Aug 2026 23:00:09 +0200 Subject: [PATCH] Start without git, and say what that costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `pip install` on a locked-down host — the case the CLI exists for — may have no git, and the store shelled out to it while building the flow repository, so the engine refused to start at all. The store is files; git is their history. Missing it is now one warning and no commits rather than a stack trace, which is the difference between a machine that runs your experiments and one that does not. Found by installing the wheels into a bare python:3.12-slim and pairing it with the portal: `fluksio enroll` took the code, `fluksio serve` dialled out, and the hub was proxying requests through the tunnel. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 ++ backend/fluksio/cli.py | 25 +++++++++-------------- backend/fluksio/flow/store.py | 38 +++++++++++++++++++++++++++-------- backend/tests/test_cli.py | 26 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index c6d7032..c85e4f7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ fluksio enroll --portal https://hub.fluksio.com # watch it from the por Nothing else has to be running. `--data-dir` puts the installation somewhere else — worth it on a cluster, where `$HOME` is often a network filesystem SQLite cannot use. +`git` is not required but is worth having: flows are files either way, and it is what +turns each save into a commit. The dashboard is served by the portal, so a machine with no inbound route is reached without opening a port: it dials out. diff --git a/backend/fluksio/cli.py b/backend/fluksio/cli.py index 2b09b11..72e51cd 100644 --- a/backend/fluksio/cli.py +++ b/backend/fluksio/cli.py @@ -124,14 +124,17 @@ def _enroll(portal: str, code: str, as_email: str | None) -> int: from fluksio.core.db import engine with Session(engine) as session: - _, generated = ensure_superuser(session, email=as_email) + admin, generated = ensure_superuser(session, email=as_email) if generated: - _print_new_admin(session, generated) + _print_new_admin(admin.email, generated) try: user = pick_superuser(session, as_email) except LookupError as exc: print(f"error: {exc}", file=sys.stderr, flush=True) return 1 + # Read before the session closes: the instance is detached after it, + # and touching an attribute then goes back to a database that is gone. + email = user.email try: config = enroll_mod.enroll(session, user, portal, code) except enroll_mod.AlreadyEnrolled as exc: @@ -146,22 +149,14 @@ def _enroll(portal: str, code: str, as_email: str | None) -> int: return 1 _say( - f"Connected to {config.portal_url} as {user.email} " + f"Connected to {config.portal_url} as {email} " f"(installation {config.installation_id})." ) return 0 -def _print_new_admin(session: object, password: str) -> None: - from sqlmodel import Session, select - - from fluksio.models import User - - assert isinstance(session, Session) - user = session.exec( - select(User).where(User.is_superuser == True) # noqa: E712 - ).first() - _say(f"Created the admin account {user.email if user else ''}") +def _print_new_admin(email: str, password: str) -> None: + _say(f"Created the admin account {email}") _say(f" password: {password}") _say(" Shown once. Change it from the dashboard.") @@ -177,11 +172,11 @@ def cmd_serve(args: argparse.Namespace) -> int: from fluksio.core.db import engine with Session(engine) as session: - _, generated = ensure_superuser( + admin, generated = ensure_superuser( session, email=args.admin_email, password=args.admin_password ) if generated: - _print_new_admin(session, generated) + _print_new_admin(admin.email, generated) if args.enroll: if not args.portal: diff --git a/backend/fluksio/flow/store.py b/backend/fluksio/flow/store.py index 5ef120f..fe2ad3f 100644 --- a/backend/fluksio/flow/store.py +++ b/backend/fluksio/flow/store.py @@ -94,6 +94,9 @@ def _same_content(left: FlowDef, right: FlowDef) -> bool: class FlowStore: """Reads and writes flows, committing every change.""" + #: One warning per process when there is no git to commit with. + _warned_no_git = False + def __init__(self, root: Path) -> None: self.root = root self.root.mkdir(parents=True, exist_ok=True) @@ -109,12 +112,27 @@ class FlowStore: # ------------------------------------------------------------------------- def _git(self, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["git", "-C", str(self.root), *args], - capture_output=True, - text=True, - check=False, - ) + try: + return subprocess.run( + ["git", "-C", str(self.root), *args], + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError: + # No git on this machine — a `pip install` on a locked-down host is + # where this happens. The flows are files either way, which is what + # the store is; what is lost is their history, so say it once and + # carry on rather than refusing to start. + if not FlowStore._warned_no_git: + FlowStore._warned_no_git = True + logger.warning( + "git is not installed, so flow changes are not versioned. " + "Install it to get a commit per save." + ) + return subprocess.CompletedProcess( + args=list(args), returncode=1, stdout="", stderr="git is not installed" + ) def _commit(self, message: str, allow_empty: bool = False) -> None: self._git("add", "-A") @@ -129,8 +147,12 @@ class FlowStore: "-m", message, ) - if result.returncode != 0 and "nothing to commit" not in result.stdout: - logger.warning("Could not commit flow change: %s", result.stdout.strip()) + if result.returncode == 0 or "nothing to commit" in result.stdout: + return + if result.stderr == "git is not installed": + # Already said once, in `_git`. Repeating it per save is noise. + return + logger.warning("Could not commit flow change: %s", result.stdout.strip()) # ------------------------------------------------------------------------- # Paths diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py index 56fdaa7..5339dbf 100644 --- a/backend/tests/test_cli.py +++ b/backend/tests/test_cli.py @@ -34,3 +34,29 @@ def test_the_secret_key_is_kept_rather_than_regenerated(tmp_path: Path) -> None: first = load_or_create_secret_key(path) assert load_or_create_secret_key(path) == first assert path.stat().st_mode & 0o777 == 0o600 + + +def test_the_store_works_without_git(tmp_path: Path, monkeypatch) -> None: + """A pip install on a locked-down host may have no git. + + Flows are files, and that is what the store is for; the history is the part + that needs git. Losing it must not be a refusal to start. + """ + import subprocess as sp + + from fluksio.flow.store import FlowStore + + real_run = sp.run + + def no_git(cmd, *args, **kwargs): + if cmd and cmd[0] == "git": + raise FileNotFoundError(2, "No such file or directory", "git") + return real_run(cmd, *args, **kwargs) + + monkeypatch.setattr(sp, "run", no_git) + monkeypatch.setattr(FlowStore, "_warned_no_git", False) + + store = FlowStore(tmp_path / "flows") + assert store.head() == "" + store.write_requirements("numpy\n") + assert store.read_requirements() == "numpy\n"