mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 18:15:33 +00:00
fix: give each native install its own services, and guard the port
From the outside-diff findings on #2800: - Service names are derived from the install directory. A service manager has one namespace per user, so two installs in different --dir directories wrote over each other's units and down, status and uninstall acted on whichever was written last. The default install keeps the readable names; another directory gets a digest suffix. - `up --native` refuses when a Docker stack in another directory publishes the same port: its API would answer the health check while these services failed to bind. The check degrades quietly when Docker is absent, which is exactly the machine a native install targets. - A Redis database path is required to be ASCII digits: str.isdigit() is true for characters int() then refuses. - DOCSGPT_PORT from a hand-edited .env is validated before conversion, and the error names where the bad value came from. - Percent signs are doubled in systemd values, arguments and log paths, since systemd expands specifiers in all of them.
This commit is contained in:
1 parent
c8ab1cbab2
commit
4976b3104c
3 files changed
+159
-33
No files matched your search
+48
-10
@@ -221,7 +221,7 @@ def _redis_urls(base: str) -> dict[str, str]:
|
||||
"database each."
|
||||
)
|
||||
path = parts.path.rstrip("/").lstrip("/")
|
||||
if path and not path.isdigit():
|
||||
if path and not (path.isascii() and path.isdigit()):
|
||||
raise DeployError(
|
||||
f"the Redis URL {base!r} has {path!r} where a database number would go. Pass a URL like "
|
||||
"redis://host:6379 or redis://host:6379/5."
|
||||
@@ -271,6 +271,36 @@ def _refuse_docker_only_options(args) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _port_number(value: object, source: str) -> int:
|
||||
"""A port from the command line or a hand-edited .env, or a DeployError naming where it came from."""
|
||||
text = str(value)
|
||||
if not (text.isascii() and text.isdigit() and 0 < int(text) < 65536):
|
||||
raise DeployError(f"{source} is {value!r}, which is not a port number between 1 and 65535.")
|
||||
return int(text)
|
||||
|
||||
|
||||
def _service_names(directory: Path) -> tuple[str, str]:
|
||||
"""This install's service names; an install elsewhere gets its own, so the two cannot collide."""
|
||||
return native.service_names(directory, stack.stack_dir(None))
|
||||
|
||||
|
||||
def _refuse_other_docker_stack(context: Context, directory: Path, port: int) -> None:
|
||||
"""A Docker stack elsewhere on this port would answer the health check the native API failed."""
|
||||
try:
|
||||
others = {path for path in context.docker.project_dirs(PROJECT) if path.resolve() != directory.resolve()}
|
||||
except DeployError:
|
||||
return # No Docker on this machine to ask, which is a fair reason to run natively.
|
||||
for other in sorted(others):
|
||||
other_port = envfile.read(other / ".env").get("DOCSGPT_PORT") or str(stack.DEFAULT_PORT)
|
||||
if str(other_port) == str(port):
|
||||
raise DeployError(
|
||||
f"Docker already runs a DocsGPT stack from {other} on port {port}, which a native "
|
||||
f"install would try to bind as well: the health check here could answer from that "
|
||||
f"stack while these services failed to start. Stop it with `docsgpt down --dir "
|
||||
f"{other}`, or give this install another port with --port."
|
||||
)
|
||||
|
||||
|
||||
def _native_port(env: Mapping[str, str]) -> str:
|
||||
"""The port a native install listens on."""
|
||||
return str(env.get("DOCSGPT_PORT") or stack.DEFAULT_PORT)
|
||||
@@ -304,7 +334,12 @@ def _native_up(args, context: Context, directory: Path) -> int:
|
||||
)
|
||||
|
||||
# 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)
|
||||
if args.port:
|
||||
port = _port_number(args.port, "--port")
|
||||
elif existing.get("DOCSGPT_PORT"):
|
||||
port = _port_number(existing["DOCSGPT_PORT"], f"DOCSGPT_PORT in {env_path}")
|
||||
else:
|
||||
port = stack.DEFAULT_PORT
|
||||
updates: dict[str, Optional[str]] = {
|
||||
"POSTGRES_URI": postgres,
|
||||
"API_URL": f"http://127.0.0.1:{port}",
|
||||
@@ -334,6 +369,8 @@ def _native_up(args, context: Context, directory: Path) -> int:
|
||||
if context.run([*launcher, "migrate"], stack_env) != 0:
|
||||
raise DeployError("`docsgpt migrate` failed; check the database URL and that the server is reachable.")
|
||||
|
||||
_refuse_other_docker_stack(context, directory, port)
|
||||
|
||||
# The record goes in before the services: if one fails to start, this is still a native install
|
||||
# that status, down and uninstall can see and clean up, rather than orphaned units.
|
||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
@@ -341,9 +378,10 @@ def _native_up(args, context: Context, directory: Path) -> int:
|
||||
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")
|
||||
|
||||
for unit in native.units_for(directory, launcher, port, directory):
|
||||
names = _service_names(directory)
|
||||
for unit in native.units_for(directory, launcher, port, directory, names):
|
||||
services.install(unit)
|
||||
for name in native.SERVICES:
|
||||
for name in names:
|
||||
print(f"Starting {name} ...")
|
||||
services.start(name)
|
||||
|
||||
@@ -358,7 +396,7 @@ def _native_up(args, context: Context, directory: Path) -> int:
|
||||
return 1
|
||||
|
||||
print(f"\nDocsGPT is running at http://localhost:{port}")
|
||||
print(f"Services: {', '.join(native.SERVICES)} under {services.name}")
|
||||
print(f"Services: {', '.join(names)} under {services.name}")
|
||||
print(f"Settings: {env_path}")
|
||||
print("Manage it with: docsgpt status | logs | down | uninstall")
|
||||
return 0
|
||||
@@ -471,7 +509,7 @@ def down(args, context: Optional[Context] = None) -> int:
|
||||
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):
|
||||
for name in reversed(_service_names(directory)):
|
||||
services.stop(name)
|
||||
return 0
|
||||
context.docker.compose(directory, *_EVERY_PROFILE, "down")
|
||||
@@ -492,7 +530,7 @@ def status(args, context: Optional[Context] = None) -> int:
|
||||
port = _native_port(env)
|
||||
print(f"DocsGPT {_record(directory).get('version', 'unknown')} in {directory} (native, {services.name})")
|
||||
print(f"Address: {_native_address(env)}")
|
||||
for name in native.SERVICES:
|
||||
for name in _service_names(directory):
|
||||
print(f" {name}: {'running' if services.is_running(name) else 'stopped'}")
|
||||
healthy = context.wait(f"http://127.0.0.1:{port}/api/health", 0)
|
||||
print("API: answering" if healthy else "API: not answering (see `docsgpt logs`)")
|
||||
@@ -515,7 +553,7 @@ def logs(args, context: Optional[Context] = None) -> int:
|
||||
return 1
|
||||
if _mode(directory) == "native":
|
||||
logs_dir = directory / "logs"
|
||||
wanted = args.services or [name.removeprefix("docsgpt-") for name in native.SERVICES]
|
||||
wanted = args.services or ["api", "worker"]
|
||||
for service in wanted:
|
||||
path = logs_dir / f"{service}.log"
|
||||
print(f"=== {path}")
|
||||
@@ -775,9 +813,9 @@ def uninstall(args, context: Optional[Context] = None) -> int:
|
||||
return 1
|
||||
if _mode(directory) == "native":
|
||||
services = context.service_manager()
|
||||
for name in reversed(native.SERVICES):
|
||||
for name in reversed(_service_names(directory)):
|
||||
services.remove(name)
|
||||
print(f"Removed the {', '.join(native.SERVICES)} services.")
|
||||
print(f"Removed the {', '.join(_service_names(directory))} services.")
|
||||
if args.purge:
|
||||
shutil.rmtree(directory)
|
||||
print(f"Removed {directory}. The database and Redis it used are untouched.")
|
||||
|
||||
Reference in new issue
Block a user