8000 feat(updates): release check, backups and one-click upgrade through a machine agent by adeelahmad · Pull Request #260 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"fastapi_backend/uv.lock": r'(^name = "lens"\nversion = ")([^"]+)(")',
"nextjs-frontend/package.json": r'(^ "version": ")([^"]+)(")',
"CloudronManifest.json": r'(^ "version": ")([^"]+)(")',
# what a running Lens says it is (the API, MCP, telemetry, and the update check in docs/updates.md)
"fastapi_backend/app/domain/__init__.py": r'(^__version__ = ")([^"]+)(")',
}

TYPES = {
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/install.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ on:
- nextjs-frontend/Dockerfile
- nextjs-frontend/package.json
- nextjs-frontend/pnpm-lock.yaml
- packaging/host/**
- .github/workflows/install.yml
push:
branches: [main]
Expand All @@ -25,6 +26,7 @@ on:
- installer/**
- docker-compose.prod.yml
- microsandbox/**
- packaging/host/**
- .github/workflows/install.yml
schedule:
- cron: "17 4 * * 1"
Expand Down Expand Up @@ -157,13 +159,32 @@ jobs:
test "$before" = "$after" || { echo "the second run changed the secrets"; exit 1; }
curl -fsS -o /dev/null "$(cat setup-url)"

- name: An upgrade asked for in the web app is installed by the machine's agent
run: |
ctl="$LENS_DIR/.lens-control"
grep -qx 'kind=systemd' "$ctl/agent" || { echo "install.sh set up no upgrade agent"; exit 1; }
# what the web app writes (docs/updates.md): the branch this install follows, as a release tag would be
printf 'id=ci\nref=%s\n' "$LENS_REF" > "$ctl/inbox/.upgrade.ci" && mv "$ctl/inbox/.upgrade.ci" "$ctl/inbox/upgrade"
i=0
until grep -qE '^state=(done|failed)$' "$ctl/upgrade-status" 2>/dev/null; do
i=$((i + 1)); [ $i -gt 450 ] && { echo "the agent didn't finish within 15 minutes"; break; }
sleep 2
done
cat "$ctl/upgrade-status" || true
tail -20 "$ctl/upgrade.log" || true
grep -qx 'id=ci' "$ctl/upgrade-status" && grep -qx 'state=done' "$ctl/upgrade-status"
curl -fsS -o /dev/null "$(cat setup-url)"
# and the containers see the folder, to ask for the next one
cd "$LENS_DIR" && docker compose -f docker-compose.prod.yml exec -T backend sh -c 'test -w /control/inbox && cat /control/agent'

- name: What the containers said
if: failure()
run: cd "$LENS_DIR" && docker compose -f docker-compose.prod.yml ps -a && docker compose -f docker-compose.prod.yml logs --tail 200

- name: Stop
if: always()
run: |
sudo systemctl disable --now lens-upgrade.path 2>/dev/null || true
if [ -d "$LENS_DIR" ]; then
cd "$LENS_DIR"
docker compose -f docker-compose.prod.yml down -v || true
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,4 @@ local-shared-data/*
/.local-ci/
.lens-build
.lens-sandbox
.lens-control/
4 changes: 4 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ x-backend: &backend
LENS_TELEMETRY_HEADERS: ${LENS_TELEMETRY_HEADERS:-}
# the volume shared with the matterbridge service (its config, written from Settings → Chat rooms)
LENS_MATTERBRIDGE_DIR: /matterbridge
# upgrading from the web app (docs/updates.md): the folder install.sh's upgrade agent watches, and the install folder
LENS_CONTROL_DIR: /control
LENS_HOST_DIR: ${LENS_DIR:-}
volumes:
- archive-data:/data
- ./.lens-control:/control
- ${AUDIO_DIR:-./local-audio}:/audio:ro
- matterbridge-data:/matterbridge
depends_on:
Expand Down
3 changes: 2 additions & 1 deletion docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ single statements and for `run()` transactions (which roll back as a whole, so r

## Backups

`lens backup` writes one into `<data_dir>/backups` (Lens also takes one before upgrading the database):
`lens backup` writes one into `<data_dir>/backups`, as does Back up now in the web app ([Updates and backups](updates.md));
Lens also takes one before upgrading the database or installing a release:

* With a server, `manual-<time>.surql.gz`: SurrealDB's export of the database, gzipped. Restore it into an empty
database with `gunzip -k` and `surreal import --conn http://host:8000 --user root --pass … --ns archive --db main
Expand Down
53 changes: 53 additions & 0 deletions docs/updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Updates and backups

Lens tells admins when a new release is out, backs the database up, and installs the release from the web app, so
upgrading never needs a terminal. Status: **built** (the check, backups and upgrades from the API and the agent);
the web app's banner and Settings page come in their own change.

## The check

Once a day (`updates.check_hours`) one process asks GitHub for the latest release of `updates.repo` (default
`adeelahmad/lense`) and keeps the answer, so every process and the web app see the same thing. That one request is all
it sends: no install id and nothing about this install. Turn it off with `updates.check: false` in Settings; admins can
still check by hand (`POST /api/v1/admin/updates/check`).

`GET /api/v1/admin/updates` says which version is running, the latest release and its notes, whether it is newer, and
how the last upgrade went.

## Upgrading

Lens can't replace its own containers (or its microsandbox) from inside them, so a small agent on the machine does it.
install.sh sets it up:

* **Linux** (systemd): `lens-upgrade.path` watches `<install folder>/.lens-control/inbox` and starts
`lens-upgrade.service`, which runs as the user who installed Lens (as root only when Docker needs root there).
* **Mac** (launchd): `~/Library/LaunchAgents/dev.lens.upgrade.plist` does the same.

The containers see the control folder as `/control` (`LENS_CONTROL_DIR`). `POST /api/v1/admin/updates/upgrade`:

1. backs the database up into `<data_dir>/backups` (`before-upgrade-<time>`, the newest 3 are kept), and stops there
if that fails;
2. drops a request for the release in the inbox;
3. the agent (`packaging/host/lens-upgrade.sh`) runs install.sh again for that release, exactly as running the install
line by hand does: the secrets, the database, the archive, the port and the address are kept;
4. it writes `upgrade-status` and `upgrade.log` next to the inbox, which the API reads back while Lens restarts on the
new version. The database upgrades itself on start ([Database](database.md#upgrades)).

The agent installs only a release tag (`vX.Y.Z`) or the branch the install already follows, whoever writes the
request, and the API refuses a release older than the one running (restore a backup instead).

With no agent (Lens wasn't installed with install.sh, the machine has neither systemd nor launchd, or install.sh ran
with `LENS_UPGRADE_AGENT=0`), the API gives the one line to run on the machine instead (`manual`).

`updates.auto_install: true` installs a new release on its own as soon as the check sees it, once per release. It is
off by default: what Lens runs changes only when an admin says so.

## Backups

* `GET /api/v1/admin/backups`: the backups in `<data_dir>/backups`, newest first.
* `POST /api/v1/admin/backups`: back up now (`manual-<time>`), as `lens backup` does.
* `GET /api/v1/admin/backups/{name}`: download one (SurrealDB exports; an embedded database's folder is copied from
the data folder instead).
* `DELETE /api/v1/admin/backups/{name}`.

Restoring one is described in [Database](database.md#backups).
2 changes: 2 additions & 0 deletions fastapi_backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
speakers,
templates,
topics,
updates,
uploads,
users,
vaults,
Expand All @@ -70,6 +71,7 @@
for module in (
users,
admin,
updates,
activity,
budgets,
fedora,
Expand Down
78 changes: 78 additions & 0 deletions fastapi_backend/app/api/v1/routes/updates.py
10BC8
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""New releases, upgrading from the web app, and the database backups (admins only; docs/updates.md)."""

from __future__ import annotations

from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse

from app.api.deps import AdminReader, AdminWriter, Cfg, Db, domain_errors
from app.domain import auth, backups, updates
from app.schemas.common import Ok
from app.schemas.updates import Backup, Backups, UpdateStatus, UpgradeRequest

router = APIRouter(tags=["updates"])


@router.get("/admin/updates")
def get_updates(user: AdminReader, db: Db, cfg: Cfg) -> UpdateStatus:
"""The version running, the latest release, and how the last upgrade went."""
return UpdateStatus.model_validate(updates.view(db, cfg))


@router.post("/admin/updates/check")
def check_updates(user: AdminWriter, db: Db, cfg: Cfg) -> UpdateStatus:
"""Ask GitHub for the latest release now."""
try:
updates.check(db, cfg)
except updates.UpdateError as e:
raise HTTPException(400, str(e)) from None
return UpdateStatus.model_validate(updates.view(db, cfg))


@router.post("/admin/updates/upgrade", status_code=202)
def start_upgrade(user: AdminWriter, db: Db, cfg: Cfg, body: UpgradeRequest | None = None) -> UpdateStatus:
"""Back the database up, then have this machine install the release (the latest unless one is named). Lens
restarts on the new version; GET /admin/updates follows along."""
body = body or UpgradeRequest()
try:
run = updates.upgrade(db, cfg, body.ref, by=user.email, backup=body.backup)
except updates.UpdateError as e:
raise HTTPException(409, str(e)) from None
auth.audit(db, user.as_audit(), "lens.upgrade", None, {"ref": (run or {}).get("ref"), "backup": body.backup})
return UpdateStatus.model_validate(updates.view(db, cfg))


@router.get("/admin/backups")
def list_backups(user: AdminReader, db: Db) -> Backups:
"""The database backups in <data_dir>/backups, newest first."""
return Backups(folder=str(backups.folder(db)), backups=[Backup(**b) for b in backups.listing(db)])


@router.post("/admin/backups", status_code=201)
def take_backup(user: AdminWriter, db: Db) -> Backup:
"""Back the database up now (it can take a while on a big archive)."""
got = backups.take(db)
if got is None:
raise HTTPException(400, "an in-memory database can't be backed up")
auth.audit(db, user.as_audit(), "backup.create", None, {"name": got["name"]})
return Backup(**got)


@router.get("/admin/backups/{name}", response_class=FileResponse, responses={200: {"content": {"application/gzip": {}}}})
def download_backup(name: str, user: AdminReader, db: Db) -> FileResponse:
"""Download a backup (exports only: an embedded database's folder is copied from the data folder)."""
with domain_errors():
p = backups.path(db, name)
if p.is_dir():
raise HTTPException(400, "this backup is a folder: copy it from the data folder on the machine")
auth.audit(db, user.as_audit(), "backup.download", None, {"name": name})
return FileResponse(p, media_type="application/gzip", filename=name)


@router.delete("/admin/backups/{name}")
def delete_backup(name: str, user: AdminWriter, db: Db) -> Ok:
"""Delete a backup."""
with domain_errors():
backups.delete(db, name)
auth.audit(db, user.as_audit(), "backup.delete", None, {"name": name})
return Ok()
3 changes: 2 additions & 1 deletion fastapi_backend/app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ def platform_main(argv, config):
import threading
import time

from .domain import auth, bridge, jobs, local_llm, notify, passkeys, routines, sensors, settings, sources, telemetry, tunnel
from .domain import auth, bridge, jobs, local_llm, notify, passkeys, routines, sensors, settings, sources, telemetry, tunnel, updates

ap = argparse.ArgumentParser(prog="lens")
sub = ap.add_subparsers(dest="cmd", required=True)
Expand Down Expand Up @@ -367,6 +367,7 @@ def password():
bridge.start(db, C, stop, lambda: cfg, name=wk.name, log_fn=print) # and the chat-room bridge, while it's on
tunnel.start(db, C, stop, name=wk.name, log=print) # and the Cloudflare tunnel, while it's on
local_llm.start(db, C, stop, name=wk.name, log=print) # and the local chat model, while it's on
updates.start(db, C, stop, log=print) # and the daily check for a new release (docs/updates.md)
_stop_on_term()
try:
wk.loop(stop)
Expand Down
2 changes: 2 additions & 0 deletions fastapi_backend/app/core/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
store,
templates,
tunnel,
updates,
)

log = logging.getLogger("lens")
Expand Down Expand Up @@ -92,6 +93,7 @@ def start_background(self) -> None:
bridge.start(self.db, self.current, self.stop, lambda: self.base, name=f"api-{os.getpid()}", log_fn=log.warning)
tunnel.start(self.db, self.current, self.stop, name=f"api-{os.getpid()}", log=log.info)
local_llm.start(self.db, self.current, self.stop, name=f"api-{os.getpid()}", log=log.info)
updates.start(self.db, self.current, self.stop, log=log.info)

def close(self) -> None:
self.stop.set()
Expand Down
2 changes: 1 addition & 1 deletion fastapi_backend/app/domain/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Lens: intelligence for your agent, kept on your own hardware."""

__version__ = "0.3.0"
__version__ = "0.6.0" # set by .github/scripts/release.py with the others
72 changes: 72 additions & 0 deletions fastapi_backend/app/domain/backups.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""The database backups in <data_dir>/backups (docs/database.md, Backups): listed, taken, downloaded and deleted from
the web app. migrations.backup() takes them, before upgrades (before-upgrade-*) and on request (manual-*)."""

from __future__ import annotations

import datetime as dt
import pathlib
import re
import shutil

from . import migrations

NAME = re.compile(r"^(?P<label>[a-z][a-z-]{0,40})-(?P<stamp>\d{8}-\d{6})(?:-\d+)?\.(?P<kind>surql\.gz|surrealkv)$")


def folder(db):
return pathlib.Path(db.data_dir or ".") / "backups"


def _size(p):
if p.is_dir():
return sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
return p.stat().st_size


def listing(db):
"""Newest first: name, label, kind (export or folder), size and when it was taken."""
d = folder(db)
out = []
if d.is_dir():
for p in d.iterdir():
m = NAME.match(p.name)
if not m:
continue
at = dt.datetime.strptime(m["stamp"], "%Y%m%d-%H%M%S").replace(tzinfo=dt.timezone.utc)
try:
size = _size(p)
except OSError:
continue
out.append(
{
"name": p.name,
"label": m["label"],
"kind": "folder" if m["kind"] == "surrealkv" else "export",
"bytes": size,
"taken_at": at.isoformat(timespec="seconds"),
}
)
return sorted(out, key=lambda b: (b["taken_at"], b["name"]), reverse=True)


def path(db, name):
"""The backup called `name`, or ValueError; never anything outside the backups folder."""
if not NAME.match(name or ""):
raise ValueError("no such backup")
p = folder(db) / name
if not p.exists():
raise ValueError("no such backup")
return p


def take(db):
"""Back up now; returns the backup's entry, or None for an in-memory database."""
dst = migrations.backup(db, "manual")
if dst is None:
return None
return next((b for b in listing(db) if b["name"] == dst.name), None)


def delete(db, name):
p = path(db, name)
shutil.rmtree(p) if p.is_dir() else p.unlink()
14 changes: 14 additions & 0 deletions fastapi_backend/app/domain/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"matterbridge": None,
"notifications": None,
"telemetry": None,
# repo is a startup setting only (what gets installed isn't chosen in the web app)
"updates": ("check", "check_hours", "auto_install"),
"activity": None,
"encryption": None,
"fedora": None,
Expand Down Expand Up @@ -333,6 +335,16 @@ def view(db, base):
return out


def _updates_setting(key, value):
if key == "check_hours":
if not (isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 24 * 30):
raise ValueError("updates.check_hours is a whole number of hours from 1 to 720")
return value
if not isinstance(value, bool):
raise ValueError(f"updates.{key} is true or false")
return value


def _check(section, key, value, default):
enum = ENUMS.get((section, key))
if enum and value not in enum:
Expand Down Expand Up @@ -384,6 +396,8 @@ def _check(section, key, value, default):
return _notify_setting(key, value)
if section == "telemetry":
return _telemetry_setting(key, value)
if section == "updates":
return _updates_setting(key, value)
if section == "activity":
return _activity_setting(key, value)
if section == "fedora":
Expand Down
3 changes: 3 additions & 0 deletions fastapi_backend/app/domain/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ def labels():
"service_name": "lens",
"prices": {},
},
# new releases (docs/updates.md): check asks GitHub for repo's latest release every check_hours, for the web app's
# banner; auto_install installs one as soon as it's seen, through the machine's upgrade agent (install.sh sets it up)
"updates": {"check": True, "check_hours": 24, "auto_install": False, "repo": "adeelahmad/lense"},
# Fedora (docs/fedora.md): a copy of the archive in a Fedora 6 repository, off until url is set (enabled: false pauses
# it). password is a
# secret; files: send recordings' files too (up to max_file_mb each, 0: any size).
Expand Down
Loading
Loading
0