Compare commits

...
4 Commits
Author SHA1 Message Date
dasandClaude Sonnet 5.5 8db767efb9 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>
2026-10-25 18:34:29 +02:00
dasandClaude Sonnet 5.5 1a62ddaeb8 Add curl to the desktop image (0.9.7)
build / build (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:22:03 +02:00
dasandClaude Sonnet 5.5 7e7bcbe017 Add nano to the desktop and server images (0.9.6)
build / build (push) Successful in 20m58s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:43:46 +02:00
dasandClaude Sonnet 5.5 60af93df74 publisher, deployer: same path logic as the originals
build / build (push) Successful in 21m27s
The packaged copies use config.default_path() and pipeline._history_path():
files next to the sources in a checkout, ~/.config/<name> and
~/.local/state/<name> when installed. The copies now differ from the
originals only in the neutral defaults.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:55:40 +02:00
18 changed files with 90 additions and 22 deletions
+5 -2
View File
@@ -1,6 +1,6 @@
# Das OS — що зробити далі # Das OS — що зробити далі
Стан на 2026-10-25: версія 0.9.5 (не закомічено), етапи 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, підказка про відкат.
@@ -117,7 +117,10 @@ Live ISO без графіки з консольним інсталятором:
- [x] Значки Wi-Fi і Bluetooth у треї лишались блідими: панельні значки Papirus (`#dfdfdf`) зроблені для темних панелей, а CSS їх не перефарбовує. Світла тема тепер на `Papirus-Light` (`#444`): `xsettings.xml`, `index.theme`, екран входу; `das-theme` 0.3.0, `das-settings` 0.6.0, 0.9.5; перевірити на ISO. - [x] Значки Wi-Fi і Bluetooth у треї лишались блідими: панельні значки Papirus (`#dfdfdf`) зроблені для темних панелей, а CSS їх не перефарбовує. Світла тема тепер на `Papirus-Light` (`#444`): `xsettings.xml`, `index.theme`, екран входу; `das-theme` 0.3.0, `das-settings` 0.6.0, 0.9.5; перевірити на ISO.
- [x] `das-panel-icon help` (`-h`, `--help`) показує підказку, а не ставить значок «?»; невідомі `-ключі` відхиляються. - [x] `das-panel-icon help` (`-h`, `--help`) показує підказку, а не ставить значок «?»; невідомі `-ключі` відхиляються.
- [x] На десктопний образ додано `publisher` (`publish`) і `deployer` (`deploy`): копії проєктів з `~/Dev/Projects/Python`, конфіг у `~/.config/<назва>/` (створюється з прикладу при першому запуску), історія в `~/.local/state/<назва>/`; перевірити на ISO. - [x] На десктопний образ додано `publisher` (`publish`) і `deployer` (`deploy`): копії проєктів з `~/Dev/Projects/Python`, конфіг у `~/.config/<назва>/` (створюється з прикладу при першому запуску), історія в `~/.local/state/<назва>/`; перевірити на ISO.
- [ ] Перенести в оригінальні `publisher` і `deployer` зміну шляхів конфігу та `history.log` (зараз вони лише в копіях пакетів), щоб копії не розходились. - [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] У десктопний образ додано `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.5 0.9.8
+2
View File
@@ -69,6 +69,8 @@ das-theme
das-tools das-tools
das-helpers das-helpers
das-shell das-shell
nano
curl
publisher publisher
deployer deployer
xtools-minimal xtools-minimal
+2 -2
View File
@@ -1,9 +1,9 @@
# Template file for 'das-server' # Template file for 'das-server'
pkgname=das-server pkgname=das-server
version=0.4.0 version=0.5.0
revision=1 revision=1
depends="openssh chrony dhcpcd NetworkManager docker docker-compose nftables depends="openssh chrony dhcpcd NetworkManager docker docker-compose nftables
fail2ban socklog-void python3 curl lazydocker lazygit mc tmux htop ncdu fail2ban socklog-void python3 curl lazydocker lazygit mc tmux htop ncdu nano
das-snapshots das-update das-helpers das-shell xtools-minimal" das-snapshots das-update das-helpers das-shell xtools-minimal"
short_desc="Das OS server: packages and secure defaults for SSH, firewall and Docker" short_desc="Das OS server: packages and secure defaults for SSH, firewall and Docker"
maintainer="Das <anry.das@gmail.com>" maintainer="Das <anry.das@gmail.com>"
@@ -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"
@@ -148,7 +148,7 @@ def main(argv=None) -> int:
args = p.parse_args(argv) args = p.parse_args(argv)
color(sys.stdout.isatty() and not args.no_color) color(sys.stdout.isatty() and not args.no_color)
cfg_path = Path(args.config) if args.config else Path(__file__).resolve().parent.parent / "deploy.toml" cfg_path = Path(args.config) if args.config else config.default_path()
try: try:
cfg = config.load(cfg_path) cfg = config.load(cfg_path)
except (OSError, config.ConfigError) as e: except (OSError, config.ConfigError) as e:
@@ -211,3 +211,11 @@ def append_apps(path: Path, apps: list[App], drafts: list[App] = ()) -> None:
block = "".join(f"# {line}\n" for line in app_to_toml(a).splitlines()) block = "".join(f"# {line}\n" for line in app_to_toml(a).splitlines())
text = text.rstrip("\n") + "\n\n# TODO: check src / build / dest, then uncomment\n" + block text = text.rstrip("\n") + "\n\n# TODO: check src / build / dest, then uncomment\n" + block
path.write_text(text) path.write_text(text)
def default_path() -> Path:
"""deploy.toml next to the sources (a checkout), else ~/.config/deployer/deploy.toml (an installed package)."""
local = Path(__file__).resolve().parent.parent / "deploy.toml"
if local.exists():
return local
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "deployer" / "deploy.toml"
@@ -13,8 +13,17 @@ from . import kinds, remote, snapshots
from .config import App, Config from .config import App, Config
from .kinds import Options, Plan from .kinds import Options, Plan
# Das OS package: /usr/lib is read-only, so the history goes to the user's state dir
HISTORY = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "deployer" / "history.log"
def _history_path() -> Path:
"""history.log next to the sources (a checkout), else ~/.local/state/deployer/ (an installed package)."""
local = Path(__file__).resolve().parent.parent
if os.access(local, os.W_OK):
return local / "history.log"
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "deployer" / "history.log"
HISTORY = _history_path()
class DeployError(RuntimeError): class DeployError(RuntimeError):
@@ -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:
try:
proc.stdin.write(stdin) proc.stdin.write(stdin)
proc.stdin.close() 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"
@@ -150,7 +150,7 @@ def main(argv=None) -> int:
args = p.parse_args(argv) args = p.parse_args(argv)
global COLOR global COLOR
COLOR = sys.stdout.isatty() and not args.no_color COLOR = sys.stdout.isatty() and not args.no_color
cfg_path = Path(args.config) if args.config else Path(__file__).resolve().parent.parent / "publish.toml" cfg_path = Path(args.config) if args.config else config.default_path()
try: try:
cfg = config.load(cfg_path) cfg = config.load(cfg_path)
except (OSError, config.ConfigError) as e: except (OSError, config.ConfigError) as e:
@@ -185,3 +185,11 @@ def append_apps(path: Path, apps: list[App]) -> None:
validate_app(a) validate_app(a)
text = text.rstrip("\n") + "\n\n" + app_to_toml(a) text = text.rstrip("\n") + "\n\n" + app_to_toml(a)
path.write_text(text) path.write_text(text)
def default_path() -> Path:
"""publish.toml next to the sources (a checkout), else ~/.config/publisher/publish.toml (an installed package)."""
local = Path(__file__).resolve().parent.parent / "publish.toml"
if local.exists():
return local
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "publisher" / "publish.toml"
@@ -13,8 +13,17 @@ from . import packages, remote
from .config import App, Config from .config import App, Config
from .packages import Kind, Local, Published, version_key from .packages import Kind, Local, Published, version_key
# Das OS package: /usr/lib is read-only, so the history goes to the user's state dir
HISTORY = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "publisher" / "history.log"
def _history_path() -> Path:
"""history.log next to the sources (a checkout), else ~/.local/state/publisher/ (an installed package)."""
local = Path(__file__).resolve().parent.parent
if os.access(local, os.W_OK):
return local / "history.log"
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "publisher" / "history.log"
HISTORY = _history_path()
# Plan item actions; the first five change the server # Plan item actions; the first five change the server
NEW, REPLACE, META, NOTES, DELETE, SAME, SKIP = "new", "replace", "meta", "notes", "delete", "same", "skip" NEW, REPLACE, META, NOTES, DELETE, SAME, SKIP = "new", "replace", "meta", "notes", "delete", "same", "skip"
@@ -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:
try:
proc.stdin.write(stdin) proc.stdin.write(stdin)
proc.stdin.close() 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"