Follows the portal: the noun is "instance" everywhere the app says it — UI strings, CLI output, error details, docs and comments. The wire keys (`instance_id`, `instance_token`) and the hub route this calls move with it. An existing cloud.json is adopted rather than refused: without the key alias the dataclass fails to parse, which the caller swallows and reads as "never enrolled" instead of "reconnect". `instance_key` on a node type becomes `target_key`. It means the outside thing a node points at, which is a different sense of the word, and keeping both would put two meanings of "instance" in one codebase.
293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""The python packages node code may import, in a venv of the user's own.
|
|
|
|
A pip manifest lives beside the flows in the same git repository, so what a
|
|
deployment installed is versioned with what uses it. The packages themselves go
|
|
into a venv on the data volume rather than into the engine's environment: a
|
|
pin here can never shadow — or be shadowed by — what the app itself runs on,
|
|
and the worker processes that import them need nothing from the app.
|
|
|
|
``uv pip sync`` rather than install, so a line taken out of the manifest is
|
|
uninstalled. The manifest is written only after a sync succeeds, which is all
|
|
the rollback a failed resolve needs.
|
|
|
|
None of that applies to an *adopted* venv. A data scientist makes a venv,
|
|
installs what they work with, and then installs Fluksio into it too — at which
|
|
point building a second environment beside it is exactly wrong: the packages
|
|
the nodes need are already here. So when the engine is running from a venv of
|
|
somebody else's, node code runs on it. That venv is theirs: `uv pip sync` is
|
|
never pointed at it, because sync means "hold exactly this" and would uninstall
|
|
their work along with the engine. It is read-only here, and `pip` is how they
|
|
change it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.metadata
|
|
import logging
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fluksio.core.config import settings
|
|
from fluksio.flow.schemas import ModulePackage, ModulesInfo
|
|
|
|
if TYPE_CHECKING:
|
|
from fluksio.flow.store import FlowStore
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Beside the flow store rather than in it: this is installed state, not source.
|
|
VENV_DIR = settings.FLOWS_DIR.parent / "user-venv"
|
|
|
|
#: A resolve that takes longer than this is not going to finish.
|
|
SYNC_TIMEOUT = 300
|
|
|
|
|
|
def uv_bin() -> str:
|
|
"""Where ``uv`` is, without depending on what PATH happens to hold.
|
|
|
|
It is a dependency, so it is installed beside the interpreter running this
|
|
— which is what a systemd unit naming an absolute ExecStart, or a container
|
|
entrypoint, would otherwise miss.
|
|
"""
|
|
beside = Path(sys.executable).with_name("uv")
|
|
if beside.exists():
|
|
return str(beside)
|
|
return shutil.which("uv") or "uv"
|
|
|
|
|
|
def adopted() -> Path | None:
|
|
"""The venv this engine was installed into, when node code should use it.
|
|
|
|
``None`` means the managed venv — one the engine builds and owns. The three
|
|
ways that is the answer, in order:
|
|
|
|
* ``NODE_VENV=managed`` says so. A container sets this: its venv holds the
|
|
app and nothing of anybody's, so there is nothing to adopt.
|
|
* There is already a managed venv. It may have packages in it that
|
|
somebody installed on purpose, and an upgrade must not take them away.
|
|
* The engine is not running from a venv at all.
|
|
|
|
Anything else — ``pip install fluksio`` into the environment you already
|
|
work in — is the case this exists for.
|
|
"""
|
|
setting = settings.NODE_VENV.strip()
|
|
if setting == "managed":
|
|
return None
|
|
if setting and setting != "auto":
|
|
return Path(setting)
|
|
if (VENV_DIR / "bin" / "python").exists():
|
|
return None
|
|
prefix = Path(sys.prefix)
|
|
if prefix == Path(sys.base_prefix) or prefix == VENV_DIR:
|
|
return None
|
|
return prefix
|
|
|
|
|
|
def venv_dir() -> Path:
|
|
"""Whichever venv node code runs from, adopted or managed."""
|
|
return adopted() or VENV_DIR
|
|
|
|
|
|
def venv_python() -> str:
|
|
"""The interpreter node code runs on.
|
|
|
|
Falls back to the engine's own when there is no venv — an instance that
|
|
could not build one still runs python nodes, it just cannot add packages
|
|
to them.
|
|
"""
|
|
root = adopted()
|
|
if root is not None:
|
|
# NODE_VENV may name a venv or the interpreter inside one; both are
|
|
# useful things to be handed, and they are told apart by shape.
|
|
inner = root / "bin" / "python"
|
|
return str(inner if inner.exists() or root.is_dir() else root)
|
|
path = VENV_DIR / "bin" / "python"
|
|
return str(path) if path.exists() else sys.executable
|
|
|
|
|
|
def _marker() -> Path:
|
|
return VENV_DIR / ".applied"
|
|
|
|
|
|
def _digest(requirements: str) -> str:
|
|
version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
return hashlib.sha256(f"{version}\n{requirements}".encode()).hexdigest()
|
|
|
|
|
|
def ensure_venv() -> None:
|
|
"""Create the venv if it is missing or its interpreter has gone."""
|
|
if adopted() is not None:
|
|
return
|
|
if (VENV_DIR / "bin" / "python").exists():
|
|
return
|
|
VENV_DIR.parent.mkdir(parents=True, exist_ok=True)
|
|
# From the base interpreter, not from the engine's venv: nesting one venv
|
|
# inside another is how a user pin ends up resolving against app packages.
|
|
subprocess.run(
|
|
[
|
|
uv_bin(),
|
|
"venv",
|
|
"--python",
|
|
str(Path(sys.base_prefix, "bin", "python3")),
|
|
str(VENV_DIR),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
timeout=SYNC_TIMEOUT,
|
|
)
|
|
|
|
|
|
#: Held for the length of a `uv pip sync`. Two applies at once — two browser
|
|
#: tabs, or an apply landing while the boot-time reconcile is still running —
|
|
#: mutate the same venv simultaneously.
|
|
_sync_lock = threading.Lock()
|
|
|
|
|
|
def sync(requirements: str) -> tuple[bool, str]:
|
|
"""Make the venv hold exactly these packages. Returns success and uv's output.
|
|
|
|
``uv`` missing, or refusing to make the venv, is a failed apply like any
|
|
other — the caller answers 400 with what came back, which is the only thing
|
|
a person can act on.
|
|
"""
|
|
if adopted() is not None:
|
|
# The one thing this must never do. `uv pip sync` makes a venv hold
|
|
# exactly the manifest, so pointed at somebody's own environment it
|
|
# uninstalls their packages — and the engine with them.
|
|
return False, (
|
|
"These packages are not Fluksio's to install: node code runs on "
|
|
f"{venv_python()}, the environment Fluksio itself was installed "
|
|
"into. Install into it with pip or uv, or set NODE_VENV=managed "
|
|
"for a venv the engine owns."
|
|
)
|
|
if not _sync_lock.acquire(timeout=0.0):
|
|
return False, (
|
|
"Another apply is already installing packages. Wait for it to "
|
|
"finish, then try again."
|
|
)
|
|
try:
|
|
return _sync(requirements)
|
|
finally:
|
|
_sync_lock.release()
|
|
|
|
|
|
def _sync(requirements: str) -> tuple[bool, str]:
|
|
"""The install itself, under `_sync_lock`."""
|
|
manifest = ""
|
|
try:
|
|
ensure_venv()
|
|
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as handle:
|
|
handle.write(requirements)
|
|
manifest = handle.name
|
|
result = subprocess.run(
|
|
# Empty means empty: without the flag uv refuses to clear a venv,
|
|
# so deleting the last line would leave the package installed.
|
|
[
|
|
uv_bin(),
|
|
"pip",
|
|
"sync",
|
|
"--allow-empty-requirements",
|
|
"--python",
|
|
venv_python(),
|
|
manifest,
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=SYNC_TIMEOUT,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
detail = str(getattr(exc, "stderr", None) or exc).strip()
|
|
return False, f"uv could not run: {detail}"
|
|
finally:
|
|
if manifest:
|
|
Path(manifest).unlink(missing_ok=True)
|
|
|
|
output = (result.stdout + result.stderr).strip()
|
|
if result.returncode == 0:
|
|
_marker().write_text(_digest(requirements))
|
|
return result.returncode == 0, output
|
|
|
|
|
|
def reconcile(store: FlowStore) -> None:
|
|
"""Bring the venv in line with the stored manifest. Blocking.
|
|
|
|
Called at startup, where nothing may be fatal: a deployment whose packages
|
|
cannot be installed still runs, with the nodes needing them reporting an
|
|
import error each time they execute.
|
|
"""
|
|
try:
|
|
root = adopted()
|
|
if root is not None:
|
|
# Nothing to reconcile: the environment is somebody else's, and
|
|
# bringing it "in line with the manifest" would empty it.
|
|
logger.info("Node code runs on the adopted venv at %s", root)
|
|
return
|
|
# First, and unconditionally: the workers are started against this
|
|
# interpreter, and it has to be the venv's one before anything is
|
|
# installed into it, not after.
|
|
ensure_venv()
|
|
requirements = store.read_requirements()
|
|
if not requirements.strip() and not _marker().exists():
|
|
return
|
|
if _marker().exists() and _marker().read_text() == _digest(requirements):
|
|
return
|
|
ok, output = sync(requirements)
|
|
if not ok:
|
|
logger.warning("Could not install the stored modules: %s", output)
|
|
except Exception:
|
|
logger.exception("Could not reconcile the module venv")
|
|
|
|
|
|
def info(store: FlowStore) -> ModulesInfo:
|
|
"""What is installed, what was asked for, and whether the two agree."""
|
|
requirements = store.read_requirements()
|
|
root = venv_dir()
|
|
is_adopted = adopted() is not None
|
|
|
|
version = ""
|
|
config = root / "pyvenv.cfg"
|
|
if config.exists():
|
|
for line in config.read_text().splitlines():
|
|
if line.startswith("version"):
|
|
version = line.split("=", 1)[1].strip()
|
|
if not version:
|
|
version = ".".join(str(part) for part in sys.version_info[:3])
|
|
|
|
packages: list[ModulePackage] = []
|
|
site = sorted(root.glob("lib/python*/site-packages"))
|
|
if site:
|
|
packages = sorted(
|
|
(
|
|
ModulePackage(name=dist.metadata["Name"] or "", version=dist.version)
|
|
for dist in importlib.metadata.distributions(path=[str(site[0])])
|
|
),
|
|
key=lambda package: package.name.lower(),
|
|
)
|
|
|
|
return ModulesInfo(
|
|
python_version=version,
|
|
venv_path=str(root),
|
|
requirements=requirements,
|
|
packages=packages,
|
|
# An adopted venv is never "out of step": the manifest does not
|
|
# describe it, so there is nothing for it to disagree with.
|
|
applied=(
|
|
True
|
|
if is_adopted
|
|
else (
|
|
_marker().read_text() == _digest(requirements)
|
|
if _marker().exists()
|
|
# Nothing asked for and nothing installed is already in step.
|
|
else not requirements.strip()
|
|
)
|
|
),
|
|
adopted=is_adopted,
|
|
)
|