refactor: fail loud, tighten types, remove speculative abstraction
Fail loud at the boundary:
- substitute_template raises ConfigError on unresolved {{...}}; no more
silent literal placeholders in download URLs.
- parse_profile raises ConfigError when 'os' is missing -- no
raw.get("os", "linux") default that silently masks typos.
- urllib download failures wrapped to FlowError.
- bootstrap _execute_action dispatches phases explicitly and raises
on unhandled phase; no more "anything else runs as shell".
Direct access over defensive wrapping:
- plan_bootstrap requires env; plan_install requires pm. Drop the
dead `or os.environ` / `or detect_package_manager()` fallbacks.
- InstalledState.from_dict raises ConfigError on missing fields
rather than .get(..., default).
- Replace `x or {}` chains with explicit `x if x is not None else {}`
in package resolution; catalog validates type/platform-map/install
shapes at parse.
One canonical form / direct access:
- Path.home() replaced with paths.HOME in services/packages.py and
commands/completion.py. paths.HOME is the single source now.
- Use Path.is_relative_to for install-path containment instead of
str.startswith.
Domain purity:
- domain/containers/resolution.resolve_mounts takes a filesystem_check
predicate; service passes the probe in. Domain no longer touches
the filesystem directly.
No speculative abstraction:
- Drop the `allow_sudo` field entirely. The _script_uses_sudo check
it gated was bypassable (substring match) and gave false confidence;
the manifest is fully user-trusted anyway.
- Delete dead terminfo_fix_command + RemoteService.fix_terminfo
(no command surface exposes them).
- FileSystem.remove_tree no longer swallows errors via ignore_errors;
callers opt into missing_ok if needed.
Typed enums:
- PackageDef.type, AppConfig.container_runtime as Literal[...].
container_runtime values validated at config parse.
Completion bypasses runtime no longer:
- complete(ctx, ...) threads context; ContainerRuntime and state-file
reads go through ctx.runtime instead of constructing primitives.
Tests added for: template raise, missing os raise, env/pm required,
unknown phase raise, no allow_sudo gate, URL download failure, install
path escape, corrupt installed.json, container_runtime Literal,
filesystem_check controls mounts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,10 +3,24 @@
|
||||
import subprocess
|
||||
|
||||
from flow.commands.completion import complete
|
||||
from flow.core.config import AppConfig, FlowContext
|
||||
from flow.core.console import Console
|
||||
from flow.core.platform import PlatformInfo
|
||||
from flow.core.runtime import SystemRuntime
|
||||
|
||||
|
||||
def _make_ctx():
|
||||
return FlowContext(
|
||||
config=AppConfig(),
|
||||
manifest={},
|
||||
platform=PlatformInfo(),
|
||||
console=Console(color=False),
|
||||
runtime=SystemRuntime(),
|
||||
)
|
||||
|
||||
|
||||
def test_complete_top_level():
|
||||
result = complete(["flow", ""], 1)
|
||||
result = complete(_make_ctx(), ["flow", ""], 1)
|
||||
assert "dotfiles" in result
|
||||
assert "packages" in result
|
||||
assert "setup" in result
|
||||
@@ -16,12 +30,12 @@ def test_complete_top_level():
|
||||
|
||||
|
||||
def test_complete_top_level_prefix():
|
||||
result = complete(["flow", "do"], 1)
|
||||
result = complete(_make_ctx(), ["flow", "do"], 1)
|
||||
assert result == ["dotfiles"]
|
||||
|
||||
|
||||
def test_complete_dotfiles_subcommands():
|
||||
result = complete(["flow", "dotfiles", ""], 2)
|
||||
result = complete(_make_ctx(), ["flow", "dotfiles", ""], 2)
|
||||
assert "link" in result
|
||||
assert "unlink" in result
|
||||
assert "status" in result
|
||||
@@ -36,7 +50,7 @@ def test_complete_dotfiles_subcommands():
|
||||
|
||||
|
||||
def test_complete_dotfiles_repos_subcommands():
|
||||
result = complete(["flow", "dotfiles", "repos", ""], 3)
|
||||
result = complete(_make_ctx(), ["flow", "dotfiles", "repos", ""], 3)
|
||||
assert "list" in result
|
||||
assert "status" in result
|
||||
assert "pull" in result
|
||||
@@ -44,43 +58,41 @@ def test_complete_dotfiles_repos_subcommands():
|
||||
|
||||
|
||||
def test_complete_dotfiles_repos_pull_flags():
|
||||
result = complete(["flow", "dotfiles", "repos", "pull", "--"], 4)
|
||||
result = complete(_make_ctx(), ["flow", "dotfiles", "repos", "pull", "--"], 4)
|
||||
assert "--repo" in result
|
||||
assert "--dry-run" in result
|
||||
|
||||
|
||||
def test_complete_dotfiles_edit_packages():
|
||||
result = complete(["flow", "dotfiles", "edit", "--"], 3)
|
||||
result = complete(_make_ctx(), ["flow", "dotfiles", "edit", "--"], 3)
|
||||
assert "--no-commit" in result
|
||||
|
||||
|
||||
def test_complete_dotfiles_link_flags():
|
||||
result = complete(["flow", "dotfiles", "link", "--"], 3)
|
||||
result = complete(_make_ctx(), ["flow", "dotfiles", "link", "--"], 3)
|
||||
assert "--profile" in result
|
||||
assert "--dry-run" in result
|
||||
|
||||
|
||||
def test_complete_unknown_command():
|
||||
result = complete(["flow", "unknown", ""], 2)
|
||||
result = complete(_make_ctx(), ["flow", "unknown", ""], 2)
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_complete_packages_subcommands():
|
||||
result = complete(["flow", "packages", ""], 2)
|
||||
result = complete(_make_ctx(), ["flow", "packages", ""], 2)
|
||||
assert "install" in result
|
||||
assert "remove" in result
|
||||
assert "list" in result
|
||||
|
||||
|
||||
def test_complete_dev_attach_returns_empty_on_timeout(monkeypatch):
|
||||
class FakeRuntime:
|
||||
def __init__(self, runner, *, mode="auto"):
|
||||
self.runner = runner
|
||||
def test_complete_dev_attach_returns_empty_on_timeout():
|
||||
ctx = _make_ctx()
|
||||
|
||||
def ps(self, **kwargs):
|
||||
assert kwargs["timeout"] == 1.0
|
||||
raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"])
|
||||
def fake_ps(**kwargs):
|
||||
assert kwargs["timeout"] == 1.0
|
||||
raise subprocess.TimeoutExpired("docker ps", kwargs["timeout"])
|
||||
|
||||
monkeypatch.setattr("flow.commands.completion.ContainerRuntime", FakeRuntime)
|
||||
result = complete(["flow", "dev", "attach", ""], 3)
|
||||
ctx.runtime.containers.ps = fake_ps # type: ignore[method-assign]
|
||||
result = complete(ctx, ["flow", "dev", "attach", ""], 3)
|
||||
assert result == []
|
||||
|
||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from flow.core.config import AppConfig, load_config, load_manifest
|
||||
from flow.core.errors import ConfigError
|
||||
|
||||
|
||||
def test_load_config_missing_path(tmp_path):
|
||||
@@ -141,3 +142,22 @@ def test_load_config_container_runtime(tmp_path):
|
||||
)
|
||||
cfg = load_config(tmp_path)
|
||||
assert cfg.container_runtime == "podman-rootful"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runtime", ["auto", "docker", "podman", "podman-rootful"])
|
||||
def test_load_config_container_runtime_accepts_known_values(tmp_path, runtime):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"defaults:\n"
|
||||
f" container-runtime: {runtime}\n"
|
||||
)
|
||||
cfg = load_config(tmp_path)
|
||||
assert cfg.container_runtime == runtime
|
||||
|
||||
|
||||
def test_load_config_container_runtime_rejects_unknown(tmp_path):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"defaults:\n"
|
||||
" container-runtime: nspawn\n"
|
||||
)
|
||||
with pytest.raises(ConfigError, match="Invalid container-runtime"):
|
||||
load_config(tmp_path)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for bootstrap planning."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.core.errors import ConfigError
|
||||
@@ -22,33 +24,45 @@ class TestParseProfile:
|
||||
assert profile.shell == "zsh"
|
||||
assert len(profile.packages) == 2
|
||||
|
||||
def test_defaults(self):
|
||||
profile = parse_profile("minimal", {})
|
||||
def test_missing_os_raises(self):
|
||||
with pytest.raises(ConfigError, match=r"Profile 'minimal': required field 'os' is missing"):
|
||||
parse_profile("minimal", {})
|
||||
|
||||
def test_optional_fields_default(self):
|
||||
profile = parse_profile("minimal", {"os": "linux"})
|
||||
assert profile.os == "linux"
|
||||
assert profile.hostname is None
|
||||
assert profile.packages == ()
|
||||
|
||||
def test_ssh_keys(self):
|
||||
raw = {"ssh-keys": [{"path": "~/.ssh/id_ed25519", "type": "ed25519"}]}
|
||||
raw = {"os": "linux", "ssh-keys": [{"path": "~/.ssh/id_ed25519", "type": "ed25519"}]}
|
||||
profile = parse_profile("test", raw)
|
||||
assert len(profile.ssh_keys) == 1
|
||||
|
||||
def test_ssh_keys_with_filename(self):
|
||||
raw = {"ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
|
||||
raw = {"os": "linux", "ssh-keys": [{"filename": "id_work", "type": "ed25519"}]}
|
||||
profile = parse_profile("test", raw)
|
||||
assert profile.ssh_keys[0]["path"] == "~/.ssh/id_work"
|
||||
|
||||
def test_env_required(self):
|
||||
profile = parse_profile("test", {"env-required": ["USER_EMAIL"]})
|
||||
profile = parse_profile("test", {"os": "linux", "env-required": ["USER_EMAIL"]})
|
||||
assert profile.env_required == ("USER_EMAIL",)
|
||||
|
||||
def test_post_link_and_dotfiles_profile(self):
|
||||
profile = parse_profile("test", {"dotfiles-profile": "linux-work", "post-link": "echo done"})
|
||||
profile = parse_profile(
|
||||
"test",
|
||||
{"os": "linux", "dotfiles-profile": "linux-work", "post-link": "echo done"},
|
||||
)
|
||||
assert profile.dotfiles_profile == "linux-work"
|
||||
assert profile.post_link == "echo done"
|
||||
|
||||
|
||||
class TestPlanBootstrap:
|
||||
def test_env_is_required_keyword(self):
|
||||
sig = inspect.signature(plan_bootstrap)
|
||||
param = sig.parameters["env"]
|
||||
assert param.default is inspect.Parameter.empty
|
||||
|
||||
def test_basic_plan(self):
|
||||
profile = Profile(
|
||||
name="test", os="linux", arch=None,
|
||||
@@ -57,7 +71,7 @@ class TestPlanBootstrap:
|
||||
packages=["fd"], env_required=[],
|
||||
)
|
||||
manifest = {"packages": [{"name": "fd", "type": "pkg"}]}
|
||||
plan = plan_bootstrap(profile, manifest)
|
||||
plan = plan_bootstrap(profile, manifest, env={})
|
||||
assert plan.profile == "test"
|
||||
assert plan.total_steps > 0
|
||||
phases = [a.phase for a in plan.actions]
|
||||
@@ -65,8 +79,7 @@ class TestPlanBootstrap:
|
||||
assert "packages" in phases
|
||||
assert "dotfiles" in phases
|
||||
|
||||
def test_missing_env_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("REQUIRED_VAR", raising=False)
|
||||
def test_missing_env_raises(self):
|
||||
profile = Profile(
|
||||
name="test", os="linux", arch=None,
|
||||
hostname=None, locale=None, shell=None,
|
||||
@@ -74,7 +87,7 @@ class TestPlanBootstrap:
|
||||
env_required=["REQUIRED_VAR"],
|
||||
)
|
||||
with pytest.raises(ConfigError, match="REQUIRED_VAR"):
|
||||
plan_bootstrap(profile, {})
|
||||
plan_bootstrap(profile, {}, env={})
|
||||
|
||||
def test_runcmd_produces_action(self):
|
||||
profile = Profile(
|
||||
@@ -83,7 +96,7 @@ class TestPlanBootstrap:
|
||||
ssh_keys=[], runcmd=["echo hello", "echo world"],
|
||||
packages=[], env_required=[],
|
||||
)
|
||||
plan = plan_bootstrap(profile, {})
|
||||
plan = plan_bootstrap(profile, {}, env={})
|
||||
runcmd_actions = [a for a in plan.actions if "custom command" in a.description.lower()]
|
||||
assert len(runcmd_actions) == 1
|
||||
|
||||
@@ -94,7 +107,7 @@ class TestPlanBootstrap:
|
||||
ssh_keys=[], runcmd=[], packages=[], env_required=[],
|
||||
post_link="echo done",
|
||||
)
|
||||
plan = plan_bootstrap(profile, {})
|
||||
plan = plan_bootstrap(profile, {}, env={})
|
||||
assert any(action.phase == "post-link" for action in plan.actions)
|
||||
|
||||
def test_ssh_keys_action(self):
|
||||
@@ -104,6 +117,6 @@ class TestPlanBootstrap:
|
||||
ssh_keys=[{"path": "~/.ssh/id", "type": "ed25519"}],
|
||||
runcmd=[], packages=[], env_required=[],
|
||||
)
|
||||
plan = plan_bootstrap(profile, {})
|
||||
plan = plan_bootstrap(profile, {}, env={})
|
||||
ssh_actions = [a for a in plan.actions if "SSH" in a.description]
|
||||
assert len(ssh_actions) == 1
|
||||
|
||||
@@ -42,28 +42,40 @@ class TestResolveMounts:
|
||||
def test_projects_mount(self, tmp_path):
|
||||
projects = tmp_path / "projects"
|
||||
projects.mkdir()
|
||||
mounts = resolve_mounts(tmp_path, project_path=str(projects))
|
||||
mounts = resolve_mounts(
|
||||
tmp_path, filesystem_check=lambda p: p.exists(), project_path=str(projects),
|
||||
)
|
||||
project_mounts = [m for m in mounts if m.target == "/workspace"]
|
||||
assert len(project_mounts) == 1
|
||||
|
||||
def test_dotfiles_mount(self, tmp_path):
|
||||
dotfiles = tmp_path / "dotfiles"
|
||||
dotfiles.mkdir()
|
||||
mounts = resolve_mounts(tmp_path, dotfiles_dir=dotfiles)
|
||||
mounts = resolve_mounts(
|
||||
tmp_path, filesystem_check=lambda p: p.exists(), dotfiles_dir=dotfiles,
|
||||
)
|
||||
assert any(m.target.endswith("/flow/dotfiles") for m in mounts)
|
||||
|
||||
def test_socket_path_mount(self, tmp_path):
|
||||
sock = tmp_path / "docker.sock"
|
||||
sock.write_text("")
|
||||
mounts = resolve_mounts(tmp_path, socket_path=sock)
|
||||
mounts = resolve_mounts(
|
||||
tmp_path, filesystem_check=lambda p: p.exists(), socket_path=sock,
|
||||
)
|
||||
socket_mounts = [m for m in mounts if m.target == "/var/run/docker.sock"]
|
||||
assert len(socket_mounts) == 1
|
||||
assert socket_mounts[0].source == sock
|
||||
|
||||
def test_no_socket_path(self, tmp_path):
|
||||
mounts = resolve_mounts(tmp_path)
|
||||
mounts = resolve_mounts(tmp_path, filesystem_check=lambda p: p.exists())
|
||||
assert not any(m.target == "/var/run/docker.sock" for m in mounts)
|
||||
|
||||
def test_filesystem_check_controls_standard_mounts(self, tmp_path):
|
||||
mounts = resolve_mounts(tmp_path, filesystem_check=lambda p: False)
|
||||
# No standard mounts present when filesystem_check returns False.
|
||||
assert not any(m.target == "/home/dev/.ssh" for m in mounts)
|
||||
assert not any(m.target.endswith("/flow/dotfiles") for m in mounts)
|
||||
|
||||
|
||||
class TestBuildContainerSpec:
|
||||
def test_basic(self):
|
||||
|
||||
@@ -67,7 +67,7 @@ class TestResolveSpec:
|
||||
name="fd", type="pkg", sources={"apt": "fd-find"},
|
||||
source=None, version=None, asset_pattern=None,
|
||||
platform_map={}, extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)}
|
||||
ref = ProfilePackageRef(name="fd", type=None, source=None, version="1.0", asset_pattern=None)
|
||||
result = resolve_spec(ref, catalog)
|
||||
@@ -86,7 +86,7 @@ class TestResolveSpec:
|
||||
name="docker", type="pkg", sources={"apt": "docker-ce"},
|
||||
source=None, version=None, asset_pattern=None,
|
||||
platform_map={}, extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)}
|
||||
ref = ProfilePackageRef(
|
||||
name="docker",
|
||||
@@ -95,11 +95,9 @@ class TestResolveSpec:
|
||||
version=None,
|
||||
asset_pattern=None,
|
||||
post_install="sudo groupadd docker || true",
|
||||
allow_sudo=True,
|
||||
)
|
||||
result = resolve_spec(ref, catalog)
|
||||
assert result.post_install == "sudo groupadd docker || true"
|
||||
assert result.allow_sudo is True
|
||||
|
||||
|
||||
class TestResolveSourceName:
|
||||
@@ -108,7 +106,7 @@ class TestResolveSourceName:
|
||||
name="fd", type="pkg", sources={"apt": "fd-find"},
|
||||
source=None, version=None, asset_pattern=None,
|
||||
platform_map={}, extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
assert resolve_source_name(pkg, "apt") == "fd-find"
|
||||
|
||||
@@ -117,7 +115,7 @@ class TestResolveSourceName:
|
||||
name="fd", type="pkg", sources={},
|
||||
source=None, version=None, asset_pattern=None,
|
||||
platform_map={}, extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
assert resolve_source_name(pkg, "apt") == "fd"
|
||||
|
||||
@@ -131,7 +129,7 @@ class TestResolveBinaryAsset:
|
||||
asset_pattern=None,
|
||||
platform_map={"linux-x64": "nvim-linux-x86_64.tar.gz"},
|
||||
extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
|
||||
|
||||
@@ -143,7 +141,7 @@ class TestResolveBinaryAsset:
|
||||
asset_pattern="fd-v10.2.0-{{arch}}-unknown-{{os}}-gnu.tar.gz",
|
||||
platform_map={},
|
||||
extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
result = resolve_binary_asset(pkg, "linux-x64")
|
||||
assert "x64" in result
|
||||
@@ -157,7 +155,7 @@ class TestResolveBinaryAsset:
|
||||
asset_pattern="nvim-{{os}}-{{arch}}.tar.gz",
|
||||
platform_map={"linux-x64": {"os": "linux", "arch": "x86_64"}},
|
||||
extract_dir="nvim-{{os}}64", install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
assert resolve_binary_asset(pkg, "linux-x64") == "nvim-linux-x86_64.tar.gz"
|
||||
assert resolve_extract_dir(pkg, "linux-x64") == "nvim-linux64"
|
||||
@@ -171,7 +169,7 @@ class TestResolveDownloadUrl:
|
||||
version="v0.10.4",
|
||||
asset_pattern=None, platform_map={},
|
||||
extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
url = resolve_download_url(pkg, "nvim.tar.gz")
|
||||
assert "github.com/neovim/neovim" in url
|
||||
@@ -184,7 +182,7 @@ class TestResolveDownloadUrl:
|
||||
version="0.10.4",
|
||||
asset_pattern=None, platform_map={},
|
||||
extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
url = resolve_download_url(pkg, "nvim.tar.gz", "linux-x64")
|
||||
assert "/download/v0.10.4/" in url
|
||||
@@ -196,7 +194,7 @@ class TestResolveDownloadUrl:
|
||||
version=None,
|
||||
asset_pattern=None, platform_map={},
|
||||
extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
url = resolve_download_url(pkg, "nvim.tar.gz")
|
||||
assert "latest" in url
|
||||
@@ -208,7 +206,7 @@ class TestResolveDownloadUrl:
|
||||
version=None,
|
||||
asset_pattern=None, platform_map={},
|
||||
extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
url = resolve_download_url(pkg, "x.tar.gz")
|
||||
assert url == "https://example.com/download/x.tar.gz"
|
||||
@@ -247,7 +245,7 @@ class TestPlanning:
|
||||
name="wezterm", type="cask", sources={"brew": "wezterm"},
|
||||
source=None, version=None, asset_pattern=None,
|
||||
platform_map={}, extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
plan = plan_install([pkg], InstalledState(), "macos-arm64", "brew")
|
||||
assert plan.install_ops[0].method == "cask"
|
||||
|
||||
@@ -32,12 +32,36 @@ def test_installed_state_version_mismatch():
|
||||
InstalledState.from_dict({"version": 99, "packages": {}})
|
||||
|
||||
|
||||
def test_installed_state_corrupt_missing_version_raises():
|
||||
with pytest.raises(ConfigError, match="missing field 'version'"):
|
||||
InstalledState.from_dict({
|
||||
"version": 1,
|
||||
"packages": {"fd": {"type": "pkg", "files": []}},
|
||||
})
|
||||
|
||||
|
||||
def test_installed_state_corrupt_missing_type_raises():
|
||||
with pytest.raises(ConfigError, match="missing field 'type'"):
|
||||
InstalledState.from_dict({
|
||||
"version": 1,
|
||||
"packages": {"fd": {"version": "1.0", "files": []}},
|
||||
})
|
||||
|
||||
|
||||
def test_installed_state_corrupt_missing_files_raises():
|
||||
with pytest.raises(ConfigError, match="missing field 'files'"):
|
||||
InstalledState.from_dict({
|
||||
"version": 1,
|
||||
"packages": {"fd": {"version": "1.0", "type": "pkg"}},
|
||||
})
|
||||
|
||||
|
||||
def test_package_def_fields():
|
||||
pkg = PackageDef(
|
||||
name="fd", type="pkg", sources={"apt": "fd-find"},
|
||||
source=None, version=None, asset_pattern=None,
|
||||
platform_map={}, extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
assert pkg.name == "fd"
|
||||
assert pkg.type == "pkg"
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Tests for package install/remove planning."""
|
||||
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.core.errors import FlowError
|
||||
from flow.domain.packages.models import (
|
||||
InstalledPackage,
|
||||
InstalledState,
|
||||
@@ -16,7 +20,7 @@ def _pkg(name, type="pkg", sources=None, source=None, version=None,
|
||||
name=name, type=type, sources=sources or {},
|
||||
source=source, version=version, asset_pattern=asset_pattern,
|
||||
platform_map=platform_map or {}, extract_dir=None, install={},
|
||||
post_install=None, allow_sudo=False,
|
||||
post_install=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,3 +66,15 @@ class TestPlanRemove:
|
||||
def test_remove_not_installed(self):
|
||||
plan = plan_remove(["missing"], InstalledState())
|
||||
assert len(plan.remove_ops) == 0
|
||||
|
||||
|
||||
class TestPlanInstallSignature:
|
||||
def test_pm_is_required_positional(self):
|
||||
sig = inspect.signature(plan_install)
|
||||
param = sig.parameters["pm"]
|
||||
assert param.default is inspect.Parameter.empty
|
||||
|
||||
def test_pkg_without_pm_raises(self):
|
||||
pkgs = [_pkg("fd", sources={"apt": "fd-find"})]
|
||||
with pytest.raises(FlowError, match="No supported package manager"):
|
||||
plan_install(pkgs, InstalledState(), "linux-x64", None)
|
||||
|
||||
@@ -10,7 +10,6 @@ from flow.domain.remote.resolution import (
|
||||
list_targets,
|
||||
parse_target,
|
||||
resolve_target,
|
||||
terminfo_fix_command,
|
||||
)
|
||||
|
||||
|
||||
@@ -84,9 +83,3 @@ class TestListTargets:
|
||||
targets = list_targets(configs)
|
||||
assert len(targets) == 2
|
||||
assert targets[0].label == "a@b"
|
||||
|
||||
|
||||
class TestTerminfoFix:
|
||||
def test_returns_command(self):
|
||||
cmd = terminfo_fix_command()
|
||||
assert "infocmp" in cmd
|
||||
|
||||
@@ -83,7 +83,6 @@ class TestBootstrapService:
|
||||
"os": "linux",
|
||||
"packages": [{
|
||||
"name": "docker",
|
||||
"allow-sudo": True,
|
||||
"post-install": "sudo groupadd docker || true",
|
||||
}],
|
||||
},
|
||||
@@ -93,9 +92,27 @@ class TestBootstrapService:
|
||||
ctx = _make_ctx(manifest)
|
||||
BootstrapService(ctx).run("linux-auto")
|
||||
|
||||
assert captured["packages"][0].allow_sudo is True
|
||||
assert captured["packages"][0].post_install == "sudo groupadd docker || true"
|
||||
|
||||
def test_unknown_phase_raises(self):
|
||||
from flow.domain.bootstrap.models import BootstrapAction, BootstrapPlan
|
||||
from flow.domain.bootstrap.models import VALID_PHASES
|
||||
|
||||
manifest = {"profiles": {"work": {"os": "linux"}}}
|
||||
ctx = _make_ctx(manifest)
|
||||
svc = BootstrapService(ctx)
|
||||
# Forge an action with a phase that VALID_PHASES contains but the
|
||||
# dispatch can't handle (shouldn't happen, but tests the explicit guard).
|
||||
# Use a phase NOT in VALID_PHASES first to confirm the "Unknown" branch.
|
||||
action = BootstrapAction.__new__(BootstrapAction)
|
||||
object.__setattr__(action, "phase", "no-such-phase")
|
||||
object.__setattr__(action, "description", "")
|
||||
object.__setattr__(action, "commands", ())
|
||||
object.__setattr__(action, "needs_sudo", False)
|
||||
plan = BootstrapPlan(profile="work", actions=(), packages_to_install=())
|
||||
with pytest.raises(FlowError, match="Unknown bootstrap phase"):
|
||||
svc._execute_action(action, plan, "work")
|
||||
|
||||
def test_run_uses_dotfiles_profile_override(self, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -12,7 +13,7 @@ from flow.core.errors import FlowError
|
||||
from flow.core.platform import PlatformInfo
|
||||
from flow.core.runtime import SystemRuntime
|
||||
from flow.core import paths
|
||||
from flow.domain.packages.models import InstalledPackage, InstalledState
|
||||
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
|
||||
from flow.services.packages import PackageService
|
||||
|
||||
|
||||
@@ -78,6 +79,7 @@ class TestPackageService:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setattr(paths, "HOME", home)
|
||||
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
|
||||
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||
|
||||
@@ -130,3 +132,105 @@ class TestPackageService:
|
||||
assert (home / ".local" / "bin" / "nvim").exists()
|
||||
assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists()
|
||||
assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists()
|
||||
|
||||
def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch):
|
||||
"""No allow_sudo gate -- post-install scripts run as written."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(paths, "HOME", home)
|
||||
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
class _Runner:
|
||||
def run_shell(self, command, **kwargs):
|
||||
calls.append(command)
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return _Result()
|
||||
|
||||
ctx = _make_ctx(tmp_path)
|
||||
ctx.runtime.runner = _Runner()
|
||||
svc = PackageService(ctx)
|
||||
pkg = PackageDef(
|
||||
name="docker", type="pkg", sources={},
|
||||
source=None, version=None, asset_pattern=None,
|
||||
platform_map={}, extract_dir=None, install={},
|
||||
post_install="sudo groupadd docker || true",
|
||||
)
|
||||
svc._run_post_install(pkg)
|
||||
assert calls == ["sudo groupadd docker || true"]
|
||||
|
||||
def test_install_binary_url_failure_raises_flow_error(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(paths, "HOME", home)
|
||||
monkeypatch.setattr(paths, "DATA_DIR", tmp_path / "data")
|
||||
monkeypatch.setattr(paths, "INSTALLED_STATE", tmp_path / "installed.json")
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise urllib.error.URLError("Network unreachable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"flow.services.packages.urllib.request.urlopen", _raise,
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"packages": [{
|
||||
"name": "neovim",
|
||||
"type": "binary",
|
||||
"source": "github:neovim/neovim",
|
||||
"version": "0.10.4",
|
||||
"platform-map": {"linux-x64": "nvim-linux-x64.tar.gz"},
|
||||
"install": {"bin": ["bin/nvim"]},
|
||||
}],
|
||||
}
|
||||
ctx = _make_ctx(tmp_path, manifest)
|
||||
svc = PackageService(ctx)
|
||||
packages = svc.resolve_install_packages(package_names=["neovim"])
|
||||
with pytest.raises(FlowError, match="Failed to download"):
|
||||
svc.install(packages)
|
||||
|
||||
def test_install_path_absolute_raises(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(paths, "HOME", tmp_path / "home")
|
||||
ctx = _make_ctx(tmp_path)
|
||||
svc = PackageService(ctx)
|
||||
with pytest.raises(FlowError, match="must be relative"):
|
||||
svc._validate_install_path("pkg", Path("/etc/passwd"))
|
||||
|
||||
def test_install_path_parent_traversal_raises(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(paths, "HOME", tmp_path / "home")
|
||||
ctx = _make_ctx(tmp_path)
|
||||
svc = PackageService(ctx)
|
||||
with pytest.raises(FlowError, match="parent traversal"):
|
||||
svc._validate_install_path("pkg", Path("../etc/passwd"))
|
||||
|
||||
def test_install_path_escapes_extract_dir_raises(self, tmp_path, monkeypatch):
|
||||
"""A relative path whose resolved location is outside the extract dir."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(paths, "HOME", home)
|
||||
ctx = _make_ctx(tmp_path)
|
||||
svc = PackageService(ctx)
|
||||
|
||||
extract_root = tmp_path / "extract"
|
||||
extract_root.mkdir()
|
||||
sibling = tmp_path / "sibling"
|
||||
sibling.mkdir()
|
||||
# Symlink inside the extract root pointing outside -- the resolved
|
||||
# source escapes the root.
|
||||
link = extract_root / "evil"
|
||||
link.symlink_to(sibling)
|
||||
|
||||
with pytest.raises(FlowError, match="escapes extract-dir"):
|
||||
svc._copy_install_item(
|
||||
"pkg",
|
||||
extract_root,
|
||||
extract_root.resolve(),
|
||||
"bin",
|
||||
"evil/escape",
|
||||
)
|
||||
|
||||
@@ -55,10 +55,3 @@ class TestRemoteService:
|
||||
svc = RemoteService(ctx)
|
||||
svc.list()
|
||||
assert "No targets" in capsys.readouterr().out
|
||||
|
||||
def test_fix_terminfo(self, capsys):
|
||||
ctx = _make_ctx()
|
||||
svc = RemoteService(ctx)
|
||||
svc.fix_terminfo("personal@orb")
|
||||
output = capsys.readouterr().out
|
||||
assert "infocmp" in output
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from flow.core.errors import ConfigError
|
||||
from flow.core.template import substitute, substitute_template
|
||||
|
||||
|
||||
@@ -36,8 +39,18 @@ class TestSubstituteTemplate:
|
||||
ctx = {"platform": {"arch": "arm64"}}
|
||||
assert substitute_template("{{ platform.arch }}", ctx) == "arm64"
|
||||
|
||||
def test_preserves_unknown_templates(self):
|
||||
assert substitute_template("{{ unknown }}", {}) == "{{ unknown }}"
|
||||
def test_unknown_variable_raises(self):
|
||||
with pytest.raises(ConfigError, match=r"\{\{ unknown \}\}"):
|
||||
substitute_template("{{ unknown }}", {})
|
||||
|
||||
def test_nested_unresolved_raises(self):
|
||||
with pytest.raises(ConfigError, match=r"\{\{ platform.missing \}\}"):
|
||||
substitute_template("{{ platform.missing }}", {"platform": {"arch": "arm64"}})
|
||||
|
||||
def test_unresolved_env_raises(self, monkeypatch):
|
||||
monkeypatch.delenv("SOME_NEVER_SET_VAR", raising=False)
|
||||
with pytest.raises(ConfigError):
|
||||
substitute_template("{{ env.SOME_NEVER_SET_VAR }}", {"env": {}})
|
||||
|
||||
def test_non_string_passthrough(self):
|
||||
assert substitute_template(42, {}) == 42
|
||||
|
||||
Reference in New Issue
Block a user