update
This commit is contained in:
@@ -56,13 +56,23 @@ class ActionExecutor:
|
|||||||
if primitive_result is None:
|
if primitive_result is None:
|
||||||
primitive_result = ActionResult(action.id, action.type, "success")
|
primitive_result = ActionResult(action.id, action.type, "success")
|
||||||
results.append(primitive_result)
|
results.append(primitive_result)
|
||||||
|
if primitive_result.status == "failed":
|
||||||
|
self.audit.write(
|
||||||
|
"action_failed",
|
||||||
|
{
|
||||||
|
"plan": plan.name,
|
||||||
|
"action": action,
|
||||||
|
"result": primitive_result,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
self.audit.write(
|
self.audit.write(
|
||||||
"action_success",
|
"action_success",
|
||||||
{"plan": plan.name, "action": action},
|
{"plan": plan.name, "action": action},
|
||||||
)
|
)
|
||||||
if action.rollback_policy == RollbackPolicy.BARRIER:
|
if action.rollback_policy == RollbackPolicy.BARRIER:
|
||||||
rollback_stack.clear()
|
rollback_stack.clear()
|
||||||
elif rollback is not None:
|
elif primitive_result.status != "failed" and rollback is not None:
|
||||||
rollback_stack.append(rollback)
|
rollback_stack.append(rollback)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append(ActionResult(action.id, action.type, "failed", str(e)))
|
results.append(ActionResult(action.id, action.type, "failed", str(e)))
|
||||||
@@ -288,7 +298,8 @@ class ActionExecutor:
|
|||||||
interactive=bool(p.get("interactive", False)),
|
interactive=bool(p.get("interactive", False)),
|
||||||
detach_keys=p.get("detach_keys"),
|
detach_keys=p.get("detach_keys"),
|
||||||
)
|
)
|
||||||
return ActionResult(action.id, action.type, "success", returncode=returncode)
|
status = "success" if returncode == 0 else "failed"
|
||||||
|
return ActionResult(action.id, action.type, status, returncode=returncode)
|
||||||
|
|
||||||
if t == "tmux.new_session":
|
if t == "tmux.new_session":
|
||||||
self.ctx.runtime.tmux.new_session(
|
self.ctx.runtime.tmux.new_session(
|
||||||
|
|||||||
@@ -65,8 +65,10 @@ class FileSystem:
|
|||||||
shutil.copy2(source, target)
|
shutil.copy2(source, target)
|
||||||
|
|
||||||
def copy_tree(self, source: Path, target: Path) -> None:
|
def copy_tree(self, source: Path, target: Path) -> None:
|
||||||
|
if target.exists() or target.is_symlink():
|
||||||
|
raise FlowError(f"Copy target already exists: {target}")
|
||||||
self.ensure_dir(target.parent)
|
self.ensure_dir(target.parent)
|
||||||
shutil.copytree(source, target, dirs_exist_ok=True)
|
shutil.copytree(source, target)
|
||||||
|
|
||||||
def create_symlink(
|
def create_symlink(
|
||||||
self,
|
self,
|
||||||
@@ -155,4 +157,3 @@ class FileSystem:
|
|||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=2)
|
json.dump(data, f, indent=2)
|
||||||
os.replace(tmp, path)
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|||||||
@@ -354,8 +354,14 @@ class PackageService:
|
|||||||
|
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
name="packages.install",
|
name="packages.install",
|
||||||
domain_actions=tuple(actions),
|
domain_actions=(
|
||||||
primitive_actions=(self._state_write_action(projected),),
|
*actions,
|
||||||
|
self._state_domain_action(
|
||||||
|
"package.state.write.install",
|
||||||
|
"install",
|
||||||
|
projected,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _remove_action_plan(
|
def _remove_action_plan(
|
||||||
@@ -399,8 +405,14 @@ class PackageService:
|
|||||||
projected.packages.pop(op.name, None)
|
projected.packages.pop(op.name, None)
|
||||||
return ActionPlan(
|
return ActionPlan(
|
||||||
name="packages.remove",
|
name="packages.remove",
|
||||||
domain_actions=tuple(actions),
|
domain_actions=(
|
||||||
primitive_actions=(self._state_write_action(projected),),
|
*actions,
|
||||||
|
self._state_domain_action(
|
||||||
|
"package.state.write.remove",
|
||||||
|
"remove",
|
||||||
|
projected,
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _binary_install_primitives(
|
def _binary_install_primitives(
|
||||||
@@ -548,6 +560,21 @@ class PackageService:
|
|||||||
rollback_policy=RollbackPolicy.ROLLBACKABLE,
|
rollback_policy=RollbackPolicy.ROLLBACKABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _state_domain_action(
|
||||||
|
self,
|
||||||
|
action_id: str,
|
||||||
|
action: str,
|
||||||
|
state: InstalledState,
|
||||||
|
) -> DomainAction:
|
||||||
|
return DomainAction(
|
||||||
|
id=action_id,
|
||||||
|
kind="package",
|
||||||
|
action=action,
|
||||||
|
description=f"Write package state to {paths.INSTALLED_STATE}",
|
||||||
|
payload={"primitive_actions": (self._state_write_action(state),)},
|
||||||
|
rollback_policy=RollbackPolicy.ROLLBACKABLE,
|
||||||
|
)
|
||||||
|
|
||||||
def _executor(self) -> ActionExecutor:
|
def _executor(self) -> ActionExecutor:
|
||||||
return ActionExecutor(self.ctx, audit_path=paths.INSTALLED_STATE.parent / "actions.jsonl")
|
return ActionExecutor(self.ctx, audit_path=paths.INSTALLED_STATE.parent / "actions.jsonl")
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -210,3 +211,64 @@ def test_executor_dispatches_container_and_tmux_primitives(tmp_path):
|
|||||||
assert [
|
assert [
|
||||||
"tmux", "set-option", "-t", "dev-api", "default-command", "flow dev exec api",
|
"tmux", "set-option", "-t", "dev-api", "default-command", "flow dev exec api",
|
||||||
] in runner.calls
|
] in runner.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_exec_nonzero_result_is_failed(tmp_path):
|
||||||
|
runner = FakeRunner(
|
||||||
|
responses={
|
||||||
|
("exec", "dev-api", "false"): subprocess.CompletedProcess(
|
||||||
|
["docker", "exec", "dev-api", "false"], 7, stdout="", stderr=""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ctx = _ctx()
|
||||||
|
ctx.runtime.runner = runner
|
||||||
|
ctx.runtime.containers = ContainerRuntime(runner, binary="docker")
|
||||||
|
plan = ActionPlan(
|
||||||
|
name="container-exec-failure",
|
||||||
|
primitive_actions=(
|
||||||
|
PrimitiveAction(
|
||||||
|
id="container.exec",
|
||||||
|
type="container.exec",
|
||||||
|
description="Run failing command in container",
|
||||||
|
payload={"name": "dev-api", "argv": ("false",)},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = ActionExecutor(ctx, audit_path=tmp_path / "actions.jsonl").execute(plan)
|
||||||
|
|
||||||
|
assert summary.results[0].status == "failed"
|
||||||
|
assert summary.results[0].returncode == 7
|
||||||
|
audit_events = [
|
||||||
|
json.loads(line)["event"]
|
||||||
|
for line in (tmp_path / "actions.jsonl").read_text(encoding="utf-8").splitlines()
|
||||||
|
]
|
||||||
|
assert "action_failed" in audit_events
|
||||||
|
assert "action_success" not in audit_events
|
||||||
|
|
||||||
|
|
||||||
|
def test_copy_directory_refuses_existing_target(tmp_path):
|
||||||
|
source = tmp_path / "source"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
source.mkdir()
|
||||||
|
target.mkdir()
|
||||||
|
(source / "new.txt").write_text("new", encoding="utf-8")
|
||||||
|
(target / "existing.txt").write_text("keep", encoding="utf-8")
|
||||||
|
plan = ActionPlan(
|
||||||
|
name="copy-dir-existing-target",
|
||||||
|
primitive_actions=(
|
||||||
|
PrimitiveAction(
|
||||||
|
id="copy-dir",
|
||||||
|
type="file.copy",
|
||||||
|
description="Copy directory",
|
||||||
|
payload={"source": source, "target": target},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="Copy target already exists"):
|
||||||
|
ActionExecutor(_ctx(), audit_path=tmp_path / "actions.jsonl").execute(plan)
|
||||||
|
|
||||||
|
assert not (target / "new.txt").exists()
|
||||||
|
assert (target / "existing.txt").read_text(encoding="utf-8") == "keep"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for PackageService."""
|
"""Tests for PackageService."""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import subprocess
|
||||||
import tarfile
|
import tarfile
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,6 +17,7 @@ from flow.core.runtime import SystemRuntime
|
|||||||
from flow.core import paths
|
from flow.core import paths
|
||||||
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
|
from flow.domain.packages.models import InstalledPackage, InstalledState, PackageDef
|
||||||
from flow.app.packages import PackageService
|
from flow.app.packages import PackageService
|
||||||
|
from tests.fakes import FakeRunner
|
||||||
|
|
||||||
|
|
||||||
def _make_ctx(tmp_path, manifest=None):
|
def _make_ctx(tmp_path, manifest=None):
|
||||||
@@ -134,6 +136,49 @@ class TestPackageService:
|
|||||||
assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists()
|
assert (home / ".local" / "share" / "nvim" / "runtime.txt").exists()
|
||||||
assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists()
|
assert (home / ".local" / "share" / "man" / "man1" / "nvim.1").exists()
|
||||||
|
|
||||||
|
def test_install_does_not_write_state_when_package_manager_install_fails(
|
||||||
|
self, tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
state_path = tmp_path / "installed.json"
|
||||||
|
monkeypatch.setattr(paths, "INSTALLED_STATE", state_path)
|
||||||
|
monkeypatch.setattr("flow.app.packages.detect_package_manager", lambda: "apt")
|
||||||
|
|
||||||
|
class FailingInstallRunner(FakeRunner):
|
||||||
|
def run(
|
||||||
|
self, argv, *, cwd=None, env=None, capture_output=True,
|
||||||
|
check=False, timeout=None,
|
||||||
|
):
|
||||||
|
parts = list(argv)
|
||||||
|
self.calls.append(parts)
|
||||||
|
self.timeouts.append(timeout)
|
||||||
|
if parts[:3] == ["sudo", "apt-get", "install"]:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
parts, 42, stdout="", stderr="install failed"
|
||||||
|
)
|
||||||
|
return subprocess.CompletedProcess(parts, 0, stdout="", stderr="")
|
||||||
|
|
||||||
|
rt = SystemRuntime()
|
||||||
|
rt.runner = FailingInstallRunner()
|
||||||
|
ctx = FlowContext(
|
||||||
|
config=AppConfig(),
|
||||||
|
manifest={},
|
||||||
|
platform=PlatformInfo(),
|
||||||
|
console=Console(color=False),
|
||||||
|
runtime=rt,
|
||||||
|
)
|
||||||
|
svc = PackageService(ctx)
|
||||||
|
pkg = PackageDef(
|
||||||
|
name="fd", type="pkg", sources={},
|
||||||
|
source=None, version=None, asset_pattern=None,
|
||||||
|
platform_map={}, extract_dir=None, install={},
|
||||||
|
post_install=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(FlowError, match="install failed"):
|
||||||
|
svc.install([pkg])
|
||||||
|
|
||||||
|
assert not state_path.exists()
|
||||||
|
|
||||||
def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch):
|
def test_post_install_with_sudo_runs_unchecked(self, tmp_path, monkeypatch):
|
||||||
"""No allow_sudo gate -- post-install scripts run as written."""
|
"""No allow_sudo gate -- post-install scripts run as written."""
|
||||||
ctx = _make_ctx(tmp_path)
|
ctx = _make_ctx(tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user