publisher, deployer: report ssh failure instead of BrokenPipeError (0.9.8)
build / build (push) Successful in 21m56s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-25 18:34:29 +02:00
co-authored by Claude Sonnet 5.5
parent 1a62ddaeb8
commit 8db767efb9
10 changed files with 43 additions and 13 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
# Das OS — що зробити далі # Das OS — що зробити далі
Стан на 2026-10-25: версія 0.9.7 (не закомічено), етапи 1–8 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs, Go-утиліти `das-update` і `das-welcome`, серверний образ з консольним інсталятором, менші ISO, консольні помічники, профіль розробника), CI (етап 9: пакети й обидва ISO збираються на власному runner, ~45 хв). Стан на 2026-10-25: версія 0.9.8 (не закомічено), етапи 1–8 готові (live ISO, власний репозиторій, оформлення, інсталятор Calamares зі знімками Btrfs, Go-утиліти `das-update` і `das-welcome`, серверний образ з консольним інсталятором, менші ISO, консольні помічники, профіль розробника), CI (етап 9: пакети й обидва ISO збираються на власному runner, ~45 хв).
## Етап 5. Go-утиліти (готово, лишилися перевірки) ## Етап 5. Go-утиліти (готово, лишилися перевірки)
- [x] `das-update`: вікно, значок у треї (лише коли є оновлення), консольний режим `--cli`, знімок snapper pre/post, встановлення через pkexec, підказка про відкат. - [x] `das-update`: вікно, значок у треї (лише коли є оновлення), консольний режим `--cli`, знімок snapper pre/post, встановлення через pkexec, підказка про відкат.
@@ -120,6 +120,7 @@ Live ISO без графіки з консольним інсталятором:
- [x] Зміну шляхів конфігу та `history.log` перенесено в оригінальні `publisher` і `deployer` (`config.default_path()`, `pipeline._history_path()`): у checkout усе як було, у пакеті — `~/.config` і `~/.local/state`. Копії пакетів відрізняються від оригіналів лише значеннями за замовчуванням (localhost, user). - [x] Зміну шляхів конфігу та `history.log` перенесено в оригінальні `publisher` і `deployer` (`config.default_path()`, `pipeline._history_path()`): у checkout усе як було, у пакеті — `~/.config` і `~/.local/state`. Копії пакетів відрізняються від оригіналів лише значеннями за замовчуванням (localhost, user).
- [x] У десктопний образ і в `das-server` 0.5.0 додано `nano`: `publish` і `deploy` відкривають конфіг у редакторі за F2 (`$VISUAL`/`$EDITOR`, інакше `nano`), без нього завершувались; 0.9.6. - [x] У десктопний образ і в `das-server` 0.5.0 додано `nano`: `publish` і `deploy` відкривають конфіг у редакторі за F2 (`$VISUAL`/`$EDITOR`, інакше `nano`), без нього завершувались; 0.9.6.
- [x] У десктопний образ додано `curl` (у `das-server` він уже був); 0.9.7. - [x] У десктопний образ додано `curl` (у `das-server` він уже був); 0.9.7.
- [x] `publish` і `deploy` падали з `BrokenPipeError`, коли `ssh` завершується одразу (недоступний хост): тепер це `RemoteError` з повідомленням `ssh` (код 255 завжди помилка), тест з фіктивним `ssh`; `publisher`/`deployer` 0.3.1, 0.9.8; виправлено й в оригіналах.
- [ ] Прибрати значки томів live-носія («Том 6,6 ГБ») з робочого столу live-сесії. - [ ] Прибрати значки томів live-носія («Том 6,6 ГБ») з робочого столу live-сесії.
- [ ] Тема GRUB у встановленій системі (зараз стандартний чорний екран). - [ ] Тема GRUB у встановленій системі (зараз стандартний чорний екран).
- [ ] Перевірити меню Whisker вручну. - [ ] Перевірити меню Whisker вручну.
+1 -1
View File
@@ -1 +1 @@
0.9.7 0.9.8
@@ -1,2 +1,2 @@
"""Deploy local projects to the home server over ssh + rsync.""" """Deploy local projects to the home server over ssh + rsync."""
__version__ = "0.3.0" __version__ = "0.3.1"
@@ -31,8 +31,11 @@ def _stream(cmd: list[str], log: Log | None, stdin: str | None = None, cwd=None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, env=env,
errors="replace") errors="replace")
if stdin is not None: if stdin is not None:
proc.stdin.write(stdin) try:
proc.stdin.close() proc.stdin.write(stdin)
proc.stdin.close()
except BrokenPipeError:
pass # ssh already exited; its message is in the output below
out = [] out = []
for line in proc.stdout: for line in proc.stdout:
line = line.rstrip("\n") line = line.rstrip("\n")
@@ -57,7 +60,7 @@ def run(server: Server, script: str, log: Log | None = None, check: bool = True,
"""Run a bash script on the server; values become shell variables in front of it.""" """Run a bash script on the server; values become shell variables in front of it."""
body = "set -uo pipefail\n" + shell_vars(**values) + script body = "set -uo pipefail\n" + shell_vars(**values) + script
rc, out = _stream(["ssh", *ssh_opts(server), server.target, "bash", "-s"], log, stdin=body) rc, out = _stream(["ssh", *ssh_opts(server), server.target, "bash", "-s"], log, stdin=body)
if check and rc != 0: if rc == 255 or (check and rc != 0):
raise RemoteError(f"remote command failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else "")) raise RemoteError(f"remote command failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
return out return out
+12
View File
@@ -1,3 +1,4 @@
import os
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -175,3 +176,14 @@ def test_python_config_layout(tmp_path):
plan.dest = "/home/user/app/docker/stack" plan.dest = "/home/user/app/docker/stack"
plan.app = config.App(name="stack", kind="docker", src=str(tmp_path), dest="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")] 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)
+1 -1
View File
@@ -3,7 +3,7 @@
# Differences from the upstream: the config is in ~/.config/deployer (see bin/deploy) and # Differences from the upstream: the config is in ~/.config/deployer (see bin/deploy) and
# history.log is in ~/.local/state/deployer. # history.log is in ~/.local/state/deployer.
pkgname=deployer pkgname=deployer
version=0.3.0 version=0.3.1
revision=1 revision=1
create_wrksrc=yes create_wrksrc=yes
hostmakedepends="python3" hostmakedepends="python3"
@@ -1,2 +1,2 @@
"""Publish apk, AppImage and binaries to the apps page of the home Tomcat over ssh + rsync.""" """Publish apk, AppImage and binaries to the apps page of the home Tomcat over ssh + rsync."""
__version__ = "0.3.0" __version__ = "0.3.1"
@@ -26,8 +26,11 @@ def _stream(cmd: list[str], log: Log | None, stdin: str | None = None, cwd=None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, env=env,
errors="replace") errors="replace")
if stdin is not None: if stdin is not None:
proc.stdin.write(stdin) try:
proc.stdin.close() proc.stdin.write(stdin)
proc.stdin.close()
except BrokenPipeError:
pass # ssh already exited; its message is in the output below
out = [] out = []
for line in proc.stdout: for line in proc.stdout:
line = line.rstrip("\n") line = line.rstrip("\n")
@@ -52,7 +55,7 @@ def run(server: Server, script: str, log: Log | None = None, check: bool = True,
"""Run a bash script on the server; values become shell variables in front of it.""" """Run a bash script on the server; values become shell variables in front of it."""
body = "set -uo pipefail\n" + shell_vars(**values) + script body = "set -uo pipefail\n" + shell_vars(**values) + script
rc, out = _stream(["ssh", *ssh_opts(server), server.target, "bash", "-s"], log, stdin=body) rc, out = _stream(["ssh", *ssh_opts(server), server.target, "bash", "-s"], log, stdin=body)
if check and rc != 0: if rc == 255 or (check and rc != 0):
raise RemoteError(f"remote command failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else "")) raise RemoteError(f"remote command failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
return out return out
+12 -1
View File
@@ -3,7 +3,7 @@ from pathlib import Path
import pytest import pytest
from publisher import config, detect, packages, pipeline from publisher import config, detect, packages, pipeline, remote
from publisher.packages import BINARY, KINDS, Published from publisher.packages import BINARY, KINDS, Published
APK, APPIMAGE, TARGZ = KINDS[0], KINDS[1], KINDS[-1] APK, APPIMAGE, TARGZ = KINDS[0], KINDS[1], KINDS[-1]
@@ -215,3 +215,14 @@ def test_detect_go(tmp_path):
def test_site_name_cleanup(): def test_site_name_cleanup():
assert detect._site_name("Das Pressure 2") == "DasPressure2" assert detect._site_name("Das Pressure 2") == "DasPressure2"
assert os.path.basename(detect._home(Path.home() / "a")) == "a" assert os.path.basename(detect._home(Path.home() / "a")) == "a"
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)
+1 -1
View File
@@ -3,7 +3,7 @@
# Differences from the upstream: the config is in ~/.config/publisher (see bin/publish) and # Differences from the upstream: the config is in ~/.config/publisher (see bin/publish) and
# history.log is in ~/.local/state/publisher. # history.log is in ~/.local/state/publisher.
pkgname=publisher pkgname=publisher
version=0.3.0 version=0.3.1
revision=1 revision=1
create_wrksrc=yes create_wrksrc=yes
hostmakedepends="python3" hostmakedepends="python3"