- 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>
160 lines
5.5 KiB
Python
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
|