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:
@@ -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.
|
||||
Executable
+8
@@ -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"]
|
||||
@@ -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")]
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user