Some checks failed
Build ISO / build-iso (push) Successful in 21m39s
CI / lint (push) Failing after 28s
CI / test (push) Successful in 1m29s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 14s
Release / release (push) Successful in 12m2s
Manifests gain an optional `requires` array. Each entry points at another app and may declare `on_install` + `on_start` hook scripts that live in the *provider's* folder and run inside its container via `docker compose exec`. Hook stdout (KEY=VALUE + optional FURTKA_JSON: sentinel) gets merged into the consumer's .env; the placeholder-secret check re-runs over the merged file. Provider apps that aren't installed get auto-installed first (topo order, cycle detection, explicit UI confirm). Removing an app is blocked while other installed apps require it. Reconcile now visits apps in dependency order so consumers' on_start hooks fire against already-up providers; per-app error isolation skips just the offending consumer's compose_up. Release 26.17-alpha. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
3.9 KiB
Python
118 lines
3.9 KiB
Python
import subprocess
|
|
|
|
import pytest
|
|
|
|
from furtka import dockerops
|
|
|
|
|
|
class FakeProc:
|
|
def __init__(self, stdout=b"", stderr=b"", returncode=0):
|
|
self.stdout = stdout
|
|
self.stderr = stderr
|
|
self.returncode = returncode
|
|
|
|
|
|
def test_compose_exec_builds_command(tmp_path, monkeypatch):
|
|
recorded = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
recorded["cmd"] = cmd
|
|
recorded["kwargs"] = kwargs
|
|
return FakeProc(stdout="ok\n", returncode=0)
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
out = dockerops.compose_exec(tmp_path, "myproj", "svc", ["echo", "hi"])
|
|
assert out == "ok\n"
|
|
cmd = recorded["cmd"]
|
|
# docker compose --project-name myproj --file <path>/docker-compose.yaml exec -T svc echo hi
|
|
assert cmd[0] == "docker"
|
|
assert cmd[1] == "compose"
|
|
assert "--project-name" in cmd and "myproj" in cmd
|
|
assert "exec" in cmd
|
|
assert "-T" in cmd
|
|
# -T must come before the service name
|
|
assert cmd.index("-T") < cmd.index("svc")
|
|
# argv appended after service
|
|
assert cmd[-2:] == ["echo", "hi"]
|
|
|
|
|
|
def test_compose_exec_propagates_env(tmp_path, monkeypatch):
|
|
recorded = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
recorded["cmd"] = cmd
|
|
return FakeProc()
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
dockerops.compose_exec(
|
|
tmp_path, "p", "s", ["true"], env={"A": "1", "B": "two"}
|
|
)
|
|
cmd = recorded["cmd"]
|
|
# `--env A=1 --env B=two` should appear before the service name.
|
|
s_idx = cmd.index("s")
|
|
env_args = cmd[:s_idx]
|
|
assert env_args.count("--env") == 2
|
|
assert "A=1" in env_args
|
|
assert "B=two" in env_args
|
|
|
|
|
|
def test_compose_exec_raises_on_nonzero(tmp_path, monkeypatch):
|
|
def fake_run(cmd, **kwargs):
|
|
return FakeProc(stdout="", stderr="boom", returncode=2)
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
with pytest.raises(dockerops.DockerError, match="exited 2"):
|
|
dockerops.compose_exec(tmp_path, "p", "s", ["fail"])
|
|
|
|
|
|
def test_compose_exec_raises_on_timeout(tmp_path, monkeypatch):
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd, timeout=kwargs.get("timeout"))
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
with pytest.raises(dockerops.DockerError, match="timed out"):
|
|
dockerops.compose_exec(tmp_path, "p", "s", ["sleep", "9999"], timeout=1)
|
|
|
|
|
|
def test_compose_exec_script_streams_via_stdin(tmp_path, monkeypatch):
|
|
script = tmp_path / "hook.sh"
|
|
body = b"#!/bin/sh\necho hello\n"
|
|
script.write_bytes(body)
|
|
recorded = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
recorded["cmd"] = cmd
|
|
recorded["input"] = kwargs["input"]
|
|
return FakeProc(stdout=b"hello\n", returncode=0)
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
out = dockerops.compose_exec_script(tmp_path, "p", "s", script)
|
|
assert out == "hello\n"
|
|
# exec ... s sh -s (script body comes in on stdin)
|
|
cmd = recorded["cmd"]
|
|
assert cmd[-3:] == ["s", "sh", "-s"]
|
|
assert recorded["input"] == body
|
|
|
|
|
|
def test_compose_exec_script_raises_on_nonzero(tmp_path, monkeypatch):
|
|
script = tmp_path / "fail.sh"
|
|
script.write_bytes(b"exit 1\n")
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
return FakeProc(stdout=b"", stderr=b"hook says no", returncode=1)
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
with pytest.raises(dockerops.DockerError, match="hook fail.sh exited 1"):
|
|
dockerops.compose_exec_script(tmp_path, "p", "s", script)
|
|
|
|
|
|
def test_compose_exec_script_raises_on_timeout(tmp_path, monkeypatch):
|
|
script = tmp_path / "slow.sh"
|
|
script.write_bytes(b"sleep 10\n")
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
raise subprocess.TimeoutExpired(cmd, timeout=kwargs.get("timeout"))
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
with pytest.raises(dockerops.DockerError, match="hook slow.sh timed out"):
|
|
dockerops.compose_exec_script(tmp_path, "p", "s", script, timeout=1)
|