Files
Cortex-Inara/install.py
Scott Idem 576f22216a feat: restic backup of home/ with systemd daily timer
- backup.sh — backs up home/ (persona data, memory, tasks, crons) to a
  local restic repo; auto-generates password on first run, prunes to
  7d/4w/6m retention; excludes sessions/ and session_data/
- install.py — setup_backup_timer() installs cortex-backup.service +
  cortex-backup.timer (daily 03:00, Persistent=true); skips gracefully
  if restic is not installed

Password lives at ~/.config/cortex/restic-password (chmod 600, not in git).
Repo defaults to ~/backups/cortex-home-restic; override via RESTIC_REPOSITORY.

Per-user encrypted backup is a noted future feature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 19:36:22 -04:00

377 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Cortex / Inara — install & configure
=====================================
Run this on any machine to set up or refresh the Cortex service.
Safe to re-run (idempotent).
Usage:
python3 install.py # install / update
python3 install.py --check # status check only, no changes
"""
import argparse
import os
import subprocess
import sys
from pathlib import Path
# ── Paths ─────────────────────────────────────────────────────────────────────
PROJECT_ROOT = Path(__file__).parent.resolve()
CORTEX_DIR = PROJECT_ROOT / "cortex"
VENV_DIR = CORTEX_DIR / ".venv"
VENV_PYTHON = VENV_DIR / "bin" / "python"
VENV_PIP = VENV_DIR / "bin" / "pip"
REQUIREMENTS = CORTEX_DIR / "requirements.txt"
ENV_FILE = CORTEX_DIR / ".env"
SERVICE_NAME = "cortex"
SERVICE_FILE = Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json"
GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json"
BACKUP_SCRIPT = PROJECT_ROOT / "backup.sh"
BACKUP_SVC_FILE = Path.home() / ".config" / "systemd" / "user" / "cortex-backup.service"
BACKUP_TMR_FILE = Path.home() / ".config" / "systemd" / "user" / "cortex-backup.timer"
# ── ANSI helpers ──────────────────────────────────────────────────────────────
RESET = "\033[0m"
BOLD = "\033[1m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
CYAN = "\033[36m"
def ok(msg): print(f" {GREEN}{RESET} {msg}")
def warn(msg): print(f" {YELLOW}!{RESET} {msg}")
def fail(msg): print(f" {RED}{RESET} {msg}")
def info(msg): print(f" {CYAN}{RESET} {msg}")
def header(msg): print(f"\n{BOLD}{msg}{RESET}")
# ── Shell helpers ─────────────────────────────────────────────────────────────
def run(cmd, *, check=True, capture=False):
"""Run a shell command. Returns CompletedProcess."""
return subprocess.run(
cmd,
shell=isinstance(cmd, str),
check=check,
capture_output=capture,
text=True,
)
def run_quiet(cmd):
"""Run and return (returncode, stdout, stderr)."""
r = subprocess.run(cmd, shell=isinstance(cmd, str),
capture_output=True, text=True)
return r.returncode, r.stdout.strip(), r.stderr.strip()
# ── Step implementations ──────────────────────────────────────────────────────
def check_python():
header("Python")
v = sys.version_info
if v < (3, 11):
fail(f"Python {v.major}.{v.minor} detected — 3.11+ recommended")
sys.exit(1)
ok(f"Python {v.major}.{v.minor}.{v.micro} ({sys.executable})")
def check_project():
header("Project directory")
if not CORTEX_DIR.is_dir():
fail(f"cortex/ not found at {CORTEX_DIR}")
fail("Is agents_sync synced on this machine? Run: syncthing")
sys.exit(1)
ok(f"Project root: {PROJECT_ROOT}")
ok(f"cortex/ {CORTEX_DIR}")
def setup_venv(dry_run=False):
header("Python venv")
venv_ok = VENV_DIR.is_dir() and VENV_PYTHON.exists()
if VENV_DIR.is_dir() and not VENV_PYTHON.exists():
# Directory was synced by Syncthing but binaries are missing/stale
if dry_run:
warn(f"venv directory exists but Python binary missing — would recreate")
warn(f" (likely synced by Syncthing from another machine)")
return
warn("venv directory exists but Python binary missing — recreating …")
import shutil
shutil.rmtree(VENV_DIR)
venv_ok = False
if not venv_ok:
if dry_run:
warn(f"venv missing — would create {VENV_DIR}")
return
info(f"Creating venv at {VENV_DIR}")
run([sys.executable, "-m", "venv", str(VENV_DIR)])
ok("venv created")
else:
ok(f"venv OK: {VENV_DIR}")
# Always (re-)install requirements so updates are picked up
if not dry_run:
info("Installing / updating requirements …")
run([str(VENV_PYTHON), "-m", "pip", "install", "--quiet", "--upgrade", "pip"])
run([str(VENV_PYTHON), "-m", "pip", "install", "--quiet", "-r", str(REQUIREMENTS)])
ok("Requirements installed")
else:
ok(f"requirements.txt: {REQUIREMENTS}")
def check_env_file():
header(".env file")
if ENV_FILE.exists():
ok(f"{ENV_FILE}")
# Parse for a couple of interesting values (no secret printing)
env = {}
for line in ENV_FILE.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
env[k.strip()] = v.strip()
agent = env.get("AGENT_NAME", "(not set)")
local_url = env.get("LOCAL_API_URL", "(not set)")
info(f"AGENT_NAME = {agent}")
info(f"LOCAL_API_URL = {local_url}")
if "localhost" not in local_url and "127.0.0.1" not in local_url:
warn("LOCAL_API_URL is not localhost — if Open WebUI/Ollama runs on "
"this machine, update it to http://localhost:<port>")
else:
fail(f"{ENV_FILE} not found")
warn("Syncthing may not have delivered it yet, or you need to copy it manually:")
warn(f" scp scott@<source-host>:{ENV_FILE} {ENV_FILE}")
def install_systemd_service(dry_run=False):
header("systemd user service")
unit_content = f"""\
[Unit]
Description=Cortex / Inara LLM Gateway
After=network.target
[Service]
Type=simple
WorkingDirectory={CORTEX_DIR}
ExecStart={VENV_DIR}/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=on-failure
RestartSec=5
EnvironmentFile=-{ENV_FILE}
TimeoutStopSec=15
[Install]
WantedBy=default.target
"""
if dry_run:
if SERVICE_FILE.exists():
existing = SERVICE_FILE.read_text()
if existing == unit_content:
ok(f"Service file up to date: {SERVICE_FILE}")
else:
warn(f"Service file differs from expected: {SERVICE_FILE}")
else:
warn(f"Service file missing — would write: {SERVICE_FILE}")
return
SERVICE_FILE.parent.mkdir(parents=True, exist_ok=True)
existing = SERVICE_FILE.read_text() if SERVICE_FILE.exists() else ""
if existing == unit_content:
ok(f"Service file unchanged: {SERVICE_FILE}")
else:
SERVICE_FILE.write_text(unit_content)
ok(f"Service file written: {SERVICE_FILE}")
run(["systemctl", "--user", "daemon-reload"])
ok("daemon-reload done")
def enable_linger(dry_run=False):
"""Enable systemd linger so the service survives logout."""
header("Linger (run without login session)")
username = os.environ.get("USER", os.environ.get("LOGNAME", ""))
code, out, _ = run_quiet(f"loginctl show-user {username} --property=Linger")
if "Linger=yes" in out:
ok(f"Linger already enabled for {username}")
else:
if dry_run:
warn(f"Linger not enabled — would run: loginctl enable-linger {username}")
else:
run(["loginctl", "enable-linger", username])
ok(f"Linger enabled for {username}")
def manage_service(dry_run=False):
header("Service enable / start")
code, _, _ = run_quiet(["systemctl", "--user", "is-enabled", SERVICE_NAME])
enabled = (code == 0)
code2, state, _ = run_quiet(["systemctl", "--user", "is-active", SERVICE_NAME])
active = (code2 == 0)
if dry_run:
ok(f"enabled: {enabled} active: {active}")
return
if not enabled:
run(["systemctl", "--user", "enable", SERVICE_NAME])
ok("Service enabled")
else:
ok("Service already enabled")
if active:
info("Service running — restarting to pick up any changes …")
run(["systemctl", "--user", "restart", SERVICE_NAME])
ok("Service restarted")
else:
run(["systemctl", "--user", "start", SERVICE_NAME])
ok("Service started")
def check_cli_auth():
header("LLM CLI authentication")
# Claude
if CLAUDE_CREDS.exists():
ok(f"Claude credentials found: {CLAUDE_CREDS}")
else:
fail(f"Claude not authenticated — run: claude")
warn(" (completes an OAuth flow in your browser)")
# Gemini
if GEMINI_CREDS.exists():
ok(f"Gemini credentials found: {GEMINI_CREDS}")
else:
fail(f"Gemini not authenticated — run: gemini")
warn(" (completes an OAuth flow in your browser)")
def setup_backup_timer(dry_run=False):
header("Backup (restic systemd timer)")
code, _, _ = run_quiet(["which", "restic"])
if code != 0:
warn("restic not found — skipping backup timer setup")
warn(" Install with: sudo pacman -S restic (or your distro's package manager)")
return
ok("restic found")
svc_content = f"""\
[Unit]
Description=Cortex home backup (restic)
After=network.target
[Service]
Type=oneshot
ExecStart={BACKUP_SCRIPT}
"""
tmr_content = """\
[Unit]
Description=Cortex home backup — daily at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
"""
if dry_run:
if BACKUP_SVC_FILE.exists() and BACKUP_TMR_FILE.exists():
ok(f"Timer already installed")
code2, out, _ = run_quiet(["systemctl", "--user", "is-enabled",
"cortex-backup.timer"])
ok(f"Timer enabled: {code2 == 0}")
else:
warn("Backup timer not installed — would write service + timer units")
return
SERVICE_FILE.parent.mkdir(parents=True, exist_ok=True)
for path, content, label in [
(BACKUP_SVC_FILE, svc_content, "service"),
(BACKUP_TMR_FILE, tmr_content, "timer"),
]:
existing = path.read_text() if path.exists() else ""
if existing == content:
ok(f"Backup {label} file unchanged")
else:
path.write_text(content)
ok(f"Backup {label} file written: {path}")
run(["systemctl", "--user", "daemon-reload"])
run(["systemctl", "--user", "enable", "--now", "cortex-backup.timer"])
ok("Backup timer enabled (daily 03:00)")
code2, out, _ = run_quiet(["systemctl", "--user", "list-timers",
"cortex-backup.timer", "--no-pager"])
for line in out.splitlines()[:4]:
info(line)
def print_status():
header("Service status")
code, out, _ = run_quiet(["systemctl", "--user", "status", SERVICE_NAME,
"--no-pager", "-l"])
# Print the first 15 lines of status
for line in out.splitlines()[:15]:
print(f" {line}")
print()
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Install / configure the Cortex service.")
parser.add_argument("--check", action="store_true",
help="Status check only — make no changes")
args = parser.parse_args()
dry = args.check
mode = "CHECK MODE (no changes)" if dry else "INSTALL / UPDATE"
print(f"\n{BOLD}{''*52}{RESET}")
print(f"{BOLD} Cortex / Inara — {mode}{RESET}")
print(f"{BOLD}{''*52}{RESET}")
check_python()
check_project()
setup_venv(dry_run=dry)
check_env_file()
install_systemd_service(dry_run=dry)
enable_linger(dry_run=dry)
if not dry:
manage_service(dry_run=dry)
check_cli_auth()
setup_backup_timer(dry_run=dry)
if not dry:
print_status()
print(f"\n{BOLD}Done.{RESET}\n")
print(" Logs: journalctl --user -u cortex -f")
print(" Swagger: http://localhost:8000/docs")
print(" Backup: ./backup.sh (also runs daily at 03:00)")
print(" Snapshots: RESTIC_REPOSITORY=~/backups/cortex-home-restic \\")
print(" RESTIC_PASSWORD_FILE=~/.config/cortex/restic-password \\")
print(" restic snapshots")
print()
if __name__ == "__main__":
main()