Files
Das-OS/srcpkgs/deployer/files/python/deployer/kinds/base.py
T
dasandClaude Sonnet 5.5 62b8b98086 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>
2026-10-25 14:44:36 +02:00

160 lines
5.5 KiB
Python

"""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