Files
Das-OS/srcpkgs/deployer/files/tests/test_core.py
T
dasandClaude Sonnet 5.5 8db767efb9
build / build (push) Successful in 21m56s
publisher, deployer: report ssh failure instead of BrokenPipeError (0.9.8)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:34:29 +02:00

190 lines
7.0 KiB
Python

import os
from pathlib import Path
import pytest
from deployer import config, detect, pipeline, remote
from deployer.kinds import get
SERVER = """
[server]
host = "h"
app_root = "/srv/app"
"""
def write(tmp_path: Path, body: str) -> Path:
p = tmp_path / "deploy.toml"
p.write_text(SERVER + body)
return p
def test_load_and_dest_paths(tmp_path):
cfg = config.load(write(tmp_path, """
[[app]]
name = "a"
kind = "python-update"
src = "~/x"
dest = "python/a"
unit = "a"
configs = ["configs"]
[[app]]
name = "b"
kind = "files"
src = "/tmp"
dest = "~/scripts"
"""))
assert [a.name for a in cfg.apps] == ["a", "b"]
assert cfg.dest_path(cfg.app("a")) == "/srv/app/python/a"
assert cfg.dest_path(cfg.app("b")) == "/home/user/scripts"
assert cfg.app("a").src_path == Path.home() / "x"
@pytest.mark.parametrize("body, msg", [
('name = "a"\nkind = "nope"\nsrc = "x"\ndest = "y"', "kind must be"),
('name = "a"\nkind = "python-update"\nsrc = "x"\ndest = "y"', "unit"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "../etc"', "unsafe dest"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "y"\ndelete = "yes"', "wrong type"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "y"\ntypo = 1', "unknown key"),
('name = "a"\nkind = "artifact"\nsrc = "x"\ndest = "y"', "artifact"),
('name = "a"\nkind = "files"\nsrc = "x"\ndest = "y"\nconfigs = ["a/b"]', "top-level"),
])
def test_validation(tmp_path, body, msg):
with pytest.raises(config.ConfigError, match=msg):
config.load(write(tmp_path, "[[app]]\n" + body))
def test_append_round_trip(tmp_path):
p = write(tmp_path, "# keep me\n")
app = config.App(name="n", kind="docker", src="~/p", dest="docker/n", build_env={"JAVA_HOME": "~/jdk"},
post='echo "hi"')
draft = config.App(name="d", kind="python-update", src="", dest="python/d", unit="d")
config.append_apps(p, [app], [draft])
text = p.read_text()
assert "# keep me" in text and '# name = "d"' in text
cfg = config.load(p)
assert [a.name for a in cfg.apps] == ["n"]
assert cfg.app("n") == app
def test_filters_protect_server_items():
rules = remote.build_filters(protected=["configs", "Makefile"], exclude=["/tests"], use_gitignore=True)
assert rules[:2] == ["- /Makefile", "- /configs"]
assert "- .venv" in rules and "- /tests" in rules
assert rules[-1] == ":- .gitignore"
assert ":- .gitignore" not in remote.build_filters(protected=[], exclude=[], use_gitignore=False)
def test_rsync_cmd_options():
s = config.Server()
cmd = remote.rsync_cmd(s, "src/", "/d/", filters=["- x"], delete=True, dry_run=True, extra=["--chmod=F640"])
assert cmd[0] == "rsync" and "-n" in cmd and "--delete" in cmd and "-c" in cmd
assert cmd.index("--chmod=F640") < cmd.index("--filter=- x")
assert cmd[-2:] == ["src/", "user@localhost:/d/"]
def test_parse_itemize():
out = "\n".join([
".d..t...... ./",
"<f+++++++++ new.py",
"<f.st...... changed.py",
".f..t...... touched.py",
"cd+++++++++ pkg/",
"*deleting old.py",
"cL+++++++++ link -> target",
"created 2 directories for /x",
])
got = [(c.action, c.path) for c in remote.parse_itemize(out)]
assert got == [("new", "new.py"), ("update", "changed.py"), ("new", "pkg/"), ("delete", "old.py"),
("new", "link -> target")]
@pytest.mark.parametrize("local, deployed, state", [
("abc1234", "abc1234", "same"),
("def5678", "abc1234", "changed"),
("def5678-dirty", "abc1234", "changed"),
("abc1234-dirty", "abc1234", "dirty"),
("abc1234", "abc1234-dirty", "dirty"),
("", "abc1234", "?"),
("abc1234", "", "?"),
("abc1234", "-", "?"),
])
def test_code_state(local, deployed, state):
assert pipeline.code_state(local, deployed) == state
def test_deployed_rev():
line = "2026-10-17T10:57:30 EO_Service {} python-update {} rev=22fc8b3 snap=20261017-105714"
assert pipeline.deployed_rev(line.format("deploy", "ok").split()) == "22fc8b3"
assert pipeline.deployed_rev(line.format("deploy", "UNHEALTHY").split()) == "22fc8b3"
assert pipeline.deployed_rev(line.format("deploy", "FAILED").split()) == ""
assert pipeline.deployed_rev(line.format("rollback", "ok").split()) == ""
def test_shell_vars_quote():
v = remote.shell_vars(A="it's", L=["x y", "$z"])
assert v == "A='it'\"'\"'s'\nL=('x y' '$z')\n"
def test_detect_kinds(tmp_path):
py = tmp_path / "MyApp"
(py / "configs").mkdir(parents=True)
(py / "app.py").write_text("")
(py / "secrets.yaml").write_text("")
(py / "docker-compose.yml").write_text("")
app = detect.new_app(py)
assert (app.kind, app.dest, app.unit, app.entry) == ("python-service", "python/MyApp", "MyApp", "app.py")
assert app.configs == ["configs", "secrets.yaml"]
dk = tmp_path / "stack"
dk.mkdir()
(dk / "compose.yaml").write_text("")
assert detect.new_app(dk).kind == "docker"
jv = tmp_path / "Svc"
jv.mkdir()
(jv / "pom.xml").write_text("<project><packaging>war</packaging></project>")
(jv / "mvnw").write_text("")
a = detect.new_app(jv)
assert a.kind == "artifact" and a.artifact == "target/*.war" and a.build.startswith("./mvnw")
def test_match_local(tmp_path):
a = tmp_path / "DasSimpleExporter"
a.mkdir()
(a / "dasExporter.service").write_text("")
b = tmp_path / "weather-proxy"
b.mkdir()
assert detect.match_local([a, b], ["WeatherProxy"]) == b
assert detect.match_local([a, b], ["DasExporter"], unit="dasExporter") == a
assert detect.match_local([a, b], ["nothing"]) is None
def test_python_config_layout(tmp_path):
(tmp_path / "configs").mkdir()
cfg = config.Config(tmp_path / "x.toml", config.Server(), [])
plan = type("P", (), {})()
plan.cfg, plan.dest = cfg, "/home/user/app/python/App"
plan.app = config.App(name="App", kind="python-update", src=str(tmp_path), dest="python/App", unit="u",
configs=["configs"])
k = get("python-update")
assert k.config_items(plan) == [("configs", "/home/user/app/configs/python/App")]
plan.app.configs = [".env", "secrets.yaml"]
assert k.config_items(plan) == [(".env", "/home/user/app/configs/python/App/app.env"),
("secrets.yaml", "/home/user/app/configs/python/App/secrets.yaml")]
plan.dest = "/home/user/app/docker/stack"
plan.app = config.App(name="stack", kind="docker", src=str(tmp_path), dest="docker/stack")
assert get("docker").config_items(plan) == [(".env", "/home/user/app/configs/docker/stack.env")]
def test_ssh_exits_early(tmp_path, monkeypatch):
# ssh dies at once (e.g. unresolvable host): the broken pipe must surface as RemoteError with its message
fake = tmp_path / "ssh"
fake.write_text("#!/bin/sh\necho \"ssh: Could not resolve hostname\" >&2\nexit 255\n")
fake.chmod(0o755)
monkeypatch.setenv("PATH", f"{tmp_path}:{os.environ['PATH']}")
srv = config.Server(host="nohost.invalid", user="u")
with pytest.raises(remote.RemoteError, match="Could not resolve"):
remote.run(srv, "x" * 5_000_000, check=False)