diff --git a/docs/installation.md b/docs/installation.md index 7661da1..e3b7393 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -161,11 +161,36 @@ lmstudio` (or `llama.cpp`, `vllm`, `mlx`). See [Other model servers](servers.md) Use [WSL2](https://learn.microsoft.com/windows/wsl/install) with Ubuntu and follow the Ubuntu steps. NVIDIA GPUs work inside WSL2 with the regular Windows driver. -## Upgrade and uninstall +## Updating ```bash -uv tool upgrade lcode-cli # upgrade (pipx: pipx upgrade lcode-cli; Homebrew: brew upgrade lcode) -uv tool uninstall lcode-cli # remove lcode (Homebrew: brew uninstall lcode) +lcode update # update lcode, and Ollama if it runs in Docker +lcode update --check # only show which versions are out +``` + +- **lcode** updates the way you installed it: `uv tool upgrade`, `pipx upgrade`, `brew upgrade` + or `pip`. If it runs from a git checkout, `git pull` (on `main`, without local changes). +- **Ollama in a Docker container** is updated to the newest release on Docker Hub. lcode looks + that up instead of trusting a local `latest` tag, which can be months old, and skips release + candidates. It pulls the version's own tag, such as `ollama/ollama:0.35.1`, so `docker ps` shows + which version runs. A ROCm image stays ROCm. + - **A container from Docker Compose:** lcode changes the `image:` line in its compose file and + runs `docker compose up -d` for that service, so Compose keeps managing it. + - **Any other container** is recreated with the same name, ports, volumes, environment, GPUs, + network and restart policy. The old one is kept until the new one answers with the new + version, and is put back if it doesn't. + - Your models stay: they're in the container's volume. +- **Ollama installed without Docker** gets the command that updates it: on Linux, + `curl -fsSL https://ollama.com/install.sh | sh`; on macOS, `brew upgrade ollama` or the app + itself. + +lcode shows what it will do and asks first. `lcode update lcode` or `lcode update ollama` updates +only one; `-y` doesn't ask. + +## Uninstall + +```bash +uv tool uninstall lcode-cli # remove lcode (pipx: pipx uninstall lcode-cli; Homebrew: brew uninstall lcode) rm -rf ~/.config/lcode ~/.local/state/lcode # remove settings and saved sessions ollama rm lcode-qwen3.6-35b qwen3.6:35b-a3b-coding # remove downloaded models ``` diff --git a/docs/usage.md b/docs/usage.md index 76770a5..a99db2f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -11,6 +11,7 @@ lcode config [set|unset] # show or change settings lcode bench [models] # score models on small coding tasks on this machine lcode mcp [catalog|add|list] # connect MCP servers: Jira, GitHub, AWS, databases… (see MCP servers) lcode acp # run inside an editor over the Agent Client Protocol (see Editors) +lcode update [--check] # update lcode, and Ollama when it runs in Docker (see Installation) ``` | Option | Meaning | diff --git a/pyproject.toml b/pyproject.toml index 72b007f..36abd97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ ignore = [ "src/lcode/gitflow.py" = ["E501"] "src/lcode/action.py" = ["E501"] "src/lcode/acp.py" = ["E501"] +"src/lcode/update.py" = ["E501"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/lcode/cli.py b/src/lcode/cli.py index 6e9d581..629e94e 100644 --- a/src/lcode/cli.py +++ b/src/lcode/cli.py @@ -678,7 +678,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="lcode", description="A local-first terminal coding agent powered by open-weight models via Ollama.", - epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index " + epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index | update " "(lcode --help). " "Docs: https://nasser1941.github.io/lcode/", ) @@ -774,6 +774,15 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]: from lcode.mcp.commands import build_parser as mcp_parser subs["mcp"] = mcp_parser() + p = argparse.ArgumentParser( + prog="lcode update", + description="Update lcode, and Ollama when it runs in Docker (to the newest version on Docker Hub, pinned " + "to its version tag).", + ) + p.add_argument("what", nargs="?", choices=["lcode", "ollama"], help="only this one (default: both)") + p.add_argument("--check", action="store_true", help="only show which versions are out") + p.add_argument("-y", "--yes", action="store_true", help="don't ask before updating") + subs["update"] = p subs["acp"] = argparse.ArgumentParser( prog="lcode acp", description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout " @@ -787,6 +796,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]: return subs +def cmd_update(args) -> None: + from lcode import update + + sys.exit(update.command(console, args.what or "", check=args.check, yes=args.yes)) + + def cmd_acp(args) -> None: from lcode import acp @@ -863,6 +878,7 @@ def cmd_mcp(args) -> None: "index": cmd_index, "action": cmd_action, "acp": cmd_acp, + "update": cmd_update, } diff --git a/src/lcode/update.py b/src/lcode/update.py new file mode 100644 index 0000000..6025103 --- /dev/null +++ b/src/lcode/update.py @@ -0,0 +1,557 @@ +"""`lcode update`: update lcode, and Ollama when it runs in Docker. + +lcode updates itself the way it was installed: uv tool, pipx, Homebrew, pip, or `git pull` for a +checkout (an editable install). + +For Ollama in Docker, the newest version is looked up on Docker Hub, never taken from a local +`latest` tag (which can be months old), and pulled by its version tag (`ollama/ollama:0.35.1`), so +`docker ps` shows which version runs. Release candidates are skipped, and a ROCm image stays ROCm. +- A container from Docker Compose gets the new tag in its compose file and is recreated with + `docker compose up`, so Compose keeps managing it. +- Any other container is recreated with the same name, ports, volumes, environment, GPUs, network + and restart policy. The old one is kept until the new one answers with the new version, and is + put back if it doesn't. +Ollama installed without Docker gets the command to update it, which lcode doesn't run itself. +""" + +from __future__ import annotations + +import importlib.metadata +import json +import re +import shutil +import subprocess +import sys +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urlsplit + +import requests + +from lcode import __version__ + +IMAGE = "ollama/ollama" +HUB_TAGS = "https://hub.docker.com/v2/namespaces/ollama/repositories/ollama/tags" +PYPI = "https://pypi.org/pypi/lcode-cli/json" +SEMVER = re.compile(r"\d+\.\d+\.\d+") +START_TIMEOUT = 120 # seconds for a recreated container to answer +NATIVE = { + "linux": "curl -fsSL https://ollama.com/install.sh | sh", + "darwin": "brew upgrade ollama (or let the Ollama app update itself)", +} + + +class UpdateError(Exception): + pass + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(p) for p in re.findall(r"\d+", version)[:3]) + + +def run(argv: list[str], cwd: Path | None = None, timeout: float = 120) -> subprocess.CompletedProcess: + """A command whose output lcode reads (and tests replace).""" + try: + return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=timeout) + except (OSError, subprocess.SubprocessError) as e: + raise UpdateError(f"{argv[0]} failed: {e}") from e + + +def stream(argv: list[str], cwd: Path | None = None) -> int: + """A command the user watches (pulls, installs); returns its exit code.""" + try: + return subprocess.call(argv, cwd=cwd) + except OSError as e: + raise UpdateError(f"{argv[0]} failed: {e}") from e + + +def get_json(url: str, **params) -> dict: + try: + r = requests.get(url, params=params or None, timeout=20) + r.raise_for_status() + return r.json() + except (requests.RequestException, ValueError) as e: + raise UpdateError(f"couldn't read {url.split('?')[0]}: {e}") from e + + +# ----------------------------------------------------------------------------- the newest versions + + +def latest_lcode() -> str: + return str(get_json(PYPI)["info"]["version"]) + + +def latest_ollama(flavor: str = "") -> str: + """The newest released Ollama image tag on Docker Hub: "0.35.1", or "0.35.1-rocm" for flavor "rocm".""" + names: list[str] = [] + url, params = HUB_TAGS, {"page_size": 100, "ordering": "last_updated"} + for _ in range(3): # the newest tags come first; three pages reach well past them + page = get_json(url, **params) + names += [str(t.get("name", "")) for t in page.get("results") or []] + url, params = page.get("next"), {} + if not url: + break + suffix = f"-{flavor}" if flavor else "" + versions = [ + n.removesuffix(suffix) for n in names if n.endswith(suffix) and SEMVER.fullmatch(n.removesuffix(suffix)) + ] + if not versions: + raise UpdateError(f"Docker Hub lists no released {IMAGE} versions{f' for {flavor}' if flavor else ''}") + return max(versions, key=version_key) + suffix + + +def image_flavor(image: str) -> str: + """The image variant to stay on: "rocm" for ollama/ollama:rocm or :0.35.1-rocm, else "".""" + tag = image.rsplit(":", 1)[1] if ":" in image.rsplit("/", 1)[-1] else "latest" + return "rocm" if tag == "rocm" or tag.endswith("-rocm") else "" + + +# ----------------------------------------------------------------------------- lcode itself + + +@dataclass +class Install: + kind: str # checkout, uv, pipx, brew, uvx, pip + command: list[str] | None = None # what updates it + path: Path | None = None # the checkout, for kind checkout + + def describe(self) -> str: + return { + "checkout": f"a git checkout at {self.path}", + "uv": "uv tool", + "pipx": "pipx", + "brew": "Homebrew", + "uvx": "uvx", + "pip": "pip", + }[self.kind] + + +def lcode_install(prefix: Path | None = None, direct_url: str | None = None) -> Install: + """How this lcode was installed.""" + prefix = (prefix or Path(sys.prefix)).resolve() + if direct_url is None: + try: + direct_url = importlib.metadata.distribution("lcode-cli").read_text("direct_url.json") or "" + except importlib.metadata.PackageNotFoundError: + direct_url = "" + try: + origin = json.loads(direct_url) if direct_url else {} + except ValueError: + origin = {} + if (origin.get("dir_info") or {}).get("editable") and str(origin.get("url", "")).startswith("file://"): + return Install("checkout", path=Path(urlsplit(origin["url"]).path)) + text = str(prefix) + if (prefix / "uv-receipt.toml").exists(): + return Install("uv", ["uv", "tool", "upgrade", "lcode-cli"]) + if "/Cellar/lcode/" in text: + return Install("brew", ["brew", "upgrade", "nasser1941/tap/lcode"]) + if "pipx" in prefix.parts: + return Install("pipx", ["pipx", "upgrade", "lcode-cli"]) + if "/uv/" in text and ("archive-v" in text or "/cache/" in text or "/.cache/" in text): + return Install("uvx") + return Install("pip", [sys.executable, "-m", "pip", "install", "--upgrade", "lcode-cli"]) + + +@dataclass +class CheckoutState: + branch: str + dirty: bool + behind: int + upstream: str + + +def checkout_state(path: Path) -> CheckoutState: + def git(*args: str) -> str: + r = run(["git", "-C", str(path), *args], timeout=120) + if r.returncode != 0: + raise UpdateError(f"git {args[0]} failed in {path}: {(r.stderr or r.stdout).strip()[:200]}") + return r.stdout.strip() + + branch = git("rev-parse", "--abbrev-ref", "HEAD") + dirty = bool(git("status", "--porcelain", "--untracked-files=no")) + upstream = run(["git", "-C", str(path), "rev-parse", "--abbrev-ref", "@{upstream}"]).stdout.strip() + behind = 0 + if upstream: + git("fetch", "-q") + behind = int(git("rev-list", "--count", "HEAD..@{upstream}") or 0) + return CheckoutState(branch, dirty, behind, upstream) + + +def update_checkout(path: Path, install_receipt: bool) -> str: + """git pull a checkout lcode runs from; reinstall its environment when its dependencies changed.""" + before = run(["git", "-C", str(path), "rev-parse", "HEAD"]).stdout.strip() + if stream(["git", "-C", str(path), "pull", "--ff-only"]) != 0: + raise UpdateError(f"git pull failed in {path}") + changed = run(["git", "-C", str(path), "diff", "--name-only", before, "HEAD"]).stdout.split() + reinstall = ["uv", "tool", "install", "--force", "--editable", str(path)] + if "pyproject.toml" in changed and install_receipt and stream(reinstall) != 0: + raise UpdateError("reinstalling lcode's environment failed: run uv tool install --force --editable .") + count = run(["git", "-C", str(path), "rev-list", "--count", f"{before}..HEAD"]).stdout.strip() or "0" + return f"{count} new commit(s)" + + +# ----------------------------------------------------------------------------- Ollama in Docker + + +@dataclass +class Container: + id: str + name: str + image: str # as configured, e.g. ollama/ollama:latest + info: dict = field(repr=False) + + @property + def compose(self) -> dict | None: + labels = (self.info.get("Config") or {}).get("Labels") or {} + project, service = labels.get("com.docker.compose.project"), labels.get("com.docker.compose.service") + if not project or not service: + return None + files = [f for f in str(labels.get("com.docker.compose.project.config_files", "")).split(",") if f] + return {"project": project, "service": service, "files": files, + "workdir": labels.get("com.docker.compose.project.working_dir", "")} # fmt: skip + + +def docker_available() -> bool: + return shutil.which("docker") is not None and run(["docker", "info", "--format", "{{.ID}}"]).returncode == 0 + + +def find_container(host: str) -> Container | None: + """The Ollama container that serves `host` (a local address), if Ollama runs in Docker.""" + parts = urlsplit(host) + if parts.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None): + return None + port = str(parts.port or 11434) + ids = run(["docker", "ps", "-q"]).stdout.split() + if not ids: + return None + infos = json.loads(run(["docker", "inspect", *ids]).stdout or "[]") + found = [] + for info in infos: + image = (info.get("Config") or {}).get("Image", "") + if not image.startswith(IMAGE): + continue + bindings = ((info.get("HostConfig") or {}).get("PortBindings") or {}).get("11434/tcp") or [] + if any(str(b.get("HostPort")) == port for b in bindings): + found.append(Container(info["Id"][:12], info["Name"].lstrip("/"), image, info)) + return found[0] if len(found) == 1 else None + + +def set_compose_image(text: str, service: str, image: str) -> tuple[str, str]: + """The compose file with the service's image changed, and the image it had.""" + lines = text.splitlines(keepends=True) + services = next((i for i, line in enumerate(lines) if re.match(r"services:\s*(#.*)?$", line)), None) + if services is None: + raise UpdateError("no services: section") + start = indent = None + for i in range(services + 1, len(lines)): + m = re.match(rf"(\s+)['\"]?{re.escape(service)}['\"]?:\s*(#.*)?$", lines[i]) + if m: + start, indent = i, len(m.group(1)) + break + if lines[i].strip() and not lines[i][0].isspace() and not lines[i].lstrip().startswith("#"): + break # the next top-level key: services ended + if start is None or indent is None: + raise UpdateError(f"no service {service} in it") + for i in range(start + 1, len(lines)): + line = lines[i] + if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip()) <= indent: + break # the next service + m = re.match(r"(\s+image:\s*)(['\"]?)([^'\"#\s]+)\2(\s*(#.*)?)$", line.rstrip("\n")) + if m: + if "${" in line: + raise UpdateError(f"the image comes from a variable ({m.group(3)}): set it to {image}") + newline = "\n" if line.endswith("\n") else "" + lines[i] = f"{m.group(1)}{m.group(2)}{image}{m.group(2)}{m.group(4)}{newline}" + return "".join(lines), m.group(3) + raise UpdateError(f"service {service} has no image: line (it may be built from a Dockerfile)") + + +def run_args(info: dict, image_info: dict, image: str) -> list[str]: + """`docker run` arguments that recreate a container with another image and the same settings.""" + config, host = info.get("Config") or {}, info.get("HostConfig") or {} + base = image_info.get("Config") or {} + args = ["docker", "run", "-d", "--name", info["Name"].lstrip("/")] + restart = host.get("RestartPolicy") or {} + if restart.get("Name") not in (None, "", "no"): + retries = restart.get("MaximumRetryCount") or 0 + args += ["--restart", restart["Name"] + (f":{retries}" if restart["Name"] == "on-failure" and retries else "")] + for port, bindings in (host.get("PortBindings") or {}).items(): + target = port.removesuffix("/tcp") + for b in bindings or []: + ip = b.get("HostIp") or "" + args += ["-p", f"{ip + ':' if ip else ''}{b.get('HostPort', '')}:{target}"] + for m in info.get("Mounts") or []: + source = m.get("Name") if m.get("Type") == "volume" else m.get("Source") + if source and m.get("Type") in ("volume", "bind"): + args += ["-v", f"{source}:{m['Destination']}" + ("" if m.get("RW", True) else ":ro")] + default_env = set(base.get("Env") or []) + for entry in config.get("Env") or []: + if entry not in default_env: # only what was set for this container; the new image brings its own PATH + args += ["-e", entry] + for request in host.get("DeviceRequests") or []: + if ["gpu"] in (request.get("Capabilities") or []): + ids = request.get("DeviceIDs") or [] + count = request.get("Count") or 0 + args += ["--gpus", f"device={','.join(ids)}" if ids else ("all" if count == -1 else str(count))] + for device in host.get("Devices") or []: + args += ["--device", f"{device['PathOnHost']}:{device['PathInContainer']}"] + if host.get("Runtime") not in (None, "", "runc"): + args += ["--runtime", host["Runtime"]] + network = host.get("NetworkMode") or "default" + if network not in ("default", "bridge") and not network.startswith("container:"): + args += ["--network", network] + endpoint = ((info.get("NetworkSettings") or {}).get("Networks") or {}).get(network) or {} + name, short_id = info["Name"].lstrip("/"), info["Id"][:12] + for alias in endpoint.get("Aliases") or []: + if alias not in (name, short_id): + args += ["--network-alias", alias] + for host_entry in host.get("ExtraHosts") or []: + args += ["--add-host", host_entry] + base_labels = base.get("Labels") or {} + for key, value in (config.get("Labels") or {}).items(): + if base_labels.get(key) != value: + args += ["--label", f"{key}={value}"] + entrypoint, cmd = config.get("Entrypoint") or [], config.get("Cmd") or [] + if entrypoint and entrypoint != (base.get("Entrypoint") or []): + args += ["--entrypoint", entrypoint[0]] + cmd = [*entrypoint[1:], *cmd] + elif cmd == (base.get("Cmd") or []): + cmd = [] + return [*args, image, *cmd] + + +def wait_for_version(host: str, wanted: str, timeout: float = START_TIMEOUT) -> bool: + from lcode.ollama import Ollama, OllamaError + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if Ollama(host).version() == wanted.removesuffix("-rocm"): + return True + except OllamaError: + pass + time.sleep(2) + return False + + +def recreate(container: Container, image: str, host: str, say: Callable[[str], None]) -> None: + """Replace a plain container with one on `image`; put the old one back if that fails.""" + image_info = json.loads(run(["docker", "image", "inspect", container.info["Image"]]).stdout or "[{}]")[0] + args = run_args(container.info, image_info, image) + backup = f"{container.name}-before-lcode-update" + for step in (["docker", "stop", container.name], ["docker", "rename", container.name, backup]): + r = run(step) + if r.returncode != 0: + raise UpdateError(f"{' '.join(step[:2])} failed: {(r.stderr or r.stdout).strip()}") + say(f"$ {' '.join(args)}") + started = run(args) + if started.returncode == 0 and wait_for_version(host, image.rsplit(":", 1)[1]): + run(["docker", "rm", backup]) + return + problem = (started.stderr or started.stdout).strip() or "it didn't answer with the new version" + run(["docker", "rm", "-f", container.name]) + run(["docker", "rename", backup, container.name]) + run(["docker", "start", container.name]) + raise UpdateError(f"the new container didn't start ({problem[:300]}); the old one is running again") + + +def recreate_with_compose(container: Container, image: str, host: str, say: Callable[[str], None]) -> None: + command, project_dir = compose_up(container.compose or {}) + say(f"$ {' '.join(command)}") + if stream(command, cwd=project_dir) != 0: + raise UpdateError("docker compose up failed") + if not wait_for_version(host, image.rsplit(":", 1)[1]): + raise UpdateError(f"Ollama didn't answer with version {image.rsplit(':', 1)[1]} after the restart") + + +def compose_up(compose: dict) -> tuple[list[str], Path]: + """`docker compose up` for just the Ollama service of its project, and where to run it.""" + files = [Path(f) for f in compose["files"]] + project_dir = Path(compose["workdir"]) if compose.get("workdir") else files[0].parent + command = ["docker", "compose", "-p", compose["project"], "--project-directory", str(project_dir)] + for f in files: + command += ["-f", str(f)] + return [*command, "up", "-d", "--no-deps", compose["service"]], project_dir + + +def compose_change(container: Container, image: str) -> tuple[Path, str, str]: + """Which compose file defines the service's image, and its new text.""" + compose = container.compose or {} + problems = [] + for path in reversed([Path(f) for f in compose.get("files") or []]): # later files override earlier ones + try: + text, old = set_compose_image(path.read_text(), compose["service"], image) + return path, text, old + except (OSError, UpdateError) as e: + problems.append(f"{path}: {e}") + raise UpdateError("couldn't find the image to change: " + "; ".join(problems)) + + +# ----------------------------------------------------------------------------- lcode update + + +def command(console, what: str = "", check: bool = False, yes: bool = False) -> int: + """Update lcode and Ollama (or `what`: "lcode" or "ollama"). Returns the exit code.""" + from rich.prompt import Confirm + + def ask(question: str) -> bool: + if yes: + return True + if not sys.stdin.isatty(): + console.print("[dim]Not asking without a terminal: run lcode update -y to go ahead.[/]") + return False + return Confirm.ask(question, default=True, console=console) + + failed = False + for part, step in (("lcode", update_lcode), ("ollama", update_ollama)): + if what and what != part: + continue + try: + step(console, check, ask) + except UpdateError as e: + console.print(f"[red]✗ {escape(str(e))}[/]") + failed = True + console.print() + return 1 if failed else 0 + + +def escape(text: str) -> str: + from rich.markup import escape as rich_escape + + return rich_escape(text) + + +def update_lcode(console, check: bool, ask: Callable[[str], bool]) -> None: + install = lcode_install() + if install.kind == "checkout" and install.path is not None: + state = checkout_state(install.path) + where = f"lcode {__version__} · {install.describe()}, branch {state.branch}" + if not state.upstream: + console.print(f"{where}: no upstream branch to update from.") + elif not state.behind: + console.print(f"[green]✓[/] {where}: up to date with {state.upstream}.") + elif check: + console.print(f"{where}: {state.behind} new commit(s) on {state.upstream}.") + elif state.dirty or state.branch not in ("main", "master"): + why = "has uncommitted changes" if state.dirty else f"is on branch {state.branch}" + console.print( + f"{where}: {state.behind} new commit(s) on {state.upstream}, but the checkout {why}. Update it yourself with git pull." + ) + elif ask(f"Pull {state.behind} new commit(s) into {install.path}?"): + receipt = (Path(sys.prefix) / "uv-receipt.toml").exists() + console.print(f"[green]✓[/] lcode updated: {update_checkout(install.path, receipt)}.") + return + latest = latest_lcode() + if version_key(latest) <= version_key(__version__): + console.print(f"[green]✓[/] lcode {__version__} is the newest version ({install.describe()}).") + return + if install.command is None: + console.print( + f"lcode {__version__} → {latest} is out. You run lcode with uvx: use uvx --refresh --from lcode-cli lcode" + ) + return + shown = " ".join(install.command) + if check: + console.print(f"lcode {__version__} → {latest} is out ({install.describe()}): {shown}") + return + if not ask(f"Update lcode {__version__} → {latest} with `{shown}`?"): + return + if stream(install.command) != 0: + raise UpdateError(f"{shown} failed") + console.print("[green]✓[/] lcode updated; the new version runs from the next `lcode`.") + if install.kind == "brew": + console.print( + "[dim]Homebrew's formula follows a release by up to a day: if it's still the old version, try again later.[/]" + ) + + +def update_ollama(console, check: bool, ask: Callable[[str], bool]) -> None: + from lcode import config + from lcode.hardware import detect + from lcode.ollama import Ollama, OllamaError + + cfg = config.load() + if cfg["backend"] != "ollama": + console.print(f"lcode uses another model server (backend = {cfg['backend']}): update it there.") + return + host = cfg["ollama_host"] + try: + current = Ollama(host).version() + except OllamaError: + current = "" + container = find_container(host) if docker_available() else None + if container is None: + if not current: + raise UpdateError(f"Ollama doesn't answer at {host}: start it, then run lcode update again") + latest = latest_ollama() + local = urlsplit(host).hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None) + if version_key(latest) <= version_key(current): + console.print(f"[green]✓[/] Ollama {current} is the newest version.") + elif not local: + console.print(f"Ollama {current} → {latest} is out. It runs on {host}: update it on that machine.") + else: + hint = NATIVE.get(detect().os, NATIVE["linux"]) + console.print( + f"Ollama {current} → {latest} is out. Ollama isn't in a Docker container here, so update it with:\n {hint}" + ) + return + latest = latest_ollama(image_flavor(container.image)) + image = f"{IMAGE}:{latest}" + compose = container.compose + where = f"container {container.name}, image {container.image}" + ( + f", Docker Compose project {compose['project']}" if compose else "" + ) + console.print(f"Ollama {current or '?'} · {where}. Newest on Docker Hub: {latest}.") + if container.image == image and current == latest.removesuffix("-rocm"): + console.print(f"[green]✓[/] Ollama is up to date, on {image}.") + return + if current == latest.removesuffix("-rocm"): + console.print( + f"[dim]It already runs {current}, but as {container.image}: pinning it to {image} shows the version in docker ps.[/]" + ) + change = compose_change(container, image) if compose else None + plan = [f"docker pull {image}"] + if change: + path, _, old = change + plan.append(f"in {path}, service {compose['service']}: image {old} → {image}") # type: ignore[index] + plan.append(f"docker compose up -d --no-deps {compose['service']}") # type: ignore[index] + else: + plan.append( + f"recreate container {container.name} on {image}, with the same name, ports, volumes, environment, GPUs, network and restart policy (the old one is kept until the new one answers)" + ) + console.print("\n".join(f" {i}. {escape(step)}" for i, step in enumerate(plan, 1))) + if check: + return + console.print("[dim]Ollama restarts: requests running now stop, and models load again on their next request.[/]") + if not ask("Update Ollama?"): + return + if stream(["docker", "pull", image]) != 0: + raise UpdateError(f"docker pull {image} failed") + + def say(text: str) -> None: + console.print(f"[dim]{escape(text)}[/]") + + if change: + path, text, _ = change + original = path.read_text() + path.write_text(text) + try: + recreate_with_compose(container, image, host, say) + except UpdateError: + path.write_text(original) + say(f"put {path} back the way it was") + command, workdir = compose_up(compose) # type: ignore[arg-type] + stream(command, cwd=workdir) + raise + else: + recreate(container, image, host, say) + console.print(f"[green]✓[/] Ollama {current or '?'} → {latest.removesuffix('-rocm')}, running as {image}.") + old_image = container.info.get("Image", "") + if old_image and not run(["docker", "ps", "-aq", "--filter", f"ancestor={old_image}"]).stdout.strip(): + size = run(["docker", "image", "inspect", old_image, "--format", "{{.Size}}"]).stdout.strip() + gb = f" ({int(size) / 1e9:.1f} GB)" if size.isdigit() else "" + console.print( + f"[dim]The previous image is still on disk{gb}. To remove it: docker image rm {old_image[7:19]}[/]" + ) diff --git a/tests/test_update.py b/tests/test_update.py new file mode 100644 index 0000000..309b7ed --- /dev/null +++ b/tests/test_update.py @@ -0,0 +1,303 @@ +import io +import json +import subprocess + +import pytest +from rich.console import Console + +from lcode import __version__, update +from lcode.update import Container, Install, UpdateError + +COMPOSE = """\ +name: lab +services: + db: + image: postgres:16 + ollama: + # the model server + image: "ollama/ollama:latest" # keep in sync + container_name: lab-ollama + ports: + - "11434:11434" + web: + build: . +volumes: + models: {} +""" + + +def hub(*names, next_page=None): + return {"results": [{"name": n} for n in names], "next": next_page} + + +def inspect(name="lab-ollama", image="ollama/ollama:latest", labels=None, network="lab_default"): + return { + "Id": "ce403d03b71e" + "0" * 52, + "Name": f"/{name}", + "Image": "sha256:" + "3886" * 16, + "Config": { + "Image": image, + "Env": ["OLLAMA_CONTEXT_LENGTH=32768", "OLLAMA_HOST=0.0.0.0:11434", "PATH=/usr/bin"], + "Cmd": ["serve"], + "Entrypoint": ["/bin/ollama"], + "Labels": {"org.opencontainers.image.version": "24.04", "team": "ml", **(labels or {})}, + }, + "HostConfig": { + "RestartPolicy": {"Name": "unless-stopped", "MaximumRetryCount": 0}, + "PortBindings": {"11434/tcp": [{"HostIp": "", "HostPort": "11434"}]}, + "DeviceRequests": [{"Driver": "", "Count": -1, "DeviceIDs": None, "Capabilities": [["gpu"]]}], + "Runtime": "runc", + "NetworkMode": network, + }, + "NetworkSettings": {"Networks": {network: {"Aliases": [name, "ollama", "ce403d03b71e"]}}}, + "Mounts": [{"Type": "volume", "Name": "models", "Destination": "/root/.ollama", "RW": True}], + } + + +IMAGE_INFO = { + "Config": { + "Env": ["PATH=/usr/bin", "OLLAMA_HOST=0.0.0.0:11434"], + "Cmd": ["serve"], + "Entrypoint": ["/bin/ollama"], + "Labels": {"org.opencontainers.image.version": "24.04"}, + } +} + + +# ----------------------------------------------------------------------------- versions + + +def test_the_newest_release_comes_from_docker_hub(monkeypatch): + pages = { + update.HUB_TAGS: hub( + "0.40.0-rc5-rocm", "0.40.0-rc5", "rocm", "latest", "0.35.1-rocm", "0.35.1", next_page="p2" + ), + "p2": hub("0.35.1-rc2", "0.35.0", "0.34.4", "0.9.10"), + } + monkeypatch.setattr(update, "get_json", lambda url, **params: pages[url]) + assert update.latest_ollama() == "0.35.1" # not the release candidates, not "latest" + assert update.latest_ollama("rocm") == "0.35.1-rocm" + pages[update.HUB_TAGS] = hub("latest", "rocm") + pages["p2"] = hub() + with pytest.raises(UpdateError, match="lists no released ollama/ollama versions"): + update.latest_ollama() + assert update.version_key("0.35.1") > update.version_key("0.9.10") + assert ( + update.image_flavor("ollama/ollama:rocm") == "rocm" + and update.image_flavor("ollama/ollama:0.35.1-rocm") == "rocm" + ) + assert update.image_flavor("ollama/ollama") == "" and update.image_flavor("ollama/ollama:latest") == "" + + +def test_how_lcode_was_installed(tmp_path): + editable = json.dumps({"url": "file:///home/me/lcode", "dir_info": {"editable": True}}) + assert update.lcode_install(tmp_path, editable).path.as_posix() == "/home/me/lcode" + (tmp_path / "uv-receipt.toml").write_text("[tool]\n") + assert update.lcode_install(tmp_path, "").command == ["uv", "tool", "upgrade", "lcode-cli"] + brew = tmp_path / "Cellar" / "lcode" / "0.20.0" / "libexec" + brew.mkdir(parents=True) + assert update.lcode_install(brew, "").kind == "brew" + pipx = tmp_path / "pipx" / "venvs" / "lcode-cli" + pipx.mkdir(parents=True) + assert update.lcode_install(pipx, "").command == ["pipx", "upgrade", "lcode-cli"] + uvx = tmp_path / ".cache" / "uv" / "archive-v0" / "abc" + uvx.mkdir(parents=True) + assert update.lcode_install(uvx, "").kind == "uvx" and update.lcode_install(uvx, "").command is None + plain = tmp_path / "venv" + plain.mkdir() + assert update.lcode_install(plain, "").command[-3:] == ["install", "--upgrade", "lcode-cli"] + + +# ----------------------------------------------------------------------------- Docker Compose + + +def test_the_compose_file_gets_the_version_tag(): + text, old = update.set_compose_image(COMPOSE, "ollama", "ollama/ollama:0.35.1") + assert old == "ollama/ollama:latest" + assert ' image: "ollama/ollama:0.35.1" # keep in sync\n' in text + assert text.replace('"ollama/ollama:0.35.1"', '"ollama/ollama:latest"') == COMPOSE # nothing else changed + with pytest.raises(UpdateError, match="no service llama in it"): + update.set_compose_image(COMPOSE, "llama", "x") + with pytest.raises(UpdateError, match="service web has no image: line"): + update.set_compose_image(COMPOSE, "web", "x") + variable = COMPOSE.replace('"ollama/ollama:latest"', "ollama/ollama:${OLLAMA_VERSION:-latest}") + with pytest.raises(UpdateError, match="the image comes from a variable"): + update.set_compose_image(variable, "ollama", "ollama/ollama:0.35.1") + + +def compose_container(tmp_path): + path = tmp_path / "compose.yaml" + path.write_text(COMPOSE) + labels = { + "com.docker.compose.project": "lab", + "com.docker.compose.service": "ollama", + "com.docker.compose.project.config_files": str(path), + "com.docker.compose.project.working_dir": str(tmp_path), + } + return Container("ce403d03b71e", "lab-ollama", "ollama/ollama:latest", inspect(labels=labels)), path + + +@pytest.fixture +def docker(monkeypatch): + """Ollama 0.32.15 in Docker, 0.35.1 on Docker Hub, and a record of the commands lcode runs.""" + calls = {"run": [], "stream": [], "stream_codes": {}} + + def fake_run(argv, cwd=None, timeout=120): + calls["run"].append(argv) + out = "" + if argv[:3] == ["docker", "image", "inspect"]: + out = json.dumps([IMAGE_INFO]) if "--format" not in argv else "3500000000" + return subprocess.CompletedProcess(argv, 0, out, "") + + def fake_stream(argv, cwd=None): + calls["stream"].append(argv) + return calls["stream_codes"].get(argv[1], 0) + + monkeypatch.setattr(update, "run", fake_run) + monkeypatch.setattr(update, "stream", fake_stream) + monkeypatch.setattr(update, "docker_available", lambda: True) + monkeypatch.setattr(update, "latest_ollama", lambda flavor="": "0.35.1") + monkeypatch.setattr(update, "wait_for_version", lambda host, wanted, timeout=0: True) + monkeypatch.setattr("lcode.ollama.Ollama.version", lambda self: "0.32.15") + monkeypatch.setattr("sys.stdin.isatty", lambda: True, raising=False) + return calls + + +def output_of(console): + return console.file.getvalue() + + +def test_a_compose_container_is_updated_through_its_compose_file(docker, tmp_path, monkeypatch): + container, path = compose_container(tmp_path) + monkeypatch.setattr(update, "find_container", lambda host: container) + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "ollama", yes=True) == 0 + assert docker["stream"][0] == ["docker", "pull", "ollama/ollama:0.35.1"] + assert docker["stream"][1][:4] == ["docker", "compose", "-p", "lab"] + assert docker["stream"][1][-4:] == ["up", "-d", "--no-deps", "ollama"] + assert 'image: "ollama/ollama:0.35.1"' in path.read_text() + text = output_of(console) + assert f"in {path}, service ollama: image ollama/ollama:latest → ollama/ollama:0.35.1" in text + assert "✓ Ollama 0.32.15 → 0.35.1, running as ollama/ollama:0.35.1." in text + assert "The previous image is still on disk (3.5 GB). To remove it: docker image rm 388638863886" in text + + +def test_a_failed_compose_update_puts_the_file_back(docker, tmp_path, monkeypatch): + container, path = compose_container(tmp_path) + monkeypatch.setattr(update, "find_container", lambda host: container) + docker["stream_codes"]["compose"] = 1 + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "ollama", yes=True) == 1 + assert path.read_text() == COMPOSE and "✗ docker compose up failed" in output_of(console) + + +def test_check_and_no_terminal_change_nothing(docker, tmp_path, monkeypatch): + container, path = compose_container(tmp_path) + monkeypatch.setattr(update, "find_container", lambda host: container) + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "ollama", check=True) == 0 + monkeypatch.setattr("sys.stdin.isatty", lambda: False, raising=False) + assert update.command(console, "ollama") == 0 + assert docker["stream"] == [] and path.read_text() == COMPOSE + assert "Not asking without a terminal: run lcode update -y" in output_of(console) + + +# ----------------------------------------------------------------------------- a plain container + + +def test_the_same_settings_for_the_new_container(): + args = update.run_args(inspect(), IMAGE_INFO, "ollama/ollama:0.35.1") + assert args == [ + "docker", "run", "-d", "--name", "lab-ollama", "--restart", "unless-stopped", + "-p", "11434:11434", "-v", "models:/root/.ollama", "-e", "OLLAMA_CONTEXT_LENGTH=32768", + "--gpus", "all", "--network", "lab_default", "--network-alias", "ollama", + "--label", "team=ml", "ollama/ollama:0.35.1", + ] # fmt: skip + info = inspect(network="default") + info["HostConfig"]["DeviceRequests"][0].update(Count=0, DeviceIDs=["1"]) + info["HostConfig"]["PortBindings"]["11434/tcp"][0]["HostIp"] = "127.0.0.1" + info["Config"]["Cmd"] = ["serve", "--verbose"] + args = update.run_args(info, IMAGE_INFO, "ollama/ollama:0.35.1") + assert "--network" not in args and args[args.index("--gpus") + 1] == "device=1" + assert "127.0.0.1:11434:11434" in args and args[-3:] == ["ollama/ollama:0.35.1", "serve", "--verbose"] + + +def test_a_plain_container_is_recreated_and_put_back_if_needed(docker, monkeypatch): + container = Container("ce403d03b71e", "my-ollama", "ollama/ollama:latest", inspect(name="my-ollama")) + monkeypatch.setattr(update, "find_container", lambda host: container) + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "ollama", yes=True) == 0 + commands = [c[:3] for c in docker["run"] if c[0] == "docker"] + assert ["docker", "stop", "my-ollama"] in commands + assert ["docker", "rename", "my-ollama"] in commands and [ + "docker", + "rm", + "my-ollama-before-lcode-update", + ] in commands + created = next(c for c in docker["run"] if c[:2] == ["docker", "run"]) + assert created[created.index("--name") + 1] == "my-ollama" and "ollama/ollama:0.35.1" in created + docker["run"].clear() + monkeypatch.setattr(update, "wait_for_version", lambda host, wanted, timeout=0: False) + assert update.command(console, "ollama", yes=True) == 1 + assert docker["run"][-3:] == [ + ["docker", "rm", "-f", "my-ollama"], + ["docker", "rename", "my-ollama-before-lcode-update", "my-ollama"], + ["docker", "start", "my-ollama"], + ] + assert "the old one is running again" in output_of(console) + + +def test_up_to_date_and_pinned(docker, monkeypatch): + container = Container("ce403d03b71e", "my-ollama", "ollama/ollama:0.35.1", inspect(image="ollama/ollama:0.35.1")) + monkeypatch.setattr(update, "find_container", lambda host: container) + monkeypatch.setattr("lcode.ollama.Ollama.version", lambda self: "0.35.1") + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "ollama", yes=True) == 0 + assert docker["stream"] == [] and "✓ Ollama is up to date, on ollama/ollama:0.35.1." in output_of(console) + + +def test_ollama_outside_docker_gets_instructions(docker, monkeypatch): + monkeypatch.setattr(update, "find_container", lambda host: None) + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "ollama", yes=True) == 0 + assert "Ollama 0.32.15 → 0.35.1 is out" in output_of(console) and docker["stream"] == [] + + +# ----------------------------------------------------------------------------- lcode itself + + +def test_lcode_updates_the_way_it_was_installed(docker, monkeypatch): + monkeypatch.setattr(update, "lcode_install", lambda: Install("uv", ["uv", "tool", "upgrade", "lcode-cli"])) + monkeypatch.setattr(update, "latest_lcode", lambda: "99.0.0") + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "lcode", yes=True) == 0 + assert docker["stream"] == [["uv", "tool", "upgrade", "lcode-cli"]] + monkeypatch.setattr(update, "latest_lcode", lambda: __version__) + update.command(console, "lcode", yes=True) + assert f"lcode {__version__} is the newest version (uv tool)" in output_of(console) + + +def test_a_checkout_is_pulled(tmp_path, monkeypatch): + def git(cwd, *args): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(tmp_path / "gitconfig")) + (tmp_path / "gitconfig").write_text("[user]\n\tname = T\n\temail = t@example.com\n") + origin, clone = tmp_path / "origin.git", tmp_path / "lcode" + git(tmp_path, "init", "-q", "--bare", "-b", "main", str(origin)) + git(tmp_path, "clone", "-q", str(origin), str(clone)) + (clone / "a.txt").write_text("1\n") + git(clone, "add", "-A") + git(clone, "commit", "-qm", "one") + git(clone, "push", "-q", "origin", "main") + other = tmp_path / "other" + git(tmp_path, "clone", "-q", str(origin), str(other)) + (other / "a.txt").write_text("2\n") + git(other, "commit", "-qam", "two") + git(other, "push", "-q") + monkeypatch.setattr(update, "lcode_install", lambda: Install("checkout", path=clone)) + console = Console(file=io.StringIO(), width=2000) + assert update.command(console, "lcode", check=True) == 0 + assert "1 new commit(s) on origin/main" in output_of(console) and (clone / "a.txt").read_text() == "1\n" + assert update.command(console, "lcode", yes=True) == 0 + assert (clone / "a.txt").read_text() == "2\n" and "lcode updated: 1 new commit(s)." in output_of(console)