From 99f65306986b32b0d17b8473ede044dadc17fbb8 Mon Sep 17 00:00:00 2001 From: stroblme Date: Mon, 24 Aug 2026 16:13:35 +0200 Subject: [PATCH] One installation per project, and no login to reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU --- backend/README.md | 14 +++- backend/fluksio/cli.py | 83 ++++++++++++++++++-- backend/fluksio/sdk/cli.py | 19 ++++- backend/fluksio/sdk/client.py | 109 ++++++++++++++++++++++++--- backend/tests/sdk/test_paths.py | 91 ++++++++++++++++++++++ backend/tests/test_cli.py | 30 ++++++++ docs/code/cli.md | 34 +++++++-- docs/getting-started/data-science.md | 80 ++++++++++++++------ docs/reference/configuration.md | 2 +- 9 files changed, 405 insertions(+), 57 deletions(-) create mode 100644 backend/tests/sdk/test_paths.py diff --git a/backend/README.md b/backend/README.md index 0359d42..642bd95 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,8 +9,17 @@ pip install fluksio fluksio serve ``` +Then login once (the token is stored on your device) with the credentials shown after the previous command + +```sh +fluksio login --url http://127.0.0.1:8000 +``` + and you're ready to go! -Fluksio keeps a SQLite database, a git repository of your flows and an artifact store under `~/.fluksio`, and prints an admin password once upon start. + +Fluksio keeps a SQLite database, a git repository of your flows and an artifact +store in a `.fluksio` beside your code — one installation per project, found +the way `.git` is. It prints an admin password once, and signs you in itself. ## For data science @@ -45,10 +54,9 @@ train = Flow("train", nodes=[prepare, fit, evaluate], ``` Fluksio will automatically infer the order of nodes based on the inputs and outputs you defined. -When everything is set, you can access a dashboard as follows: +When everything is set, you can launch your first run as follows: ```sh -fluksio login --url http://127.0.0.1:8000 fluksio sync myresearch fluksio run train --lr 0.05 --wait ``` diff --git a/backend/fluksio/cli.py b/backend/fluksio/cli.py index 25a4e94..fa8abf1 100644 --- a/backend/fluksio/cli.py +++ b/backend/fluksio/cli.py @@ -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 --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") diff --git a/backend/fluksio/sdk/cli.py b/backend/fluksio/sdk/cli.py index 7246600..4af39fe 100644 --- a/backend/fluksio/sdk/cli.py +++ b/backend/fluksio/sdk/cli.py @@ -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( diff --git a/backend/fluksio/sdk/client.py b/backend/fluksio/sdk/client.py index b505e4e..5456793 100644 --- a/backend/fluksio/sdk/client.py +++ b/backend/fluksio/sdk/client.py @@ -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) # --------------------------------------------------------------------------- diff --git a/backend/tests/sdk/test_paths.py b/backend/tests/sdk/test_paths.py new file mode 100644 index 0000000..602eaaa --- /dev/null +++ b/backend/tests/sdk/test_paths.py @@ -0,0 +1,91 @@ +"""Which installation a command is for, and where its token lives. + +A repository with its own venv gets its own engine, so "which one" is a fact +about the working directory rather than about the machine. +""" + +import json +from pathlib import Path + +import pytest + +from fluksio.sdk import client + + +@pytest.fixture +def elsewhere(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A working directory with no installation above it.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg")) + monkeypatch.setattr(client, "GLOBAL_DATA_DIR", tmp_path / "home" / ".fluksio") + return tmp_path + + +def test_a_project_local_installation_is_found_from_below(elsewhere: Path): + (elsewhere / ".fluksio").mkdir() + deep = elsewhere / "src" / "pkg" / "sub" + deep.mkdir(parents=True) + + assert client.find_data_dir(deep) == elsewhere / ".fluksio" + assert client.config_path(client.data_dir(deep)).parent.name == ".fluksio" + + +def test_the_nearest_one_wins(elsewhere: Path): + """An installation inside another belongs to the directory it is in.""" + (elsewhere / ".fluksio").mkdir() + inner = elsewhere / "inner" + (inner / ".fluksio").mkdir(parents=True) + + assert client.find_data_dir(inner) == inner / ".fluksio" + + +def test_with_none_above_it_the_shared_one_answers(elsewhere: Path): + assert client.find_data_dir() is None + assert client.data_dir() == elsewhere / "home" / ".fluksio" + + +def test_a_token_is_written_only_for_its_owner(elsewhere: Path): + path = client.write_config("http://127.0.0.1:8000", "abc", elsewhere / ".fluksio") + + assert json.loads(path.read_text()) == { + "url": "http://127.0.0.1:8000", + "token": "abc", + } + # It is a credential sitting in somebody's project directory. + assert path.stat().st_mode & 0o077 == 0 + # And one nothing can commit by accident. + assert (path.parent / ".gitignore").read_text().endswith("*\n") + + +def test_the_credential_beside_the_data_is_the_one_used(elsewhere: Path): + client.write_config("http://127.0.0.1:8131", "local", elsewhere / ".fluksio") + + assert client._stored()["token"] == "local" + + +def test_a_login_from_before_installations_were_local_still_works(elsewhere: Path): + """`fluksio login` wrote to XDG once; that must not stop answering.""" + legacy = client._legacy_config_path() + legacy.parent.mkdir(parents=True) + legacy.write_text(json.dumps({"url": "http://elsewhere:8000", "token": "old"})) + + assert client._stored()["token"] == "old" + + +def test_a_local_credential_wins_over_the_legacy_one(elsewhere: Path): + legacy = client._legacy_config_path() + legacy.parent.mkdir(parents=True) + legacy.write_text(json.dumps({"url": "http://elsewhere:8000", "token": "old"})) + client.write_config("http://127.0.0.1:8131", "local", elsewhere / ".fluksio") + + assert client._stored()["token"] == "local" + + +def test_ignoring_itself_leaves_an_existing_gitignore_alone(elsewhere: Path): + directory = elsewhere / ".fluksio" + directory.mkdir() + (directory / ".gitignore").write_text("mine\n") + + client.ignore_self(directory) + + assert (directory / ".gitignore").read_text() == "mine\n" diff --git a/backend/tests/test_cli.py b/backend/tests/test_cli.py index dada60d..95e836b 100644 --- a/backend/tests/test_cli.py +++ b/backend/tests/test_cli.py @@ -86,3 +86,33 @@ def test_run_arguments_are_typed_by_the_flow_they_are_for() -> None: with pytest.raises(SyncError, match="not an input of this flow"): _params(definition, ["--nonesuch", "1"]) + + +def test_serve_uses_the_installation_the_directory_belongs_to( + tmp_path: Path, monkeypatch +) -> None: + """A repository with its own venv gets its own engine, not the machine's.""" + from fluksio import cli + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli, "DEFAULT_HOME", tmp_path / "home" / ".fluksio") + + # Nothing above it yet: one is made here rather than in the home directory. + made = cli._data_dir(None) + assert made == (tmp_path / ".fluksio").resolve() + assert (made / ".gitignore").exists() + + # And is then found again from anywhere below it. + deep = tmp_path / "src" / "deep" + deep.mkdir(parents=True) + monkeypatch.chdir(deep) + assert cli._data_dir(None) == made + + # `--global` asks for the shared one even so. + assert ( + cli._data_dir(None, shared=True) == (tmp_path / "home" / ".fluksio").resolve() + ) + + # And `--data-dir` still names any directory outright. + named = cli._data_dir(str(tmp_path / "named")) + assert named == (tmp_path / "named").resolve() diff --git a/docs/code/cli.md b/docs/code/cli.md index b0cf97c..4f595b7 100644 --- a/docs/code/cli.md +++ b/docs/code/cli.md @@ -15,6 +15,18 @@ The command is two things at once: `serve`, `enroll` and `worker` *are* an installation, while `login`, `sync`, `run` and `runs` talk to one that may be anywhere. +## Where an installation lives + +`.fluksio` beside your code, found the way `.git` is: from the working +directory, or any directory above it. Two repositories on one machine are +therefore two engines, with their own flows, runs and token. `fluksio serve` +makes one where there is none, and it ignores itself from within — a +`.gitignore` of `*`, so a database and a credential cannot be committed by +accident. + +`--global` uses `~/.fluksio` instead, shared by every directory. `--data-dir` +(or `FLUKSIO_HOME`) names any directory outright and wins over both. + ## `fluksio serve` Runs the engine. @@ -29,7 +41,7 @@ Docker. | Option | Default | What it does | |---|---|---| -| `--data-dir PATH` | `~/.fluksio` (or `$FLUKSIO_HOME`) | where this installation keeps everything | +| `--data-dir PATH` | `./.fluksio` (or `$FLUKSIO_HOME`) | where this installation keeps everything | | `--host HOST` | `127.0.0.1` | what to bind | | `--port PORT` | `8000` | what to listen on | | `--log-level LEVEL` | `info` | uvicorn's log level | @@ -112,13 +124,17 @@ address an engine over its API rather than being one. ### `fluksio login` ```sh -fluksio login --url http://127.0.0.1:8000 +fluksio login --url https://api.example.com ``` -Asks for an email and password, and keeps the token it gets in -`~/.config/fluksio/client.json` (`$XDG_CONFIG_HOME` is honoured). Everything -below reads it from there, or from `FLUKSIO_URL` and `FLUKSIO_TOKEN`, or from -its own `--url` and `--token`. +For an engine somewhere *else*. One you started yourself needs no login: +`fluksio serve` writes the token as it comes up and says where it put it. + +The token goes in this project's `.fluksio/client.json`, or with `--global` in +`~/.fluksio/client.json`. Every command below reads it from there — nearest +first, walking up from the working directory — or from `FLUKSIO_URL` and +`FLUKSIO_TOKEN`, or from its own `--url` and `--token`. A token an older +version wrote to `~/.config/fluksio/client.json` is still read. ### `fluksio sync` @@ -164,7 +180,9 @@ commit of the repository it came from, and its parameters. ## What lives in the data directory ```text -~/.fluksio/ +.fluksio/ (or ~/.fluksio, with `--global`) +├── client.json the token `serve` wrote, mode 600 +├── .gitignore `*` — a database and a credential, ignored from within ├── fluksio.db SQLite: users, runs, metrics, observability, agents ├── flows/ a git repository — one directory per flow │ ├── house/ @@ -203,7 +221,7 @@ directory. The ones you are most likely to touch: | Variable | Default | What it does | |---|---|---| -| `DATA_DIR` | `~/.fluksio` via the CLI | everything below it derives from this | +| `DATA_DIR` | `./.fluksio` via the CLI; `~/.fluksio` with `--global` | everything below it derives from this | | `DATABASE_URL` | SQLite in the data dir | any SQLAlchemy URL | | `NODE_VENV` | `auto` | which interpreter node code runs on: `auto` adopts the venv Fluksio was installed into, `managed` builds one of its own, or name an interpreter | | `REDIS_HOST` | unset | flow state in Redis instead of memory; survives a restart | diff --git a/docs/getting-started/data-science.md b/docs/getting-started/data-science.md index 8372243..65dbba1 100644 --- a/docs/getting-started/data-science.md +++ b/docs/getting-started/data-science.md @@ -6,36 +6,74 @@ answer to "what was the learning rate on the run that got 94%?", and the This page adds Fluksio to what you already have. It takes about five minutes, installs one Python package, and does not ask you to restructure anything. - ## Install ```sh +cd my-research pip install fluksio fluksio serve ``` That is the whole installation. No Docker, no database server, no ports to -open. The first run prints something like: +open, and no login. The first run prints something like: ```text Created the admin account admin@example.com password: k3Qm-8vTpLdX Shown once. Change it from the dashboard. -Fluksio 0.1.0 — data in /home/you/.fluksio +Fluksio 0.1.0 — data in /home/you/my-research/.fluksio API http://127.0.0.1:8000/api/v1 + Nodes /home/you/my-research/.venv/bin/python + your environment, adopted. Add packages with pip. No portal. Pair this installation with: fluksio enroll --portal https://hub.example.com + Signed in as admin@example.com + token in /home/you/my-research/.fluksio/client.json ``` -**Write that password down.** It is shown once and it is how you authenticate -from here on. +Read the last two lines: you are already signed in. Signing in to your own +machine is a formality — the password was printed by the same process that +would have checked it — so `serve` writes the token itself and every command +below just works. `fluksio login` is for an engine somewhere *else*. -Everything the installation owns lives in `~/.fluksio`: a SQLite database, a -git repository holding your flows, the artifact store, and a virtual -environment your node code runs in. Move it with `--data-dir`, which is worth -doing on a cluster where `$HOME` is a network filesystem — SQLite's -write-ahead log does not work on NFS, and `fluksio serve` warns you when it -notices. +**Write that password down** anyway. It is shown once, and it is what the +dashboard asks for. + +### One installation per project + +`.fluksio` sits beside your code, and is found the way `.git` is — from the +directory you are standing in, or any directory above it. So two repositories +on one machine are two engines: separate flows, separate run history, +separate token, and no chance of one experiment's graph turning up in the +other's. + +```text +~/research/protein-fold/ + .venv/ torch, fluksio + .fluksio/ its own database, flows, artifacts, token + myresearch/ + +~/research/climate-sim/ + .venv/ jax, fluksio + .fluksio/ its own everything + climate/ +``` + +It holds a database and a credential, so it ignores itself from within — a +`.gitignore` of `*`, the same thing `uv` writes into `.venv`. Nothing to add +to your project's own. + +Give them different ports (`--port`) if you want two running at once. + +!!! tip "One engine for the machine instead" + + `fluksio serve --global` uses `~/.fluksio` — shared by every directory, + which is what you want for a personal server rather than a project. When + both exist, the banner says which one you are looking at and how to reach + the other. `--data-dir` still names any directory outright, which is worth + doing on a cluster where `$HOME` is a network filesystem: SQLite's + write-ahead log does not work on NFS, and `fluksio serve` warns you when + it notices. !!! tip "Keep it running" @@ -44,25 +82,23 @@ notices. orchestrator spends before it does anything. Leave it in a `tmux` window, or write a small `systemd --user` unit for it. -## Log in - -```sh -fluksio login --url http://127.0.0.1:8000 -``` - -It asks for the email and password printed above and keeps the token in -`~/.config/fluksio/client.json`, so nothing below needs credentials again. +## Talking to it over HTTP Everything the commands do is the HTTP API, and some of this page shows it -directly. For that, grab the same token as a shell variable: +directly. For that, take the token `serve` already wrote: ```sh export FLUKSIO=http://127.0.0.1:8000/api/v1 -export TOKEN=$(jq -r .token ~/.config/fluksio/client.json) +export TOKEN=$(jq -r .token .fluksio/client.json) ``` While you are experimenting, the interactive schema at is the fastest way to see what is available. + +For an engine on another machine, `fluksio login --url https://…` asks for a +password and stores the token the same way — in this project's `.fluksio`, or +with `--global` in `~/.fluksio`. + ## Your packages are already there If you installed Fluksio into the environment you work in — the venv that @@ -492,7 +528,7 @@ every version of this thing I keep tweaking", note that **your flows are already a git repository**: ```sh -cd ~/.fluksio/flows +cd .fluksio/flows git log --oneline ``` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index b77b1f5..88ea74d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -14,7 +14,7 @@ Anything already exported wins over the file. | Variable | Default | Notes | |---|---|---| -| `DATA_DIR` | `flow-data` (`~/.fluksio` via the CLI) | everything below derives from this | +| `DATA_DIR` | `flow-data` (`./.fluksio` via the CLI; `~/.fluksio` with `--global`) | everything below derives from this | | `DATABASE_URL` | SQLite in `DATA_DIR` | any SQLAlchemy URL | | `FLOWS_DIR` | `$DATA_DIR/flows` | the git repository holding flows | | `SECRETS_FILE` | `$DATA_DIR/secrets.enc` | encrypted credentials, deliberately outside the repo |