Fix pale Wi-Fi/Bluetooth tray icons, das-panel-icon help, publisher and deployer (0.9.5)

- Light theme uses Papirus-Light: the panel icons of Papirus are #dfdfdf,
  made for dark panels (xsettings, theme index, login screen).
- das-panel-icon help/-h/--help print usage instead of setting an icon;
  unknown options are rejected.
- New packages publisher (publish) and deployer (deploy) on the desktop
  image: config in ~/.config/<name>/ (neutral example copied on first run),
  history in ~/.local/state/<name>/.
- das-theme 0.3.0, das-settings 0.6.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
das
2026-10-25 14:44:36 +02:00
co-authored by Claude Sonnet 5.5
parent ea62ec7a26
commit 62b8b98086
42 changed files with 4063 additions and 11 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Das
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# deployer launcher: the config lives in ~/.config/deployer/deploy.toml (copied from the example on the first run).
cfg=${XDG_CONFIG_HOME:-$HOME/.config}/deployer/deploy.toml
if [ ! -f "$cfg" ]; then
mkdir -p "${cfg%/*}" && cp /usr/share/das/deployer/deploy.toml.example "$cfg" || exit 1
echo "deploy: created $cfg, edit [server] there" >&2
fi
PYTHONPATH=/usr/lib/das/deployer exec python3 -c 'import sys; from deployer.cli import main; sys.exit(main())' --config "$cfg" "$@"
@@ -0,0 +1,2 @@
"""Deploy local projects to the home server over ssh + rsync."""
__version__ = "0.3.0"
@@ -0,0 +1,171 @@
"""Command-line mode: the same pipeline as the TUI, for scripts and quick deploys."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from . import __version__, config, detect, pipeline, remote, snapshots
from .kinds import Options, Plan
C = {"new": "\033[32m+", "update": "\033[33m~", "delete": "\033[31m-"}
RESET = "\033[0m"
def color(on: bool):
if not on:
C.update(new="+", update="~", delete="-")
globals()["RESET"] = ""
def print_plan(plan: Plan) -> None:
a = plan.app
print(f"\n{a.name} [{a.kind}] {plan.rsync_src} -> {plan.server.target}:{plan.dest}"
f" {('rev ' + plan.git_rev) if plan.git_rev else ''}")
for c in plan.changes:
print(f" {C[c.action]} {c.path}{RESET}")
if not plan.changes:
print(" (no file changes)")
for n in plan.notes:
print(f" ! {n}")
def confirm(question: str, yes: bool) -> bool:
if yes:
return True
if not sys.stdin.isatty():
print("not a terminal: pass --yes to confirm", file=sys.stderr)
return False
return input(f"{question} [y/N] ").strip().lower() in ("y", "yes", "т", "так")
def options(args) -> Options:
return Options(build=not args.no_build, install_deps=args.deps, restart=not args.no_restart,
copy_configs=not args.no_configs, delete=args.delete)
def cmd_list(cfg, args):
st = {} if args.offline else pipeline.status_all(cfg)
for a in cfg.apps:
state, last, code = st.get(a.name, ("", "", ""))
print(f"{a.name:22} {a.kind:15} {state:14} {code:8} {cfg.dest_path(a):48} {last}")
def cmd_deploy(cfg, args):
app = cfg.app(args.app)
plan = pipeline.preview(cfg, app, options(args), log=print)
print_plan(plan)
if args.dry_run:
return 0
if not plan.changes and not plan.first_install and not args.force:
print("nothing to sync: use --force to run install/restart anyway")
return 0
if not confirm("deploy?", args.yes):
return 1
res = pipeline.execute(plan, log=print)
if not res.ok and res.snapshot and confirm(f"roll back to {res.snapshot}?", False):
res = pipeline.rollback(cfg, app, res.snapshot, log=print)
return 0 if res.ok else 2
def cmd_rollback(cfg, args):
app = cfg.app(args.app)
snaps = snapshots.listing(cfg.server, app.name)
if args.list or not snaps:
print("\n".join(snaps) or "no snapshots")
return 0
ts = args.ts or snaps[0]
if not confirm(f"restore {app.name} from {ts}?", args.yes):
return 1
res = pipeline.rollback(cfg, app, ts, log=print)
print(res.message)
return 0 if res.ok else 2
def cmd_logs(cfg, args):
return remote.interactive(cfg.server, pipeline.logs_command(cfg, cfg.app(args.app)))
def cmd_import(cfg, args):
found = detect.import_server(cfg, log=print)
if not found:
print("nothing new on the server")
return 0
ready = [i.app for i in found if i.matched and i.app.kind.startswith("python")]
drafts = [i.app for i in found if i.app not in ready]
print(f"\n{len(ready)} ready, {len(drafts)} as commented drafts")
if confirm(f"append to {cfg.path}?", args.yes):
config.append_apps(cfg.path, ready, drafts)
return 0
def cmd_add(cfg, args):
app = detect.new_app(Path(args.path), args.name, args.kind)
print(config.app_to_toml(app))
if confirm(f"append to {cfg.path}?", args.yes):
config.append_apps(cfg.path, [app])
return 0
def main(argv=None) -> int:
p = argparse.ArgumentParser(prog="deploy", description="Deploy local projects to the server. "
"No command: start the TUI.")
p.add_argument("--config", default=None, help="deploy.toml (default: next to deploy.py)")
p.add_argument("--no-color", action="store_true")
p.add_argument("-V", "--version", action="version", version=__version__)
sub = p.add_subparsers(dest="cmd")
s = sub.add_parser("list", help="apps and their state on the server")
s.add_argument("--offline", action="store_true", help="do not ask the server")
for name, hlp in (("deploy", "preview and deploy an app"), ("preview", "show what a deploy would change")):
s = sub.add_parser(name, help=hlp)
s.add_argument("app")
s.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
s.add_argument("-n", "--dry-run", action="store_true", default=name == "preview")
s.add_argument("--force", action="store_true", help="install/restart even without file changes")
s.add_argument("--no-build", action="store_true")
s.add_argument("--no-restart", action="store_true", help="no restart, no post command, no health check")
s.add_argument("--no-configs", action="store_true", help="do not create missing config links")
s.add_argument("--deps", action="store_true", help="pip install even if requirements.txt is unchanged")
s.add_argument("--delete", action=argparse.BooleanOptionalAction, default=None,
help="delete server files that are absent locally (default: app setting)")
s = sub.add_parser("rollback", help="restore the latest (or given) snapshot")
s.add_argument("app")
s.add_argument("ts", nargs="?")
s.add_argument("--list", action="store_true")
s.add_argument("-y", "--yes", action="store_true")
s = sub.add_parser("logs", help="follow the app log")
s.add_argument("app")
s = sub.add_parser("import", help="add apps that already run on the server to deploy.toml")
s.add_argument("-y", "--yes", action="store_true")
s = sub.add_parser("add", help="add a local project to deploy.toml")
s.add_argument("path")
s.add_argument("--name")
s.add_argument("--kind", choices=config.KINDS)
s.add_argument("-y", "--yes", action="store_true")
sub.add_parser("check", help="validate deploy.toml")
sub.add_parser("tui", help="start the TUI (default)")
args = p.parse_args(argv)
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"
try:
cfg = config.load(cfg_path)
except (OSError, config.ConfigError) as e:
print(f"config: {e}", file=sys.stderr)
return 1
if args.cmd in (None, "tui"):
from .tui import run
return run(cfg)
if args.cmd == "check":
print(f"{cfg_path}: {len(cfg.apps)} apps, OK")
return 0
handlers = {"list": cmd_list, "deploy": cmd_deploy, "preview": cmd_deploy, "rollback": cmd_rollback,
"logs": cmd_logs, "import": cmd_import, "add": cmd_add}
try:
return handlers[args.cmd](cfg, args) or 0
except (config.ConfigError, pipeline.DeployError, remote.RemoteError) as e:
print(f"error: {e}", file=sys.stderr)
return 1
except KeyboardInterrupt:
return 130
@@ -0,0 +1,213 @@
"""deploy.toml: server settings and the registry of deployable apps."""
from __future__ import annotations
import os
import re
import tomllib
from dataclasses import dataclass, field, fields
from pathlib import Path
KINDS = ("python-service", "python-update", "docker", "files", "artifact")
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
UNIT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.@-]{0,99}$")
class ConfigError(ValueError):
pass
@dataclass
class Server:
host: str = "192.168.88.60"
user: str = "das"
port: int = 22
app_root: str = "/home/das/app"
configs_root: str = "/home/das/app/configs"
state_dir: str = "/home/das/app_deploy"
keep: int = 5
local_roots: list[str] = field(default_factory=lambda: ["~/Dev/Projects"])
# Development-only files: not synced for python/docker/files apps (an app can sync them via its own rules)
dev_exclude: list[str] = field(default_factory=lambda: [
"/tests", "/test", "/CLAUDE.md", "/.claude", "/README*.md", "/requirements-dev.txt", "/.gitignore",
"/pytest.ini", "/.coverage", "/htmlcov"])
@property
def target(self) -> str:
return f"{self.user}@{self.host}"
@property
def snapshots(self) -> str:
return f"{self.state_dir}/snapshots"
@property
def home(self) -> str:
return "/root" if self.user == "root" else f"/home/{self.user}"
@dataclass
class App:
name: str
kind: str
src: str
dest: str
# python-*
unit: str = ""
entry: str = "main.py"
user: str = "das"
description: str = ""
env_file: str = ""
syslog_id: str = ""
# items kept in configs_root and linked into the app dir (python, docker)
configs: list[str] = field(default_factory=list)
# sync options
exclude: list[str] = field(default_factory=list)
use_gitignore: bool = True
delete: bool = False
# build / artifact
build: str = ""
build_env: dict[str, str] = field(default_factory=dict)
artifact: str = ""
# files / artifact
mode: str = ""
post: str = ""
# health
health_container: str = ""
health_url: str = ""
health_timeout: int = 120
@property
def src_path(self) -> Path:
return Path(os.path.expanduser(self.src))
@property
def is_python(self) -> bool:
return self.kind.startswith("python")
@dataclass
class Config:
path: Path
server: Server
apps: list[App]
def app(self, name: str) -> App:
for a in self.apps:
if a.name == name:
return a
raise ConfigError(f"unknown app: {name}")
def dest_path(self, app: App) -> str:
"""Absolute remote path; relative dests are under app_root, '~' is the remote home."""
d = app.dest
if d.startswith("~"):
d = self.server.home + d[1:]
elif not d.startswith("/"):
d = f"{self.server.app_root}/{d}"
return d.rstrip("/") or "/"
def _check_types(obj, data: dict, where: str):
known = {f.name: f for f in fields(obj)}
for k, v in data.items():
if k not in known:
raise ConfigError(f"{where}: unknown key '{k}'")
default = getattr(obj, k)
if isinstance(default, bool):
ok = isinstance(v, bool)
elif isinstance(default, int):
ok = isinstance(v, int) and not isinstance(v, bool)
elif isinstance(default, list):
ok = isinstance(v, list) and all(isinstance(x, str) for x in v)
elif isinstance(default, dict):
ok = isinstance(v, dict) and all(isinstance(x, str) for x in v.values())
else:
ok = isinstance(v, str)
if not ok:
raise ConfigError(f"{where}: '{k}' has wrong type")
setattr(obj, k, v)
def validate_app(app: App) -> None:
w = f"app '{app.name}'"
if not NAME_RE.match(app.name):
raise ConfigError(f"{w}: bad name")
if app.kind not in KINDS:
raise ConfigError(f"{w}: kind must be one of {', '.join(KINDS)}")
if not app.src or not app.dest:
raise ConfigError(f"{w}: src and dest are required")
if ".." in Path(app.dest).parts or app.dest.strip("/~") == "":
raise ConfigError(f"{w}: unsafe dest '{app.dest}'")
if app.is_python:
if not UNIT_RE.match(app.unit):
raise ConfigError(f"{w}: python apps need a valid 'unit'")
if not app.entry.strip():
raise ConfigError(f"{w}: empty entry")
if app.kind == "artifact" and not app.artifact:
raise ConfigError(f"{w}: artifact apps need an 'artifact' glob")
for c in app.configs:
if "/" in c.strip("/") or c in ("", ".", ".."):
raise ConfigError(f"{w}: configs items must be top-level names, got '{c}'")
if app.mode and not re.fullmatch(r"[0-7]{3,4}", app.mode):
raise ConfigError(f"{w}: mode must be octal like 755")
def load(path: str | Path) -> Config:
path = Path(path)
with open(path, "rb") as f:
try:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ConfigError(f"{path}: {e}") from None
server = Server()
_check_types(server, data.get("server", {}), "[server]")
apps, seen = [], set()
for i, raw in enumerate(data.get("app", [])):
raw = dict(raw)
try:
app = App(name=raw.pop("name"), kind=raw.pop("kind"), src=raw.pop("src"), dest=raw.pop("dest"))
except KeyError as e:
raise ConfigError(f"app #{i + 1}: missing {e}") from None
_check_types(app, raw, f"app '{app.name}'")
validate_app(app)
if app.name in seen:
raise ConfigError(f"duplicate app '{app.name}'")
seen.add(app.name)
apps.append(app)
return Config(path, server, apps)
def _toml_value(v) -> str:
if isinstance(v, bool):
return "true" if v else "false"
if isinstance(v, int):
return str(v)
if isinstance(v, list):
return "[" + ", ".join(_toml_value(x) for x in v) + "]"
if isinstance(v, dict):
return "{ " + ", ".join(f"{k} = {_toml_value(x)}" for k, x in v.items()) + " }"
s = str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{s}"'
def app_to_toml(app: App) -> str:
"""[[app]] block with the non-default keys only."""
blank = App(name="", kind="", src="", dest="")
lines = ["[[app]]"]
for f in fields(app):
v = getattr(app, f.name)
if f.name in ("name", "kind", "src", "dest") or v != getattr(blank, f.name):
lines.append(f"{f.name} = {_toml_value(v)}")
return "\n".join(lines) + "\n"
def append_apps(path: Path, apps: list[App], drafts: list[App] = ()) -> None:
"""Append blocks to the file: hand-written comments and order are kept.
drafts are written commented out, for review."""
text = path.read_text() if path.exists() else ""
for a in apps:
validate_app(a)
text = text.rstrip("\n") + "\n\n" + app_to_toml(a)
for a in drafts:
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
path.write_text(text)
@@ -0,0 +1,176 @@
"""Guess how a local project is deployed, and import the apps that already run on the server."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
from . import remote
from .config import App, Config
ENTRIES = ("main.py", "app.py", "app/main.py", "run.py", "server.py")
CONFIG_NAMES = ("configs", "config", ".env", "secrets.yaml", "secrets.yml")
CONFIG_RE = re.compile(r"(config|secret)[^/]*\.(ya?ml|json|toml|ini)$", re.I)
COMPOSE = ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")
def norm(name: str) -> str:
return re.sub(r"[^a-z0-9]", "", name.lower())
def safe_name(name: str) -> str:
return re.sub(r"[^A-Za-z0-9_.-]", "-", name).strip("-.") or "app"
def guess_kind(path: Path) -> str:
if path.is_file():
return "artifact" if path.suffix in (".jar", ".war", ".zip") else "files"
names = {p.name for p in path.iterdir()}
if names & {"pom.xml", "build.gradle", "build.gradle.kts"}:
return "artifact"
if names & {"requirements.txt", "pyproject.toml", "main.py", "app.py"}:
return "python-service"
if names & set(COMPOSE):
return "docker"
return "files"
def guess_entry(path: Path) -> str:
return next((e for e in ENTRIES if (path / e).is_file()), "main.py")
def guess_configs(path: Path) -> list[str]:
found = [n for n in CONFIG_NAMES if (path / n).exists()]
found += sorted(p.name for p in path.iterdir()
if p.is_file() and CONFIG_RE.search(p.name) and p.name not in found and p.suffix != ".py")
return found
def java_build(path: Path) -> tuple[str, str]:
"""(build command, artifact glob) for Maven / Gradle projects."""
if (path / "pom.xml").is_file():
pom = (path / "pom.xml").read_text(errors="replace")
ext = "war" if re.search(r"<packaging>\s*war\s*</packaging>", pom) else "jar"
mvn = "./mvnw" if (path / "mvnw").is_file() else "mvn"
return f"{mvn} -B -q clean package -DskipTests", f"target/*.{ext}"
gradle = "./gradlew" if (path / "gradlew").is_file() else "gradle"
return f"{gradle} -q build -x test", "build/libs/*[!n].jar" # skip *-plain.jar
def new_app(path: Path, name: str | None = None, kind: str | None = None) -> App:
"""Defaults for a project that is not on the server yet."""
path = path.expanduser().resolve()
name = safe_name(name or path.stem)
kind = kind or guess_kind(path)
src = str(path).replace(str(Path.home()), "~", 1)
app = App(name=name, kind=kind, src=src, dest=name)
if kind.startswith("python"):
app.dest = f"python/{name}"
app.unit = name
app.entry = guess_entry(path)
app.description = name
app.configs = guess_configs(path)
elif kind == "docker":
app.dest = f"docker/{name}"
app.configs = []
elif kind == "artifact":
if path.is_dir():
app.build, app.artifact = java_build(path)
else:
app.src, app.artifact = str(Path(src).parent), path.name
app.dest = f"docker/java_services/data/app/{name}/"
else:
app.dest = f"other/{name}/" if path.is_file() else f"other/{name}"
return app
_IMPORT = r'''
for f in "$ROOT"/python/*/*.service; do
[ -f "$f" ] || continue
d=$(dirname "$f"); g() { sed -nE "s/^\s*$1\s*=\s*(.*\S)\s*$/\1/p" "$f" | head -1; }
entry=$(sed -nE 's/^ENTRY="(.*)"$/\1/p' "$d/start.sh" 2>/dev/null)
links=$(find "$d" -mindepth 1 -maxdepth 1 -type l -lname "$CFG/*" -printf '%f,')
echo "P|$(basename "$d")|$(basename "$f" .service)|$(g Description)|$(g User)|$(g SyslogIdentifier)|$(g EnvironmentFile)|$entry|$links"
done
for d in "$ROOT"/docker/*/; do
ls "$d" | grep -qE '^(docker-)?compose\.ya?ml$' && echo "D|$(basename "$d")"
done
for j in "$ROOT"/docker/java_services/data/app/*/*.jar; do
[ -f "$j" ] && echo "J|$(basename "$(dirname "$j")")|$(basename "$j")"
done
true
'''
@dataclass
class Imported:
app: App
matched: bool # a local source was found
def local_projects(cfg: Config) -> list[Path]:
found = []
for root in cfg.server.local_roots:
base = Path(os.path.expanduser(root))
if not base.is_dir():
continue
for p in base.glob("*/*"):
if p.is_dir() and not p.name.startswith("."):
found.append(p)
found += [q for q in p.iterdir() if q.is_dir() and not q.name.startswith(".")
and ((q / "pom.xml").exists() or (q / "build.gradle").exists())]
return found
def match_local(projects: list[Path], names: list[str], unit: str = "") -> Path | None:
keys = [norm(n) for n in names if n]
for p in projects: # exact name
if norm(p.name) in keys:
return p
if unit: # the project ships a unit file with the same name
for p in projects:
if (p / f"{unit}.service").is_file():
return p
return None
def tilde(p: Path) -> str:
return str(p).replace(str(Path.home()), "~", 1)
def import_server(cfg: Config, log=print) -> list[Imported]:
out = remote.run(cfg.server, _IMPORT, log=None, ROOT=cfg.server.app_root, CFG=cfg.server.configs_root)
projects = local_projects(cfg)
known = {a.name for a in cfg.apps} | {cfg.dest_path(a) for a in cfg.apps}
res = []
for line in out.splitlines():
f = line.split("|")
if f[0] == "P" and len(f) == 9:
_, d, unit, desc, user, sid, envf, entry, links = f
app = App(name=d, kind="python-update", src="", dest=f"python/{d}", unit=unit, entry=entry or "main.py",
user=user or "das", description=desc, syslog_id="" if sid == unit else sid, env_file=envf,
configs=[x for x in links.split(",") if x])
local = match_local(projects, [d, sid], unit)
elif f[0] == "D" and len(f) == 2:
app = App(name=f[1], kind="docker", src="", dest=f"docker/{f[1]}")
local = match_local(projects, [f[1]])
if not local:
continue # image-only projects: nothing to deploy from here
elif f[0] == "J" and len(f) == 3:
dname, jar = f[1], f[2]
stem = re.sub(r"[-_]?\d[\w.]*$", "", Path(jar).stem)
app = App(name=f"{dname}-jar", kind="artifact", src="", dest=f"docker/java_services/data/app/{dname}/{jar}",
post="docker restart JavaApplications", health_container="JavaApplications")
local = match_local(projects, [stem, dname])
if local:
app.build, app.artifact = java_build(local)
app.artifact = app.artifact.replace("*.", f"{stem}*.", 1)
else:
continue
if app.name in known or cfg.dest_path(app) in known:
continue
app.src = tilde(local) if local else ""
res.append(Imported(app, local is not None))
log(f"{'+' if local else '?'} {app.kind:15} {app.name:20} {app.src or '(no local source found)'}")
return res
@@ -0,0 +1,6 @@
from .base import KINDS_BY_NAME, Kind, Options, Plan, link_configs # noqa: F401
from . import docker, files, python # noqa: F401 (register the kinds)
def get(name: str) -> Kind:
return KINDS_BY_NAME[name]
@@ -0,0 +1,159 @@
"""Shared pieces of the install kinds."""
from __future__ import annotations
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from .. import remote
from ..config import App, Config
@dataclass
class Options:
build: bool = True
install_deps: bool = False # force pip install even if requirements.txt did not change
restart: bool = True
copy_configs: bool = True # upload missing config items and link them into the app dir
delete: bool | None = None # None = app setting
@dataclass
class Plan:
cfg: Config
app: App
opts: Options
dest: str
first_install: bool
rsync_src: str
filters: list[str]
delete: bool
extra: list[str] = field(default_factory=list) # more rsync options
# top-level entries of dest on the server: name -> f (file) | d (dir) | l (link)
remote_entries: dict[str, str] = field(default_factory=dict)
changes: list[remote.Change] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
artifact_file: Path | None = None
git_rev: str = ""
@property
def server(self):
return self.cfg.server
class Kind:
name = ""
# Server-side items that are never synced, relative to the app dir
protected: tuple[str, ...] = ()
dest_is_file = False
def config_items(self, plan: Plan) -> list[tuple[str, str]]:
"""(name in the app dir, absolute target under configs_root) of config links."""
return []
def local_config(self, plan: Plan, name: str) -> Path:
return plan.app.src_path / name
def install(self, plan: Plan, log) -> None:
pass
def health(self, plan: Plan, log) -> bool:
return health_extra(plan, log)
def after_restore(self, plan: Plan, log) -> None:
self.install(plan, log)
def status_script(self) -> str:
"""Bash fragment printing a one-word state; gets $DEST, $UNIT, $C."""
return ('if [ -n "$C" ]; then docker inspect -f "{{.State.Status}}" "$C" 2>/dev/null || echo missing; '
'elif [ -e "$DEST" ]; then echo present; else echo missing; fi')
def logs_command(self, cfg: Config, app: App, dest: str) -> str:
if app.health_container:
return f"docker logs -f --tail 100 {shlex.quote(app.health_container)}"
return f"tail -n 50 -f {shlex.quote(cfg.server.state_dir + '/deploy.log')}"
_WAIT_CONTAINER = r'''
start=$SECONDS; end=$((SECONDS + TIMEOUT))
while :; do
st=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$C" 2>/dev/null || echo missing)
case $st in
# "running" must hold for a few seconds: a crash loop shows up as restarting/exited
healthy) echo "$C: $st"; exit 0 ;;
running) [ $SECONDS -ge $((start + 6)) ] && { echo "$C: $st"; exit 0; } ;;
unhealthy|exited|dead|missing) echo "$C: $st"; docker logs --tail 50 "$C" 2>&1; exit 1 ;;
esac
[ $SECONDS -lt $end ] || { echo "$C: still $st after ${TIMEOUT}s"; docker logs --tail 50 "$C" 2>&1; exit 1; }
sleep 2
done
'''
_CHECK_URL = r'''
end=$((SECONDS + TIMEOUT))
while :; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$URL")
case $code in 2*|3*) echo "$URL: HTTP $code"; exit 0 ;; esac
[ $SECONDS -lt $end ] || { echo "$URL: HTTP ${code:-none} after ${TIMEOUT}s"; exit 1; }
sleep 2
done
'''
def health_extra(plan: Plan, log) -> bool:
"""Optional checks for any kind: a container and/or an URL (requested from the server itself)."""
app = plan.app
try:
if app.health_container:
remote.run(plan.server, _WAIT_CONTAINER, log, C=app.health_container, TIMEOUT=app.health_timeout)
if app.health_url:
remote.run(plan.server, _CHECK_URL, log, URL=app.health_url, TIMEOUT=app.health_timeout)
except remote.RemoteError:
return False
return True
_LINK = r'''
for i in "${!NAMES[@]}"; do
n=${NAMES[$i]}; t=${TARGETS[$i]}
[ -e "$t" ] || { echo "WARN: $t is missing, $n is not linked"; continue; }
ln -s "$t" "$DEST/$n" && echo "linked $n -> $t"
done
find "$CFGROOT" -mindepth 1 -type d -exec chmod 700 {} + ; find "$CFGROOT" -type f -exec chmod 600 {} +
'''
def link_configs(plan: Plan, log) -> None:
"""Config items missing in the app dir: upload the local copy into the configs dir (never overwriting)
and link it into the app dir."""
kind = KINDS_BY_NAME[plan.app.kind]
names, targets = [], []
for name, target in kind.config_items(plan):
state = plan.remote_entries.get(name)
if state == "l":
continue
if state:
log(f"WARN: {name} is a real {'dir' if state == 'd' else 'file'} on the server, left as is")
continue
if not names:
log("== configs")
local = kind.local_config(plan, name)
if local.is_dir():
remote.rsync(plan.server, f"{local}/", f"{target}/", filters=["- .git"], extra=["--ignore-existing"],
log=log)
elif local.is_file():
remote.rsync(plan.server, str(local), target, extra=["--ignore-existing"], log=log)
# No local copy: link anyway if the target already exists on the server
names.append(name)
targets.append(target)
if names:
remote.run(plan.server, _LINK, log, NAMES=names, TARGETS=targets, DEST=plan.dest,
CFGROOT=plan.cfg.server.configs_root)
KINDS_BY_NAME: dict[str, Kind] = {}
def register(cls: type[Kind]) -> type[Kind]:
KINDS_BY_NAME[cls.name] = cls()
return cls
@@ -0,0 +1,68 @@
"""docker: compose project in ~/app/docker/<app>, .env linked from configs/docker/<app>.env."""
from __future__ import annotations
import shlex
from pathlib import Path, PurePosixPath
from .. import remote
from ..config import App, Config
from .base import Kind, Plan, health_extra, register
_UP = r'''
cd "$DEST" || exit 1
if [ ! -e Makefile ] && [ ! -L Makefile ] && [ -f "$APPROOT/docker/Makefile" ] && [ "$(dirname "$DEST")" = "$APPROOT/docker" ]; then
ln -s "$APPROOT/docker/Makefile" Makefile && echo "linked Makefile"
fi
docker compose config -q || exit 1
if [ "$RESTART" = 1 ]; then docker compose up -d --build --remove-orphans; else docker compose build; fi
'''
_HEALTH = r'''
cd "$DEST" || exit 1
start=$SECONDS; end=$((SECONDS + TIMEOUT))
while :; do
ps=$(docker compose ps -a --format '{{.Name}} {{.State}} {{.Health}}')
bad=$(echo "$ps" | awk '$2=="exited" || $2=="dead" || $3=="unhealthy"')
if [ -n "$bad" ]; then echo "$ps"; docker compose logs --tail 30; exit 1; fi
wait=$(echo "$ps" | awk 'NF && ($2!="running" || ($3!="" && $3!="healthy"))')
if [ -z "$wait" ] && [ $SECONDS -ge $((start + 6)) ]; then echo "$ps"; exit 0; fi
[ $SECONDS -lt $end ] || { echo "$ps"; echo "not ready after ${TIMEOUT}s"; exit 1; }
sleep 3
done
'''
@register
class Docker(Kind):
name = "docker"
protected = (".env", "data", "Makefile")
def config_items(self, plan: Plan) -> list[tuple[str, str]]:
app = PurePosixPath(plan.dest).name
root = f"{plan.cfg.server.configs_root}/docker"
return [(".env", f"{root}/{app}.env")] + [(n, f"{root}/{app}/{n}") for n in plan.app.configs]
def local_config(self, plan: Plan, name: str) -> Path:
p = plan.app.src_path / name
if name == ".env" and not p.exists():
return plan.app.src_path / ".env.example"
return p
def install(self, plan: Plan, log) -> None:
remote.run(plan.server, _UP, log, DEST=plan.dest, APPROOT=plan.cfg.server.app_root,
RESTART=int(plan.opts.restart))
def health(self, plan: Plan, log) -> bool:
try:
remote.run(plan.server, _HEALTH, log, DEST=plan.dest, TIMEOUT=plan.app.health_timeout)
except remote.RemoteError:
return False
return health_extra(plan, log)
def status_script(self) -> str:
return ('cd "$DEST" 2>/dev/null || { echo missing; exit; }; '
't=$(docker compose ps -a -q 2>/dev/null | wc -l); r=$(docker compose ps -q --status running 2>/dev/null | wc -l); '
'[ "$t" -gt 0 ] && echo "running $r/$t" || echo stopped')
def logs_command(self, cfg: Config, app: App, dest: str) -> str:
return f"cd {shlex.quote(dest)} && docker compose logs -f --tail=100"
@@ -0,0 +1,42 @@
"""files: a dir or a file copied to any place; artifact: a built file (jar, war, ...) under a fixed name.
Both run the optional `post` command on the server after the copy (skipped when restart is off)."""
from __future__ import annotations
from .. import remote
from .base import Kind, Plan, register
# mode is applied by rsync (--chmod), so unchanged files keep it too
_POST = r'''
if [ -n "$POST" ]; then
if [ "$IS_FILE" = 1 ]; then cd "$(dirname "$DEST")"; else cd "$DEST"; fi || exit 1
echo "+ $POST"; bash -c "$POST" || exit 1
fi
'''
def run_post(plan: Plan, log, is_file: bool) -> None:
if plan.app.post:
remote.run(plan.server, _POST, log, DEST=plan.dest, POST=plan.app.post, IS_FILE=int(is_file))
@register
class Files(Kind):
name = "files"
def install(self, plan: Plan, log) -> None:
if plan.opts.restart:
run_post(plan, log, is_file=plan.app.src_path.is_file())
else:
log("restart is off: post command skipped")
@register
class Artifact(Kind):
name = "artifact"
dest_is_file = True
def install(self, plan: Plan, log) -> None:
if plan.opts.restart:
run_post(plan, log, is_file=True)
else:
log("restart is off: post command skipped")
@@ -0,0 +1,103 @@
"""python-service: full install via ~/app/python/make_py_service.sh; python-update: code + changed deps."""
from __future__ import annotations
import shlex
from pathlib import PurePosixPath
from .. import remote
from ..config import App, Config
from .base import Kind, Plan, health_extra, register
_DEPS = r'''
cd "$DEST" || exit 1
[ -x .venv/bin/python ] || { echo "no .venv in $DEST: deploy it as python-service first"; exit 1; }
if [ -f requirements.txt ]; then
new=$(sha256sum requirements.txt | cut -d' ' -f1); old=$(cat .deploy_req.sha 2>/dev/null)
if [ "$FORCE" = 1 ] || [ "$new" != "$old" ]; then
echo "installing requirements"
.venv/bin/pip install --disable-pip-version-check -q -r requirements.txt || exit 1
echo "$new" > .deploy_req.sha
else
echo "requirements.txt unchanged"
fi
fi
'''
_SERVICE = r'''
cd "$DEST" || exit 1
args=(--entry "$ENTRY" --user "$RUSER" --syslog-id "$SID")
[ -n "$ENVF" ] && args+=(--env-file "$ENVF")
"$MAKE" "$DEST" "$UNIT" "$DESC" "${args[@]}" || exit 1
[ -f requirements.txt ] && sha256sum requirements.txt | cut -d' ' -f1 > .deploy_req.sha
'''
_RESTART = r'''sudo systemctl restart "$UNIT" && echo "restarted $UNIT"'''
_HEALTH = r'''
sleep 3; a=$(systemctl show -p NRestarts --value "$UNIT")
sleep 8; b=$(systemctl show -p NRestarts --value "$UNIT"); st=$(systemctl is-active "$UNIT")
if [ "$st" = active ] && [ "$a" = "$b" ]; then echo "$UNIT: active, NRestarts=$b"; exit 0; fi
echo "$UNIT: $st, NRestarts $a -> $b"
journalctl -u "$UNIT" -n 40 --no-pager
exit 1
'''
class PythonBase(Kind):
protected = (".env", "start.sh", "*.service", "Makefile", ".deploy_req.sha")
def config_items(self, plan: Plan) -> list[tuple[str, str]]:
"""A single dir item becomes configs/python/<App> itself, otherwise each item goes inside it
(.env as app.env): the same layout as the existing apps."""
app = PurePosixPath(plan.dest).name
root = f"{plan.cfg.server.configs_root}/python/{app}"
items = plan.app.configs
if len(items) == 1 and (plan.app.src_path / items[0]).is_dir():
return [(items[0], root)]
return [(n, f"{root}/{'app.env' if n == '.env' else n}") for n in items]
def restart(self, plan: Plan, log) -> None:
if plan.opts.restart:
remote.run(plan.server, _RESTART, log, UNIT=plan.app.unit)
def health(self, plan: Plan, log) -> bool:
try:
remote.run(plan.server, _HEALTH, log, UNIT=plan.app.unit)
except remote.RemoteError:
return False
return health_extra(plan, log)
def deps(self, plan: Plan, log) -> None:
remote.run(plan.server, _DEPS, log, DEST=plan.dest, FORCE=int(plan.opts.install_deps))
def after_restore(self, plan: Plan, log) -> None:
self.deps(plan, log)
self.restart(plan, log)
def status_script(self) -> str:
return 'systemctl is-active "$UNIT" 2>/dev/null || true'
def logs_command(self, cfg: Config, app: App, dest: str) -> str:
return f"journalctl -u {shlex.quote(app.unit)} -n 100 -f"
@register
class PythonService(PythonBase):
name = "python-service"
def install(self, plan: Plan, log) -> None:
a = plan.app
remote.run(plan.server, _SERVICE, log, DEST=plan.dest, UNIT=a.unit, DESC=a.description or a.name,
ENTRY=a.entry, RUSER=a.user, SID=a.syslog_id or a.unit, ENVF=a.env_file,
MAKE=f"{plan.cfg.server.app_root}/python/make_py_service.sh")
# make_py_service.sh installs requirements.txt itself
self.restart(plan, log)
@register
class PythonUpdate(PythonBase):
name = "python-update"
def install(self, plan: Plan, log) -> None:
self.deps(plan, log)
self.restart(plan, log)
@@ -0,0 +1,271 @@
"""Deploy steps: preview() builds a Plan (nothing changes on the server), execute() applies it."""
from __future__ import annotations
import os
import glob
import shlex
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from . import kinds, remote, snapshots
from .config import App, Config
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"
class DeployError(RuntimeError):
pass
@dataclass
class Result:
ok: bool
snapshot: str | None
message: str
_PROBE = r'''
if [ -d "$DEST" ]; then
echo "EXISTS d"
find "$DEST" -mindepth 1 -maxdepth 1 -printf 'E %y %f\n'
find "$DEST" -mindepth 2 -maxdepth 2 -type l -printf 'L %P\n'
elif [ -e "$DEST" ]; then echo "EXISTS f"
else echo "EXISTS -"
fi
'''
def git_rev(src: Path) -> str:
try:
rev = subprocess.run(["git", "-C", str(src), "rev-parse", "--short", "HEAD"], capture_output=True,
text=True, timeout=10).stdout.strip()
if not rev:
return ""
dirty = subprocess.run(["git", "-C", str(src), "status", "--porcelain"], capture_output=True, text=True,
timeout=10).stdout.strip()
return rev + ("-dirty" if dirty else "")
except (OSError, subprocess.SubprocessError):
return ""
def find_artifact(app: App) -> Path:
files = [Path(p) for p in glob.glob(str(app.src_path / app.artifact)) if Path(p).is_file()]
if not files:
raise DeployError(f"no file matches {app.artifact} in {app.src_path}")
return max(files, key=lambda p: p.stat().st_mtime)
def probe(cfg: Config, dest: str) -> tuple[str, dict[str, str], list[str]]:
"""(d|f|-, top-level entries, symlinks two levels deep)."""
out = remote.run(cfg.server, _PROBE, DEST=dest)
exists, entries, links = "-", {}, []
for line in out.splitlines():
tag, _, rest = line.partition(" ")
if tag == "EXISTS":
exists = rest
elif tag == "E":
t, _, name = rest.partition(" ")
entries[name] = "l" if t == "l" else ("d" if t == "d" else "f")
elif tag == "L":
links.append(rest)
return exists, entries, links
def preview(cfg: Config, app: App, opts: Options, log=print) -> Plan:
kind = kinds.get(app.kind)
src = app.src_path
if not src.exists():
raise DeployError(f"local source not found: {src}")
if opts.build and app.build:
log(f"== build: {app.build}")
remote.run_local(app.build, src if src.is_dir() else src.parent, log, app.build_env)
dest = cfg.dest_path(app)
artifact = None
if app.kind == "artifact":
artifact = find_artifact(app)
if app.dest.endswith("/"):
dest = f"{dest}/{artifact.name}"
rsync_src = str(artifact)
elif src.is_file():
if app.dest.endswith("/"):
dest = f"{dest}/{src.name}"
rsync_src = str(src)
else:
rsync_src = f"{src}/"
is_file = rsync_src != f"{src}/"
log(f"== probe {cfg.server.target}:{dest}")
exists, entries, links = probe(cfg, dest)
if exists == ("d" if is_file else "f"):
raise DeployError(f"{dest} is a {'directory' if exists == 'd' else 'file'} on the server")
first = exists == "-"
if first and app.kind == "python-update":
raise DeployError(f"{dest} does not exist: deploy it with kind python-service first")
protected = set() if is_file else {*kind.protected, *app.configs, *links,
*(n for n, t in entries.items() if t == "l")}
filters = [] if is_file else remote.build_filters(protected=protected,
exclude=[*cfg.server.dev_exclude, *app.exclude],
use_gitignore=app.use_gitignore)
delete = (opts.delete if opts.delete is not None else app.delete) and not is_file
extra = [f"--chmod=F{app.mode}"] if app.mode else []
plan = Plan(cfg=cfg, app=app, opts=opts, dest=dest, first_install=first, rsync_src=rsync_src,
filters=filters, delete=delete, extra=extra, remote_entries=entries, artifact_file=artifact,
git_rev=git_rev(src if src.is_dir() else src.parent))
rsync_dest = dest if is_file else f"{dest}/"
plan.changes = remote.rsync(cfg.server, rsync_src, rsync_dest, filters=filters, delete=delete,
extra=extra, dry_run=True)
if first:
plan.notes.append(f"first install: {dest} will be created")
if artifact:
plan.notes.append(f"artifact: {artifact.relative_to(src)} ({artifact.stat().st_size // 1024} KiB, "
f"{datetime.fromtimestamp(artifact.stat().st_mtime):%Y-%m-%d %H:%M})")
if not is_file and opts.copy_configs:
for name, target in kind.config_items(plan):
state = entries.get(name)
if state is None:
local = kind.local_config(plan, name)
plan.notes.append(f"config {name}: link -> {target}"
+ (f" (upload {local.name} if missing)" if local.exists() else " (no local copy)"))
elif state != "l":
plan.notes.append(f"config {name}: real {'dir' if state == 'd' else 'file'} on the server, kept")
deleted = sum(c.action == "delete" for c in plan.changes)
if deleted:
plan.notes.append(f"{deleted} file(s) on the server will be DELETED")
if plan.git_rev.endswith("-dirty"):
plan.notes.append("working tree has uncommitted changes")
return plan
def _record(plan: Plan, action: str, result: str, snap: str | None, log) -> None:
ts = datetime.now().isoformat(timespec="seconds")
line = f"{ts} {plan.app.name} {action} {plan.app.kind} {result} rev={plan.git_rev or '-'} snap={snap or '-'}"
try:
remote.run(plan.server, 'mkdir -p "$STATE" && printf "%s\\n" "$LINE" >> "$STATE/deploy.log"',
STATE=plan.server.state_dir, LINE=line)
except remote.RemoteError as e:
log(f"WARN: deploy.log not written: {e}")
HISTORY.parent.mkdir(parents=True, exist_ok=True)
with open(HISTORY, "a") as f:
f.write(line + "\n")
def execute(plan: Plan, log=print) -> Result:
kind = kinds.get(plan.app.kind)
is_file = plan.rsync_src != f"{plan.app.src_path}/"
snap = None
try:
if not plan.first_install:
added = [c.path for c in plan.changes if c.action == "new" and not c.path.endswith("/")]
snap = snapshots.create(plan.server, plan.app.name, plan.dest, [] if is_file else added, log)
log(f"== snapshot {snap}")
log("== sync")
rsync_dest = plan.dest if is_file else f"{plan.dest}/"
remote.rsync(plan.server, plan.rsync_src, rsync_dest, filters=plan.filters, delete=plan.delete,
extra=plan.extra, log=log)
if not is_file and plan.opts.copy_configs:
kinds.link_configs(plan, log)
log("== install")
kind.install(plan, log)
ok = True
if plan.opts.restart:
log("== health")
ok = kind.health(plan, log)
except (remote.RemoteError, DeployError) as e:
log(f"ERROR: {e}")
_record(plan, "deploy", "FAILED", snap, log)
return Result(False, snap, str(e))
_record(plan, "deploy", "ok" if ok else "UNHEALTHY", snap, log)
msg = "deployed" if ok else "deployed, but the health check failed"
log(f"== {msg}" + (f" (rollback: snapshot {snap})" if snap and not ok else ""))
return Result(ok, snap, msg)
def base_plan(cfg: Config, app: App, opts: Options | None = None) -> Plan:
"""Plan without a preview: for rollback, status and logs."""
dest = cfg.dest_path(app)
if app.kind == "artifact" and app.dest.endswith("/"):
dest = f"{dest}/{find_artifact(app).name}"
elif app.kind == "files" and app.src_path.is_file() and app.dest.endswith("/"):
dest = f"{dest}/{app.src_path.name}"
return Plan(cfg=cfg, app=app, opts=opts or Options(), dest=dest, first_install=False, rsync_src="",
filters=[], delete=False, git_rev=git_rev(app.src_path if app.src_path.is_dir() else app.src_path.parent))
def rollback(cfg: Config, app: App, ts: str | None = None, log=print) -> Result:
kind = kinds.get(app.kind)
plan = base_plan(cfg, app)
snaps = snapshots.listing(cfg.server, app.name)
if not snaps:
return Result(False, None, "no snapshots")
ts = ts or snaps[0]
if ts not in snaps:
return Result(False, None, f"no snapshot {ts}")
is_file = kind.dest_is_file or app.src_path.is_file()
try:
log(f"== restore {ts}")
snapshots.restore(cfg.server, app.name, plan.dest, ts, is_file, log)
kind.after_restore(plan, log)
ok = kind.health(plan, log) if plan.opts.restart else True
except (remote.RemoteError, DeployError) as e:
log(f"ERROR: {e}")
_record(plan, "rollback", "FAILED", ts, log)
return Result(False, ts, str(e))
_record(plan, "rollback", "ok" if ok else "UNHEALTHY", ts, log)
return Result(ok, ts, f"rolled back to {ts}" + ("" if ok else ", health check failed"))
def code_state(local: str, deployed: str) -> str:
"""Local git rev vs the deployed one: same | changed | dirty (same commit, uncommitted changes) | ? (unknown)."""
if not local or not deployed or deployed == "-":
return "?"
base = local.removesuffix("-dirty")
if base != deployed.removesuffix("-dirty"):
return "changed"
return "same" if local == deployed == base else "dirty"
def deployed_rev(fields: list[str]) -> str:
"""Rev from a deploy.log line; "" when the server code is not known to match it (failed deploy, rollback)."""
if len(fields) >= 6 and fields[2] == "deploy" and fields[4] in ("ok", "UNHEALTHY") \
and fields[5].startswith("rev="):
return fields[5][4:]
return ""
def status_all(cfg: Config) -> dict[str, tuple[str, str, str]]:
"""name -> (state, last deploy line, code state vs local) in one ssh call."""
parts = []
for a in cfg.apps:
k = kinds.get(a.kind)
dest = cfg.dest_path(a)
parts.append(f"DEST={shlex.quote(dest)} UNIT={shlex.quote(a.unit)} C={shlex.quote(a.health_container)}\n"
f"printf 'S %s ' {shlex.quote(a.name)}; ( {k.status_script()} ) | head -1")
parts.append(f'[ -f {shlex.quote(cfg.server.state_dir)}/deploy.log ] && '
f'tail -n 1000 {shlex.quote(cfg.server.state_dir)}/deploy.log | sed "s/^/D /"')
out = remote.run(cfg.server, "\n".join(parts) + "\ntrue\n", check=False)
states, last, deployed = {}, {}, {}
for line in out.splitlines():
if line.startswith("S "):
_, name, *rest = line.split(" ", 2)
states[name] = rest[0].strip() if rest else "?"
elif line.startswith("D "):
f = line[2:].split()
if len(f) >= 5:
last[f[1]] = f"{f[0][:16].replace('T', ' ')} {f[2]} {f[4]}"
deployed[f[1]] = deployed_rev(f)
return {a.name: (states.get(a.name, "?"), last.get(a.name, ""),
code_state(git_rev(a.src_path if a.src_path.is_dir() else a.src_path.parent),
deployed.get(a.name, "")))
for a in cfg.apps}
def logs_command(cfg: Config, app: App) -> str:
return kinds.get(app.kind).logs_command(cfg, app, cfg.dest_path(app))
@@ -0,0 +1,136 @@
"""ssh / rsync wrappers. One multiplexed ssh connection is reused by all calls of a run."""
from __future__ import annotations
import os
import shlex
import subprocess
from dataclasses import dataclass
from typing import Callable, Iterable
from .config import Server
Log = Callable[[str], None]
# Never synced (and so never deleted on the server)
ALWAYS_EXCLUDE = [".git", ".idea", ".vscode", ".venv", "venv", "__pycache__", ".pytest_cache",
".ruff_cache", ".mypy_cache", "*.pyc", ".DS_Store", "*.iml"]
class RemoteError(RuntimeError):
pass
def ssh_opts(server: Server) -> list[str]:
return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "ControlMaster=auto",
"-o", f"ControlPath={os.path.expanduser('~/.ssh')}/cm-%r@%h:%p", "-o", "ControlPersist=120",
"-p", str(server.port)]
def _stream(cmd: list[str], log: Log | None, stdin: str | None = None, cwd=None, env=None) -> tuple[int, str]:
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, env=env,
errors="replace")
if stdin is not None:
proc.stdin.write(stdin)
proc.stdin.close()
out = []
for line in proc.stdout:
line = line.rstrip("\n")
out.append(line)
if log:
log(line)
return proc.wait(), "\n".join(out)
def shell_vars(**values) -> str:
"""Bash assignments for passing values into a remote script."""
lines = []
for k, v in values.items():
if isinstance(v, (list, tuple)):
lines.append(f"{k}=({' '.join(shlex.quote(str(x)) for x in v)})")
else:
lines.append(f"{k}={shlex.quote(str(v))}")
return "\n".join(lines) + "\n"
def run(server: Server, script: str, log: Log | None = None, check: bool = True, **values) -> str:
"""Run a bash script on the server; values become shell variables in front of it."""
body = "set -uo pipefail\n" + shell_vars(**values) + script
rc, out = _stream(["ssh", *ssh_opts(server), server.target, "bash", "-s"], log, stdin=body)
if check and rc != 0:
raise RemoteError(f"remote command failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
return out
def run_local(cmd: str, cwd, log: Log | None, env: dict[str, str] | None = None) -> None:
full_env = {**os.environ, **{k: os.path.expandvars(os.path.expanduser(v)) for k, v in (env or {}).items()}}
rc, _ = _stream(["bash", "-c", cmd], log, cwd=cwd, env=full_env)
if rc != 0:
raise RemoteError(f"local command failed (exit {rc}): {cmd}")
def interactive(server: Server, command: str) -> int:
"""ssh with a terminal (logs -f); the caller suspends the TUI."""
return subprocess.call(["ssh", "-t", *ssh_opts(server), server.target, command])
@dataclass
class Change:
action: str # new | update | delete
path: str
def rsync_cmd(server: Server, src: str, dest: str, *, filters: Iterable[str] = (), delete: bool = False,
dry_run: bool = False, extra: Iterable[str] = ()) -> list[str]:
# -c: compare by content, so a touched but unchanged file is not reported (or sent) as changed
cmd = ["rsync", "-a", "-c", "-z", "--mkpath", "-i", "--out-format=%i %n%L",
"-e", shlex.join(["ssh", *ssh_opts(server)])]
if delete:
cmd.append("--delete")
if dry_run:
cmd.append("-n")
cmd += list(extra)
cmd += [f"--filter={f}" for f in filters]
return cmd + [src, f"{server.target}:{dest}"]
def build_filters(*, protected: Iterable[str], exclude: Iterable[str], use_gitignore: bool) -> list[str]:
"""rsync filter rules. Anchored excludes keep server-side items (config links, .venv, data) untouched;
excluded paths are also protected from --delete."""
rules = []
for p in sorted(set(protected)):
rules.append(f"- /{p.strip('/')}")
for p in [*ALWAYS_EXCLUDE, *exclude]:
rules.append(f"- {p}")
if use_gitignore:
rules.append(":- .gitignore")
return rules
def parse_itemize(output: str) -> list[Change]:
changes = []
for line in output.splitlines():
if line.startswith("*deleting"):
changes.append(Change("delete", line.split(None, 1)[1]))
continue
if len(line) < 13 or line[11] != " ":
continue
flags, path = line[:11], line[12:]
if path in ("./", "."):
continue
y, x = flags[0], flags[1]
if y not in "<>ch":
continue # "." = attributes only
if x == "d" and y == "c" and "+" not in flags:
continue
changes.append(Change("new" if flags[2:].strip("+") == "" else "update", path))
return changes
def rsync(server: Server, src: str, dest: str, *, filters: Iterable[str] = (), delete: bool = False,
dry_run: bool = False, extra: Iterable[str] = (), log: Log | None = None) -> list[Change]:
cmd = rsync_cmd(server, src, dest, filters=filters, delete=delete, dry_run=dry_run, extra=extra)
rc, out = _stream(cmd, None if dry_run else log)
if rc != 0:
raise RemoteError(f"rsync failed (exit {rc})" + (f": {out.splitlines()[-1]}" if out else ""))
return parse_itemize(out)
@@ -0,0 +1,70 @@
"""Remote snapshots: <state_dir>/snapshots/<app>/<ts>.tgz (+ <ts>.added: files the deploy created)."""
from __future__ import annotations
import re
from . import remote
from .config import Server
TS_RE = re.compile(r"^\d{8}-\d{6}$")
# Heavy or runtime-owned items: not part of the code, never snapshotted or restored
# .deploy_req.sha stays out so that a rollback sees changed requirements and reinstalls them
SKIP_DIRS = [".venv", "data", "logs", "node_modules", ".deploy_req.sha"]
_CREATE = r'''
d="$SNAP/$APP"; mkdir -p "$d"; chmod 700 "$STATE" "$STATE/snapshots" "$d"
ts=$(date +%Y%m%d-%H%M%S)
if [ -f "$DEST" ]; then
tar czf "$d/$ts.tgz" -C "$(dirname "$DEST")" "$(basename "$DEST")"; rc=$?
elif [ -d "$DEST" ]; then
ex=(); for s in "${SKIP[@]}"; do ex+=(--exclude="./$s"); done
tar czf "$d/$ts.tgz" --ignore-failed-read "${ex[@]}" -C "$DEST" .; rc=$?
else
echo "nothing to snapshot"; exit 0
fi
# tar: 1 = some files changed while reading, still a usable archive
[ $rc -le 1 ] || { rm -f "$d/$ts.tgz"; echo "snapshot failed"; exit 1; }
chmod 600 "$d/$ts.tgz"
printf '%s\n' "${ADDED[@]}" | sed '/^$/d' > "$d/$ts.added"
ls -1t "$d"/*.tgz | tail -n +$((KEEP + 1)) | while read -r f; do rm -f "$f" "${f%.tgz}.added"; done
echo "SNAPSHOT $ts"
'''
_LIST = r'''ls -1t "$SNAP/$APP" 2>/dev/null | sed -n 's/\.tgz$//p' '''
_RESTORE = r'''
f="$SNAP/$APP/$TS.tgz"; [ -f "$f" ] || { echo "no snapshot $TS"; exit 1; }
if [ "$IS_FILE" = 1 ]; then
mkdir -p "$(dirname "$DEST")"; tar xzf "$f" -C "$(dirname "$DEST")"
else
mkdir -p "$DEST"
# Files that this deploy created did not exist before it
if [ -f "$SNAP/$APP/$TS.added" ]; then
while IFS= read -r p; do
case "$p" in /*|*..*) continue ;; esac
[ -f "$DEST/$p" ] || [ -L "$DEST/$p" ] && rm -f "$DEST/$p" && echo "removed $p"
done < "$SNAP/$APP/$TS.added"
fi
tar xzf "$f" -C "$DEST" && echo "restored $TS into $DEST"
fi
'''
def create(server: Server, app: str, dest: str, added: list[str], log=None) -> str | None:
out = remote.run(server, _CREATE, log, SNAP=server.snapshots, STATE=server.state_dir, APP=app, DEST=dest,
KEEP=server.keep, SKIP=SKIP_DIRS, ADDED=added)
for line in out.splitlines():
if line.startswith("SNAPSHOT "):
return line.split()[1]
return None
def listing(server: Server, app: str) -> list[str]:
out = remote.run(server, _LIST, check=False, SNAP=server.snapshots, APP=app)
return [x for x in out.split() if TS_RE.match(x)]
def restore(server: Server, app: str, dest: str, ts: str, is_file: bool, log=None) -> None:
if not TS_RE.match(ts):
raise ValueError(f"bad snapshot id: {ts}")
remote.run(server, _RESTORE, log, SNAP=server.snapshots, APP=app, DEST=dest, TS=ts, IS_FILE=int(is_file))
@@ -0,0 +1,528 @@
"""Textual UI: app list, per-run options, preview → confirm → deploy, rollback, logs, add/import."""
from __future__ import annotations
import os
import shlex
import subprocess
from pathlib import Path
from rich.markup import escape
from rich.text import Text
from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.coordinate import Coordinate
from textual.screen import ModalScreen
from textual.theme import Theme
from textual.widgets import Button, Checkbox, DataTable, Footer, Input, Label, OptionList, RichLog, Select, Static
from . import __version__, config, detect, pipeline, remote, snapshots
from .config import KINDS, Config
from .kinds import Options, Plan
# Classic CGA palette of Norton / Midnight Commander (same as ServiceManagerTUI)
BLACK, BLUE, CYAN, GRAY, WHITE, YELLOW = "#000000", "#0000AA", "#00AAAA", "#AAAAAA", "#FFFFFF", "#FFFF55"
MIDNIGHT = Theme(
name="midnight", primary=CYAN, secondary=BLUE, accent=YELLOW, warning=YELLOW, error="#FF5555",
success="#55FF55", foreground=GRAY, background=BLUE, surface=BLUE, panel=CYAN, dark=True,
variables={
"border": GRAY, "border-blurred": GRAY,
"block-cursor-foreground": BLACK, "block-cursor-background": CYAN, "block-cursor-text-style": "none",
"block-cursor-blurred-foreground": BLACK, "block-cursor-blurred-background": CYAN,
"block-cursor-blurred-text-style": "none", "block-hover-background": BLUE,
"footer-background": BLACK, "footer-foreground": WHITE, "footer-key-foreground": WHITE,
"footer-key-background": BLACK, "footer-description-foreground": BLACK,
"footer-description-background": CYAN, "footer-item-background": BLACK,
"input-cursor-background": BLACK, "input-cursor-foreground": CYAN,
"scrollbar": CYAN, "scrollbar-background": BLUE, "scrollbar-corner-color": BLUE,
},
)
STATE_STYLE = {"active": "bold #55FF55", "running": "bold #55FF55", "present": "#55FF55",
"failed": "bold #FF5555", "inactive": "#FFFF55", "missing": "#FF5555", "stopped": "#FFFF55",
"exited": "#FF5555"}
CODE_STYLE = {"same": "#55FF55", "changed": "bold #FFFF55", "dirty": "#FFFF55"}
CHANGE_STYLE = {"new": ("+", "#55FF55"), "update": ("~", "#FFFF55"), "delete": ("-", "bold #FF5555")}
def line_text(line: str) -> Text:
style = ""
if line.startswith("== "):
style = "bold #55FFFF"
elif line.startswith(("ERROR", "error:")) or "failed" in line.lower():
style = "bold #FF5555"
elif line.startswith("WARN"):
style = "#FFFF55"
return Text(line, style=style)
class ConfirmPlan(ModalScreen[bool]):
BINDINGS = [Binding("escape,n", "dismiss(False)", "Cancel"), Binding("y", "dismiss(True)", "Deploy")]
def __init__(self, plan: Plan):
super().__init__()
self.plan = plan
def compose(self) -> ComposeResult:
p = self.plan
body = Text()
body.append(f"{p.rsync_src}\n→ {p.server.target}:{p.dest}\n", style="bold")
if p.git_rev:
body.append(f"git {p.git_rev}\n")
body.append("\n")
for c in p.changes:
sign, style = CHANGE_STYLE[c.action]
body.append(f"{sign} {c.path}\n", style=style)
if not p.changes:
body.append("(no file changes: install/restart only)\n", style="italic")
for n in p.notes:
body.append(f"! {n}\n", style="bold #FFFF55" if "DELETED" not in n else "bold #FF5555")
with Vertical(id="dialog"):
yield Label(Text(f" Deploy {p.app.name} [{p.app.kind}] "), id="dialog-title")
with VerticalScroll():
yield Static(body)
with Horizontal(id="buttons"):
yield Button("Deploy (y)", id="yes", variant="success")
yield Button("Cancel (n)", id="no")
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
self.dismiss(e.button.id == "yes")
class Ask(ModalScreen[bool]):
BINDINGS = [Binding("escape,n", "dismiss(False)", "No"), Binding("y", "dismiss(True)", "Yes")]
def __init__(self, title: str, text: str):
super().__init__()
self.title_, self.text = title, text
def compose(self) -> ComposeResult:
with Vertical(id="dialog", classes="small"):
yield Label(Text(f" {self.title_} "), id="dialog-title")
with VerticalScroll():
yield Static(self.text)
with Horizontal(id="buttons"):
yield Button("Yes (y)", id="yes", variant="warning")
yield Button("No (n)", id="no")
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
self.dismiss(e.button.id == "yes")
class PickSnapshot(ModalScreen[str | None]):
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
def __init__(self, app_name: str, snaps: list[str]):
super().__init__()
self.app_name, self.snaps = app_name, snaps
def compose(self) -> ComposeResult:
with Vertical(id="dialog", classes="small"):
yield Label(Text(f" Rollback {self.app_name}: pick a snapshot "), id="dialog-title")
yield OptionList(*self.snaps)
@on(OptionList.OptionSelected)
def picked(self, e: OptionList.OptionSelected) -> None:
self.dismiss(self.snaps[e.option_index])
class AddApp(ModalScreen[config.App | None]):
BINDINGS = [Binding("escape", "dismiss(None)", "Cancel")]
def compose(self) -> ComposeResult:
with Vertical(id="dialog"):
yield Label(" New app ", id="dialog-title")
yield Label("Local project dir or file:")
yield Input(placeholder="~/Dev/Projects/Python/MyApp", id="path")
yield Label("Name (empty = folder name):")
yield Input(id="name")
yield Label("Kind (empty = detect):")
yield Select([(k, k) for k in KINDS], id="kind", prompt="detect")
yield Static("", id="toml")
with Horizontal(id="buttons"):
yield Button("Detect", id="detect")
yield Button("Add", id="add", variant="success")
yield Button("Cancel", id="cancel")
def build(self) -> config.App | None:
raw = self.query_one("#path", Input).value.strip()
path = Path(os.path.expanduser(raw)) if raw else None
if not path or not path.exists():
self.query_one("#toml", Static).update(Text("path not found", style="bold #FF5555"))
return None
kind = self.query_one("#kind", Select).value
app = detect.new_app(path, self.query_one("#name", Input).value.strip() or None,
None if kind is Select.BLANK else kind)
self.query_one("#toml", Static).update(Text(config.app_to_toml(app)))
return app
@on(Button.Pressed)
def pressed(self, e: Button.Pressed) -> None:
if e.button.id == "cancel":
self.dismiss(None)
elif e.button.id == "detect":
self.build()
else:
app = self.build()
if app:
try:
config.validate_app(app)
except config.ConfigError as err:
self.query_one("#toml", Static).update(Text(str(err), style="bold #FF5555"))
return
self.dismiss(app)
class DeployerApp(App):
TITLE = f"Deployer {__version__}"
CSS = """
Screen { background: $background; }
#top { height: 1fr; }
#apps { width: 3fr; border: double $border; border-title-color: $accent; }
#apps > .datatable--header { background: $panel; color: #000000; text-style: bold; }
#apps > .datatable--header-hover { background: $panel; color: #000000; }
#apps > .datatable--header-cursor { background: $panel; color: #000000; }
#side { width: 2fr; border: double $border; border-title-color: $accent; padding: 0 1; }
#info { height: auto; margin-bottom: 1; }
#side Checkbox { background: $background; border: none; padding: 0; height: 1; }
#side Select { margin-top: 1; }
#log { height: 14; border: double $border; border-title-color: $accent; background: $background; }
ModalScreen { align: center middle; background: $background 60%; }
#dialog { width: 90; max-width: 95%; height: 80%; background: $panel; color: #000000;
border: double #FFFFFF; padding: 0 1; }
#dialog.small { height: auto; max-height: 80%; }
#dialog VerticalScroll { height: 1fr; background: $background; color: $foreground; }
#dialog.small VerticalScroll { height: auto; max-height: 20; }
#dialog-title { width: 100%; content-align: center middle; color: #0000AA; text-style: bold; }
#dialog Input, #dialog Select { margin-bottom: 1; }
#dialog OptionList { height: auto; max-height: 20; }
#toml { height: auto; background: $background; color: $foreground; }
#buttons { height: 3; align: center middle; }
#buttons Button { margin: 0 2; }
Toast { background: #000000; color: #FFFFFF; }
"""
BINDINGS = [
Binding("f2", "edit", "Edit"),
Binding("f3", "preview", "Preview"),
Binding("f4", "logs", "Logs"),
Binding("f5", "deploy", "Deploy"),
Binding("f6", "refresh", "Refresh"),
Binding("f7", "add", "Add"),
Binding("f8", "rollback", "Rollback"),
Binding("f9", "import_apps", "Import"),
Binding("f10,q", "quit", "Quit"),
]
def __init__(self, cfg: Config):
super().__init__()
self.cfg = cfg
self.status: dict[str, tuple[str, str, str]] = {}
self.busy = False
# ---------- layout ----------
def compose(self) -> ComposeResult:
with Horizontal(id="top"):
yield DataTable(id="apps", cursor_type="row", zebra_stripes=False)
with VerticalScroll(id="side"):
yield Static(id="info")
yield Select([(k, k) for k in KINDS], id="kind", allow_blank=False, value=KINDS[0])
yield Checkbox("Build locally", True, id="o_build")
yield Checkbox("Restart + health check", True, id="o_restart")
yield Checkbox("Force pip install", False, id="o_deps")
yield Checkbox("Create missing config links", True, id="o_configs")
yield Checkbox("Delete files absent locally", False, id="o_delete")
yield RichLog(id="log", wrap=True, markup=False, max_lines=5000)
yield Footer()
def on_mount(self) -> None:
self.register_theme(MIDNIGHT)
self.theme = "midnight"
t = self.query_one("#apps", DataTable)
t.border_title = f"Apps → {self.cfg.server.target}"
t.add_columns("Name", "Kind", "State", "Code", "Last deploy")
self.query_one("#side").border_title = "Details / options"
self.query_one("#log").border_title = "Output"
self.fill_table()
self.action_refresh()
def fill_table(self) -> None:
t = self.query_one("#apps", DataTable)
row = t.cursor_row
t.clear()
for a in self.cfg.apps:
state, last, code = self.status.get(a.name, ("…", "", ""))
t.add_row(a.name, a.kind, Text(state, style=STATE_STYLE.get(state.split()[0] if state else "", "")),
Text(code, style=CODE_STYLE.get(code, "")), last, key=a.name)
if self.cfg.apps:
t.move_cursor(row=min(row, len(self.cfg.apps) - 1))
self.show_details()
def current(self) -> config.App | None:
t = self.query_one("#apps", DataTable)
if not self.cfg.apps or t.row_count == 0:
return None
key = t.coordinate_to_cell_key(Coordinate(t.cursor_row, 0)).row_key.value
return self.cfg.app(key)
@on(DataTable.RowHighlighted)
def show_details(self) -> None:
a = self.current()
info = self.query_one("#info", Static)
if not a:
info.update("No apps: F7 add, F9 import from the server")
return
lines = [f"[b]{escape(a.name)}[/b]", f"src: {escape(a.src)}", f"dest: {escape(self.cfg.dest_path(a))}"]
if a.is_python:
lines.append(f"unit: {escape(a.unit)} entry: {escape(a.entry)} user: {escape(a.user)}")
if a.configs:
lines.append(f"configs: {escape(', '.join(a.configs))}")
if a.build:
lines.append(f"build: {escape(a.build)}")
if a.artifact:
lines.append(f"artifact: {escape(a.artifact)}")
if a.post:
lines.append(f"post: {escape(a.post)}")
hc = ", ".join(x for x in (a.health_container, a.health_url) if x)
if hc:
lines.append(f"health: {escape(hc)}")
info.update("\n".join(lines))
kind = self.query_one("#kind", Select)
kind.value = a.kind
self.query_one("#o_delete", Checkbox).value = a.delete
self.query_one("#o_build", Checkbox).disabled = not a.build
def options(self) -> Options:
v = lambda i: self.query_one(f"#{i}", Checkbox).value # noqa: E731
return Options(build=v("o_build"), restart=v("o_restart"), install_deps=v("o_deps"),
copy_configs=v("o_configs"), delete=v("o_delete"))
def run_app(self) -> config.App | None:
"""The selected app with the kind chosen for this run."""
a = self.current()
if not a:
return None
kind = self.query_one("#kind", Select).value
if kind != a.kind:
from dataclasses import replace
a = replace(a, kind=kind)
try:
config.validate_app(a)
except config.ConfigError as e:
self.log_line(f"ERROR: {e}")
return None
return a
# ---------- output ----------
def log_line(self, line: str) -> None:
self.query_one("#log", RichLog).write(line_text(line))
def tlog(self, line: str) -> None:
"""Log from a worker thread."""
self.call_from_thread(self.log_line, line)
def start(self) -> bool:
if self.busy:
self.notify("Another operation is running", severity="warning")
return False
self.busy = True
return True
def done(self) -> None:
self.busy = False
# ---------- actions ----------
def action_refresh(self) -> None:
self.refresh_status()
@work(thread=True, group="status", exclusive=True)
def refresh_status(self) -> None:
try:
st = pipeline.status_all(self.cfg)
except remote.RemoteError as e:
self.tlog(f"ERROR: status: {e}")
return
self.call_from_thread(self._set_status, st)
def _set_status(self, st) -> None:
self.status = st
self.fill_table()
def action_preview(self) -> None:
a = self.run_app()
if a and self.start():
self.do_preview(a, self.options(), then_deploy=False)
def action_deploy(self) -> None:
a = self.run_app()
if a and self.start():
self.do_preview(a, self.options(), then_deploy=True)
@work(thread=True, group="op")
def do_preview(self, a: config.App, opts: Options, then_deploy: bool) -> None:
try:
self.tlog(f"== preview {a.name} [{a.kind}]")
plan = pipeline.preview(self.cfg, a, opts, log=self.tlog)
except (pipeline.DeployError, remote.RemoteError, config.ConfigError) as e:
self.tlog(f"ERROR: {e}")
self.call_from_thread(self.done)
return
for c in plan.changes:
sign, _ = CHANGE_STYLE[c.action]
self.tlog(f" {sign} {c.path}")
for n in plan.notes:
self.tlog(f"WARN: {n}")
if not plan.changes:
self.tlog(" (no file changes)")
if not then_deploy:
self.call_from_thread(self.done)
return
def answered(ok: bool | None) -> None:
if ok:
self.do_execute(plan)
else:
self.log_line("cancelled")
self.done()
self.call_from_thread(self.push_screen, ConfirmPlan(plan), answered)
@work(thread=True, group="op")
def do_execute(self, plan: Plan) -> None:
res = pipeline.execute(plan, log=self.tlog)
self.call_from_thread(self.after_execute, plan, res)
def after_execute(self, plan: Plan, res: pipeline.Result) -> None:
self.done()
self.notify(f"{plan.app.name}: {res.message}", severity="information" if res.ok else "error",
timeout=8)
self.action_refresh()
if not res.ok and res.snapshot:
def answered(ok: bool | None) -> None:
if ok and self.start():
self.do_rollback(plan.app, res.snapshot)
self.push_screen(Ask("Deploy failed", f"Roll {plan.app.name} back to snapshot {res.snapshot}?"),
answered)
def action_rollback(self) -> None:
a = self.current()
if a and self.start():
self.load_snapshots(a)
@work(thread=True, group="op")
def load_snapshots(self, a: config.App) -> None:
try:
snaps = snapshots.listing(self.cfg.server, a.name)
except remote.RemoteError as e:
snaps = []
self.tlog(f"ERROR: {e}")
self.call_from_thread(self._pick_snapshot, a, snaps)
def _pick_snapshot(self, a: config.App, snaps: list[str]) -> None:
if not snaps:
self.done()
self.notify(f"{a.name}: no snapshots", severity="warning")
return
def picked(ts: str | None) -> None:
if not ts:
self.done()
return
def confirmed(ok: bool | None) -> None:
if ok:
self.do_rollback(a, ts)
else:
self.done()
self.push_screen(Ask("Rollback", f"Restore {a.name} from {ts} and restart it?"), confirmed)
self.push_screen(PickSnapshot(a.name, snaps), picked)
@work(thread=True, group="op")
def do_rollback(self, a: config.App, ts: str) -> None:
res = pipeline.rollback(self.cfg, a, ts, log=self.tlog)
self.tlog(f"== {res.message}")
self.call_from_thread(self._rolled_back, a, res)
def _rolled_back(self, a: config.App, res: pipeline.Result) -> None:
self.done()
self.notify(f"{a.name}: {res.message}", severity="information" if res.ok else "error", timeout=8)
self.action_refresh()
def action_logs(self) -> None:
a = self.current()
if not a or self.busy:
return
with self.suspend():
print(f"{a.name}: Ctrl+C to return\n")
try:
remote.interactive(self.cfg.server, pipeline.logs_command(self.cfg, a))
except KeyboardInterrupt:
pass
def action_edit(self) -> None:
if self.busy:
return
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
with self.suspend():
subprocess.call([*shlex.split(editor), str(self.cfg.path)])
self.reload()
def reload(self) -> None:
try:
self.cfg = config.load(self.cfg.path)
except (OSError, config.ConfigError) as e:
self.log_line(f"ERROR: config: {e} (old config kept)")
return
self.log_line(f"== config reloaded: {len(self.cfg.apps)} apps")
self.fill_table()
self.action_refresh()
def action_add(self) -> None:
def added(app: config.App | None) -> None:
if app:
if any(x.name == app.name for x in self.cfg.apps):
self.notify(f"{app.name} already exists", severity="error")
return
config.append_apps(self.cfg.path, [app])
self.log_line(f"== added {app.name}: review it with F2 before the first deploy")
self.reload()
self.push_screen(AddApp(), added)
def action_import_apps(self) -> None:
if self.start():
self.do_import()
@work(thread=True, group="op")
def do_import(self) -> None:
try:
found = detect.import_server(self.cfg, log=self.tlog)
except remote.RemoteError as e:
self.tlog(f"ERROR: {e}")
found = []
self.call_from_thread(self._imported, found)
def _imported(self, found) -> None:
self.done()
if not found:
self.notify("Nothing new on the server")
return
ready = [i.app for i in found if i.matched and i.app.kind.startswith("python")]
drafts = [i.app for i in found if i.app not in ready]
text = "\n".join(f"{'+' if i.app in ready else '?'} {i.app.name} {i.app.src or '(no local source)'}"
for i in found)
def answered(ok: bool | None) -> None:
if ok:
config.append_apps(self.cfg.path, ready, drafts)
self.reload()
self.push_screen(Ask(f"Import: {len(ready)} ready, {len(drafts)} commented drafts",
text + "\n\nAppend to deploy.toml?"), answered)
def run(cfg: Config) -> int:
DeployerApp(cfg).run()
return 0
@@ -0,0 +1,19 @@
# Deployer registry. Edit by hand or append with `deploy add <path>` / `deploy import`.
#
# kind: python-service full install: .venv + unit via ~/app/python/make_py_service.sh + restart
# python-update sync code, pip install only when requirements.txt changed, restart
# docker sync a compose project, `docker compose up -d --build`
# files sync a dir or a file anywhere (dest relative to app_root, or ~/..., /...)
# artifact local build, copy the newest `artifact` match to dest (dest/ = keep its name)
# configs: top-level items kept in configs_root and linked into the app dir; never synced
# Common keys: exclude, use_gitignore (true), delete (false), build, build_env, post, mode,
# health_container, health_url (requested on the server), health_timeout
[server]
host = "server.example.com"
user = "user"
app_root = "/home/user/app"
configs_root = "/home/user/app/configs"
state_dir = "/home/user/app_deploy"
keep = 5
local_roots = ["~/Projects"]
+177
View File
@@ -0,0 +1,177 @@
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/das/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/", "das@192.168.88.60:/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/das/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/das/app/configs/python/App")]
plan.app.configs = [".env", "secrets.yaml"]
assert k.config_items(plan) == [(".env", "/home/das/app/configs/python/App/app.env"),
("secrets.yaml", "/home/das/app/configs/python/App/secrets.yaml")]
plan.dest = "/home/das/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/das/app/configs/docker/stack.env")]
+27
View File
@@ -0,0 +1,27 @@
# Template file for 'deployer'
# In-tree package: sources are in files/ (FILESDIR), a copy of ~/Dev/Projects/Python/deployer.
# Differences from the upstream: the config is in ~/.config/deployer (see bin/deploy) and
# history.log is in ~/.local/state/deployer.
pkgname=deployer
version=0.3.0
revision=1
create_wrksrc=yes
hostmakedepends="python3"
depends="python3 python3-textual openssh rsync"
short_desc="TUI/CLI deploy of local projects to a server over ssh + rsync"
maintainer="Das <anry.das@gmail.com>"
license="MIT"
homepage="https://git.das.org.ua/das/Das-OS"
pycompile_dirs="usr/lib/das/deployer"
python_version=3
do_install() {
local f dir
for f in "${FILESDIR}"/python/deployer/*.py "${FILESDIR}"/python/deployer/kinds/*.py; do
dir=${f#"${FILESDIR}"/python/}
vinstall "${f}" 644 "usr/lib/das/deployer/${dir%/*}"
done
vbin "${FILESDIR}/bin/deploy"
vinstall "${FILESDIR}/share/deploy.toml.example" 644 usr/share/das/deployer
vlicense "${FILESDIR}/LICENSE"
}