From 33cb982f996ee0a1e3f63732029bf105bf5f5646 Mon Sep 17 00:00:00 2001 From: zTz01 <1773266173@qq.com> Date: Fri, 7 Aug 2026 19:19:26 +0800 Subject: [PATCH 1/4] fix: add fluxon_py quick start entry --- fluxon_py/quick_start.py | 622 +++++++++++++++++++++++++ fluxon_py/runtime/process_runner.py | 18 + fluxon_py/tests/test_process_runner.py | 9 + fluxon_py/tests/test_quick_start.py | 99 ++++ 4 files changed, 748 insertions(+) create mode 100644 fluxon_py/quick_start.py create mode 100644 fluxon_py/tests/test_quick_start.py diff --git a/fluxon_py/quick_start.py b/fluxon_py/quick_start.py new file mode 100644 index 0000000..2606089 --- /dev/null +++ b/fluxon_py/quick_start.py @@ -0,0 +1,622 @@ +"""Compatibility quick-start helpers for installed `fluxon_py` packages.""" + +from __future__ import annotations + +from collections.abc import Mapping +import copy +import os +import re +import socket +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from fluxon_py.config import _to_plain_yaml_obj +from fluxon_py.runtime import ( + start_fs_agent_process, + start_fs_master_process, + start_kv_master_process, + start_owner_kvclient_process, +) +from fluxon_py.runtime.process_runner import ManagedSubprocess, wait_subproc_or_ctrlc + +__all__ = ["serve_s3_single_node"] + +_DEFAULT_PANEL_PORT = 26180 +_DEFAULT_EXPORT_NAME = "quick-start-export" +_DEFAULT_CACHE_MAX_BYTES = 1024 * 1024 * 1024 +_DEFAULT_CLUSTER_NAME = "fluxon_s3" +_DEFAULT_FS_MASTER_INSTANCE_KEY = "fluxon_s3_fs_master" +_DEFAULT_FS_AGENT_INSTANCE_KEY = "fluxon_s3_fs_agent" +_DEFAULT_KV_MASTER_INSTANCE_KEY = "fluxon_s3_master" +_DEFAULT_KV_OWNER_INSTANCE_KEY = "fluxon_s3_owner" +_DEFAULT_SHARE_MEM_DIRNAME = "sharemem" +_DEFAULT_FS_MASTER_LOG_DIRNAME = "kv-master" +_DEFAULT_FS_OWNER_LOG_DIRNAME = "kv-owner" +_DEFAULT_ACCESS_DB_RELATIVE_PATH = Path("fs_master") / "access.db" +_DEFAULT_PY_REACTOR_MODE = "event_driven" +_EXPORT_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$") + + +@dataclass(frozen=True) +class _S3SingleNodeBundle: + data_root: Path + state_root: Path + kv_master_config: dict[str, Any] + kv_owner_config: dict[str, Any] + fs_master_config: dict[str, Any] + fs_agent_config: dict[str, Any] + kv_master_config_path: Path + kv_owner_config_path: Path + fs_master_config_path: Path + fs_agent_config_path: Path + kv_master_workdir: Path + kv_owner_workdir: Path + fs_master_workdir: Path + fs_agent_workdir: Path + share_mem_path: Path + access_db_path: Path + panel_port: int + panel_public_base_url: str + export_name: str + + @property + def s3_endpoint(self) -> str: + return f"{self.panel_public_base_url}/fs_s3" + + @property + def s3_ui_url(self) -> str: + return f"{self.s3_endpoint}/ui/" + + +def serve_s3_single_node( + data_dir: str | os.PathLike[str], + state_dir: str | os.PathLike[str], + *, + kv_master_config: Mapping[str, Any], + kv_owner_config: Mapping[str, Any], + export_name: str = _DEFAULT_EXPORT_NAME, + start_middleware: bool = False, + greptime_base_url: str | None = None, + panel_port: int = _DEFAULT_PANEL_PORT, + panel_listen_host: str = "0.0.0.0", + bootstrap_username: str = "admin", + bootstrap_password: str = "admin", + export_cache_max_bytes: int = _DEFAULT_CACHE_MAX_BYTES, +) -> None: + if start_middleware: + raise NotImplementedError("quick_start only supports start_middleware=False") + + bundle = _build_s3_single_node_bundle( + data_dir=data_dir, + state_dir=state_dir, + kv_master_config=kv_master_config, + kv_owner_config=kv_owner_config, + export_name=export_name, + greptime_base_url=greptime_base_url, + panel_port=panel_port, + panel_listen_host=panel_listen_host, + bootstrap_username=bootstrap_username, + bootstrap_password=bootstrap_password, + export_cache_max_bytes=export_cache_max_bytes, + ) + + _prepare_runtime_dirs(bundle) + + children: list[ManagedSubprocess] = [] + started = False + try: + print("[fluxon_quick_start] starting kv master...") + kv_master_proc = start_kv_master_process( + workdir=bundle.kv_master_workdir, + config_path=bundle.kv_master_config_path, + log_path=bundle.state_root / "log" / "kv_master.log", + ) + children.append(ManagedSubprocess(label="kv_master", proc=kv_master_proc)) + _wait_for_process_alive(kv_master_proc, label="kv_master", seconds=10, log_path=bundle.state_root / "log" / "kv_master.log") + + print("[fluxon_quick_start] starting owner kvclient...") + _clear_stale_shared_json(bundle.share_mem_path, bundle.kv_owner_config["fluxonkv_spec"]["cluster_name"]) + owner_proc = start_owner_kvclient_process( + workdir=bundle.kv_owner_workdir, + config_path=bundle.kv_owner_config_path, + log_path=bundle.state_root / "log" / "kv_owner.log", + ) + children.append(ManagedSubprocess(label="kv_owner", proc=owner_proc)) + _wait_for_shared_json( + share_mem_path=bundle.share_mem_path, + cluster_name=bundle.kv_owner_config["fluxonkv_spec"]["cluster_name"], + proc=owner_proc, + label="kv_owner", + log_path=bundle.state_root / "log" / "kv_owner.log", + ) + + print("[fluxon_quick_start] starting fluxon_fs master...") + fs_master_proc = start_fs_master_process( + workdir=bundle.fs_master_workdir, + config_path=bundle.fs_master_config_path, + log_path=bundle.state_root / "log" / "fs_master.log", + ) + children.append(ManagedSubprocess(label="fs_master", proc=fs_master_proc)) + _wait_for_tcp_ready( + fs_master_proc, + label="fs_master", + host="127.0.0.1", + port=bundle.panel_port, + timeout=30, + log_path=bundle.state_root / "log" / "fs_master.log", + ) + + print("[fluxon_quick_start] starting fluxon_fs agent...") + fs_agent_proc = start_fs_agent_process( + workdir=bundle.fs_agent_workdir, + config_path=bundle.fs_agent_config_path, + log_path=bundle.state_root / "log" / "fs_agent.log", + ) + children.append(ManagedSubprocess(label="fs_agent", proc=fs_agent_proc)) + _wait_for_log_text( + bundle.state_root / "log" / "fs_agent.log", + "fluxon_fs agent ready", + proc=fs_agent_proc, + label="fs_agent", + ) + + print() + print(f"S3 endpoint: {bundle.s3_endpoint}") + print(f"Web UI: {bundle.s3_ui_url}") + print(f"bucket: {bundle.export_name}") + print(f"Basic Auth: {bootstrap_username} / {bootstrap_password}") + print(f"data dir: {bundle.data_root}") + print(f"state dir: {bundle.state_root}") + + started = True + wait_subproc_or_ctrlc(children, stop_timeout_seconds=5.0) + finally: + if not started: + _terminate_children(children) + + +def _build_s3_single_node_bundle( + *, + data_dir: str | os.PathLike[str], + state_dir: str | os.PathLike[str], + kv_master_config: Mapping[str, Any], + kv_owner_config: Mapping[str, Any], + export_name: str, + greptime_base_url: str | None, + panel_port: int, + panel_listen_host: str, + bootstrap_username: str, + bootstrap_password: str, + export_cache_max_bytes: int, +) -> _S3SingleNodeBundle: + if panel_port <= 0: + raise ValueError("panel_port must be > 0") + if export_cache_max_bytes <= 0: + raise ValueError("export_cache_max_bytes must be > 0") + if not bootstrap_username.strip(): + raise ValueError("bootstrap_username must be non-empty") + if not bootstrap_password.strip(): + raise ValueError("bootstrap_password must be non-empty") + if not panel_listen_host.strip(): + raise ValueError("panel_listen_host must be non-empty") + _validate_export_name(export_name) + + data_root = Path(data_dir).expanduser().resolve() + state_root = Path(state_dir).expanduser().resolve() + data_root.mkdir(parents=True, exist_ok=True) + state_root.mkdir(parents=True, exist_ok=True) + + kv_master = _plain_mapping_copy(kv_master_config, "kv_master_config") + kv_owner = _plain_mapping_copy(kv_owner_config, "kv_owner_config") + + master_cluster_name = _require_str( + kv_master.get("cluster_name") or _DEFAULT_CLUSTER_NAME, + "kv_master_config.cluster_name", + ) + _ensure_master_defaults(kv_master, state_root=state_root, greptime_base_url=greptime_base_url) + + owner_spec = _require_mapping(kv_owner.get("fluxonkv_spec"), "kv_owner_config.fluxonkv_spec") + owner_cluster_name = owner_spec.get("cluster_name") + if owner_cluster_name is None: + owner_spec["cluster_name"] = master_cluster_name + else: + owner_cluster_name = _require_str(owner_cluster_name, "kv_owner_config.fluxonkv_spec.cluster_name") + if owner_cluster_name != master_cluster_name: + raise ValueError( + "kv_owner_config.fluxonkv_spec.cluster_name must match kv_master_config.cluster_name" + ) + + etcd_endpoints = kv_master.get("etcd_endpoints") + if not isinstance(etcd_endpoints, list) or not etcd_endpoints: + raise ValueError("kv_master_config.etcd_endpoints must be a non-empty list") + normalized_etcd_endpoints = [_require_str(endpoint, "kv_master_config.etcd_endpoints[]") for endpoint in etcd_endpoints] + + share_mem_path = Path( + owner_spec.get("share_mem_path") + or (state_root / _DEFAULT_SHARE_MEM_DIRNAME) + ).expanduser().resolve() + owner_spec["share_mem_path"] = str(share_mem_path) + owner_spec.setdefault("sub_cluster", "default") + owner_spec.setdefault("large_file_paths", [str(state_root / "kv-owner" / "large")]) + owner_spec.setdefault("etcd_addresses", list(normalized_etcd_endpoints)) + _ensure_owner_defaults(kv_owner, master_cluster_name=master_cluster_name) + + panel_public_base_url = f"http://127.0.0.1:{panel_port}" + prometheus_base_url = _resolve_prometheus_base_url( + greptime_base_url=greptime_base_url, + kv_master_config=kv_master, + ) + access_db_path = (state_root / _DEFAULT_ACCESS_DB_RELATIVE_PATH).resolve() + + fs_master_instance_key = str( + kv_master.get("fs_master_instance_key") + or f"{master_cluster_name}_fs_master" + or _DEFAULT_FS_MASTER_INSTANCE_KEY + ) + fs_agent_instance_key = str( + kv_master.get("fs_agent_instance_key") + or f"{master_cluster_name}_fs_agent" + or _DEFAULT_FS_AGENT_INSTANCE_KEY + ) + + fs_master_config = { + "kvclient": _build_external_kvclient_config( + instance_key=fs_master_instance_key, + cluster_name=master_cluster_name, + share_mem_path=share_mem_path, + ), + "fluxon_fs": { + "master": { + "instance_key": fs_master_instance_key, + "pull_interval_ms": 1000, + }, + "master_panel": { + "listen_addr": f"{panel_listen_host}:{panel_port}", + "public_base_url": panel_public_base_url, + "prometheus_base_url": prometheus_base_url, + "auto_refresh_interval_secs": 2, + "access_db_path": str(access_db_path), + "bootstrap_access_model": { + "users": [ + { + "username": bootstrap_username, + "password": bootstrap_password, + "can_manage_users": True, + } + ], + "scope_access": [], + }, + "s3_gateway": { + "get_object_inflight_pieces": 8, + "kv_miss_policy": "remote_read", + }, + }, + "cache": { + "stale_window_ms": 1000, + "rules": [], + "exports": { + export_name: { + "remote_root_dir_abs": str(data_root), + "cache_max_bytes": export_cache_max_bytes, + } + }, + }, + }, + } + + fs_agent_config = { + "kvclient": _build_external_kvclient_config( + instance_key=fs_agent_instance_key, + cluster_name=master_cluster_name, + share_mem_path=share_mem_path, + ), + "fluxon_fs": { + "master": { + "instance_key": fs_master_instance_key, + }, + "cache": { + "stale_window_ms": 1000, + "rules": [], + "exports": { + export_name: { + "remote_root_dir_abs": str(data_root), + "cache_max_bytes": export_cache_max_bytes, + } + }, + }, + }, + } + + kv_master_config = dict(kv_master) + kv_owner_config = dict(kv_owner) + + kv_master_workdir = state_root / _DEFAULT_FS_MASTER_LOG_DIRNAME + kv_owner_workdir = state_root / _DEFAULT_FS_OWNER_LOG_DIRNAME + fs_master_workdir = state_root / "fs_master_runtime" + fs_agent_workdir = state_root / "fs_agent_runtime" + + kv_master_config_path = kv_master_workdir / "config.yaml" + kv_owner_config_path = kv_owner_workdir / "config.yaml" + fs_master_config_path = fs_master_workdir / "config.yaml" + fs_agent_config_path = fs_agent_workdir / "config.yaml" + + return _S3SingleNodeBundle( + data_root=data_root, + state_root=state_root, + kv_master_config=kv_master_config, + kv_owner_config=kv_owner_config, + fs_master_config=fs_master_config, + fs_agent_config=fs_agent_config, + kv_master_config_path=kv_master_config_path, + kv_owner_config_path=kv_owner_config_path, + fs_master_config_path=fs_master_config_path, + fs_agent_config_path=fs_agent_config_path, + kv_master_workdir=kv_master_workdir, + kv_owner_workdir=kv_owner_workdir, + fs_master_workdir=fs_master_workdir, + fs_agent_workdir=fs_agent_workdir, + share_mem_path=share_mem_path, + access_db_path=access_db_path, + panel_port=panel_port, + panel_public_base_url=panel_public_base_url, + export_name=export_name, + ) + + +def _ensure_master_defaults( + kv_master: dict[str, Any], + *, + state_root: Path, + greptime_base_url: str | None, +) -> None: + kv_master.setdefault("cluster_name", _DEFAULT_CLUSTER_NAME) + kv_master.setdefault("instance_key", _DEFAULT_KV_MASTER_INSTANCE_KEY) + kv_master.setdefault("port", 25100) + kv_master.setdefault("log_dir", str(state_root / "kv-master" / "log")) + kv_master.setdefault("network", {"tcp_reactor_mode": _DEFAULT_PY_REACTOR_MODE}) + if "monitoring" not in kv_master: + kv_master["monitoring"] = _build_monitoring_block(greptime_base_url) + + +def _ensure_owner_defaults(kv_owner: dict[str, Any], *, master_cluster_name: str) -> None: + kv_owner.setdefault("instance_key", _DEFAULT_KV_OWNER_INSTANCE_KEY) + kv_owner.setdefault("contribute_to_cluster_pool_size", {"dram": 1024 * 1024 * 1024, "vram": {}}) + kv_owner.setdefault("network", {"tcp_reactor_mode": _DEFAULT_PY_REACTOR_MODE}) + owner_spec = _require_mapping(kv_owner.get("fluxonkv_spec"), "kv_owner_config.fluxonkv_spec") + owner_spec.setdefault("cluster_name", master_cluster_name) + + +def _build_external_kvclient_config( + *, + instance_key: str, + cluster_name: str, + share_mem_path: Path, +) -> dict[str, Any]: + return { + "instance_key": instance_key, + "network": {"tcp_reactor_mode": _DEFAULT_PY_REACTOR_MODE}, + "fluxonkv_spec": { + "cluster_name": cluster_name, + "share_mem_path": str(share_mem_path), + }, + } + + +def _build_monitoring_block(greptime_base_url: str | None) -> dict[str, Any]: + base_url = _resolve_greptime_base_url(greptime_base_url) + return { + "prometheus_base_url": f"{base_url}/v1/prometheus", + "prom_remote_write_url": [f"{base_url}/v1/prometheus/write"], + "otlp_log_api": { + "otlp_endpoint": f"{base_url}/v1/otlp/v1/logs", + "db_name": "public", + "table_name": "fluxon_logs", + }, + } + + +def _resolve_prometheus_base_url(*, greptime_base_url: str | None, kv_master_config: dict[str, Any]) -> str: + if greptime_base_url: + return f"{greptime_base_url.rstrip('/')}/v1/prometheus" + monitoring = kv_master_config.get("monitoring") + if isinstance(monitoring, Mapping): + prometheus_base_url = monitoring.get("prometheus_base_url") + if isinstance(prometheus_base_url, str) and prometheus_base_url.strip(): + return prometheus_base_url + return "http://127.0.0.1:24000/v1/prometheus" + + +def _resolve_greptime_base_url(greptime_base_url: str | None) -> str: + if greptime_base_url: + return greptime_base_url.rstrip("/") + return "http://127.0.0.1:24000" + + +def _plain_mapping_copy(value: Mapping[str, Any], name: str) -> dict[str, Any]: + plain = _to_plain_yaml_obj(value, name) + if not isinstance(plain, dict): + raise TypeError(f"{name} must decode to a mapping") + return copy.deepcopy(plain) + + +def _require_mapping(value: Any, name: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise TypeError(f"{name} must be a mapping") + return value + + +def _require_str(value: Any, name: str) -> str: + if not isinstance(value, str): + raise TypeError(f"{name} must be a string") + stripped = value.strip() + if not stripped: + raise ValueError(f"{name} must be non-empty") + return stripped + + +def _validate_export_name(export_name: str) -> None: + if not _EXPORT_NAME_RE.fullmatch(export_name): + raise ValueError( + "export_name must match ^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$" + ) + + +def _prepare_runtime_dirs(bundle: _S3SingleNodeBundle) -> None: + for path in ( + bundle.state_root / "log", + bundle.kv_master_workdir, + bundle.kv_master_workdir / "log", + bundle.kv_owner_workdir, + bundle.fs_master_workdir, + bundle.fs_agent_workdir, + bundle.share_mem_path, + bundle.access_db_path.parent, + bundle.state_root / "kv-owner" / "large", + ): + path.mkdir(parents=True, exist_ok=True) + _write_yaml(bundle.kv_master_config_path, bundle.kv_master_config) + _write_yaml(bundle.kv_owner_config_path, bundle.kv_owner_config) + _write_yaml(bundle.fs_master_config_path, bundle.fs_master_config) + _write_yaml(bundle.fs_agent_config_path, bundle.fs_agent_config) + + +def _write_yaml(path: Path, value: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + plain = _to_plain_yaml_obj(value, str(path)) + path.write_text(yaml.safe_dump(plain, sort_keys=False), encoding="utf-8") + + +def _clear_stale_shared_json(share_mem_path: Path, cluster_name: str) -> None: + target = share_mem_path / cluster_name / "shared.json" + if target.exists(): + target.unlink() + + +def _wait_for_shared_json( + *, + share_mem_path: Path, + cluster_name: str, + timeout: int = 180, + proc: subprocess.Popen[bytes] | None = None, + label: str = "owner", + log_path: Path | None = None, +) -> None: + target = share_mem_path / cluster_name / "shared.json" + deadline = time.time() + timeout + while time.time() < deadline: + _raise_if_process_exited(proc, label=label, log_path=log_path) + if target.exists(): + return + time.sleep(0.5) + _raise_if_process_exited(proc, label=label, log_path=log_path) + raise RuntimeError(f"{label} did not create shared.json under {target.parent} within {timeout}s") + + +def _wait_for_tcp_ready( + proc: subprocess.Popen[bytes], + *, + label: str, + host: str, + port: int, + timeout: int, + log_path: Path | None = None, +) -> None: + probe_host = "127.0.0.1" if host in {"0.0.0.0", "::", "[::]"} else host + deadline = time.time() + timeout + while time.time() < deadline: + _raise_if_process_exited(proc, label=label, log_path=log_path) + try: + with socket.create_connection((probe_host, port), timeout=1): + return + except OSError: + time.sleep(0.5) + _raise_if_process_exited(proc, label=label, log_path=log_path) + raise RuntimeError(f"{label} did not open {probe_host}:{port} within {timeout}s") + + +def _wait_for_log_text( + log_path: Path, + needle: str, + *, + proc: subprocess.Popen[bytes] | None = None, + label: str = "process", + timeout: int = 60, +) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + _raise_if_process_exited(proc, label=label, log_path=log_path) + if log_path.exists(): + try: + text = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + text = "" + if needle in text: + return + time.sleep(0.5) + _raise_if_process_exited(proc, label=label, log_path=log_path) + raise RuntimeError(f"{label} log did not contain {needle!r} within {timeout}s: {log_path}") + + +def _wait_for_process_alive( + proc: subprocess.Popen[bytes], + *, + label: str, + seconds: int, + log_path: Path | None = None, +) -> None: + deadline = time.time() + seconds + while time.time() < deadline: + _raise_if_process_exited(proc, label=label, log_path=log_path) + time.sleep(0.5) + _raise_if_process_exited(proc, label=label, log_path=log_path) + + +def _raise_if_process_exited( + proc: subprocess.Popen[bytes] | None, + *, + label: str, + log_path: Path | None = None, +) -> None: + if proc is None: + return + rc = proc.poll() + if rc is None: + return + detail = f"{label} exited unexpectedly with rc={rc}" + if log_path is not None and log_path.exists(): + detail += f"; log tail:\n{_tail_text(log_path)}" + raise RuntimeError(detail) + + +def _tail_text(path: Path, limit: int = 4000) -> str: + try: + data = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + if len(data) <= limit: + return data + return data[-limit:] + + +def _terminate_children(children: list[ManagedSubprocess], timeout_seconds: float = 5.0) -> None: + for child in reversed(children): + if child.proc.poll() is None: + try: + child.proc.terminate() + except Exception: + pass + deadline = time.time() + timeout_seconds + while time.time() < deadline: + if all(child.proc.poll() is not None for child in children): + return + time.sleep(0.2) + for child in reversed(children): + if child.proc.poll() is None: + try: + child.proc.kill() + except Exception: + pass diff --git a/fluxon_py/runtime/process_runner.py b/fluxon_py/runtime/process_runner.py index 0421fff..2cefa78 100644 --- a/fluxon_py/runtime/process_runner.py +++ b/fluxon_py/runtime/process_runner.py @@ -22,6 +22,7 @@ RuntimeConfigInput = Path | Mapping[str, Any] FORCE_KILL_WAIT_SECONDS = 10.0 +_PACKAGE_ROOT = Path(__file__).resolve().parents[2] @dataclass(frozen=True) @@ -517,6 +518,7 @@ def _start_runtime_process( ) -> subprocess.Popen[bytes]: popen_kwargs: dict[str, Any] = { "preexec_fn": build_parent_death_sigterm_preexec(expected_parent_pid=os.getpid()), + "env": _build_runtime_subprocess_env(), } if cwd is not None: popen_kwargs["cwd"] = str(cwd) @@ -538,6 +540,22 @@ def _start_runtime_process( return proc +def _build_runtime_subprocess_env() -> dict[str, str]: + env = os.environ.copy() + existing_pythonpath = env.get("PYTHONPATH", "") + package_root = str(_PACKAGE_ROOT) + pythonpath_parts: list[str] = [package_root] + if existing_pythonpath: + for entry in existing_pythonpath.split(os.pathsep): + stripped = entry.strip() + if not stripped or stripped == package_root: + continue + pythonpath_parts.append(stripped) + env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts) + env.setdefault("PYTHONUNBUFFERED", "1") + return env + + def _set_parent_death_sigterm(*, expected_parent_pid: int) -> None: # Keep this even in the attached parent/child model: # - A plain attached child does not die automatically when the parent is diff --git a/fluxon_py/tests/test_process_runner.py b/fluxon_py/tests/test_process_runner.py index ae9dbef..7c01c83 100644 --- a/fluxon_py/tests/test_process_runner.py +++ b/fluxon_py/tests/test_process_runner.py @@ -10,6 +10,7 @@ import threading import time import unittest +from unittest import mock from pathlib import Path @@ -22,6 +23,7 @@ def main() -> None: from fluxon_py.runtime.process_runner import ( # noqa: E402 + _build_runtime_subprocess_env, build_runtime_singleton_spec, register_ctrlc_callback, _stop_existing_processes_if_running, @@ -64,6 +66,13 @@ def setUp(self) -> None: self._tmp = _new_test_dir("process_runner") self.addCleanup(lambda: shutil.rmtree(self._tmp, ignore_errors=False)) + def test_build_runtime_subprocess_env_prepends_repo_root(self) -> None: + with mock.patch.dict(os.environ, {"PYTHONPATH": "/tmp/custom"}, clear=True): + env = _build_runtime_subprocess_env() + + self.assertEqual(env["PYTHONPATH"], f"{REPO_ROOT}:/tmp/custom") + self.assertEqual(env["PYTHONUNBUFFERED"], "1") + def test_register_ctrlc_callback_runs_outside_signal_frame(self) -> None: script_path = self._tmp / "ctrlc_callback.py" marker_path = self._tmp / "ctrlc_callback.txt" diff --git a/fluxon_py/tests/test_quick_start.py b/fluxon_py/tests/test_quick_start.py new file mode 100644 index 0000000..7cc9ba0 --- /dev/null +++ b/fluxon_py/tests/test_quick_start.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import importlib +import tempfile +from pathlib import Path +import unittest + + +class QuickStartCompatTest(unittest.TestCase): + def test_import_quick_start_module(self) -> None: + module = importlib.import_module("fluxon_py.quick_start") + self.assertTrue(hasattr(module, "serve_s3_single_node")) + + def test_build_s3_single_node_bundle_uses_expected_paths(self) -> None: + module = importlib.import_module("fluxon_py.quick_start") + + with tempfile.TemporaryDirectory() as td: + root = Path(td) + data_dir = root / "data" + state_dir = root / "state" + data_dir.mkdir() + state_dir.mkdir() + + kv_master_config = { + "etcd_endpoints": ["127.0.0.1:22379"], + "cluster_name": "fluxon_s3", + "instance_key": "fluxon_s3_master", + "port": 25100, + "log_dir": "/tmp/unused", + "monitoring": { + "prometheus_base_url": "http://127.0.0.1:24000/v1/prometheus", + "prom_remote_write_url": ["http://127.0.0.1:24000/v1/prometheus/write"], + "otlp_log_api": { + "otlp_endpoint": "http://127.0.0.1:24000/v1/otlp/v1/logs", + "db_name": "public", + "table_name": "fluxon_logs", + }, + }, + } + kv_owner_config = { + "instance_key": "fluxon_s3_owner", + "contribute_to_cluster_pool_size": {"dram": 1024 * 1024 * 1024, "vram": {}}, + "fluxonkv_spec": { + "etcd_addresses": ["127.0.0.1:22379"], + "cluster_name": "fluxon_s3", + "share_mem_path": str(state_dir / "sharemem"), + "sub_cluster": "default", + "large_file_paths": [str(state_dir / "large" / "owner")], + }, + } + + bundle = module._build_s3_single_node_bundle( + data_dir=data_dir, + state_dir=state_dir, + kv_master_config=kv_master_config, + kv_owner_config=kv_owner_config, + export_name="quick-start-export", + greptime_base_url="http://127.0.0.1:24000", + panel_port=26180, + panel_listen_host="0.0.0.0", + bootstrap_username="admin", + bootstrap_password="admin", + export_cache_max_bytes=1024 * 1024 * 1024, + ) + + self.assertEqual(bundle.s3_endpoint, "http://127.0.0.1:26180/fs_s3") + self.assertEqual(bundle.s3_ui_url, "http://127.0.0.1:26180/fs_s3/ui/") + self.assertEqual( + bundle.fs_master_config["fluxon_fs"]["cache"]["exports"]["quick-start-export"]["remote_root_dir_abs"], + str(data_dir.resolve()), + ) + self.assertEqual( + bundle.fs_master_config["fluxon_fs"]["master_panel"]["access_db_path"], + str((state_dir / "fs_master" / "access.db").resolve()), + ) + self.assertEqual( + bundle.fs_master_config["fluxon_fs"]["master_panel"]["bootstrap_access_model"]["users"][0]["username"], + "admin", + ) + self.assertEqual( + bundle.fs_agent_config["fluxon_fs"]["cache"]["exports"]["quick-start-export"]["remote_root_dir_abs"], + str(data_dir.resolve()), + ) + self.assertEqual( + bundle.kv_owner_config["fluxonkv_spec"]["share_mem_path"], + str((state_dir / "sharemem").resolve()), + ) + + def test_start_middleware_true_is_rejected(self) -> None: + module = importlib.import_module("fluxon_py.quick_start") + with self.assertRaises(NotImplementedError): + module.serve_s3_single_node( + "/tmp/data", + "/tmp/state", + kv_master_config={}, + kv_owner_config={}, + start_middleware=True, + ) + From 7f598c3f6df93f2fcbc8c921735467215ac33785 Mon Sep 17 00:00:00 2001 From: zTz01 <1773266173@qq.com> Date: Fri, 7 Aug 2026 21:17:06 +0800 Subject: [PATCH 2/4] release: bump version to 0.2.3 --- README.md | 8 ++-- README_CN.md | 8 ++-- examples/fluxon_quick_start/README.md | 8 ++-- examples/fluxon_quick_start/build_image.py | 2 +- ...23\345\255\230\351\223\276\350\267\257.md" | 2 +- fluxon_py/__init__.py | 2 +- fluxon_release/release_notes/v0.2.3.md | 46 +++++++++++++++++++ fluxon_rs/Cargo.toml | 2 +- fluxon_rs/fluxon_cli/Cargo.toml | 2 +- fluxon_rs/fluxon_commu/Cargo.toml | 2 +- .../Cargo.toml | 2 +- fluxon_rs/fluxon_framework/Cargo.toml | 2 +- .../fluxon_framework_compiled/Cargo.toml | 2 +- fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml | 2 +- fluxon_rs/fluxon_kv/Cargo.toml | 2 +- fluxon_rs/fluxon_mq/Cargo.toml | 2 +- fluxon_rs/fluxon_observability/Cargo.toml | 2 +- fluxon_rs/fluxon_ops/Cargo.toml | 2 +- fluxon_rs/fluxon_pyo3/Cargo.toml | 2 +- fluxon_rs/fluxon_util/Cargo.toml | 2 +- fluxon_rs/limit_thirdparty/Cargo.toml | 2 +- fluxon_rs/setup.py | 2 +- .../utils/docker_build_runtime_utils.py | 2 +- 23 files changed, 77 insertions(+), 31 deletions(-) create mode 100644 fluxon_release/release_notes/v0.2.3.md diff --git a/README.md b/README.md index ffc9dcb..2a1e54f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ An AI-native distributed data plane that supports high performance RPC, KV Cache [![Linux Only](https://img.shields.io/badge/Linux-Only-2ea44f)](#runtime-requirements) [![Python](https://img.shields.io/badge/Python-%3E%3D3.10-3776AB)](#runtime-requirements) [![Rust](https://img.shields.io/badge/Rust-1.93.0-000000)](./fluxon_rs/rust-toolchain.toml) -[![Latest](https://img.shields.io/badge/Latest-v0.2.2-f28500)](./fluxon_release) +[![Latest](https://img.shields.io/badge/Latest-v0.2.3-f28500)](./fluxon_release) [![Interfaces](https://img.shields.io/badge/Interfaces-KV%2FRPC%20%7C%20MQ%20%7C%20FS-1f6feb)](#interface-capabilities)
@@ -201,7 +201,7 @@ The distribution installs the `fluxon_py` import package. Service-plane runtimes ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.2 \ + hanbaoaaa/fluxon_quick_start:0.2.3 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -234,7 +234,7 @@ Related interface docs: ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.2 \ + hanbaoaaa/fluxon_quick_start:0.2.3 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -265,7 +265,7 @@ Related interface docs: ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.2 \ + hanbaoaaa/fluxon_quick_start:0.2.3 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/README_CN.md b/README_CN.md index e5d01b8..58b82a8 100644 --- a/README_CN.md +++ b/README_CN.md @@ -9,7 +9,7 @@ [![Linux Only](https://img.shields.io/badge/Linux-Only-2ea44f)](#运行要求) [![Python](https://img.shields.io/badge/Python-%3E%3D3.10-3776AB)](#运行要求) [![Rust](https://img.shields.io/badge/Rust-1.93.0-000000)](./fluxon_rs/rust-toolchain.toml) -[![Latest](https://img.shields.io/badge/Latest-v0.2.2-f28500)](./fluxon_release) +[![Latest](https://img.shields.io/badge/Latest-v0.2.3-f28500)](./fluxon_release) [![Interfaces](https://img.shields.io/badge/Interfaces-KV%2FRPC%20%7C%20MQ%20%7C%20FS-1f6feb)](#接口能力)
@@ -198,7 +198,7 @@ python3 -m pip install fluxon-py ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.2 \ + hanbaoaaa/fluxon_quick_start:0.2.3 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -231,7 +231,7 @@ del demo:hello ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.2 \ + hanbaoaaa/fluxon_quick_start:0.2.3 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -262,7 +262,7 @@ exit ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.2 \ + hanbaoaaa/fluxon_quick_start:0.2.3 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/examples/fluxon_quick_start/README.md b/examples/fluxon_quick_start/README.md index 1c1a3bd..352c4ad 100644 --- a/examples/fluxon_quick_start/README.md +++ b/examples/fluxon_quick_start/README.md @@ -11,7 +11,7 @@ It does not replace the formal service-plane, KV, MQ, or FS interface docs. - unified quick-start entrypoint - `build_image.py` - quick-start image build entrypoint -- `fluxon_quick_start:0.2.2` +- `fluxon_quick_start:0.2.3` - quick-start Docker image ## Runtime Modes @@ -86,7 +86,7 @@ Python environment can already import both `fluxon_py` and `fluxon_pyo3`. ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.2 \ + fluxon_quick_start:0.2.3 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -119,7 +119,7 @@ del demo:hello ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.2 \ + fluxon_quick_start:0.2.3 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -155,7 +155,7 @@ The background consumer keeps printing received messages. ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.2 \ + fluxon_quick_start:0.2.3 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/examples/fluxon_quick_start/build_image.py b/examples/fluxon_quick_start/build_image.py index 6c14a64..4a6762b 100644 --- a/examples/fluxon_quick_start/build_image.py +++ b/examples/fluxon_quick_start/build_image.py @@ -22,7 +22,7 @@ SCRIPTS_DIR = REPO_ROOT / "setup_and_pack" DOCKERFILE_PATH = SCRIPT_DIR / "Dockerfile" IMAGE_NAME = "fluxon_quick_start" -IMAGE_TAG = "0.2.2" +IMAGE_TAG = "0.2.3" # Binaries to copy from ext_images into quick_start bin/. EXT_BINARIES = ("etcd/etcd", "etcd/etcdctl", "greptime/greptime") diff --git "a/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" "b/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" index 4126b3c..55f1acc 100644 --- "a/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" +++ "b/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" @@ -207,7 +207,7 @@ docker run -d --name fluxon-s3 ` --mount "type=bind,src=C:\fluxon-s3\data,dst=/data" ` --mount "type=bind,src=C:\fluxon-s3\state,dst=/state" ` --entrypoint python3 ` - "hanbaoaaa/fluxon_quick_start:0.2.2" ` + "hanbaoaaa/fluxon_quick_start:0.2.3" ` -c " from fluxon_py.quick_start import serve_s3_single_node diff --git a/fluxon_py/__init__.py b/fluxon_py/__init__.py index b5b6e45..391acee 100644 --- a/fluxon_py/__init__.py +++ b/fluxon_py/__init__.py @@ -58,7 +58,7 @@ from typing import Any -__version__ = "0.2.2" +__version__ = "0.2.3" __all__ = [ # Core API "KvClient", diff --git a/fluxon_release/release_notes/v0.2.3.md b/fluxon_release/release_notes/v0.2.3.md new file mode 100644 index 0000000..17955d3 --- /dev/null +++ b/fluxon_release/release_notes/v0.2.3.md @@ -0,0 +1,46 @@ +# Fluxon v0.2.3 + +`v0.2.3` is a packaging and quick-start release for the public `fluxon-py` +distribution. It keeps the closed communication SDK and open-surface contract at +their existing versions while making the installed wheel usable for the S3 +single-node quick-start flow. + +## Highlights + +- Added the installed-package entrypoint + `fluxon_py.quick_start.serve_s3_single_node(...)` for exposing a local + directory through FluxonFS S3. +- Kept the public PyPI distribution name as `fluxon-py`, with the import package + remaining `fluxon_py`. +- Aligned runtime subprocess imports with the installed `fluxon_py` package so + child service processes use the same package tree as the caller. +- Verified the Linux pip flow with a locally built release wheel, including + service startup, first-credential update, rclone bucket listing, upload, + local read, local append, S3 readback, and S3 delete. + +## Version and SDK Contract + +- Public Python package, Rust workspace, and Quick Start version: `0.2.3`. +- Closed communication SDK version: `0.2.1`. +- Closed SDK required open-surface contract version: `0.2.1`. + +The SDK version and open-surface contract version are independent from the +public release version. This release does not change the closed SDK manifest, +closed ABI/schema constants, or `FLUXON_COMMU_OPEN_SURFACE_VERSION`. + +## Release Artifacts + +- PyPI: `fluxon-py==0.2.3`. +- Docker Hub: `hanbaoaaa/fluxon_quick_start:0.2.3`. +- GitHub Release: `fluxon_release.tar.gz`. +- GitHub Release: `fluxon_quick_start_0.2.3_docker_image.tar.gz`. + +The Docker publication does not update `latest`. + +## Runtime Requirements and Known Limits + +- Linux only; Python `>=3.10`. +- The S3 pip quick start reuses external etcd and GreptimeDB when + `start_middleware=False`. +- Dynamic bucket creation is not part of the quick-start contract; configure the + exported bucket name up front. diff --git a/fluxon_rs/Cargo.toml b/fluxon_rs/Cargo.toml index 6cb3c08..5fbb1eb 100644 --- a/fluxon_rs/Cargo.toml +++ b/fluxon_rs/Cargo.toml @@ -22,7 +22,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.2" +version = "0.2.3" edition = "2024" license = "" authors = ["teleai_infra"] diff --git a/fluxon_rs/fluxon_cli/Cargo.toml b/fluxon_rs/fluxon_cli/Cargo.toml index 88be1e4..668faa8 100644 --- a/fluxon_rs/fluxon_cli/Cargo.toml +++ b/fluxon_rs/fluxon_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_cli" -version = "0.2.2" +version = "0.2.3" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_commu/Cargo.toml b/fluxon_rs/fluxon_commu/Cargo.toml index 0373ca2..af67440 100644 --- a/fluxon_rs/fluxon_commu/Cargo.toml +++ b/fluxon_rs/fluxon_commu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_commu" -version = "0.2.2" +version = "0.2.3" edition = "2024" build = "build.rs" diff --git a/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml b/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml index a6ca631..437ab27 100644 --- a/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml +++ b/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_commu_closed_sdk_consumer" -version = "0.2.2" +version = "0.2.3" edition = "2024" build = "build.rs" diff --git a/fluxon_rs/fluxon_framework/Cargo.toml b/fluxon_rs/fluxon_framework/Cargo.toml index b3a3d86..d579a24 100644 --- a/fluxon_rs/fluxon_framework/Cargo.toml +++ b/fluxon_rs/fluxon_framework/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_framework" -version = "0.2.2" +version = "0.2.3" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_framework_compiled/Cargo.toml b/fluxon_rs/fluxon_framework_compiled/Cargo.toml index 5a8c95c..715bd11 100644 --- a/fluxon_rs/fluxon_framework_compiled/Cargo.toml +++ b/fluxon_rs/fluxon_framework_compiled/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_framework_compiled" -version = "0.2.2" +version = "0.2.3" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml b/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml index e6caba2..d2bbb34 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml +++ b/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_fs_s3_gateway" -version = "0.2.2" +version = "0.2.3" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_kv/Cargo.toml b/fluxon_rs/fluxon_kv/Cargo.toml index 9ddeae3..c49b502 100644 --- a/fluxon_rs/fluxon_kv/Cargo.toml +++ b/fluxon_rs/fluxon_kv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_kv" -version = "0.2.2" +version = "0.2.3" edition = "2024" [features] diff --git a/fluxon_rs/fluxon_mq/Cargo.toml b/fluxon_rs/fluxon_mq/Cargo.toml index 8a0324e..69dd113 100644 --- a/fluxon_rs/fluxon_mq/Cargo.toml +++ b/fluxon_rs/fluxon_mq/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_mq" -version = "0.2.2" +version = "0.2.3" edition = "2021" [lib] diff --git a/fluxon_rs/fluxon_observability/Cargo.toml b/fluxon_rs/fluxon_observability/Cargo.toml index f8c1572..00dbf2c 100644 --- a/fluxon_rs/fluxon_observability/Cargo.toml +++ b/fluxon_rs/fluxon_observability/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_observability" -version = "0.2.2" +version = "0.2.3" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_ops/Cargo.toml b/fluxon_rs/fluxon_ops/Cargo.toml index 5cf2e0e..c1637db 100644 --- a/fluxon_rs/fluxon_ops/Cargo.toml +++ b/fluxon_rs/fluxon_ops/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_ops" -version = "0.2.2" +version = "0.2.3" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_pyo3/Cargo.toml b/fluxon_rs/fluxon_pyo3/Cargo.toml index a74496b..4d0438a 100644 --- a/fluxon_rs/fluxon_pyo3/Cargo.toml +++ b/fluxon_rs/fluxon_pyo3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_pyo3" -version = "0.2.2" +version = "0.2.3" edition = "2024" [lib] diff --git a/fluxon_rs/fluxon_util/Cargo.toml b/fluxon_rs/fluxon_util/Cargo.toml index cb7ef9b..305f50c 100644 --- a/fluxon_rs/fluxon_util/Cargo.toml +++ b/fluxon_rs/fluxon_util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_util" -version = "0.2.2" +version = "0.2.3" edition = "2024" authors = ["Your Name "] description = "Utility crate with macros and helper functions" diff --git a/fluxon_rs/limit_thirdparty/Cargo.toml b/fluxon_rs/limit_thirdparty/Cargo.toml index f91e685..b946566 100644 --- a/fluxon_rs/limit_thirdparty/Cargo.toml +++ b/fluxon_rs/limit_thirdparty/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "limit_thirdparty" -version = "0.2.2" +version = "0.2.3" edition = "2024" [dependencies] diff --git a/fluxon_rs/setup.py b/fluxon_rs/setup.py index 0bc10e5..b87a0c5 100644 --- a/fluxon_rs/setup.py +++ b/fluxon_rs/setup.py @@ -36,7 +36,7 @@ def find_libs(): setup( name="fluxon_pyo3", - version="0.2.2", + version="0.2.3", description="for export fluxonkv core to python layer", long_description=open("README.md").read() if os.path.exists("README.md") else "", long_description_content_type="text/markdown", diff --git a/setup_and_pack/utils/docker_build_runtime_utils.py b/setup_and_pack/utils/docker_build_runtime_utils.py index 47ec139..c40b23a 100644 --- a/setup_and_pack/utils/docker_build_runtime_utils.py +++ b/setup_and_pack/utils/docker_build_runtime_utils.py @@ -550,7 +550,7 @@ def build_docker_run_cmd( """Build a `docker run` command (without executing it). Args: - image: Image name (with tag), e.g. "fluxon_quick_start:0.2.2". + image: Image name (with tag), e.g. "fluxon_quick_start:0.2.3". name: Container name (`--name`). remove: Auto-remove on exit (`--rm`). detach: Run detached (`-d`). From 6e594dca792cc25faa94e7cc7b4ac0d189eb2f25 Mon Sep 17 00:00:00 2001 From: zTz01 <1773266173@qq.com> Date: Fri, 7 Aug 2026 21:51:39 +0800 Subject: [PATCH 3/4] docs: update v0.2.3 release notes --- fluxon_release/release_notes/v0.2.3.md | 124 +++++++++++++++++++------ 1 file changed, 98 insertions(+), 26 deletions(-) diff --git a/fluxon_release/release_notes/v0.2.3.md b/fluxon_release/release_notes/v0.2.3.md index 17955d3..a546cc4 100644 --- a/fluxon_release/release_notes/v0.2.3.md +++ b/fluxon_release/release_notes/v0.2.3.md @@ -1,34 +1,92 @@ -# Fluxon v0.2.3 +# 🚀 Fluxon v0.2.3 -`v0.2.3` is a packaging and quick-start release for the public `fluxon-py` -distribution. It keeps the closed communication SDK and open-surface contract at -their existing versions while making the installed wheel usable for the S3 -single-node quick-start flow. +`v0.2.3` rolls up the mainline work merged from July 14 through August 5, 2026, together with the new release pipeline in the tagged revision. The largest changes are a distributed SSD backing tier for Fluxon KV, hybrid S3 object writes, communication ABI 9, event-driven TCP reactors, stronger MQ and framework lifecycle handling, and expanded release-grade CI. -## Highlights +## ✨ Highlights -- Added the installed-package entrypoint - `fluxon_py.quick_start.serve_s3_single_node(...)` for exposing a local - directory through FluxonFS S3. -- Kept the public PyPI distribution name as `fluxon-py`, with the import package - remaining `fluxon_py`. -- Aligned runtime subprocess imports with the installed `fluxon_py` package so - child service processes use the same package tree as the caller. -- Verified the Linux pip flow with a locally built release wheel, including - service startup, first-credential update, rclone bucket listing, upload, - local read, local append, S3 readback, and S3 delete. +- Added an owner-local SSD backing tier behind the existing Fluxon KV `put` / `get` / `delete` contract. +- Added size-aware S3 writes, KV-backed write sessions, shared lease keepalive, and retryable temporary-key cleanup. +- Added `FluxonFsVideoReader` and a pooled reader API for cached random access to video data. +- Upgraded the closed communication boundary to ABI 9 and added event-driven TCP reactor support. +- Strengthened KV member cleanup, MQ close semantics, framework shutdown barriers, and background-task ownership. +- Added rclone S3 integration coverage and a resource-bounded large-scale MPMC MQ CI scenario. +- Unified GitHub Release, PyPI, and Docker Hub publication behind one parameter-free GitHub Actions entrypoint. -## Version and SDK Contract +## 🗄️ Fluxon KV: DRAM + SSD Backing Tier + +Fluxon KV can now use each owner's local SSD as a runtime backing layer for DRAM replicas without adding a second public storage API. + +- `put` completes when the memory replica is published; SSD persistence proceeds asynchronously. +- `get` remains memory-first and enters SSD refill only when no readable memory replica is available. +- Local refill can write directly into the requester target when alignment permits; remote refill pipelines SSD reads and chunked transfer to the requester. +- Owner-local `O_DIRECT` / `io_uring` I/O, bounded queues, a fixed-capacity ring, read pins, and route-commit pins protect in-flight data from overwrite. +- Key-version checks reject stale SSD commits and stale routes; eviction notifications and bounded retry converge control-plane state. +- Capacity and usage are reported separately for memory segments and KV SSD storage. + +The public API still returns `MemHolder`. SSD is a runtime cache: it is rebuilt empty after owner restart, does not provide cold-start recovery, and does not stripe one value across multiple devices. + +In the documented single-node H100 SSD-pressure experiment, measured through CUDA event completion, Fluxon's c16 hit-payload throughput for 4 / 8 / 16 MiB values was 3.83× / 5.61× / 6.73× that of the faster of the two measured Mooncake topologies. These figures apply only to the documented dataset, capacity, concurrency, topology, and counting boundary. + +## 🪣 FluxonFS and S3 Data Path + +S3-compatible object I/O now chooses between a small-object fast path and the general write-session pipeline: + +- Objects below `4 MiB` use one `put_small_object` RPC that creates missing parents and writes the complete object. +- Objects at or above `4 MiB` use a bounded write session; the same policy applies to `PutObject`, multipart part upload, and final multipart assembly. +- Write-session batches use KV references for the normal payload path and retain Raw RPC as a correctness fallback. +- A controller-owned cleanup actor retains final responsibility for temporary KV keys when put completion is uncertain or eager deletion fails. +- FS and MQ reuse a bounded shared lease-keepalive actor instead of creating one keepalive loop per payload. +- KV cache hits on S3 reads can produce holder-backed `Bytes` without materializing the complete encoded `FlatDict`; `TCP_NODELAY` reduces avoidable small-response latency. +- FS-before-KV shutdown barriers keep holders, sessions, cleanup actors, and registered tasks alive until their dependents quiesce. + +The documented single-node `rclone v1.60.1` comparison reports that FluxonFS led Alluxio S3 Proxy in all 18 persisted-PUT and cold-read object-size/concurrency combinations. Hot-read gains were strongest for 4 KiB objects, while sequential medium- and large-object hot-read throughput was generally close. This result is scoped to that published setup. + +## 🎬 Cached Video Reading + +- Added `FluxonFsPatcher.open_video_reader(...) -> FluxonFsVideoReader` for random byte reads through FluxonFS export, permission, and cache paths. +- Added `open_video_reader_pool(...)` to reuse readers in dataloader-style workloads. +- Added a benchmark and analysis harness that compares the Fluxon-backed path with the original `decord.VideoReader` path under an explicit dataset and sampling plan. + +## 🌐 Communication and KV Lifecycle + +- Updated the closed communication boundary to ABI 9 while retaining runtime ABI, open-surface, boundary-mode, and provider-anchor checks. +- Added event-driven TCP reactor mode alongside the existing busy-poll path, plus bounded test controls for reactor shards and control/bulk lanes. +- Added explicit KV member lifecycle indexes and cleanup paths for member departure, in-flight requests, replicas, holders, and allocation ownership. +- Tightened configuration validation and separated stable network configuration from developer-only `test_spec_config` switches. + +## 📬 MQ Reliability and Scale Coverage + +- Strengthened MPSC/MPMC producer and consumer close behavior, lazy producer binding, ready-state publication, and lease-backed membership cleanup. +- Public callers close every producer or consumer and consume its `Result` before closing the backing KV store. +- Endpoint-local shutdown remains a strong contract; leased-key deletion is best effort and falls back to backend TTL after keepalive release. +- Added a direct-process large-scale MPMC scenario with resource limits, readiness checks, complete worker-result validation, and orderly process cleanup in GitHub Actions. + +## 🧪 CI, Packaging, and Release Safety + +- Added rclone-based S3 end-to-end coverage to the virtual-node CI path. +- Added validated publication of the release-built `fluxon-py` wheel, including wheel-tag, Python-version, checksum, size, and `twine check` gates. +- Added Codex-assisted CI failure analysis with bounded, read-only evidence collection. +- Added tag provenance that distinguishes an actual tag ref from a same-named branch and binds release artifacts to the tested commit. +- Added deterministic artifact checksums and a read-only Codex release-readiness report. +- The parameter-free `create_release_tag` Action derives the tag and release notes from the repository; no release version or body is entered in the GitHub UI. +- After common validation, GitHub Release, PyPI, and Docker Hub jobs become eligible in parallel and wait on their own protected environments. + +## 📚 Documentation and Project Presentation + +- Reworked the README around Fluxon as an AI-native distributed data plane spanning KV/RPC, MQ, and FS/S3 interfaces. +- Added scoped benchmark explanations for KV SSD and S3, together with architecture, lifecycle, documentation-review, event-subscription, index-design, and test-extension guidance. +- Added practical deep dives for KV SSD storage and serving a local directory through FluxonFS S3. +- Added the project WeChat contact entry and refreshed the public overview and background narrative. + +## 🔐 Version and SDK Contract - Public Python package, Rust workspace, and Quick Start version: `0.2.3`. - Closed communication SDK version: `0.2.1`. - Closed SDK required open-surface contract version: `0.2.1`. -The SDK version and open-surface contract version are independent from the -public release version. This release does not change the closed SDK manifest, -closed ABI/schema constants, or `FLUXON_COMMU_OPEN_SURFACE_VERSION`. +The SDK version and open-surface contract version are independent from the public release version. The tagged runtime must still pass ABI, open-surface, boundary-mode, and provider-anchor validation. -## Release Artifacts +## 📦 Release Artifacts - PyPI: `fluxon-py==0.2.3`. - Docker Hub: `hanbaoaaa/fluxon_quick_start:0.2.3`. @@ -37,10 +95,24 @@ closed ABI/schema constants, or `FLUXON_COMMU_OPEN_SURFACE_VERSION`. The Docker publication does not update `latest`. -## Runtime Requirements and Known Limits +## ⚠️ Runtime Requirements and Known Limits - Linux only; Python `>=3.10`. -- The S3 pip quick start reuses external etcd and GreptimeDB when - `start_middleware=False`. -- Dynamic bucket creation is not part of the quick-start contract; configure the - exported bucket name up front. +- Building and running the full stack requires the external services and native dependencies documented by each interface. +- KV SSD is a runtime backing cache and does not recover old shard contents after restart. +- The published KV SSD and S3 benchmark conclusions are limited to their documented hardware, topology, workload, capacity, and measurement boundaries. +- Release CI covers the configured virtual-node, large-scale MQ, rclone S3, packaging, SDK contract, wheel, and Quick Start checks. It does not establish compatibility for untested platforms or production environments. + +## 🧾 Included Mainline Changes + +- #43 — add the WeChat contact entry to the README. +- #34 — run large-scale MQ coverage in GitHub Actions with bounded resources. +- #37 — add distributed owner SSD backing storage for Fluxon KV, VideoReader, benchmarks, and supporting lifecycle work. +- #46 — add rclone S3 integration coverage. +- #45 — publish the validated `fluxon-py` wheel through PyPI trusted publishing; this path is now folded into the unified release workflow. +- #48, #49, #51, #52 — expand and refine the public Fluxon overview and AI-native distributed-data-plane positioning. +- #47 — support communication ABI 9 and event-driven TCP reactors. +- #50 — add hybrid S3 writes, shared lease keepalive, cleanup ownership, and shutdown barriers. +- #58 — restore the public PyPI distribution name to `fluxon-py` while keeping `fluxon_py` as the Python import package. +- #59 — add the installed-package `fluxon_py.quick_start.serve_s3_single_node(...)` entrypoint for starting the local-directory S3 Quick Start flow. +- The tagged `v0.2.3` revision also contains the unified GitHub Release / PyPI / Docker Hub release workflow and its deterministic readiness gates. From c6b18e1d8fb9ec11ec263abfa1469facb7187e87 Mon Sep 17 00:00:00 2001 From: zTz01 <1773266173@qq.com> Date: Sat, 8 Aug 2026 15:28:36 +0800 Subject: [PATCH 4/4] fix: rclone v1.60.1 multipart ETag compatibility --- README.md | 8 +- README_CN.md | 8 +- examples/fluxon_quick_start/README.md | 8 +- examples/fluxon_quick_start/build_image.py | 2 +- ...23\345\255\230\351\223\276\350\267\257.md" | 2 +- fluxon_py/__init__.py | 2 +- fluxon_py/tests/fluxon_fs_rclone_e2e.py | 16 +- fluxon_release/release_notes/v0.2.4.md | 70 ++ fluxon_rs/Cargo.lock | 37 +- fluxon_rs/Cargo.toml | 2 +- fluxon_rs/fluxon_cli/Cargo.toml | 2 +- fluxon_rs/fluxon_commu/Cargo.toml | 2 +- .../Cargo.toml | 2 +- fluxon_rs/fluxon_framework/Cargo.toml | 2 +- .../fluxon_framework_compiled/Cargo.toml | 2 +- fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml | 3 +- fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs | 611 +++++++++++++++++- fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs | 49 ++ .../src/ui_ssr_render_handlers.rs | 140 +++- .../templates/ui/account_password_main.html | 38 +- fluxon_rs/fluxon_kv/Cargo.toml | 2 +- fluxon_rs/fluxon_mq/Cargo.toml | 2 +- fluxon_rs/fluxon_observability/Cargo.toml | 2 +- fluxon_rs/fluxon_ops/Cargo.toml | 2 +- fluxon_rs/fluxon_pyo3/Cargo.toml | 2 +- fluxon_rs/fluxon_util/Cargo.toml | 2 +- fluxon_rs/limit_thirdparty/Cargo.toml | 2 +- fluxon_rs/setup.py | 2 +- ...est_top_attention_fs_s3_rclone_contract.py | 20 +- .../utils/docker_build_runtime_utils.py | 2 +- 30 files changed, 939 insertions(+), 105 deletions(-) create mode 100644 fluxon_release/release_notes/v0.2.4.md diff --git a/README.md b/README.md index 2a1e54f..30babc8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ An AI-native distributed data plane that supports high performance RPC, KV Cache [![Linux Only](https://img.shields.io/badge/Linux-Only-2ea44f)](#runtime-requirements) [![Python](https://img.shields.io/badge/Python-%3E%3D3.10-3776AB)](#runtime-requirements) [![Rust](https://img.shields.io/badge/Rust-1.93.0-000000)](./fluxon_rs/rust-toolchain.toml) -[![Latest](https://img.shields.io/badge/Latest-v0.2.3-f28500)](./fluxon_release) +[![Latest](https://img.shields.io/badge/Latest-v0.2.4-f28500)](./fluxon_release) [![Interfaces](https://img.shields.io/badge/Interfaces-KV%2FRPC%20%7C%20MQ%20%7C%20FS-1f6feb)](#interface-capabilities)
@@ -201,7 +201,7 @@ The distribution installs the `fluxon_py` import package. Service-plane runtimes ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -234,7 +234,7 @@ Related interface docs: ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -265,7 +265,7 @@ Related interface docs: ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/README_CN.md b/README_CN.md index 58b82a8..e3ac218 100644 --- a/README_CN.md +++ b/README_CN.md @@ -9,7 +9,7 @@ [![Linux Only](https://img.shields.io/badge/Linux-Only-2ea44f)](#运行要求) [![Python](https://img.shields.io/badge/Python-%3E%3D3.10-3776AB)](#运行要求) [![Rust](https://img.shields.io/badge/Rust-1.93.0-000000)](./fluxon_rs/rust-toolchain.toml) -[![Latest](https://img.shields.io/badge/Latest-v0.2.3-f28500)](./fluxon_release) +[![Latest](https://img.shields.io/badge/Latest-v0.2.4-f28500)](./fluxon_release) [![Interfaces](https://img.shields.io/badge/Interfaces-KV%2FRPC%20%7C%20MQ%20%7C%20FS-1f6feb)](#接口能力)
@@ -198,7 +198,7 @@ python3 -m pip install fluxon-py ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -231,7 +231,7 @@ del demo:hello ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -262,7 +262,7 @@ exit ```bash docker run --rm -it --network host \ - hanbaoaaa/fluxon_quick_start:0.2.3 \ + hanbaoaaa/fluxon_quick_start:0.2.4 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/examples/fluxon_quick_start/README.md b/examples/fluxon_quick_start/README.md index 352c4ad..20b569b 100644 --- a/examples/fluxon_quick_start/README.md +++ b/examples/fluxon_quick_start/README.md @@ -11,7 +11,7 @@ It does not replace the formal service-plane, KV, MQ, or FS interface docs. - unified quick-start entrypoint - `build_image.py` - quick-start image build entrypoint -- `fluxon_quick_start:0.2.3` +- `fluxon_quick_start:0.2.4` - quick-start Docker image ## Runtime Modes @@ -86,7 +86,7 @@ Python environment can already import both `fluxon_py` and `fluxon_pyo3`. ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.3 \ + fluxon_quick_start:0.2.4 \ --mode kv \ --etcd-client-port 12379 \ --master-p2p-port 31000 \ @@ -119,7 +119,7 @@ del demo:hello ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.3 \ + fluxon_quick_start:0.2.4 \ --mode mq \ --etcd-client-port 37379 \ --kv-master-port 34200 \ @@ -155,7 +155,7 @@ The background consumer keeps printing received messages. ```bash docker run --rm -it --network host \ - fluxon_quick_start:0.2.3 \ + fluxon_quick_start:0.2.4 \ --mode fs \ --etcd-client-port 36379 \ --kv-master-port 34100 \ diff --git a/examples/fluxon_quick_start/build_image.py b/examples/fluxon_quick_start/build_image.py index 4a6762b..7f34a2e 100644 --- a/examples/fluxon_quick_start/build_image.py +++ b/examples/fluxon_quick_start/build_image.py @@ -22,7 +22,7 @@ SCRIPTS_DIR = REPO_ROOT / "setup_and_pack" DOCKERFILE_PATH = SCRIPT_DIR / "Dockerfile" IMAGE_NAME = "fluxon_quick_start" -IMAGE_TAG = "0.2.3" +IMAGE_TAG = "0.2.4" # Binaries to copy from ext_images into quick_start bin/. EXT_BINARIES = ("etcd/etcd", "etcd/etcdctl", "greptime/greptime") diff --git "a/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" "b/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" index 55f1acc..107d45e 100644 --- "a/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" +++ "b/fluxon_doc_cn/blog/blog_3_\346\212\212\344\270\200\344\270\252\346\234\254\345\234\260\346\226\207\344\273\266\345\244\271\345\217\230\346\210\220 S3 \346\234\215\345\212\241\357\274\232FluxonFS \347\232\204\347\233\256\345\275\225\343\200\201\345\257\271\350\261\241\344\270\216\347\274\223\345\255\230\351\223\276\350\267\257.md" @@ -207,7 +207,7 @@ docker run -d --name fluxon-s3 ` --mount "type=bind,src=C:\fluxon-s3\data,dst=/data" ` --mount "type=bind,src=C:\fluxon-s3\state,dst=/state" ` --entrypoint python3 ` - "hanbaoaaa/fluxon_quick_start:0.2.3" ` + "hanbaoaaa/fluxon_quick_start:0.2.4" ` -c " from fluxon_py.quick_start import serve_s3_single_node diff --git a/fluxon_py/__init__.py b/fluxon_py/__init__.py index 391acee..03ce0b0 100644 --- a/fluxon_py/__init__.py +++ b/fluxon_py/__init__.py @@ -58,7 +58,7 @@ from typing import Any -__version__ = "0.2.3" +__version__ = "0.2.4" __all__ = [ # Core API "KvClient", diff --git a/fluxon_py/tests/fluxon_fs_rclone_e2e.py b/fluxon_py/tests/fluxon_fs_rclone_e2e.py index a84dae9..1214fab 100644 --- a/fluxon_py/tests/fluxon_fs_rclone_e2e.py +++ b/fluxon_py/tests/fluxon_fs_rclone_e2e.py @@ -28,6 +28,18 @@ RCLONE_COMMAND_TIMEOUT_SECS = 120 RCLONE_COMPLEX_COPY_TIMEOUT_SECS = 600 RCLONE_LIST_READY_TIMEOUT_SECS = 180.0 +# rclone v1.60.1 defaults to a 200 MiB upload cutoff. Keep this test +# independent of that default so the fixture always exercises multipart. +RCLONE_MULTIPART_UPLOAD_CUTOFF = "5Mi" +RCLONE_MULTIPART_CHUNK_SIZE = "5Mi" +RCLONE_MULTIPART_CHUNK_SIZE_BYTES = 5 * 1024 * 1024 +RCLONE_MULTIPART_FIXTURE_SIZE_BYTES = 12 * 1024 * 1024 +RCLONE_MULTIPART_COPY_FLAGS = ( + "--s3-upload-cutoff", + RCLONE_MULTIPART_UPLOAD_CUTOFF, + "--s3-chunk-size", + RCLONE_MULTIPART_CHUNK_SIZE, +) COMPLEX_GROUP_COUNT = 8 COMPLEX_FILES_PER_GROUP = 50 COMPLEX_EXPECTED_FILE_COUNT = 405 @@ -239,7 +251,8 @@ def _build_complex_fixture_files() -> dict[str, bytes]: "configs/dev/app.yaml": b"environment: dev\n", "configs/prod/app.yaml": b"environment: prod\n", "blobs/small.bin": bytes(range(64)), - "blobs/medium-8m.bin": bytes(range(256)) * (8 * 1024 * 1024 // 256), + "blobs/multipart-12m.bin": bytes(range(256)) + * (RCLONE_MULTIPART_FIXTURE_SIZE_BYTES // 256), } ) assert len(files) == COMPLEX_EXPECTED_FILE_COUNT @@ -338,6 +351,7 @@ def run_e2e(*, image_ref: str) -> None: work_root=work_root, args=[ "copy", + *RCLONE_MULTIPART_COPY_FLAGS, f"{RCLONE_CONTAINER_WORKDIR}/complex-source", complex_remote_root, ], diff --git a/fluxon_release/release_notes/v0.2.4.md b/fluxon_release/release_notes/v0.2.4.md new file mode 100644 index 0000000..5fbb21f --- /dev/null +++ b/fluxon_release/release_notes/v0.2.4.md @@ -0,0 +1,70 @@ +# Fluxon v0.2.4 + +`v0.2.4` is a focused FluxonFS S3 compatibility and first-run security +release. It fixes multipart completion for the pinned rclone client, makes the +bootstrap credential transition explicit, and enforces private access-database +permissions on Unix. + +## Highlights + +- Accepts the XML-escaped quoted ETags sent by official rclone `v1.60.1` when + completing a multipart upload. +- Extends the rclone end-to-end test to force a real three-part upload using a + 12 MiB object with a 5 MiB upload cutoff and chunk size. +- Requires a fresh `admin/admin` installation to enter **Change Credentials** + before bucket or S3 access, and allows the initial username and password to + be changed together. +- Creates and repairs `access.db`, `access.db-wal`, and `access.db-shm` with + mode `0600` on Unix. + +## S3 Multipart Compatibility + +rclone `v1.60.1` serializes completed part ETags as quoted XML text. On the +wire, the quotes can appear as `"`, while the uploaded-part metadata stores +the unquoted hash. FluxonFS now decodes XML entities before normalizing and +validating each ETag, preserving strict part identity checks without rejecting +the valid rclone request. + +The regression coverage uses the actual rclone XML shape, including the S3 XML +namespace and `ETag` before `PartNumber`. The Docker end-to-end path pins +`rclone/rclone:1.60.1` and verifies multipart upload, completion, and object +readback. + +## Initial Credentials and Access Database + +Fresh state bootstrapped with `admin/admin` is marked as requiring an initial +credential change. The Web UI redirects the first login to the credential form, +and S3 authentication remains unavailable until that transition completes. +The renamed administrator and completion state persist across restart. Existing +installations that have already replaced the default credentials are not forced +through the flow again. + +On Unix, FluxonFS creates the SQLite access database with mode `0600` and also +repairs permissions on an existing database and its WAL/shared-memory sidecar +files. + +## Version and SDK Contract + +- Public Python package, Rust workspace, and Quick Start version: `0.2.4`. +- Closed communication SDK version: `0.2.1`. +- Closed SDK required open-surface contract version: `0.2.1`. + +The SDK and open-surface contract versions remain independent from the public +release version. + +## Release Artifacts + +- PyPI: `fluxon-py==0.2.4`. +- Docker Hub: `hanbaoaaa/fluxon_quick_start:0.2.4`. +- GitHub Release: `fluxon_release.tar.gz`. +- GitHub Release: `fluxon_quick_start_0.2.4_docker_image.tar.gz`. + +The Docker publication does not update `latest`. + +## Runtime Requirements + +- Linux only; Python `>=3.10`. +- The Quick Start still requires the configured external middleware when + `start_middleware=False`. +- Dynamic bucket creation remains outside the Quick Start contract; exported + bucket names are configured before startup. diff --git a/fluxon_rs/Cargo.lock b/fluxon_rs/Cargo.lock index 876f37f..6962082 100644 --- a/fluxon_rs/Cargo.lock +++ b/fluxon_rs/Cargo.lock @@ -1031,7 +1031,7 @@ dependencies = [ [[package]] name = "fluxon_cli" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "askama", @@ -1056,7 +1056,7 @@ dependencies = [ [[package]] name = "fluxon_commu" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "fluxon_commu_closed_sdk_consumer" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "bitcode", @@ -1094,7 +1094,7 @@ dependencies = [ [[package]] name = "fluxon_commu_contract" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1117,7 +1117,7 @@ dependencies = [ [[package]] name = "fluxon_framework" -version = "0.2.2" +version = "0.2.4" dependencies = [ "async-trait", "fluxon_framework_compiled", @@ -1133,7 +1133,7 @@ dependencies = [ [[package]] name = "fluxon_framework_compiled" -version = "0.2.2" +version = "0.2.4" dependencies = [ "async-trait", "crossbeam-queue", @@ -1151,7 +1151,7 @@ dependencies = [ [[package]] name = "fluxon_fs" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1190,7 +1190,7 @@ dependencies = [ [[package]] name = "fluxon_fs_core" -version = "0.2.2" +version = "0.2.4" dependencies = [ "base64 0.21.7", "hex", @@ -1205,7 +1205,7 @@ dependencies = [ [[package]] name = "fluxon_fs_s3_gateway" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "askama", @@ -1225,6 +1225,7 @@ dependencies = [ "hyper 0.14.32", "parking_lot", "postcard", + "quick-xml", "reqwest", "rusqlite", "serde", @@ -1243,7 +1244,7 @@ dependencies = [ [[package]] name = "fluxon_kv" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-stream", @@ -1303,7 +1304,7 @@ dependencies = [ [[package]] name = "fluxon_mq" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1327,7 +1328,7 @@ dependencies = [ [[package]] name = "fluxon_observability" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1348,7 +1349,7 @@ dependencies = [ [[package]] name = "fluxon_ops" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "askama", @@ -1378,7 +1379,7 @@ dependencies = [ [[package]] name = "fluxon_proxy" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "bitcode", @@ -1392,14 +1393,14 @@ dependencies = [ [[package]] name = "fluxon_proxy_proto" -version = "0.2.2" +version = "0.2.4" dependencies = [ "bitcode", ] [[package]] name = "fluxon_pyo3" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "async-trait", @@ -1435,7 +1436,7 @@ dependencies = [ [[package]] name = "fluxon_util" -version = "0.2.2" +version = "0.2.4" dependencies = [ "anyhow", "chrono", @@ -2741,7 +2742,7 @@ dependencies = [ [[package]] name = "limit_thirdparty" -version = "0.2.2" +version = "0.2.4" dependencies = [ "tokio", ] diff --git a/fluxon_rs/Cargo.toml b/fluxon_rs/Cargo.toml index 5fbb1eb..af96e78 100644 --- a/fluxon_rs/Cargo.toml +++ b/fluxon_rs/Cargo.toml @@ -22,7 +22,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2024" license = "" authors = ["teleai_infra"] diff --git a/fluxon_rs/fluxon_cli/Cargo.toml b/fluxon_rs/fluxon_cli/Cargo.toml index 668faa8..cb1d279 100644 --- a/fluxon_rs/fluxon_cli/Cargo.toml +++ b/fluxon_rs/fluxon_cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_cli" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_commu/Cargo.toml b/fluxon_rs/fluxon_commu/Cargo.toml index af67440..8bf2968 100644 --- a/fluxon_rs/fluxon_commu/Cargo.toml +++ b/fluxon_rs/fluxon_commu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_commu" -version = "0.2.3" +version = "0.2.4" edition = "2024" build = "build.rs" diff --git a/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml b/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml index 437ab27..d673e27 100644 --- a/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml +++ b/fluxon_rs/fluxon_commu_closed_sdk_consumer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_commu_closed_sdk_consumer" -version = "0.2.3" +version = "0.2.4" edition = "2024" build = "build.rs" diff --git a/fluxon_rs/fluxon_framework/Cargo.toml b/fluxon_rs/fluxon_framework/Cargo.toml index d579a24..1a5b919 100644 --- a/fluxon_rs/fluxon_framework/Cargo.toml +++ b/fluxon_rs/fluxon_framework/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_framework" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_framework_compiled/Cargo.toml b/fluxon_rs/fluxon_framework_compiled/Cargo.toml index 715bd11..e81f1e7 100644 --- a/fluxon_rs/fluxon_framework_compiled/Cargo.toml +++ b/fluxon_rs/fluxon_framework_compiled/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_framework_compiled" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml b/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml index d2bbb34..55100d0 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml +++ b/fluxon_rs/fluxon_fs_s3_gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_fs_s3_gateway" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] @@ -22,6 +22,7 @@ hmac = "0.12" hex = "0.4" chrono = { workspace = true } uuid = { workspace = true } +quick-xml = "0.26" base64 = "0.21" urlencoding = "2" parking_lot = { workspace = true } diff --git a/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs b/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs index 99d0a7b..64b81a3 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs +++ b/fluxon_rs/fluxon_fs_s3_gateway/src/lib.rs @@ -32,10 +32,17 @@ use futures::StreamExt; use futures::future::BoxFuture; use hmac::{Hmac, Mac as _}; use parking_lot::{Mutex, RwLock}; -use rusqlite::{Connection, params}; +use quick_xml::escape::unescape as xml_unescape; +use rusqlite::{Connection, OptionalExtension as _, params}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; +#[cfg(unix)] +use std::fs::OpenOptions; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path as FsPath, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use tower::ServiceExt as _; use uuid::Uuid; @@ -174,6 +181,16 @@ pub struct GatewayAccessConfig { pub transfer_state_store: Option, } +const ACCESS_META_INITIAL_CREDENTIALS_REQUIRED: &str = "initial_credentials_change_required"; +const ACCESS_META_VALUE_TRUE: &str = "true"; +const ACCESS_META_VALUE_FALSE: &str = "false"; + +fn is_default_bootstrap_credentials(model: &FluxonFsAccessModel) -> bool { + model.users.len() == 1 + && model.users[0].username == "admin" + && model.users[0].password == "admin" +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct FsMountRegistryRecord { pub external_instance_key: String, @@ -464,6 +481,7 @@ pub struct GatewayState { transfer_state_store: Option>, transfer_reconcile_handle: Option>, permission_list: Arc>>, + initial_credentials_change_required: Arc, fs_cache: Arc, s3_cfg: FluxonFsS3GatewayConfig, backend: Arc, @@ -719,7 +737,7 @@ impl GatewayState { transfer_history_query: Option, ) -> Result { let access_db = Arc::new(Mutex::new(open_access_db(&access.access_db_path)?)); - let permission_list = { + let (permission_list, initial_credentials_change_required) = { let mut conn = access_db.lock(); load_or_bootstrap_permission_list_from_db( &mut conn, @@ -759,6 +777,9 @@ impl GatewayState { transfer_state_store, transfer_reconcile_handle, permission_list: Arc::new(RwLock::new(permission_list)), + initial_credentials_change_required: Arc::new(AtomicBool::new( + initial_credentials_change_required, + )), fs_cache, s3_cfg, backend, @@ -1035,6 +1056,11 @@ impl GatewayState { self.access_db_path.as_str() } + fn initial_credentials_change_required(&self) -> bool { + self.initial_credentials_change_required + .load(Ordering::Acquire) + } + pub(crate) fn load_effective_fs_exports( &self, ) -> Result, String> { @@ -1570,6 +1596,53 @@ impl GatewayState { Ok(()) } + fn complete_initial_credentials_change( + &self, + current_username: &str, + current_password: &str, + new_username: String, + new_password: String, + ) -> Result<(), String> { + let mut conn = self.access_db.lock(); + if !load_initial_credentials_change_required_from_db(&conn)? { + return Err("initial credentials have already been changed".to_string()); + } + + let mut permission_list = load_permission_list_from_db(&conn)?; + if new_username != current_username + && permission_list + .iter() + .any(|account| account.username == new_username) + { + return Err(format!("username already exists: {}", new_username)); + } + let account = permission_list + .iter_mut() + .find(|account| account.username == current_username) + .ok_or_else(|| { + format!( + "bootstrap account no longer exists in access db: {}", + current_username + ) + })?; + if account.password != current_password { + return Err("bootstrap credentials changed; authenticate again".to_string()); + } + account.username = new_username; + account.password = new_password; + + let normalized_permission_list = + persist_permission_list_to_db_with_initial_credentials_state( + &mut conn, + &permission_list, + false, + )?; + *self.permission_list.write() = normalized_permission_list; + self.initial_credentials_change_required + .store(false, Ordering::Release); + Ok(()) + } + fn create_ui_transfer_task( &self, owner_username: String, @@ -1792,19 +1865,36 @@ fn open_access_db(path: &str) -> Result { if path.trim().is_empty() { return Err("access_db_path must be non-empty".to_string()); } - let db_path = std::path::Path::new(path); - if let Some(parent_dir) = db_path.parent() { - if !parent_dir.as_os_str().is_empty() { - std::fs::create_dir_all(parent_dir).map_err(|e| { - format!( - "create access db parent dir failed: path={} parent={} err={}", - path, - parent_dir.display(), - e - ) - })?; + let is_in_memory = path == ":memory:"; + let db_path = FsPath::new(path); + if !is_in_memory { + if let Some(parent_dir) = db_path.parent() { + if !parent_dir.as_os_str().is_empty() { + std::fs::create_dir_all(parent_dir).map_err(|e| { + format!( + "create access db parent dir failed: path={} parent={} err={}", + path, + parent_dir.display(), + e + ) + })?; + } } } + #[cfg(unix)] + if !is_in_memory { + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .open(db_path) + .map_err(|e| format!("create access db with mode 0600 failed: {}", e))?; + drop(file); + } + if !is_in_memory { + restrict_access_db_permissions(db_path)?; + } let conn = Connection::open(path).map_err(|e| format!("open access db failed: {}", e))?; conn.execute_batch( r#" @@ -1816,6 +1906,10 @@ CREATE TABLE IF NOT EXISTS access_users ( password TEXT NOT NULL, can_manage_users INTEGER NOT NULL CHECK (can_manage_users IN (0, 1)) ); +CREATE TABLE IF NOT EXISTS access_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS access_scope ( scope_id INTEGER PRIMARY KEY AUTOINCREMENT, export_name TEXT NOT NULL, @@ -1862,6 +1956,9 @@ CREATE TABLE IF NOT EXISTS fs_export_overlay_upsert ( ) .map_err(|e| format!("initialize access db schema failed: {}", e))?; ensure_fs_export_registry_export_json_column(&conn)?; + if !is_in_memory { + restrict_access_db_permissions(db_path)?; + } Ok(conn) } @@ -1908,6 +2005,59 @@ fn access_db_user_count(conn: &Connection) -> Result { .map_err(|e| format!("query access user count failed: {}", e)) } +fn persist_initial_credentials_change_required_to_db( + conn: &Connection, + required: bool, +) -> Result<(), String> { + let value = if required { + ACCESS_META_VALUE_TRUE + } else { + ACCESS_META_VALUE_FALSE + }; + conn.execute( + "INSERT INTO access_meta(key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value=excluded.value", + params![ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, value], + ) + .map_err(|e| { + format!( + "persist access metadata {} failed: {}", + ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, e + ) + })?; + Ok(()) +} + +fn load_initial_credentials_change_required_from_db(conn: &Connection) -> Result { + let value = conn + .query_row( + "SELECT value FROM access_meta WHERE key = ?1", + params![ACCESS_META_INITIAL_CREDENTIALS_REQUIRED], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| { + format!( + "load access metadata {} failed: {}", + ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, e + ) + })?; + match value.as_deref() { + Some(ACCESS_META_VALUE_TRUE) => Ok(true), + Some(ACCESS_META_VALUE_FALSE) => Ok(false), + Some(value) => Err(format!( + "invalid access metadata {} value: {}", + ACCESS_META_INITIAL_CREDENTIALS_REQUIRED, value + )), + None => { + let model = load_access_model_from_db(conn)?; + let required = is_default_bootstrap_credentials(&model); + persist_initial_credentials_change_required_to_db(conn, required)?; + Ok(required) + } + } +} + fn load_access_model_from_db(conn: &Connection) -> Result { let mut users: Vec = Vec::new(); let mut user_stmt = conn @@ -1997,16 +2147,34 @@ fn load_or_bootstrap_permission_list_from_db( conn: &mut Connection, bootstrap_access_model: &AccessModel, _fs_cache: &FluxonFsGlobalConfig, -) -> Result, String> { +) -> Result<(Vec, bool), String> { if access_db_user_count(conn)? != 0 { - return load_permission_list_from_db(conn); - } - persist_access_model_to_db(conn, bootstrap_access_model) + let permission_list = load_permission_list_from_db(conn)?; + let initial_credentials_change_required = + load_initial_credentials_change_required_from_db(conn)?; + return Ok((permission_list, initial_credentials_change_required)); + } + let initial_credentials_change_required = + is_default_bootstrap_credentials(bootstrap_access_model); + let permission_list = persist_access_model_to_db_with_initial_credentials_state( + conn, + bootstrap_access_model, + Some(initial_credentials_change_required), + )?; + Ok((permission_list, initial_credentials_change_required)) } fn persist_access_model_to_db( conn: &mut Connection, model: &AccessModel, +) -> Result, String> { + persist_access_model_to_db_with_initial_credentials_state(conn, model, None) +} + +fn persist_access_model_to_db_with_initial_credentials_state( + conn: &mut Connection, + model: &AccessModel, + initial_credentials_change_required: Option, ) -> Result, String> { let permission_list = s3_permission_list_from_access_model(model)?; let normalized_model = access_model_from_s3_permission_list(&permission_list)?; @@ -2069,6 +2237,9 @@ fn persist_access_model_to_db( } } + if let Some(required) = initial_credentials_change_required { + persist_initial_credentials_change_required_to_db(&tx, required)?; + } tx.commit() .map_err(|e| format!("commit state db transaction failed: {}", e))?; Ok(normalized_permission_list) @@ -2082,6 +2253,19 @@ fn persist_permission_list_to_db( persist_access_model_to_db(conn, &model) } +fn persist_permission_list_to_db_with_initial_credentials_state( + conn: &mut Connection, + permission_list: &[FluxonFsS3PermissionAccount], + initial_credentials_change_required: bool, +) -> Result, String> { + let model = access_model_from_s3_permission_list(permission_list)?; + persist_access_model_to_db_with_initial_credentials_state( + conn, + &model, + Some(initial_credentials_change_required), + ) +} + fn persist_fs_mount_registry_record_to_db( conn: &Connection, record: &FsMountRegistryRecord, @@ -2661,6 +2845,43 @@ fn normalize_external_base_path(path: &str) -> String { format!("/{}", no_trailing) } +#[cfg(unix)] +fn restrict_access_db_file_permissions(path: &FsPath) -> Result<(), String> { + let metadata = match std::fs::metadata(path) { + Ok(value) => value, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(format!( + "stat access db file permissions failed: path={} err={}", + path.display(), + err + )); + } + }; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(path, permissions).map_err(|err| { + format!( + "set access db file permissions failed: path={} mode=0600 err={}", + path.display(), + err + ) + }) +} + +#[cfg(not(unix))] +fn restrict_access_db_file_permissions(_path: &FsPath) -> Result<(), String> { + Ok(()) +} + +fn restrict_access_db_permissions(path: &FsPath) -> Result<(), String> { + restrict_access_db_file_permissions(path)?; + let wal_path = PathBuf::from(format!("{}-wal", path.display())); + restrict_access_db_file_permissions(wal_path.as_path())?; + let shm_path = PathBuf::from(format!("{}-shm", path.display())); + restrict_access_db_file_permissions(shm_path.as_path()) +} + #[derive(Debug, Clone)] struct AuthAccount { username: String, @@ -4877,7 +5098,12 @@ fn extract_xml_tag_text(xml: &str, tag: &str) -> Result { detail: format!("missing closing tag: {}", tag), }); }; - Ok(rest[..b].trim().to_string()) + let raw = rest[..b].trim(); + xml_unescape(raw) + .map(|value| value.into_owned()) + .map_err(|err| S3Error::InvalidRequest { + detail: format!("invalid XML entity in {}: {}", tag, err), + }) } async fn multipart_complete( @@ -5120,6 +5346,12 @@ fn auth_verify_sigv4(st: &GatewayState, headers: &HeaderMap) -> Result"abc"", "ETag").unwrap(), + "\"abc\"" + ); + assert_eq!( + super::extract_xml_tag_text(""abc"", "ETag").unwrap(), + "\"abc\"" + ); + assert_eq!( + super::extract_xml_tag_text(""abc"", "ETag").unwrap(), + "\"abc\"" + ); + assert_eq!( + super::extract_xml_tag_text("\"abc\"", "ETag").unwrap(), + "\"abc\"" + ); + } + + #[test] + fn test_parse_complete_multipart_body_accepts_rclone_quoted_etag_entities() { + let parts = super::parse_complete_multipart_body( + br#" + "etag-one"1 + "etag-two"2 + "#, + ) + .unwrap(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0].part_number, 1); + assert_eq!(parts[0].etag, "etag-one"); + assert_eq!(parts[1].part_number, 2); + assert_eq!(parts[1].etag, "etag-two"); + } + fn test_gateway_access_config() -> GatewayAccessConfig { shared_test_tikv_access_config() } @@ -7419,6 +7728,45 @@ max-background-jobs = {TEST_TIKV_RAFTDB_MAX_BACKGROUND_JOBS}\n" ) } + fn test_state_with_bootstrap_access_model( + backend: Arc, + buckets: &[&str], + access_db_path: String, + bootstrap_access_model: FluxonFsAccessModel, + ) -> Arc { + let mut exports = BTreeMap::new(); + for bucket in buckets { + exports.insert((*bucket).to_string(), test_export(bucket)); + } + Arc::new( + GatewayState::new( + "test_cluster".to_string(), + String::new(), + GatewayAccessConfig { + access_db_path, + bootstrap_access_model, + transfer_state_store: None, + }, + Arc::new(FluxonFsGlobalConfig { + stale_window_ms: 0, + write_session_target_inflight_bytes: + FS_CACHE_DEFAULT_WRITE_SESSION_TARGET_INFLIGHT_BYTES_V1, + rules: Vec::new(), + exports, + }), + FluxonFsS3GatewayConfig { + get_object_inflight_pieces: 4, + kv_miss_policy: FluxonFsS3KvMissPolicy::RemoteRead, + }, + backend, + Arc::new(TestFsMasterAdminBackend), + None, + None, + ) + .unwrap(), + ) + } + fn test_state_with_permission_list( backend: Arc, buckets: &[&str], @@ -10539,6 +10887,231 @@ max-background-jobs = {TEST_TIKV_RAFTDB_MAX_BACKGROUND_JOBS}\n" headers } + fn sigv4_auth_headers(access_key: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_str(&format!( + "AWS4-HMAC-SHA256 Credential={}/20260808/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=00", + access_key + )) + .unwrap(), + ); + headers.insert("x-amz-date", HeaderValue::from_static("20260808T000000Z")); + headers + } + + #[tokio::test] + async fn test_initial_credentials_require_setup_and_persist_renamed_account() { + let root = test_tikv_work_root().join(format!( + "fluxon_fs_s3_gateway_initial_credentials_pid{}_{}", + process::id(), + Uuid::new_v4() + )); + std::fs::create_dir_all(&root).unwrap(); + let db_path = root.join("access.db"); + let db_path_text = db_path.to_string_lossy().into_owned(); + let bootstrap_model = FluxonFsAccessModel { + users: vec![FluxonFsAccessUser { + username: "admin".to_string(), + password: "admin".to_string(), + can_manage_users: true, + }], + scope_access: Vec::new(), + }; + let st = test_state_with_bootstrap_access_model( + Arc::new(ObjectBackend::default()), + &["demo"], + db_path_text.clone(), + bootstrap_model.clone(), + ); + + assert!(st.initial_credentials_change_required()); + let err = super::auth_verify_sigv4(&st, &sigv4_auth_headers("admin")).unwrap_err(); + assert!( + matches!(err, S3Error::AccessDenied { detail } if detail.contains("change the initial admin credentials")) + ); + let mut malformed_proxy_headers = HeaderMap::new(); + malformed_proxy_headers.insert(super::HDR_ORIGINAL_URI, HeaderValue::from_static("/")); + let malformed_redirect = + super::ui_initial_credentials_redirect(&malformed_proxy_headers, &st); + assert_eq!( + malformed_redirect + .headers() + .get(axum::http::header::LOCATION) + .unwrap(), + "/ui/account/password/" + ); + + let admin_auth = basic_auth_headers("admin", "admin") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(); + let redirect = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/") + .header(axum::http::header::AUTHORIZATION, admin_auth.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(redirect.status(), StatusCode::SEE_OTHER); + assert_eq!( + redirect + .headers() + .get(axum::http::header::LOCATION) + .unwrap(), + "/ui/account/password/" + ); + + let setup_page = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/account/password/") + .header(axum::http::header::AUTHORIZATION, admin_auth.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(setup_page.status(), StatusCode::OK); + let setup_body = hyper::body::to_bytes(setup_page.into_body()).await.unwrap(); + let setup_html = String::from_utf8_lossy(&setup_body); + assert!(setup_html.contains("Change Credentials")); + assert!(setup_html.contains("name=\"new_username\"")); + + let setup_response = super::build_router(st.clone()) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/ui/account/password/") + .header(axum::http::header::AUTHORIZATION, admin_auth.clone()) + .header( + axum::http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Body::from( + "new_username=operator&new_password=s3-secret&confirm_new_password=s3-secret", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(setup_response.status(), StatusCode::SEE_OTHER); + assert_eq!( + setup_response + .headers() + .get(axum::http::header::LOCATION) + .unwrap(), + "../../" + ); + + assert!(!st.initial_credentials_change_required()); + assert!(super::find_account_by_username(&st, "admin").is_none()); + let account = super::find_account_by_username(&st, "operator").unwrap(); + assert_eq!(account.password, "s3-secret"); + assert!(account.can_manage_users); + assert!(super::auth_verify_sigv4(&st, &sigv4_auth_headers("operator")).is_ok()); + + let old_credentials = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/") + .header(axum::http::header::AUTHORIZATION, admin_auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(old_credentials.status(), StatusCode::UNAUTHORIZED); + + let new_auth = basic_auth_headers("operator", "s3-secret") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(); + let buckets_page = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/") + .header(axum::http::header::AUTHORIZATION, new_auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(buckets_page.status(), StatusCode::OK); + + let password_page = super::build_router(st.clone()) + .oneshot( + Request::builder() + .uri("/ui/account/password/") + .header( + axum::http::header::AUTHORIZATION, + basic_auth_headers("operator", "s3-secret") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(password_page.status(), StatusCode::OK); + let password_page_body = hyper::body::to_bytes(password_page.into_body()) + .await + .unwrap(); + let password_page_html = String::from_utf8_lossy(&password_page_body); + assert!(password_page_html.contains("Change Password")); + assert!(!password_page_html.contains("name=\"new_username\"")); + + let password_update = super::build_router(st.clone()) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/ui/account/password/") + .header( + axum::http::header::AUTHORIZATION, + basic_auth_headers("operator", "s3-secret") + .get(axum::http::header::AUTHORIZATION) + .unwrap() + .clone(), + ) + .header( + axum::http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Body::from( + "current_password=s3-secret&new_password=next-secret&confirm_new_password=next-secret", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(password_update.status(), StatusCode::SEE_OTHER); + assert_eq!( + super::find_account_by_username(&st, "operator") + .unwrap() + .password, + "next-secret" + ); + + drop(st); + let restarted = test_state_with_bootstrap_access_model( + Arc::new(ObjectBackend::default()), + &["demo"], + db_path_text, + bootstrap_model, + ); + assert!(!restarted.initial_credentials_change_required()); + assert!(super::find_account_by_username(&restarted, "admin").is_none()); + assert!(super::find_account_by_username(&restarted, "operator").is_some()); + drop(restarted); + std::fs::remove_dir_all(&root).unwrap(); + } + #[test] fn test_ui_require_identity_admin_can_view_as() { let backend = Arc::new(ObjectBackend::default()); @@ -11364,7 +11937,7 @@ max-background-jobs = {TEST_TIKV_RAFTDB_MAX_BACKGROUND_JOBS}\n" backend.reset_write_metrics(); let complete_xml = format!( - "1\"{}\"2\"{}\"", + "1"{}"2"{}"", etag1, etag2 ); let complete_resp = super::multipart_complete( diff --git a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs index 8fbe639..646c60a 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs +++ b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr.rs @@ -78,15 +78,64 @@ fn ui_normalize_as_user(as_user: Option) -> Option { Some(t.to_string()) } +fn ui_initial_credentials_redirect(headers: &HeaderMap, st: &GatewayState) -> Response { + let proxy_href = headers + .get(HDR_ORIGINAL_URI) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + let path = value.split_once('?').map(|(path, _)| path).unwrap_or(value); + path.find("/ui/") + .or_else(|| path.strip_suffix("/ui").map(|_| path.len() - "/ui".len())) + .map(|index| { + format!( + "{}/ui/account/password/", + path[..index].trim_end_matches('/') + ) + }) + }); + let href = proxy_href.unwrap_or_else(|| { + format!( + "{}/ui/account/password/", + st.external_base_path.trim_end_matches('/') + ) + }); + let mut resp = Response::new(boxed(Body::empty())); + *resp.status_mut() = StatusCode::SEE_OTHER; + let location = + HeaderValue::from_str(&href).unwrap_or_else(|_| HeaderValue::from_static("/ui/account/password/")); + resp.headers_mut().insert(header::LOCATION, location); + resp +} + fn ui_require_identity( headers: &HeaderMap, st: &GatewayState, as_user: Option, +) -> Result { + ui_require_identity_inner(headers, st, as_user, false) +} + +fn ui_require_identity_for_credentials( + headers: &HeaderMap, + st: &GatewayState, + as_user: Option, +) -> Result { + ui_require_identity_inner(headers, st, as_user, true) +} + +fn ui_require_identity_inner( + headers: &HeaderMap, + st: &GatewayState, + as_user: Option, + allow_initial_credentials_change: bool, ) -> Result { let viewer = match ui_basic_auth_account(headers, st) { Some(v) => v, None => return Err(ui_basic_auth_required()), }; + if st.initial_credentials_change_required() && !allow_initial_credentials_change { + return Err(ui_initial_credentials_redirect(headers, st)); + } let as_user = ui_normalize_as_user(as_user); let Some(as_username) = as_user.clone() else { diff --git a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs index 2ee1e67..2c8c8f1 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs +++ b/fluxon_rs/fluxon_fs_s3_gateway/src/ui_ssr_render_handlers.rs @@ -147,6 +147,8 @@ struct UiAdminHomeMainTemplate { struct UiAccountPasswordMainTemplate { show_error: bool, error_msg: String, + initial_credentials_change_required: bool, + viewer_username: String, } #[derive(Template)] @@ -1921,7 +1923,8 @@ async fn ui_admin_fs_master_export_remove( #[derive(serde::Deserialize)] struct UiAccountPasswordForm { - current_password: String, + current_password: Option, + new_username: Option, new_password: String, confirm_new_password: String, } @@ -1931,6 +1934,12 @@ fn ui_render_account_password_page( identity: &UiIdentity, error_msg: Option<&str>, ) -> String { + let initial_credentials_change_required = st.initial_credentials_change_required(); + let page_title = if initial_credentials_change_required { + "Change Credentials" + } else { + "Change Password" + }; let crumbs = render_template(&UiCrumbsTemplate { crumbs: vec![ UiCrumbView { @@ -1942,7 +1951,7 @@ fn ui_render_account_password_page( UiCrumbView { href: String::new(), has_href: false, - label: "Change Password".to_string(), + label: page_title.to_string(), current: true, }, ], @@ -1961,6 +1970,8 @@ fn ui_render_account_password_page( let main = render_template(&UiAccountPasswordMainTemplate { show_error: error_msg.is_some(), error_msg: error_msg.unwrap_or("").to_string(), + initial_credentials_change_required, + viewer_username: identity.viewer_username().to_string(), }); let home_href = ui_href_with_as("../../", identity.as_user.as_deref()); @@ -1968,16 +1979,20 @@ fn ui_render_account_password_page( let transfers_href = ui_href_with_as("../../transfers/", identity.as_user.as_deref()); let account_password_href = ui_href_with_as("./", identity.as_user.as_deref()); let admin_manage_href = ui_href_with_as("../../admin/", identity.as_user.as_deref()); - let user_actions_html = ui_user_actions_html(identity, &account_password_href, &admin_manage_href, "./"); + let user_actions_html = if initial_credentials_change_required { + String::new() + } else { + ui_user_actions_html(identity, &account_password_href, &admin_manage_href, "./") + }; ui_page_html( - "Change Password", + page_title, &home_href, &buckets_href, &transfers_href, "buckets", &crumbs, - "Change Password", + page_title, Some(&subtitle), None, &user_actions_html, @@ -1990,52 +2005,119 @@ async fn ui_account_password_page( headers: HeaderMap, axum::extract::Query(q): axum::extract::Query, ) -> Response { - let identity = match ui_require_identity(&headers, &st, q.as_user) { + let identity = match ui_require_identity_for_credentials(&headers, &st, q.as_user) { Ok(v) => v, Err(resp) => return resp, }; Html(ui_render_account_password_page(&st, &identity, None)).into_response() } +fn ui_account_password_error_response( + st: &GatewayState, + identity: &UiIdentity, + status: StatusCode, + message: &str, +) -> Response { + let html = ui_render_account_password_page(st, identity, Some(message)); + let mut resp = Html(html).into_response(); + *resp.status_mut() = status; + resp +} + async fn ui_account_password_save( State(st): State>, headers: HeaderMap, axum::extract::Query(q): axum::extract::Query, Form(f): Form, ) -> Response { - let identity = match ui_require_identity(&headers, &st, q.as_user.clone()) { + let identity = match ui_require_identity_for_credentials(&headers, &st, q.as_user.clone()) { Ok(v) => v, Err(resp) => return resp, }; + let initial_credentials_change_required = st.initial_credentials_change_required(); - if identity.viewer.password != f.current_password { - let html = ui_render_account_password_page(&st, &identity, Some("current password mismatch")); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::FORBIDDEN; - return resp; + if !initial_credentials_change_required + && f.current_password.as_deref() != Some(identity.viewer.password.as_str()) + { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::FORBIDDEN, + "current password mismatch", + ); } - let new_password = f.new_password.trim().to_string(); - if new_password.is_empty() { - let html = ui_render_account_password_page(&st, &identity, Some("new_password must be non-empty")); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::BAD_REQUEST; - return resp; - } - if f.new_password != new_password { - let html = ui_render_account_password_page( + let new_password = match ui_validate_password_no_whitespace(&f.new_password, "new_password") { + Ok(value) => value, + Err(err) => { + let status = err.status(); + let message = err.message(); + return ui_account_password_error_response( + &st, + &identity, + status, + message.as_str(), + ); + } + }; + if f.confirm_new_password != new_password { + return ui_account_password_error_response( &st, &identity, - Some("new_password must not have leading/trailing whitespace"), + StatusCode::BAD_REQUEST, + "confirm_new_password mismatch", ); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::BAD_REQUEST; - return resp; } - if f.confirm_new_password != new_password { - let html = ui_render_account_password_page(&st, &identity, Some("confirm_new_password mismatch")); - let mut resp = Html(html).into_response(); - *resp.status_mut() = StatusCode::BAD_REQUEST; + + if initial_credentials_change_required { + let new_username = + match ui_validate_username_for_basic_auth(f.new_username.as_deref().unwrap_or("")) { + Ok(value) => value, + Err(err) => { + let status = err.status(); + let message = err.message(); + return ui_account_password_error_response( + &st, + &identity, + status, + message.as_str(), + ); + } + }; + if new_username == "admin" { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::BAD_REQUEST, + "new_username must differ from the bootstrap username admin", + ); + } + if new_password == "admin" { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::BAD_REQUEST, + "new_password must differ from the bootstrap password admin", + ); + } + if let Err(err) = st.complete_initial_credentials_change( + identity.viewer.username.as_str(), + identity.viewer.password.as_str(), + new_username, + new_password, + ) { + return ui_account_password_error_response( + &st, + &identity, + StatusCode::CONFLICT, + err.as_str(), + ); + } + + let mut resp = Response::new(boxed(Body::empty())); + *resp.status_mut() = StatusCode::SEE_OTHER; + resp.headers_mut() + .insert(header::LOCATION, HeaderValue::from_static("../../")); return resp; } diff --git a/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html b/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html index bf938c1..308cb42 100644 --- a/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html +++ b/fluxon_rs/fluxon_fs_s3_gateway/templates/ui/account_password_main.html @@ -1,25 +1,53 @@ {% if show_error %}
{{ error_msg }}
{% endif %} +{% if initial_credentials_change_required %}
- Change password for the viewer account. Password changes apply to both UI - Basic Auth and S3 SigV4 secret_key. + Replace the bootstrap admin / admin credentials before using FluxonFS. + The new username and password apply to both UI Basic Auth and S3 SigV4. +
+
+
+ + +
+
+ + +
+
+ + +
+
+ Both values must differ from admin. Values must be non-empty and have + no leading or trailing whitespace; the username must not contain :. +
+
+ +
+
+{% else %} +
+ Change password for the {{ viewer_username }} account. Password changes + apply to both UI Basic Auth and S3 SigV4 secret_key.
- +
- +
- +
Rules: non-empty; no leading/trailing whitespace.
+{% endif %} diff --git a/fluxon_rs/fluxon_kv/Cargo.toml b/fluxon_rs/fluxon_kv/Cargo.toml index c49b502..c0ab6fc 100644 --- a/fluxon_rs/fluxon_kv/Cargo.toml +++ b/fluxon_rs/fluxon_kv/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_kv" -version = "0.2.3" +version = "0.2.4" edition = "2024" [features] diff --git a/fluxon_rs/fluxon_mq/Cargo.toml b/fluxon_rs/fluxon_mq/Cargo.toml index 69dd113..5601af5 100644 --- a/fluxon_rs/fluxon_mq/Cargo.toml +++ b/fluxon_rs/fluxon_mq/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_mq" -version = "0.2.3" +version = "0.2.4" edition = "2021" [lib] diff --git a/fluxon_rs/fluxon_observability/Cargo.toml b/fluxon_rs/fluxon_observability/Cargo.toml index 00dbf2c..b266510 100644 --- a/fluxon_rs/fluxon_observability/Cargo.toml +++ b/fluxon_rs/fluxon_observability/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_observability" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_ops/Cargo.toml b/fluxon_rs/fluxon_ops/Cargo.toml index c1637db..9d73326 100644 --- a/fluxon_rs/fluxon_ops/Cargo.toml +++ b/fluxon_rs/fluxon_ops/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_ops" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/fluxon_pyo3/Cargo.toml b/fluxon_rs/fluxon_pyo3/Cargo.toml index 4d0438a..b074911 100644 --- a/fluxon_rs/fluxon_pyo3/Cargo.toml +++ b/fluxon_rs/fluxon_pyo3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_pyo3" -version = "0.2.3" +version = "0.2.4" edition = "2024" [lib] diff --git a/fluxon_rs/fluxon_util/Cargo.toml b/fluxon_rs/fluxon_util/Cargo.toml index 305f50c..b330fe3 100644 --- a/fluxon_rs/fluxon_util/Cargo.toml +++ b/fluxon_rs/fluxon_util/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxon_util" -version = "0.2.3" +version = "0.2.4" edition = "2024" authors = ["Your Name "] description = "Utility crate with macros and helper functions" diff --git a/fluxon_rs/limit_thirdparty/Cargo.toml b/fluxon_rs/limit_thirdparty/Cargo.toml index b946566..a061438 100644 --- a/fluxon_rs/limit_thirdparty/Cargo.toml +++ b/fluxon_rs/limit_thirdparty/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "limit_thirdparty" -version = "0.2.3" +version = "0.2.4" edition = "2024" [dependencies] diff --git a/fluxon_rs/setup.py b/fluxon_rs/setup.py index b87a0c5..7479c6f 100644 --- a/fluxon_rs/setup.py +++ b/fluxon_rs/setup.py @@ -36,7 +36,7 @@ def find_libs(): setup( name="fluxon_pyo3", - version="0.2.3", + version="0.2.4", description="for export fluxonkv core to python layer", long_description=open("README.md").read() if os.path.exists("README.md") else "", long_description_content_type="text/markdown", diff --git a/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py b/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py index b6eaaf7..5a5883d 100644 --- a/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py +++ b/fluxon_test_stack/tests/test_top_attention_fs_s3_rclone_contract.py @@ -129,14 +129,30 @@ def test_s3_harness_config_has_no_transfer_state_store(self) -> None: }, ) - def test_complex_fixture_stays_below_one_s3_list_page(self) -> None: + def test_complex_fixture_stays_below_one_s3_list_page_and_forces_multipart(self) -> None: files = _E2E._build_complex_fixture_files() self.assertEqual(len(files), 405) self.assertLess(len(files), 1000) self.assertEqual(sum(relpath.startswith("fanout/") for relpath in files), 400) self.assertIn("deep/l1/l2/l3/l4/l5/l6/l7/l8/final.bin", files) - self.assertEqual(len(files["blobs/medium-8m.bin"]), 8 * 1024 * 1024) + multipart_size = len(files["blobs/multipart-12m.bin"]) + self.assertEqual(multipart_size, _E2E.RCLONE_MULTIPART_FIXTURE_SIZE_BYTES) + self.assertGreater(multipart_size, _E2E.RCLONE_MULTIPART_CHUNK_SIZE_BYTES) + self.assertEqual( + (multipart_size + _E2E.RCLONE_MULTIPART_CHUNK_SIZE_BYTES - 1) + // _E2E.RCLONE_MULTIPART_CHUNK_SIZE_BYTES, + 3, + ) + self.assertEqual( + _E2E.RCLONE_MULTIPART_COPY_FLAGS, + ( + "--s3-upload-cutoff", + "5Mi", + "--s3-chunk-size", + "5Mi", + ), + ) self.assertTrue(all(" " not in relpath for relpath in files)) def test_main_runs_direct_e2e_with_pinned_image(self) -> None: diff --git a/setup_and_pack/utils/docker_build_runtime_utils.py b/setup_and_pack/utils/docker_build_runtime_utils.py index c40b23a..aa7a657 100644 --- a/setup_and_pack/utils/docker_build_runtime_utils.py +++ b/setup_and_pack/utils/docker_build_runtime_utils.py @@ -550,7 +550,7 @@ def build_docker_run_cmd( """Build a `docker run` command (without executing it). Args: - image: Image name (with tag), e.g. "fluxon_quick_start:0.2.3". + image: Image name (with tag), e.g. "fluxon_quick_start:0.2.4". name: Container name (`--name`). remove: Auto-remove on exit (`--rm`). detach: Run detached (`-d`).