One installation per project, and no login to reach it
Two things a local install should not have asked for. `fluksio serve` now signs you in. Logging in to your own machine was a formality — the password was printed by the same process that would have checked it, and the database it authenticates against sits in the directory the token goes into — so `serve` mints the token itself and says where it put it. `fluksio login` is left for an engine somewhere else. And an installation is `.fluksio` beside the code, found the way `.git` is, rather than one `~/.fluksio` for the machine. A repository with its own venv was already getting its own engine; it now gets its own flows, run history and token too, instead of three repositories sharing one database and fighting over one port. `--global` asks for the shared one, `--data-dir` still names any directory, and when both exist the banner says which you are looking at and how to reach the other. The directory ignores itself from within — a `.gitignore` of `*`, the way uv writes one into `.venv` — because it holds a credential and a database, and neither belongs in anybody's history. The token is written mode 600. A login an older version wrote to ~/.config/fluksio is still read, so nothing that worked stops working. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU
This commit is contained in:
+76
-7
@@ -20,6 +20,7 @@ import os
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import fluksio
|
||||
|
||||
@@ -41,12 +42,37 @@ def _say(message: str = "") -> None:
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def _data_dir(raw: str | None) -> Path:
|
||||
path = Path(raw).expanduser() if raw else DEFAULT_HOME.expanduser()
|
||||
def _data_dir(raw: str | None, shared: bool = False) -> Path:
|
||||
"""Which installation this command is for.
|
||||
|
||||
A repository with its own venv wants its own engine too — its own flows,
|
||||
its own run history, its own token — so the default is a `.fluksio` beside
|
||||
the code, found the way `.git` is. `--global` asks for the shared one
|
||||
instead, and `--data-dir` names any directory outright.
|
||||
"""
|
||||
from fluksio.sdk.client import find_data_dir, ignore_self
|
||||
|
||||
if raw:
|
||||
path = Path(raw).expanduser()
|
||||
elif shared:
|
||||
path = DEFAULT_HOME.expanduser()
|
||||
else:
|
||||
found = find_data_dir()
|
||||
path = found if found is not None else Path.cwd() / ".fluksio"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
ignore_self(path)
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _mention_other_installation(data_dir: Path) -> None:
|
||||
"""Say when there is a second engine, so neither goes looking lost."""
|
||||
shared = DEFAULT_HOME.expanduser().resolve()
|
||||
if data_dir == shared or not (shared / "fluksio.db").exists():
|
||||
return
|
||||
_say(f" Note {shared} holds another installation; this one is separate.")
|
||||
_say(" `fluksio serve --global` runs that one instead.")
|
||||
|
||||
|
||||
def _warn_if_networked(path: Path) -> None:
|
||||
"""A cluster's $HOME is often NFS, and SQLite's WAL does not work there."""
|
||||
try:
|
||||
@@ -161,8 +187,35 @@ def _print_new_admin(email: str, password: str) -> None:
|
||||
_say(" Shown once. Change it from the dashboard.")
|
||||
|
||||
|
||||
#: How long the token `serve` writes for its own machine is good for. Long,
|
||||
#: because the thing it saves you from is logging in again, and it is rewritten
|
||||
#: on every start anyway — an engine left running for a year is the only case
|
||||
#: this has to cover.
|
||||
LOCAL_TOKEN_DAYS = 365
|
||||
|
||||
|
||||
def _sign_in(admin_id: Any, url: str, data_dir: Path) -> Path:
|
||||
"""Write the credential for the engine this command is about to start.
|
||||
|
||||
Logging in to your own machine is a formality: the password was printed by
|
||||
this same process, and the database it authenticates against is in the
|
||||
directory the token goes into. So it costs nothing to be honest about it
|
||||
and hand the client a token, rather than asking somebody to type back
|
||||
something we just told them.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from fluksio.core import security
|
||||
from fluksio.sdk.client import write_config
|
||||
|
||||
token = security.create_access_token(
|
||||
admin_id, expires_delta=timedelta(days=LOCAL_TOKEN_DAYS)
|
||||
)
|
||||
return write_config(url, token, data_dir)
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> int:
|
||||
data_dir = _data_dir(args.data_dir)
|
||||
data_dir = _data_dir(args.data_dir, args.shared)
|
||||
_prepare(data_dir)
|
||||
|
||||
from sqlmodel import Session
|
||||
@@ -175,8 +228,9 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
admin, generated = ensure_superuser(
|
||||
session, email=args.admin_email, password=args.admin_password
|
||||
)
|
||||
admin_id, admin_email = admin.id, admin.email
|
||||
if generated:
|
||||
_print_new_admin(admin.email, generated)
|
||||
_print_new_admin(admin_email, generated)
|
||||
|
||||
if args.enroll:
|
||||
if not args.portal:
|
||||
@@ -194,9 +248,15 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
from fluksio.flow import modules
|
||||
from fluksio.main import app
|
||||
|
||||
# The client talks to this engine, and 0.0.0.0 is not an address to talk
|
||||
# to — it is a statement about which interfaces to listen on.
|
||||
reachable = "127.0.0.1" if args.host in ("0.0.0.0", "::", "") else args.host
|
||||
url = f"http://{reachable}:{args.port}"
|
||||
token_path = _sign_in(admin_id, url, data_dir)
|
||||
|
||||
config = cloud_config.load()
|
||||
_say(f"Fluksio {fluksio.__version__} — data in {data_dir}")
|
||||
_say(f" API http://{args.host}:{args.port}{'/api/v1'}")
|
||||
_say(f" API {url}/api/v1")
|
||||
# Which environment node code imports from is the thing a data scientist
|
||||
# most needs to know at this moment, and the answer differs depending on
|
||||
# how Fluksio was installed. Saying it costs one line.
|
||||
@@ -215,6 +275,9 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
else:
|
||||
_say(" No portal. Pair this installation with:")
|
||||
_say(" fluksio enroll <code> --portal https://hub.example.com")
|
||||
_say(f" Signed in as {admin_email}")
|
||||
_say(f" token in {token_path}")
|
||||
_mention_other_installation(data_dir)
|
||||
|
||||
# One process: it holds the flow engine, and a second worker would be a
|
||||
# second engine — duplicated subscriptions, cron ticks and webhooks.
|
||||
@@ -223,7 +286,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def cmd_enroll(args: argparse.Namespace) -> int:
|
||||
data_dir = _data_dir(args.data_dir)
|
||||
data_dir = _data_dir(args.data_dir, args.shared)
|
||||
_prepare(data_dir)
|
||||
result = _enroll(args.portal, args.code, args.as_email)
|
||||
if result == 0:
|
||||
@@ -248,7 +311,13 @@ def _parser() -> argparse.ArgumentParser:
|
||||
sub.add_argument(
|
||||
"--data-dir",
|
||||
default=os.environ.get("FLUKSIO_HOME"),
|
||||
help=f"where this installation keeps everything (default {DEFAULT_HOME})",
|
||||
help="where this installation keeps everything (default: ./.fluksio)",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--global",
|
||||
dest="shared",
|
||||
action="store_true",
|
||||
help=f"use the shared installation at {DEFAULT_HOME} instead",
|
||||
)
|
||||
|
||||
serve = subparsers.add_parser("serve", help="run the engine")
|
||||
|
||||
@@ -17,9 +17,10 @@ from typing import Any
|
||||
|
||||
from fluksio.sdk import FLOWS, Flow, SyncError
|
||||
from fluksio.sdk.client import (
|
||||
GLOBAL_DATA_DIR,
|
||||
ApiError,
|
||||
Client,
|
||||
config_path,
|
||||
data_dir,
|
||||
login,
|
||||
origin_of,
|
||||
repo_root,
|
||||
@@ -104,13 +105,15 @@ def discover(targets: list[str]) -> list[Flow]:
|
||||
|
||||
|
||||
def cmd_login(args: argparse.Namespace) -> int:
|
||||
"""Only needed for an engine somewhere else — `serve` signs you in here."""
|
||||
email = args.email or input("Email: ")
|
||||
password = args.password or getpass.getpass("Password: ")
|
||||
directory = GLOBAL_DATA_DIR.expanduser() if args.shared else data_dir()
|
||||
try:
|
||||
login(args.url, email, password)
|
||||
path = login(args.url, email, password, directory=directory)
|
||||
except ApiError as exc:
|
||||
return _fail(f"could not log in: {exc.detail}")
|
||||
_say(f"Logged in to {args.url}; the token is in {config_path()}.")
|
||||
_say(f"Logged in to {args.url}; the token is in {path}.")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -263,10 +266,18 @@ def add_parsers(subparsers: Any) -> None:
|
||||
)
|
||||
sub.add_argument("--token", default="", help="override the stored token")
|
||||
|
||||
parser = subparsers.add_parser("login", help="store a token for an engine")
|
||||
parser = subparsers.add_parser(
|
||||
"login", help="store a token for an engine elsewhere"
|
||||
)
|
||||
parser.add_argument("--url", default="http://localhost:8000")
|
||||
parser.add_argument("--email", default="")
|
||||
parser.add_argument("--password", default="")
|
||||
parser.add_argument(
|
||||
"--global",
|
||||
dest="shared",
|
||||
action="store_true",
|
||||
help="store it for the machine rather than this project",
|
||||
)
|
||||
parser.set_defaults(func=cmd_login)
|
||||
|
||||
parser = subparsers.add_parser(
|
||||
|
||||
@@ -17,21 +17,72 @@ from typing import Any
|
||||
|
||||
from fluksio.sdk import MARKER, Flow, SyncError
|
||||
|
||||
__all__ = ["Client", "RunHandle", "SyncReport", "config_path", "login", "sync"]
|
||||
__all__ = [
|
||||
"Client",
|
||||
"RunHandle",
|
||||
"SyncReport",
|
||||
"config_path",
|
||||
"data_dir",
|
||||
"find_data_dir",
|
||||
"ignore_self",
|
||||
"login",
|
||||
"sync",
|
||||
"write_config",
|
||||
]
|
||||
|
||||
API = "/api/v1"
|
||||
#: A run is over when it reaches one of these.
|
||||
DONE = frozenset({"ok", "error", "cancelled", "abandoned"})
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
"""Where ``fluksio login`` leaves the engine it talked to."""
|
||||
#: What a project-local installation is called, beside `.venv` and `.git`.
|
||||
DATA_DIR_NAME = ".fluksio"
|
||||
|
||||
#: The shared one, for a machine that wants a single engine rather than one
|
||||
#: per repository. `fluksio serve --global` is how you ask for it.
|
||||
GLOBAL_DATA_DIR = Path("~/.fluksio")
|
||||
|
||||
|
||||
def find_data_dir(start: Path | None = None) -> Path | None:
|
||||
"""The nearest project-local installation, walking up from ``start``.
|
||||
|
||||
The same search `.git` and `.venv` get, and for the same reason: which
|
||||
installation you mean is a fact about where you are standing, not about
|
||||
which machine you are on. Several repositories on one device each keep
|
||||
their own flows, runs and token this way rather than sharing one.
|
||||
"""
|
||||
directory = (start or Path.cwd()).resolve()
|
||||
for candidate in (directory, *directory.parents):
|
||||
local = candidate / DATA_DIR_NAME
|
||||
if local.is_dir():
|
||||
return local
|
||||
return None
|
||||
|
||||
|
||||
def data_dir(start: Path | None = None) -> Path:
|
||||
"""The installation this working directory belongs to."""
|
||||
found = find_data_dir(start)
|
||||
return found if found is not None else GLOBAL_DATA_DIR.expanduser()
|
||||
|
||||
|
||||
def config_path(directory: Path | None = None) -> Path:
|
||||
"""Where the token for an installation lives — beside the data it opens.
|
||||
|
||||
Not a single file per machine: a token is for one engine, and with an
|
||||
installation per repository there is more than one. Keeping it inside the
|
||||
data directory means the client finds the credential for the engine whose
|
||||
directory it is standing in, without either having to be told.
|
||||
"""
|
||||
return (directory or data_dir()) / "client.json"
|
||||
|
||||
|
||||
def _legacy_config_path() -> Path:
|
||||
"""Where `fluksio login` used to write, before installations were local."""
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
||||
return Path(base) / "fluksio" / "client.json"
|
||||
|
||||
|
||||
def _stored() -> dict[str, str]:
|
||||
path = config_path()
|
||||
def _read(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
@@ -41,6 +92,15 @@ def _stored() -> dict[str, str]:
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _stored() -> dict[str, str]:
|
||||
"""The nearest credential: this project's, then the machine's."""
|
||||
for path in (config_path(), _legacy_config_path()):
|
||||
found = _read(path)
|
||||
if found:
|
||||
return found
|
||||
return {}
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""The engine refused, with what it said."""
|
||||
|
||||
@@ -247,7 +307,37 @@ class RunHandle:
|
||||
return f"RunHandle({self.id!r}, status={self.status!r})"
|
||||
|
||||
|
||||
def login(url: str, email: str, password: str, timeout: float = 30.0) -> str:
|
||||
def ignore_self(directory: Path) -> None:
|
||||
"""Keep an installation out of the repository it sits in.
|
||||
|
||||
It holds a token and a database, neither of which belongs in anybody's
|
||||
history. A `.gitignore` of `*` inside the directory ignores it from within,
|
||||
so nothing has to be added to the project's own — the same thing `uv` does
|
||||
for the venv it builds.
|
||||
"""
|
||||
marker = directory / ".gitignore"
|
||||
if not marker.exists():
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("# A Fluksio installation: a database, and a token.\n*\n")
|
||||
|
||||
|
||||
def write_config(url: str, token: str, directory: Path | None = None) -> Path:
|
||||
"""Store the credential for an engine, readable only by its owner."""
|
||||
path = config_path(directory)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
ignore_self(path.parent)
|
||||
path.write_text(json.dumps({"url": url.rstrip("/"), "token": token}, indent=2))
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
def login(
|
||||
url: str,
|
||||
email: str,
|
||||
password: str,
|
||||
timeout: float = 30.0,
|
||||
directory: Path | None = None,
|
||||
) -> Path:
|
||||
"""Exchange credentials for a token and remember the engine."""
|
||||
import httpx
|
||||
|
||||
@@ -258,12 +348,7 @@ def login(url: str, email: str, password: str, timeout: float = 30.0) -> str:
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise ApiError(response.status_code, _detail(response))
|
||||
token = str(response.json()["access_token"])
|
||||
path = config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"url": url.rstrip("/"), "token": token}, indent=2))
|
||||
path.chmod(0o600)
|
||||
return token
|
||||
return write_config(url, str(response.json()["access_token"]), directory)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user