feat: docsgpt up --native

Run DocsGPT without Docker: the API and the worker each become a service
on the machine itself, a launchd agent on macOS and a systemd user unit
on Linux, pointed at a PostgreSQL and a Redis that already run.

`docsgpt up --native --postgres-uri ... --redis-url ...` writes the same
.env a Docker install uses, applies the migrations and starts both
services. status, logs, down and uninstall work on a native install the
same way they do on a Docker one, and never touch the database or Redis:
they were the user's to begin with.

One Redis URL covers the broker, the result backend and the cache on
three consecutive databases, starting at the one the URL names, so a
Redis that already holds something else can be shared.

Windows has neither service manager, so native mode refuses it and says
what to do instead.
This commit is contained in:
Alex committed 2026-09-16 21:51:43 +01:00
1 parent fe68fec69e
commit f23a32d9c5
7 files changed
+722 -7

No files matched your search

+175 -5
View File
@@ -6,6 +6,7 @@ import getpass
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
@@ -18,7 +19,7 @@ from pathlib import Path
from typing import Any, Optional
from docsgpt.deploy import backup as backup_format
from docsgpt.deploy import envfile, stack
from docsgpt.deploy import envfile, native, stack
from docsgpt.deploy.docker import DeployError, Docker, lan_ip, wait_healthy
PROJECT = "docsgpt"
@@ -78,9 +79,10 @@ def detect_installer() -> str:
return "pip"
def _run_command(args: list[str]) -> int:
def _run_command(args: list[str], env: Optional[Mapping[str, str]] = None) -> int:
"""Run ``args``; ``env`` adds to this process's environment rather than replacing it."""
try:
return subprocess.call(args)
return subprocess.call(args, env={**os.environ, **env} if env else None)
except FileNotFoundError as exc:
raise DeployError(f"{args[0]} is not on PATH") from exc
@@ -106,8 +108,9 @@ class Context:
wait: Callable[[str, float], bool] = wait_healthy
open_browser: Callable[[str], Any] = webbrowser.open
installer: Callable[[], str] = detect_installer
run: Callable[[list[str]], int] = _run_command
run: Callable[..., int] = _run_command
exec_up: Callable[[list[str]], int] = _exec_command
services: Any = None
@classmethod
def default(cls, args) -> "Context":
@@ -116,10 +119,33 @@ class Context:
interactive = sys.stdin.isatty() and not getattr(args, "yes", False)
return cls(docker=Docker(), prompter=Prompter(), interactive=interactive, version=__version__)
def service_manager(self):
"""The launchd or systemd wrapper, made on first use so Docker installs never touch it."""
if self.services is None:
self.services = native.services_for_platform()
return self.services
def _record(directory: Path) -> dict:
"""What ``install.json`` says about this install (empty when there is none)."""
path = directory / stack.RECORD_FILE
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except ValueError:
return {}
def _mode(directory: Path) -> str:
"""``native`` or ``docker``, from the install record."""
return "native" if _record(directory).get("mode") == "native" else "docker"
def _installed(directory: Path) -> Optional[dict[str, str]]:
"""The stack's settings, or None (with a message) when there is no install in ``directory``."""
if not (directory / stack.COMPOSE_FILE).is_file():
installed = (directory / stack.COMPOSE_FILE).is_file() or _mode(directory) == "native"
if not installed:
print(f"No DocsGPT install in {directory}. Run `docsgpt up` first, or pass --dir.", file=sys.stderr)
return None
return envfile.read(directory / ".env")
@@ -174,10 +200,110 @@ def _choose_provider(args, context: Context, existing: Mapping[str, str], ask: b
raise DeployError(str(exc)) from exc
def _redis_urls(base: str) -> dict[str, str]:
"""Celery's broker and result backend, and the cache, on three consecutive databases of one Redis.
They start at database 0, or at the one the URL names: ``redis://host:6379/5`` puts them on 5, 6
and 7, which is how one Redis is shared with something that already uses the first databases.
"""
trimmed = base.rstrip("/")
first = 0
head, _, tail = trimmed.rpartition("/")
if tail.isdigit():
trimmed, first = head, int(tail)
return {
"CELERY_BROKER_URL": f"{trimmed}/{first}",
"CELERY_RESULT_BACKEND": f"{trimmed}/{first + 1}",
"CACHE_REDIS_URL": f"{trimmed}/{first + 2}",
}
def _native_up(args, context: Context, directory: Path) -> int:
"""Run the API and the worker as services on this machine, against an existing Postgres and Redis."""
services = context.service_manager()
env_path = directory / ".env"
existing = envfile.read(env_path)
record = _record(directory)
configured = bool(record)
postgres = args.postgres_uri or existing.get("POSTGRES_URI")
redis = args.redis_url or ""
ask = context.interactive and (not configured or args.reconfigure)
if not postgres and ask:
postgres = context.prompter.text("PostgreSQL URL (postgresql://user:password@host:5432/docsgpt)")
if not redis and ask:
redis = context.prompter.text("Redis URL", default="redis://localhost:6379")
if not postgres:
raise DeployError(
"native mode needs a database: pass --postgres-uri postgresql://user:password@host:5432/docsgpt "
"(Redis defaults to redis://localhost:6379)."
)
# An install keeps the port it was given: a later `up` with no --port must not move it back to the default.
port = int(args.port or existing.get("DOCSGPT_PORT") or stack.DEFAULT_PORT)
updates: dict[str, Optional[str]] = {
"POSTGRES_URI": postgres,
"API_URL": f"http://127.0.0.1:{port}",
"DOCSGPT_PORT": str(port),
}
if redis or "CELERY_BROKER_URL" not in existing:
updates.update(_redis_urls(redis or "redis://localhost:6379"))
for key in ("INTERNAL_KEY", "JWT_SECRET_KEY"):
if not existing.get(key):
updates[key] = secrets.token_hex(32)
if "VITE_API_STREAMING" not in existing:
updates["VITE_API_STREAMING"] = "true"
provider = _choose_provider(args, context, existing, ask)
if provider:
updates.update(provider)
elif "LLM_PROVIDER" not in existing:
updates.update(stack.provider_settings("docsgpt"))
directory.mkdir(parents=True, exist_ok=True)
(directory / "logs").mkdir(exist_ok=True)
envfile.update(env_path, updates)
executable = shutil.which("docsgpt") or sys.argv[0]
print("Applying database migrations ...")
# The child reads the stack's settings, not a .env in whatever directory this was run from.
stack_env = {"DOCSGPT_HOME": str(directory), "DOCSGPT_ENV_FILE": str(env_path)}
if context.run([executable, "migrate"], stack_env) != 0:
raise DeployError("`docsgpt migrate` failed; check the database URL and that the server is reachable.")
for unit in native.units_for(directory, executable, port, directory):
services.install(unit)
for name in native.SERVICES:
print(f"Starting {name} ...")
services.start(name)
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
record = record or {"installed_at": now}
record.update(version=context.version, mode="native", updated_at=now)
(directory / stack.RECORD_FILE).write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
health = f"http://127.0.0.1:{port}/api/health"
print("Waiting for DocsGPT to answer ...")
if not context.wait(health, args.timeout):
print(
f"DocsGPT did not answer at {health} within {args.timeout} seconds. "
"See what happened with `docsgpt logs`.",
file=sys.stderr,
)
return 1
print(f"\nDocsGPT is running at http://localhost:{port}")
print(f"Services: {', '.join(native.SERVICES)} under {services.name}")
print(f"Settings: {env_path}")
print("Manage it with: docsgpt status | logs | down | uninstall")
return 0
def up(args, context: Optional[Context] = None) -> int:
"""Install or update the stack in its directory and start it."""
context = context or Context.default(args)
directory = stack.stack_dir(args.dir)
if getattr(args, "native", False) or _mode(directory) == "native":
return _native_up(args, context, directory)
env_path = directory / ".env"
record_path = directory / stack.RECORD_FILE
existing = envfile.read(env_path)
@@ -269,6 +395,12 @@ def down(args, context: Optional[Context] = None) -> int:
directory = stack.stack_dir(args.dir)
if _installed(directory) is None:
return 1
if _mode(directory) == "native":
services = context.service_manager()
# The worker goes first: it talks to the API, not the other way round.
for name in reversed(native.SERVICES):
services.stop(name)
return 0
context.docker.compose(directory, *_EVERY_PROFILE, "down")
return 0
@@ -280,6 +412,16 @@ def status(args, context: Optional[Context] = None) -> int:
env = _installed(directory)
if env is None:
return 1
if _mode(directory) == "native":
services = context.service_manager()
print(f"DocsGPT {_record(directory).get('version', 'unknown')} in {directory} (native, {services.name})")
print(f"Address: {stack.url(env, context.lan_ip())}")
for name in native.SERVICES:
print(f" {name}: {'running' if services.is_running(name) else 'stopped'}")
healthy = context.wait(stack.health_url(env), 0)
print("API: answering" if healthy else "API: not answering (see `docsgpt logs`)")
return 0 if healthy else 1
tag = env.get("DOCSGPT_IMAGE_TAG", "unknown") + env.get("DOCSGPT_IMAGE_VARIANT", "")
print(f"DocsGPT {tag} in {directory}")
print(f"Address: {stack.url(env, context.lan_ip())} ({stack.exposure(env)})")
@@ -295,6 +437,20 @@ def logs(args, context: Optional[Context] = None) -> int:
directory = stack.stack_dir(args.dir)
if _installed(directory) is None:
return 1
if _mode(directory) == "native":
logs_dir = directory / "logs"
wanted = args.services or [name.removeprefix("docsgpt-") for name in native.SERVICES]
for service in wanted:
path = logs_dir / f"{service}.log"
print(f"=== {path}")
if path.is_file():
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
print("\n".join(lines[-args.tail:] if args.tail else lines))
else:
print("(nothing logged yet)")
if args.follow:
print("Following is not supported in native mode; use `tail -f` on the files above.", file=sys.stderr)
return 0
options = (["--follow"] if args.follow else []) + (["--tail", str(args.tail)] if args.tail else [])
return context.docker.compose(directory, "logs", *options, *args.services, check=False).returncode
@@ -499,6 +655,20 @@ def uninstall(args, context: Optional[Context] = None) -> int:
if not context.prompter.confirm(f"Remove the DocsGPT {what} in {directory}?", default=False):
print("Nothing removed.")
return 1
if _mode(directory) == "native":
services = context.service_manager()
for name in reversed(native.SERVICES):
services.remove(name)
print(f"Removed the {', '.join(native.SERVICES)} services.")
if args.purge:
shutil.rmtree(directory)
print(f"Removed {directory}. The database and Redis it used are untouched.")
else:
(directory / stack.RECORD_FILE).unlink(missing_ok=True)
print(f"Settings stay in {directory / '.env'}; the database and Redis are untouched.")
print(f"To remove the docsgpt command too: {_uninstall_hint(context.installer())}")
return 0
context.docker.compose(directory, *_EVERY_PROFILE, "down", "--remove-orphans", *(["-v"] if args.purge else []))
if args.purge:
shutil.rmtree(directory)
+218
View File
@@ -0,0 +1,218 @@
"""Running DocsGPT without Docker, supervised by the system's own service manager.
The API and the worker each become one service: a launchd agent on macOS, a
systemd user unit on Linux. Both run the ``docsgpt`` command of this
installation with the stack directory as their data home, so a native install
keeps its settings in the same ``.env`` a Docker install would.
Postgres and Redis are not started here; a native install points at ones that
already run (``--postgres-uri``, ``--redis-url``).
"""
from __future__ import annotations
import os
import plistlib
import shlex
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from docsgpt.deploy.docker import DeployError
API_SERVICE = "docsgpt-api"
WORKER_SERVICE = "docsgpt-worker"
SERVICES = (API_SERVICE, WORKER_SERVICE)
LABEL_PREFIX = "cloud.docsgpt"
@dataclass
class Unit:
"""One supervised process, in the terms every service manager needs."""
name: str
arguments: list[str]
environment: dict[str, str]
working_directory: str
log_file: str
def label_for(name: str) -> str:
"""The launchd label for a service name (``docsgpt-api`` -> ``cloud.docsgpt.api``)."""
return f"{LABEL_PREFIX}.{name.removeprefix('docsgpt-')}"
def launchd_plist(unit: Unit, label: Optional[str] = None) -> str:
"""The launchd agent for ``unit``: kept alive, with its output in the stack's log file."""
body = {
"Label": label or label_for(unit.name),
"ProgramArguments": list(unit.arguments),
"EnvironmentVariables": dict(unit.environment),
"WorkingDirectory": unit.working_directory,
"StandardOutPath": unit.log_file,
"StandardErrorPath": unit.log_file,
"KeepAlive": True,
"RunAtLoad": True,
"ProcessType": "Background",
}
return plistlib.dumps(body).decode("utf-8")
def systemd_unit(unit: Unit) -> str:
"""The systemd user unit for ``unit``; arguments are quoted, so a path with spaces survives."""
environment = "\n".join(f'Environment="{key}={value}"' for key, value in sorted(unit.environment.items()))
command = " ".join(shlex.quote(argument) for argument in unit.arguments)
return f"""[Unit]
Description=DocsGPT ({unit.name})
After=network-online.target
[Service]
Type=simple
ExecStart={command}
WorkingDirectory={unit.working_directory}
{environment}
Restart=always
RestartSec=5
StandardOutput=append:{unit.log_file}
StandardError=append:{unit.log_file}
[Install]
WantedBy=default.target
"""
class LaunchdServices:
"""launchd user agents in ~/Library/LaunchAgents (macOS)."""
name = "launchd"
poll_interval = 0.2
unload_timeout = 15.0
def __init__(self, runner=subprocess.run, home: Optional[Path] = None) -> None:
self._run = runner
self.directory = (home or Path.home()) / "Library" / "LaunchAgents"
def _plist(self, name: str) -> Path:
return self.directory / f"{label_for(name)}.plist"
def _target(self, name: str) -> str:
return f"gui/{os.getuid()}/{label_for(name)}"
def _print(self, name: str):
return self._run(["launchctl", "print", self._target(name)], capture_output=True, text=True, check=False)
def _bootout(self, name: str) -> None:
"""Unload the job and wait for it to go.
``bootout`` returns before launchd has finished unloading, and bootstrapping a label that is
still on its way out fails with "Bootstrap failed: 5: Input/output error" — which is what a
restart used to hit. Waiting for the label to stop resolving makes stop and start ordered.
"""
self._run(["launchctl", "bootout", self._target(name)], capture_output=True, text=True, check=False)
deadline = time.monotonic() + self.unload_timeout
while self._print(name).returncode == 0:
if time.monotonic() >= deadline:
raise DeployError(f"{label_for(name)} is still loaded after {self.unload_timeout:.0f}s")
time.sleep(self.poll_interval)
def install(self, unit: Unit) -> None:
self.directory.mkdir(parents=True, exist_ok=True)
self._plist(unit.name).write_text(launchd_plist(unit), encoding="utf-8")
def start(self, name: str) -> None:
# bootout first: a reinstall must pick up the new plist rather than the loaded one.
self._bootout(name)
result = self._run(
["launchctl", "bootstrap", f"gui/{os.getuid()}", str(self._plist(name))],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
raise DeployError(f"launchctl could not start {name}: {(result.stderr or '').strip()}")
def stop(self, name: str) -> None:
self._bootout(name)
def remove(self, name: str) -> None:
self.stop(name)
self._plist(name).unlink(missing_ok=True)
def is_running(self, name: str) -> bool:
"""A loaded job is not a running one: a crashed service still answers ``launchctl print``."""
result = self._print(name)
return result.returncode == 0 and "state = running" in (result.stdout or "")
class SystemdServices:
"""systemd user units in ~/.config/systemd/user (Linux)."""
name = "systemd"
def __init__(self, runner=subprocess.run, home: Optional[Path] = None) -> None:
self._run = runner
base = os.environ.get("XDG_CONFIG_HOME")
root = Path(base) if base else (home or Path.home()) / ".config"
self.directory = root / "systemd" / "user"
def _unit_file(self, name: str) -> Path:
return self.directory / f"{name}.service"
def _systemctl(self, *args: str, check: bool = False):
result = self._run(["systemctl", "--user", *args], capture_output=True, text=True, check=False)
if check and result.returncode != 0:
raise DeployError(f"systemctl --user {' '.join(args)} failed: {(result.stderr or '').strip()}")
return result
def install(self, unit: Unit) -> None:
self.directory.mkdir(parents=True, exist_ok=True)
self._unit_file(unit.name).write_text(systemd_unit(unit), encoding="utf-8")
self._systemctl("daemon-reload")
def start(self, name: str) -> None:
self._systemctl("enable", "--now", f"{name}.service", check=True)
def stop(self, name: str) -> None:
self._systemctl("stop", f"{name}.service")
def remove(self, name: str) -> None:
self._systemctl("disable", "--now", f"{name}.service")
self._unit_file(name).unlink(missing_ok=True)
self._systemctl("daemon-reload")
def is_running(self, name: str) -> bool:
return self._systemctl("is-active", "--quiet", f"{name}.service").returncode == 0
def services_for_platform(platform: str = sys.platform):
"""The service manager for this machine, or a DeployError saying what to do instead."""
if platform == "darwin":
return LaunchdServices()
if platform.startswith("linux"):
return SystemdServices()
raise DeployError(
"native mode supervises services with launchd or systemd, which Windows does not have. "
"Run DocsGPT on Docker with `docsgpt up`, or start `docsgpt api` and `docsgpt worker` yourself."
)
def units_for(stack_directory: Path, executable: str, port: int, home: Path) -> list[Unit]:
"""The API and worker services for a native install in ``stack_directory``."""
environment = {"DOCSGPT_HOME": str(home)}
return [
Unit(
name=API_SERVICE,
arguments=[executable, "api", "--host", "127.0.0.1", "--port", str(port)],
environment=dict(environment),
working_directory=str(stack_directory),
log_file=str(stack_directory / "logs" / "api.log"),
),
Unit(
name=WORKER_SERVICE,
arguments=[executable, "worker"],
environment=dict(environment),
working_directory=str(stack_directory),
log_file=str(stack_directory / "logs" / "worker.log"),
),
]