forked from Shifty/pyserveX
pyservectl init
This commit is contained in:
parent
b4f63c6804
commit
80544d5b95
96
poetry.lock
generated
96
poetry.lock
generated
@ -147,14 +147,14 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.2.1"
|
version = "8.3.1"
|
||||||
description = "Composable command line interface toolkit"
|
description = "Composable command line interface toolkit"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
groups = ["main", "dev"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"},
|
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
|
||||||
{file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"},
|
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
@ -602,6 +602,30 @@ MarkupSafe = ">=2.0"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
i18n = ["Babel (>=2.7)"]
|
i18n = ["Babel (>=2.7)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.0.0"
|
||||||
|
description = "Python port of markdown-it. Markdown parsing, done right!"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
|
||||||
|
{file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
mdurl = ">=0.1,<1.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
|
||||||
|
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
|
||||||
|
linkify = ["linkify-it-py (>=1,<3)"]
|
||||||
|
plugins = ["mdit-py-plugins (>=0.5.0)"]
|
||||||
|
profiling = ["gprof2dot"]
|
||||||
|
rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
|
||||||
|
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markupsafe"
|
name = "markupsafe"
|
||||||
version = "3.0.3"
|
version = "3.0.3"
|
||||||
@ -714,6 +738,18 @@ files = [
|
|||||||
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
description = "Markdown URL utilities"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
|
||||||
|
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.17.1"
|
version = "1.17.1"
|
||||||
@ -843,6 +879,39 @@ files = [
|
|||||||
dev = ["pre-commit", "tox"]
|
dev = ["pre-commit", "tox"]
|
||||||
testing = ["coverage", "pytest", "pytest-benchmark"]
|
testing = ["coverage", "pytest", "pytest-benchmark"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psutil"
|
||||||
|
version = "7.1.3"
|
||||||
|
description = "Cross-platform lib for process and system monitoring."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc"},
|
||||||
|
{file = "psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0"},
|
||||||
|
{file = "psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7"},
|
||||||
|
{file = "psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251"},
|
||||||
|
{file = "psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa"},
|
||||||
|
{file = "psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee"},
|
||||||
|
{file = "psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353"},
|
||||||
|
{file = "psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b"},
|
||||||
|
{file = "psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9"},
|
||||||
|
{file = "psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f"},
|
||||||
|
{file = "psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7"},
|
||||||
|
{file = "psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264"},
|
||||||
|
{file = "psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab"},
|
||||||
|
{file = "psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880"},
|
||||||
|
{file = "psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3"},
|
||||||
|
{file = "psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b"},
|
||||||
|
{file = "psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd"},
|
||||||
|
{file = "psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1"},
|
||||||
|
{file = "psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pyreadline ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""]
|
||||||
|
test = ["pytest", "pytest-instafail", "pytest-subtests", "pytest-xdist", "pywin32 ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "setuptools", "wheel ; os_name == \"nt\" and platform_python_implementation != \"PyPy\"", "wmi ; os_name == \"nt\" and platform_python_implementation != \"PyPy\""]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycodestyle"
|
name = "pycodestyle"
|
||||||
version = "2.14.0"
|
version = "2.14.0"
|
||||||
@ -1180,6 +1249,25 @@ files = [
|
|||||||
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
|
{file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "14.2.0"
|
||||||
|
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8.0"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd"},
|
||||||
|
{file = "rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
markdown-it-py = ">=2.2.0"
|
||||||
|
pygments = ">=2.13.0,<3.0.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "80.9.0"
|
version = "80.9.0"
|
||||||
@ -1621,4 +1709,4 @@ wsgi = ["a2wsgi"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.12"
|
python-versions = ">=3.12"
|
||||||
content-hash = "32ebf260f6792987cb4236fe29ad3329374e063504d507b5a0319684e24a30a8"
|
content-hash = "359245cc9d83f36b9eff32deaf7a4665b25149c89b4062abab72db1c07607500"
|
||||||
|
|||||||
@ -15,10 +15,14 @@ dependencies = [
|
|||||||
"types-pyyaml (>=6.0.12.20250822,<7.0.0.0)",
|
"types-pyyaml (>=6.0.12.20250822,<7.0.0.0)",
|
||||||
"structlog (>=25.4.0,<26.0.0)",
|
"structlog (>=25.4.0,<26.0.0)",
|
||||||
"httpx (>=0.27.0,<0.28.0)",
|
"httpx (>=0.27.0,<0.28.0)",
|
||||||
|
"click (>=8.0)",
|
||||||
|
"rich (>=13.0)",
|
||||||
|
"psutil (>=5.9)",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
pyserve = "pyserve.cli:main"
|
pyserve = "pyserve.cli:main"
|
||||||
|
pyservectl = "pyserve.ctl:main"
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@ -11,22 +11,25 @@ The WSGI app path is passed via environment variables:
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional, Type
|
||||||
|
|
||||||
|
WSGIMiddlewareType = Optional[Type[Any]]
|
||||||
WSGI_ADAPTER: Optional[str] = None
|
WSGI_ADAPTER: Optional[str] = None
|
||||||
|
WSGIMiddleware: WSGIMiddlewareType = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from a2wsgi import WSGIMiddleware
|
from a2wsgi import WSGIMiddleware as _A2WSGIMiddleware
|
||||||
|
|
||||||
|
WSGIMiddleware = _A2WSGIMiddleware
|
||||||
WSGI_ADAPTER = "a2wsgi"
|
WSGI_ADAPTER = "a2wsgi"
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
try:
|
||||||
from asgiref.wsgi import WsgiToAsgi as WSGIMiddleware # type: ignore
|
from asgiref.wsgi import WsgiToAsgi as _AsgirefMiddleware
|
||||||
|
|
||||||
|
WSGIMiddleware = _AsgirefMiddleware
|
||||||
WSGI_ADAPTER = "asgiref"
|
WSGI_ADAPTER = "asgiref"
|
||||||
except ImportError:
|
except ImportError:
|
||||||
WSGIMiddleware = None # type: ignore
|
pass
|
||||||
WSGI_ADAPTER = None
|
|
||||||
|
|
||||||
|
|
||||||
def _load_wsgi_app() -> Callable[..., Any]:
|
def _load_wsgi_app() -> Callable[..., Any]:
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
"""
|
||||||
|
PyServe CLI - Server entry point
|
||||||
|
|
||||||
|
Simple CLI for running the PyServe HTTP server.
|
||||||
|
For service management, use pyservectl.
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -9,12 +16,32 @@ def main() -> None:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="PyServe - HTTP web server",
|
description="PyServe - HTTP web server",
|
||||||
prog="pyserve",
|
prog="pyserve",
|
||||||
|
epilog="For service management (start/stop/restart/logs), use: pyservectl",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c", "--config",
|
||||||
|
default="config.yaml",
|
||||||
|
help="Path to configuration file (default: config.yaml)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
help="Host to bind the server to",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
help="Port to bind the server to",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug",
|
||||||
|
action="store_true",
|
||||||
|
help="Enable debug mode",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--version",
|
||||||
|
action="version",
|
||||||
|
version=f"%(prog)s {__version__}",
|
||||||
)
|
)
|
||||||
parser.add_argument("-c", "--config", default="config.yaml", help="Path to configuration file (default: config.yaml)")
|
|
||||||
parser.add_argument("--host", help="Host to bind the server to")
|
|
||||||
parser.add_argument("--port", type=int, help="Port to bind the server to")
|
|
||||||
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
|
||||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
26
pyserve/ctl/__init__.py
Normal file
26
pyserve/ctl/__init__.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
PyServeCtl - Service management CLI
|
||||||
|
|
||||||
|
Docker-compose-like tool for managing PyServe services.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pyservectl [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
init Initialize a new project
|
||||||
|
config Configuration management
|
||||||
|
up Start all services
|
||||||
|
down Stop all services
|
||||||
|
start Start specific services
|
||||||
|
stop Stop specific services
|
||||||
|
restart Restart services
|
||||||
|
ps Show service status
|
||||||
|
logs View service logs
|
||||||
|
top Live monitoring dashboard
|
||||||
|
health Check service health
|
||||||
|
scale Scale services
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .main import cli, main
|
||||||
|
|
||||||
|
__all__ = ["cli", "main"]
|
||||||
93
pyserve/ctl/_daemon.py
Normal file
93
pyserve/ctl/_daemon.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
PyServe Daemon Process
|
||||||
|
|
||||||
|
Runs pyserve services in background mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="PyServe Daemon")
|
||||||
|
parser.add_argument("--config", required=True, help="Configuration file path")
|
||||||
|
parser.add_argument("--state-dir", required=True, help="State directory path")
|
||||||
|
parser.add_argument("--services", default=None, help="Comma-separated list of services")
|
||||||
|
parser.add_argument("--scale", action="append", default=[], help="Scale overrides (name=workers)")
|
||||||
|
parser.add_argument("--force-recreate", action="store_true", help="Force recreate services")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config_path = Path(args.config)
|
||||||
|
state_dir = Path(args.state_dir)
|
||||||
|
|
||||||
|
services = args.services.split(",") if args.services else None
|
||||||
|
|
||||||
|
scale_map = {}
|
||||||
|
for scale in args.scale:
|
||||||
|
name, workers = scale.split("=")
|
||||||
|
scale_map[name] = int(workers)
|
||||||
|
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
config = Config.from_yaml(str(config_path))
|
||||||
|
|
||||||
|
from .state import StateManager
|
||||||
|
|
||||||
|
state_manager = StateManager(state_dir)
|
||||||
|
|
||||||
|
log_file = state_dir / "logs" / "daemon.log"
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(log_file),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("pyserve.daemon")
|
||||||
|
|
||||||
|
pid_file = state_dir / "pyserve.pid"
|
||||||
|
pid_file.write_text(str(os.getpid()))
|
||||||
|
|
||||||
|
logger.info(f"Starting daemon with PID {os.getpid()}")
|
||||||
|
|
||||||
|
from ._runner import ServiceRunner
|
||||||
|
|
||||||
|
runner = ServiceRunner(config, state_manager)
|
||||||
|
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
logger.info(f"Received signal {signum}, shutting down...")
|
||||||
|
runner.stop()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
runner.start(
|
||||||
|
services=services,
|
||||||
|
scale_map=scale_map,
|
||||||
|
force_recreate=args.force_recreate,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Daemon error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if pid_file.exists():
|
||||||
|
pid_file.unlink()
|
||||||
|
logger.info("Daemon stopped")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import os
|
||||||
|
|
||||||
|
main()
|
||||||
391
pyserve/ctl/_runner.py
Normal file
391
pyserve/ctl/_runner.py
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
"""
|
||||||
|
PyServe Service Runner
|
||||||
|
|
||||||
|
Handles starting, stopping, and managing services.
|
||||||
|
Integrates with ProcessManager for actual process management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from ..config import Config
|
||||||
|
from ..process_manager import ProcessConfig, ProcessInfo, ProcessManager, ProcessState
|
||||||
|
from .state import ServiceState, StateManager
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceDefinition:
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
|
app_path: str
|
||||||
|
app_type: str = "asgi"
|
||||||
|
module_path: Optional[str] = None
|
||||||
|
workers: int = 1
|
||||||
|
health_check_path: str = "/health"
|
||||||
|
health_check_interval: float = 10.0
|
||||||
|
health_check_timeout: float = 5.0
|
||||||
|
health_check_retries: int = 3
|
||||||
|
max_restart_count: int = 5
|
||||||
|
restart_delay: float = 1.0
|
||||||
|
shutdown_timeout: float = 30.0
|
||||||
|
strip_path: bool = True
|
||||||
|
env: Dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceRunner:
|
||||||
|
def __init__(self, config: Config, state_manager: StateManager):
|
||||||
|
self.config = config
|
||||||
|
self.state_manager = state_manager
|
||||||
|
self._process_manager: Optional[ProcessManager] = None
|
||||||
|
self._services: Dict[str, ServiceDefinition] = {}
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
self._parse_services()
|
||||||
|
|
||||||
|
def _parse_services(self) -> None:
|
||||||
|
for ext in self.config.extensions:
|
||||||
|
if ext.type == "process_orchestration":
|
||||||
|
apps = ext.config.get("apps", [])
|
||||||
|
for app_config in apps:
|
||||||
|
service = ServiceDefinition(
|
||||||
|
name=app_config.get("name", "unnamed"),
|
||||||
|
path=app_config.get("path", "/"),
|
||||||
|
app_path=app_config.get("app_path", ""),
|
||||||
|
app_type=app_config.get("app_type", "asgi"),
|
||||||
|
module_path=app_config.get("module_path"),
|
||||||
|
workers=app_config.get("workers", 1),
|
||||||
|
health_check_path=app_config.get("health_check_path", "/health"),
|
||||||
|
health_check_interval=app_config.get("health_check_interval", 10.0),
|
||||||
|
health_check_timeout=app_config.get("health_check_timeout", 5.0),
|
||||||
|
health_check_retries=app_config.get("health_check_retries", 3),
|
||||||
|
max_restart_count=app_config.get("max_restart_count", 5),
|
||||||
|
restart_delay=app_config.get("restart_delay", 1.0),
|
||||||
|
shutdown_timeout=app_config.get("shutdown_timeout", 30.0),
|
||||||
|
strip_path=app_config.get("strip_path", True),
|
||||||
|
env=app_config.get("env", {}),
|
||||||
|
)
|
||||||
|
self._services[service.name] = service
|
||||||
|
|
||||||
|
def get_services(self) -> Dict[str, ServiceDefinition]:
|
||||||
|
return self._services.copy()
|
||||||
|
|
||||||
|
def get_service(self, name: str) -> Optional[ServiceDefinition]:
|
||||||
|
return self._services.get(name)
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self,
|
||||||
|
services: Optional[List[str]] = None,
|
||||||
|
scale_map: Optional[Dict[str, int]] = None,
|
||||||
|
force_recreate: bool = False,
|
||||||
|
wait_healthy: bool = False,
|
||||||
|
timeout: int = 60,
|
||||||
|
) -> None:
|
||||||
|
from .output import console, print_error, print_info, print_success
|
||||||
|
|
||||||
|
scale_map = scale_map or {}
|
||||||
|
|
||||||
|
target_services = services or list(self._services.keys())
|
||||||
|
|
||||||
|
if not target_services:
|
||||||
|
print_info("No services configured. Add services to your config.yaml")
|
||||||
|
return
|
||||||
|
|
||||||
|
for name in target_services:
|
||||||
|
if name not in self._services:
|
||||||
|
print_error(f"Service '{name}' not found in configuration")
|
||||||
|
return
|
||||||
|
|
||||||
|
port_range = (9000, 9999)
|
||||||
|
for ext in self.config.extensions:
|
||||||
|
if ext.type == "process_orchestration":
|
||||||
|
port_range = tuple(ext.config.get("port_range", [9000, 9999]))
|
||||||
|
break
|
||||||
|
|
||||||
|
self._process_manager = ProcessManager(
|
||||||
|
port_range=port_range,
|
||||||
|
health_check_enabled=True,
|
||||||
|
)
|
||||||
|
await self._process_manager.start()
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
for name in target_services:
|
||||||
|
service = self._services[name]
|
||||||
|
workers = scale_map.get(name, service.workers)
|
||||||
|
|
||||||
|
proc_config = ProcessConfig(
|
||||||
|
name=name,
|
||||||
|
app_path=service.app_path,
|
||||||
|
app_type=service.app_type,
|
||||||
|
workers=workers,
|
||||||
|
module_path=service.module_path,
|
||||||
|
health_check_enabled=True,
|
||||||
|
health_check_path=service.health_check_path,
|
||||||
|
health_check_interval=service.health_check_interval,
|
||||||
|
health_check_timeout=service.health_check_timeout,
|
||||||
|
health_check_retries=service.health_check_retries,
|
||||||
|
max_restart_count=service.max_restart_count,
|
||||||
|
restart_delay=service.restart_delay,
|
||||||
|
shutdown_timeout=service.shutdown_timeout,
|
||||||
|
env=service.env,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._process_manager.register(proc_config)
|
||||||
|
success = await self._process_manager.start_process(name)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
info = self._process_manager.get_process(name)
|
||||||
|
if info:
|
||||||
|
self.state_manager.update_service(
|
||||||
|
name,
|
||||||
|
state="running",
|
||||||
|
pid=info.pid,
|
||||||
|
port=info.port,
|
||||||
|
workers=workers,
|
||||||
|
started_at=time.time(),
|
||||||
|
)
|
||||||
|
print_success(f"Started service: {name}")
|
||||||
|
else:
|
||||||
|
self.state_manager.update_service(name, state="failed")
|
||||||
|
print_error(f"Failed to start service: {name}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error starting {name}: {e}")
|
||||||
|
self.state_manager.update_service(name, state="failed")
|
||||||
|
|
||||||
|
if wait_healthy:
|
||||||
|
print_info("Waiting for services to be healthy...")
|
||||||
|
await self._wait_healthy(target_services, timeout)
|
||||||
|
|
||||||
|
console.print("\n[bold]Services running. Press Ctrl+C to stop.[/bold]\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
await self._sync_state()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
await self.stop_all()
|
||||||
|
|
||||||
|
async def _sync_state(self) -> None:
|
||||||
|
if not self._process_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
for name, info in self._process_manager.get_all_processes().items():
|
||||||
|
state_str = info.state.value
|
||||||
|
health_status = "healthy" if info.health_check_failures == 0 else "unhealthy"
|
||||||
|
|
||||||
|
self.state_manager.update_service(
|
||||||
|
name,
|
||||||
|
state=state_str,
|
||||||
|
pid=info.pid,
|
||||||
|
port=info.port,
|
||||||
|
)
|
||||||
|
|
||||||
|
service_state = self.state_manager.get_service(name)
|
||||||
|
if service_state:
|
||||||
|
service_state.health.status = health_status
|
||||||
|
service_state.health.failures = info.health_check_failures
|
||||||
|
self.state_manager.save()
|
||||||
|
|
||||||
|
async def _wait_healthy(self, services: List[str], timeout: int) -> None:
|
||||||
|
from .output import print_info, print_warning
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
all_healthy = True
|
||||||
|
|
||||||
|
for name in services:
|
||||||
|
if not self._process_manager:
|
||||||
|
continue
|
||||||
|
|
||||||
|
info = self._process_manager.get_process(name)
|
||||||
|
if not info or info.state != ProcessState.RUNNING:
|
||||||
|
all_healthy = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if all_healthy:
|
||||||
|
print_info("All services healthy")
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
print_warning("Timeout waiting for services to become healthy")
|
||||||
|
|
||||||
|
async def stop_all(self, timeout: int = 30) -> None:
|
||||||
|
from .output import print_info
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._process_manager:
|
||||||
|
print_info("Stopping all services...")
|
||||||
|
await self._process_manager.stop()
|
||||||
|
self._process_manager = None
|
||||||
|
|
||||||
|
for name in self._services:
|
||||||
|
self.state_manager.update_service(
|
||||||
|
name,
|
||||||
|
state="stopped",
|
||||||
|
pid=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
async def start_service(self, name: str, timeout: int = 60) -> bool:
|
||||||
|
from .output import print_error
|
||||||
|
|
||||||
|
service = self._services.get(name)
|
||||||
|
if not service:
|
||||||
|
print_error(f"Service '{name}' not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._process_manager:
|
||||||
|
self._process_manager = ProcessManager()
|
||||||
|
await self._process_manager.start()
|
||||||
|
|
||||||
|
proc_config = ProcessConfig(
|
||||||
|
name=name,
|
||||||
|
app_path=service.app_path,
|
||||||
|
app_type=service.app_type,
|
||||||
|
workers=service.workers,
|
||||||
|
module_path=service.module_path,
|
||||||
|
health_check_enabled=True,
|
||||||
|
health_check_path=service.health_check_path,
|
||||||
|
env=service.env,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = self._process_manager.get_process(name)
|
||||||
|
if not existing:
|
||||||
|
await self._process_manager.register(proc_config)
|
||||||
|
|
||||||
|
success = await self._process_manager.start_process(name)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
info = self._process_manager.get_process(name)
|
||||||
|
self.state_manager.update_service(
|
||||||
|
name,
|
||||||
|
state="running",
|
||||||
|
pid=info.pid if info else None,
|
||||||
|
port=info.port if info else 0,
|
||||||
|
started_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error starting {name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def stop_service(self, name: str, timeout: int = 30, force: bool = False) -> bool:
|
||||||
|
if not self._process_manager:
|
||||||
|
self.state_manager.update_service(name, state="stopped", pid=None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = await self._process_manager.stop_process(name)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
self.state_manager.update_service(
|
||||||
|
name,
|
||||||
|
state="stopped",
|
||||||
|
pid=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from .output import print_error
|
||||||
|
|
||||||
|
print_error(f"Error stopping {name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def restart_service(self, name: str, timeout: int = 60) -> bool:
|
||||||
|
if not self._process_manager:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.state_manager.update_service(name, state="restarting")
|
||||||
|
success = await self._process_manager.restart_process(name)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
info = self._process_manager.get_process(name)
|
||||||
|
self.state_manager.update_service(
|
||||||
|
name,
|
||||||
|
state="running",
|
||||||
|
pid=info.pid if info else None,
|
||||||
|
port=info.port if info else 0,
|
||||||
|
started_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
from .output import print_error
|
||||||
|
|
||||||
|
print_error(f"Error restarting {name}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def scale_service(self, name: str, workers: int, timeout: int = 60, wait: bool = True) -> bool:
|
||||||
|
# For now, this requires restart with new worker count
|
||||||
|
# In future, could implement hot-reloading
|
||||||
|
|
||||||
|
service = self._services.get(name)
|
||||||
|
if not service:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update service definition
|
||||||
|
service.workers = workers
|
||||||
|
|
||||||
|
# Restart with new configuration
|
||||||
|
return await self.restart_service(name, timeout)
|
||||||
|
|
||||||
|
def start_daemon(
|
||||||
|
self,
|
||||||
|
services: Optional[List[str]] = None,
|
||||||
|
scale_map: Optional[Dict[str, int]] = None,
|
||||||
|
force_recreate: bool = False,
|
||||||
|
) -> int:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pyserve.cli._daemon",
|
||||||
|
"--config",
|
||||||
|
str(self.state_manager.state_dir.parent / "config.yaml"),
|
||||||
|
"--state-dir",
|
||||||
|
str(self.state_manager.state_dir),
|
||||||
|
]
|
||||||
|
|
||||||
|
if services:
|
||||||
|
cmd.extend(["--services", ",".join(services)])
|
||||||
|
|
||||||
|
if scale_map:
|
||||||
|
for name, workers in scale_map.items():
|
||||||
|
cmd.extend(["--scale", f"{name}={workers}"])
|
||||||
|
|
||||||
|
if force_recreate:
|
||||||
|
cmd.append("--force-recreate")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return process.pid
|
||||||
25
pyserve/ctl/commands/__init__.py
Normal file
25
pyserve/ctl/commands/__init__.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from .config import config_cmd
|
||||||
|
from .down import down_cmd
|
||||||
|
from .health import health_cmd
|
||||||
|
from .init import init_cmd
|
||||||
|
from .logs import logs_cmd
|
||||||
|
from .scale import scale_cmd
|
||||||
|
from .service import restart_cmd, start_cmd, stop_cmd
|
||||||
|
from .status import ps_cmd
|
||||||
|
from .top import top_cmd
|
||||||
|
from .up import up_cmd
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"init_cmd",
|
||||||
|
"config_cmd",
|
||||||
|
"up_cmd",
|
||||||
|
"down_cmd",
|
||||||
|
"start_cmd",
|
||||||
|
"stop_cmd",
|
||||||
|
"restart_cmd",
|
||||||
|
"ps_cmd",
|
||||||
|
"logs_cmd",
|
||||||
|
"top_cmd",
|
||||||
|
"health_cmd",
|
||||||
|
"scale_cmd",
|
||||||
|
]
|
||||||
420
pyserve/ctl/commands/config.py
Normal file
420
pyserve/ctl/commands/config.py
Normal file
@ -0,0 +1,420 @@
|
|||||||
|
"""
|
||||||
|
pyserve config - Configuration management commands
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@click.group("config")
|
||||||
|
def config_cmd():
|
||||||
|
"""
|
||||||
|
Configuration management commands.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Commands:
|
||||||
|
validate Validate configuration file
|
||||||
|
show Display current configuration
|
||||||
|
get Get a specific configuration value
|
||||||
|
set Set a configuration value
|
||||||
|
diff Compare two configuration files
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@config_cmd.command("validate")
|
||||||
|
@click.option(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
"config_file",
|
||||||
|
default=None,
|
||||||
|
help="Path to configuration file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--strict",
|
||||||
|
is_flag=True,
|
||||||
|
help="Enable strict validation (warn on unknown fields)",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def validate_cmd(ctx, config_file: Optional[str], strict: bool):
|
||||||
|
"""
|
||||||
|
Validate a configuration file.
|
||||||
|
|
||||||
|
Checks for syntax errors, missing required fields, and invalid values.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve config validate
|
||||||
|
pyserve config validate -c production.yaml
|
||||||
|
pyserve config validate --strict
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_success, print_warning
|
||||||
|
|
||||||
|
config_path = Path(config_file or ctx.config_file)
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
console.print(f"Validating [cyan]{config_path}[/cyan]...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
print_error("Configuration file is empty")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
config = Config.from_yaml(str(config_path))
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
if not (1 <= config.server.port <= 65535):
|
||||||
|
errors.append(f"Invalid server port: {config.server.port}")
|
||||||
|
|
||||||
|
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
||||||
|
if config.logging.level.upper() not in valid_levels:
|
||||||
|
errors.append(f"Invalid logging level: {config.logging.level}")
|
||||||
|
|
||||||
|
if config.ssl.enabled:
|
||||||
|
if not Path(config.ssl.cert_file).exists():
|
||||||
|
warnings.append(f"SSL cert file not found: {config.ssl.cert_file}")
|
||||||
|
if not Path(config.ssl.key_file).exists():
|
||||||
|
warnings.append(f"SSL key file not found: {config.ssl.key_file}")
|
||||||
|
|
||||||
|
valid_extension_types = [
|
||||||
|
"routing",
|
||||||
|
"process_orchestration",
|
||||||
|
"asgi_mount",
|
||||||
|
]
|
||||||
|
for ext in config.extensions:
|
||||||
|
if ext.type not in valid_extension_types:
|
||||||
|
warnings.append(f"Unknown extension type: {ext.type}")
|
||||||
|
|
||||||
|
if strict:
|
||||||
|
known_top_level = {"http", "server", "ssl", "logging", "extensions"}
|
||||||
|
for key in data.keys():
|
||||||
|
if key not in known_top_level:
|
||||||
|
warnings.append(f"Unknown top-level field: {key}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
print_error(error)
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
for warning in warnings:
|
||||||
|
print_warning(warning)
|
||||||
|
|
||||||
|
print_success("Configuration is valid!")
|
||||||
|
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
print_error(f"YAML syntax error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Validation error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
|
||||||
|
@config_cmd.command("show")
|
||||||
|
@click.option(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
"config_file",
|
||||||
|
default=None,
|
||||||
|
help="Path to configuration file",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["yaml", "json", "table"]),
|
||||||
|
default="yaml",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--section",
|
||||||
|
"section",
|
||||||
|
default=None,
|
||||||
|
help="Show only a specific section (e.g., server, logging)",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def show_cmd(ctx, config_file: Optional[str], output_format: str, section: Optional[str]):
|
||||||
|
"""
|
||||||
|
Display current configuration.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve config show
|
||||||
|
pyserve config show --format json
|
||||||
|
pyserve config show --section server
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error
|
||||||
|
|
||||||
|
config_path = Path(config_file or ctx.config_file)
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if section:
|
||||||
|
if section in data:
|
||||||
|
data = {section: data[section]}
|
||||||
|
else:
|
||||||
|
print_error(f"Section '{section}' not found in configuration")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
if output_format == "yaml":
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
|
||||||
|
yaml_str = yaml.dump(data, default_flow_style=False, sort_keys=False)
|
||||||
|
syntax = Syntax(yaml_str, "yaml", theme="monokai", line_numbers=False)
|
||||||
|
console.print(syntax)
|
||||||
|
|
||||||
|
elif output_format == "json":
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
|
||||||
|
json_str = json.dumps(data, indent=2)
|
||||||
|
syntax = Syntax(json_str, "json", theme="monokai", line_numbers=False)
|
||||||
|
console.print(syntax)
|
||||||
|
|
||||||
|
elif output_format == "table":
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.tree import Tree
|
||||||
|
|
||||||
|
def build_tree(data, tree):
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
branch = tree.add(f"[cyan]{key}[/cyan]")
|
||||||
|
build_tree(value, branch)
|
||||||
|
else:
|
||||||
|
tree.add(f"[cyan]{key}[/cyan]: [green]{value}[/green]")
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for i, item in enumerate(data):
|
||||||
|
if isinstance(item, (dict, list)):
|
||||||
|
branch = tree.add(f"[dim][{i}][/dim]")
|
||||||
|
build_tree(item, branch)
|
||||||
|
else:
|
||||||
|
tree.add(f"[dim][{i}][/dim] [green]{item}[/green]")
|
||||||
|
|
||||||
|
tree = Tree(f"[bold]Configuration: {config_path}[/bold]")
|
||||||
|
build_tree(data, tree)
|
||||||
|
console.print(tree)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error reading configuration: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
|
||||||
|
@config_cmd.command("get")
|
||||||
|
@click.argument("key")
|
||||||
|
@click.option(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
"config_file",
|
||||||
|
default=None,
|
||||||
|
help="Path to configuration file",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def get_cmd(ctx, key: str, config_file: Optional[str]):
|
||||||
|
"""
|
||||||
|
Get a specific configuration value.
|
||||||
|
|
||||||
|
Use dot notation to access nested values.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve config get server.port
|
||||||
|
pyserve config get logging.level
|
||||||
|
pyserve config get extensions.0.type
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error
|
||||||
|
|
||||||
|
config_path = Path(config_file or ctx.config_file)
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
value = data
|
||||||
|
for part in key.split("."):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if part in value:
|
||||||
|
value = value[part]
|
||||||
|
else:
|
||||||
|
print_error(f"Key '{key}' not found")
|
||||||
|
raise click.Abort()
|
||||||
|
elif isinstance(value, list):
|
||||||
|
try:
|
||||||
|
index = int(part)
|
||||||
|
value = value[index]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
print_error(f"Invalid index '{part}' in key '{key}'")
|
||||||
|
raise click.Abort()
|
||||||
|
else:
|
||||||
|
print_error(f"Cannot access '{part}' in {type(value).__name__}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
console.print(yaml.dump(value, default_flow_style=False))
|
||||||
|
else:
|
||||||
|
console.print(str(value))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
|
||||||
|
@config_cmd.command("set")
|
||||||
|
@click.argument("key")
|
||||||
|
@click.argument("value")
|
||||||
|
@click.option(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
"config_file",
|
||||||
|
default=None,
|
||||||
|
help="Path to configuration file",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def set_cmd(ctx, key: str, value: str, config_file: Optional[str]):
|
||||||
|
"""
|
||||||
|
Set a configuration value.
|
||||||
|
|
||||||
|
Use dot notation to access nested values.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve config set server.port 8080
|
||||||
|
pyserve config set logging.level DEBUG
|
||||||
|
"""
|
||||||
|
from ..output import print_error, print_success
|
||||||
|
|
||||||
|
config_path = Path(config_file or ctx.config_file)
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
|
||||||
|
parsed_value: Any
|
||||||
|
if value.lower() == "true":
|
||||||
|
parsed_value = True
|
||||||
|
elif value.lower() == "false":
|
||||||
|
parsed_value = False
|
||||||
|
elif value.isdigit():
|
||||||
|
parsed_value = int(value)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
parsed_value = float(value)
|
||||||
|
except ValueError:
|
||||||
|
parsed_value = value
|
||||||
|
|
||||||
|
parts = key.split(".")
|
||||||
|
current = data
|
||||||
|
for part in parts[:-1]:
|
||||||
|
if isinstance(current, dict):
|
||||||
|
if part not in current:
|
||||||
|
current[part] = {}
|
||||||
|
current = current[part]
|
||||||
|
elif isinstance(current, list):
|
||||||
|
index = int(part)
|
||||||
|
current = current[index]
|
||||||
|
|
||||||
|
final_key = parts[-1]
|
||||||
|
if isinstance(current, dict):
|
||||||
|
current[final_key] = parsed_value
|
||||||
|
elif isinstance(current, list):
|
||||||
|
current[int(final_key)] = parsed_value
|
||||||
|
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
||||||
|
|
||||||
|
print_success(f"Set {key} = {parsed_value}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
|
||||||
|
@config_cmd.command("diff")
|
||||||
|
@click.argument("file1", type=click.Path(exists=True))
|
||||||
|
@click.argument("file2", type=click.Path(exists=True))
|
||||||
|
def diff_cmd(file1: str, file2: str):
|
||||||
|
"""
|
||||||
|
Compare two configuration files.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve config diff config.yaml production.yaml
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file1) as f:
|
||||||
|
data1 = yaml.safe_load(f)
|
||||||
|
with open(file2) as f:
|
||||||
|
data2 = yaml.safe_load(f)
|
||||||
|
|
||||||
|
def compare_dicts(d1, d2, path=""):
|
||||||
|
differences = []
|
||||||
|
|
||||||
|
all_keys = set(d1.keys() if d1 else []) | set(d2.keys() if d2 else [])
|
||||||
|
|
||||||
|
for key in sorted(all_keys):
|
||||||
|
current_path = f"{path}.{key}" if path else key
|
||||||
|
v1 = d1.get(key) if d1 else None
|
||||||
|
v2 = d2.get(key) if d2 else None
|
||||||
|
|
||||||
|
if key not in (d1 or {}):
|
||||||
|
differences.append(("added", current_path, None, v2))
|
||||||
|
elif key not in (d2 or {}):
|
||||||
|
differences.append(("removed", current_path, v1, None))
|
||||||
|
elif isinstance(v1, dict) and isinstance(v2, dict):
|
||||||
|
differences.extend(compare_dicts(v1, v2, current_path))
|
||||||
|
elif v1 != v2:
|
||||||
|
differences.append(("changed", current_path, v1, v2))
|
||||||
|
|
||||||
|
return differences
|
||||||
|
|
||||||
|
differences = compare_dicts(data1, data2)
|
||||||
|
|
||||||
|
if not differences:
|
||||||
|
console.print("[green]Files are identical[/green]")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print(f"\n[bold]Differences between {file1} and {file2}:[/bold]\n")
|
||||||
|
|
||||||
|
for diff_type, path, v1, v2 in differences:
|
||||||
|
if diff_type == "added":
|
||||||
|
console.print(f" [green]+ {path}: {v2}[/green]")
|
||||||
|
elif diff_type == "removed":
|
||||||
|
console.print(f" [red]- {path}: {v1}[/red]")
|
||||||
|
elif diff_type == "changed":
|
||||||
|
console.print(f" [yellow]~ {path}:[/yellow]")
|
||||||
|
console.print(f" [red]- {v1}[/red]")
|
||||||
|
console.print(f" [green]+ {v2}[/green]")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
122
pyserve/ctl/commands/down.py
Normal file
122
pyserve/ctl/commands/down.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
pyserve down - Stop all services
|
||||||
|
"""
|
||||||
|
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, cast
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("down")
|
||||||
|
@click.option(
|
||||||
|
"--timeout",
|
||||||
|
"timeout",
|
||||||
|
default=30,
|
||||||
|
type=int,
|
||||||
|
help="Timeout in seconds for graceful shutdown",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-v",
|
||||||
|
"--volumes",
|
||||||
|
is_flag=True,
|
||||||
|
help="Remove volumes/data",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--remove-orphans",
|
||||||
|
is_flag=True,
|
||||||
|
help="Remove orphaned services",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def down_cmd(
|
||||||
|
ctx,
|
||||||
|
timeout: int,
|
||||||
|
volumes: bool,
|
||||||
|
remove_orphans: bool,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Stop and remove all services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve down # Stop all services
|
||||||
|
pyserve down --timeout 60 # Extended shutdown timeout
|
||||||
|
pyserve down -v # Remove volumes too
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_info, print_success, print_warning
|
||||||
|
from ..state import StateManager
|
||||||
|
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
|
||||||
|
if state_manager.is_daemon_running():
|
||||||
|
daemon_pid = state_manager.get_daemon_pid()
|
||||||
|
console.print(f"[bold]Stopping PyServe daemon (PID: {daemon_pid})...[/bold]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
# FIXME: Please fix the cast usage here
|
||||||
|
os.kill(cast(int, daemon_pid), signal.SIGTERM)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
while time.time() - start_time < timeout:
|
||||||
|
try:
|
||||||
|
# FIXME: Please fix the cast usage here
|
||||||
|
os.kill(cast(int, daemon_pid), 0)
|
||||||
|
time.sleep(0.5)
|
||||||
|
except ProcessLookupError:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print_warning("Graceful shutdown timed out, forcing...")
|
||||||
|
try:
|
||||||
|
# FIXME: Please fix the cast usage here
|
||||||
|
os.kill(cast(int, daemon_pid), signal.SIGKILL)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
state_manager.clear_daemon_pid()
|
||||||
|
print_success("PyServe daemon stopped")
|
||||||
|
|
||||||
|
except ProcessLookupError:
|
||||||
|
print_info("Daemon was not running")
|
||||||
|
state_manager.clear_daemon_pid()
|
||||||
|
except PermissionError:
|
||||||
|
print_error("Permission denied to stop daemon")
|
||||||
|
raise click.Abort()
|
||||||
|
else:
|
||||||
|
services = state_manager.get_all_services()
|
||||||
|
|
||||||
|
if not services:
|
||||||
|
print_info("No services are running")
|
||||||
|
return
|
||||||
|
|
||||||
|
console.print("[bold]Stopping services...[/bold]")
|
||||||
|
|
||||||
|
from .._runner import ServiceRunner
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
config_path = Path(ctx.config_file)
|
||||||
|
if config_path.exists():
|
||||||
|
config = Config.from_yaml(str(config_path))
|
||||||
|
else:
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
runner = ServiceRunner(config, state_manager)
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(runner.stop_all(timeout=timeout))
|
||||||
|
print_success("All services stopped")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error stopping services: {e}")
|
||||||
|
|
||||||
|
if volumes:
|
||||||
|
console.print("Cleaning up state...")
|
||||||
|
state_manager.clear()
|
||||||
|
print_info("State cleared")
|
||||||
|
|
||||||
|
if remove_orphans:
|
||||||
|
# This would remove services that are in state but not in config
|
||||||
|
pass
|
||||||
160
pyserve/ctl/commands/health.py
Normal file
160
pyserve/ctl/commands/health.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
pyserve health - Check health of services
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("health")
|
||||||
|
@click.argument("services", nargs=-1)
|
||||||
|
@click.option(
|
||||||
|
"--timeout",
|
||||||
|
"timeout",
|
||||||
|
default=5,
|
||||||
|
type=int,
|
||||||
|
help="Health check timeout in seconds",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["table", "json"]),
|
||||||
|
default="table",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def health_cmd(ctx, services: tuple, timeout: int, output_format: str):
|
||||||
|
"""
|
||||||
|
Check health of services.
|
||||||
|
|
||||||
|
Performs active health checks on running services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve health # Check all services
|
||||||
|
pyserve health api admin # Check specific services
|
||||||
|
pyserve health --format json # JSON output
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_info
|
||||||
|
from ..state import StateManager
|
||||||
|
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
all_services = state_manager.get_all_services()
|
||||||
|
|
||||||
|
if services:
|
||||||
|
all_services = {k: v for k, v in all_services.items() if k in services}
|
||||||
|
|
||||||
|
if not all_services:
|
||||||
|
print_info("No services to check")
|
||||||
|
return
|
||||||
|
|
||||||
|
results = asyncio.run(_check_health(all_services, timeout))
|
||||||
|
|
||||||
|
if output_format == "json":
|
||||||
|
import json
|
||||||
|
|
||||||
|
console.print(json.dumps(results, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
from rich.table import Table
|
||||||
|
from ..output import format_health
|
||||||
|
|
||||||
|
table = Table(show_header=True, header_style="bold")
|
||||||
|
table.add_column("SERVICE", style="cyan")
|
||||||
|
table.add_column("HEALTH")
|
||||||
|
table.add_column("CHECKS", justify="right")
|
||||||
|
table.add_column("LAST CHECK", style="dim")
|
||||||
|
table.add_column("RESPONSE TIME", justify="right")
|
||||||
|
|
||||||
|
for name, result in results.items():
|
||||||
|
health_str = format_health(result["status"])
|
||||||
|
checks = f"{result['successes']}/{result['total']}"
|
||||||
|
last_check = result.get("last_check", "-")
|
||||||
|
response_time = f"{result['response_time_ms']:.0f}ms" if result.get("response_time_ms") else "-"
|
||||||
|
|
||||||
|
table.add_row(name, health_str, checks, last_check, response_time)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print(table)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
healthy = sum(1 for r in results.values() if r["status"] == "healthy")
|
||||||
|
unhealthy = sum(1 for r in results.values() if r["status"] == "unhealthy")
|
||||||
|
|
||||||
|
if unhealthy:
|
||||||
|
print_error(f"{unhealthy} service(s) unhealthy")
|
||||||
|
raise SystemExit(1)
|
||||||
|
else:
|
||||||
|
from ..output import print_success
|
||||||
|
|
||||||
|
print_success(f"All {healthy} service(s) healthy")
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_health(services: dict, timeout: int) -> dict:
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
except ImportError:
|
||||||
|
return {name: {"status": "unknown", "error": "httpx not installed"} for name in services}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
for name, service in services.items():
|
||||||
|
if service.state != "running" or not service.port:
|
||||||
|
results[name] = {
|
||||||
|
"status": "unknown",
|
||||||
|
"successes": 0,
|
||||||
|
"total": 0,
|
||||||
|
"error": "Service not running",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
health_path = "/health"
|
||||||
|
url = f"http://127.0.0.1:{service.port}{health_path}"
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
response_time = (time.time() - start_time) * 1000
|
||||||
|
|
||||||
|
if resp.status_code < 500:
|
||||||
|
results[name] = {
|
||||||
|
"status": "healthy",
|
||||||
|
"successes": 1,
|
||||||
|
"total": 1,
|
||||||
|
"response_time_ms": response_time,
|
||||||
|
"last_check": "just now",
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
results[name] = {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"successes": 0,
|
||||||
|
"total": 1,
|
||||||
|
"response_time_ms": response_time,
|
||||||
|
"last_check": "just now",
|
||||||
|
"status_code": resp.status_code,
|
||||||
|
}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
results[name] = {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"successes": 0,
|
||||||
|
"total": 1,
|
||||||
|
"error": "timeout",
|
||||||
|
"last_check": "just now",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
results[name] = {
|
||||||
|
"status": "unhealthy",
|
||||||
|
"successes": 0,
|
||||||
|
"total": 1,
|
||||||
|
"error": str(e),
|
||||||
|
"last_check": "just now",
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
433
pyserve/ctl/commands/init.py
Normal file
433
pyserve/ctl/commands/init.py
Normal file
@ -0,0 +1,433 @@
|
|||||||
|
"""
|
||||||
|
pyserve init - Initialize a new pyserve project
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
TEMPLATES = {
|
||||||
|
"basic": {
|
||||||
|
"description": "Basic configuration with static files and routing",
|
||||||
|
"filename": "config.yaml",
|
||||||
|
},
|
||||||
|
"orchestration": {
|
||||||
|
"description": "Process orchestration with multiple ASGI/WSGI apps",
|
||||||
|
"filename": "config.yaml",
|
||||||
|
},
|
||||||
|
"asgi": {
|
||||||
|
"description": "ASGI mount configuration for in-process apps",
|
||||||
|
"filename": "config.yaml",
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"description": "Full configuration with all features",
|
||||||
|
"filename": "config.yaml",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
BASIC_TEMPLATE = """\
|
||||||
|
# PyServe Configuration
|
||||||
|
# Generated by: pyserve init
|
||||||
|
|
||||||
|
http:
|
||||||
|
static_dir: ./static
|
||||||
|
templates_dir: ./templates
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8080
|
||||||
|
backlog: 100
|
||||||
|
proxy_timeout: 30.0
|
||||||
|
|
||||||
|
ssl:
|
||||||
|
enabled: false
|
||||||
|
cert_file: ./ssl/cert.pem
|
||||||
|
key_file: ./ssl/key.pem
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
console_output: true
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: true
|
||||||
|
show_module: true
|
||||||
|
timestamp_format: "%Y-%m-%d %H:%M:%S"
|
||||||
|
console:
|
||||||
|
level: INFO
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: true
|
||||||
|
files:
|
||||||
|
- path: ./logs/pyserve.log
|
||||||
|
level: INFO
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: false
|
||||||
|
|
||||||
|
extensions:
|
||||||
|
- type: routing
|
||||||
|
config:
|
||||||
|
regex_locations:
|
||||||
|
# Health check endpoint
|
||||||
|
"=/health":
|
||||||
|
return: "200 OK"
|
||||||
|
content_type: "text/plain"
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
"^/static/":
|
||||||
|
root: "./static"
|
||||||
|
strip_prefix: "/static"
|
||||||
|
|
||||||
|
# Default fallback
|
||||||
|
"__default__":
|
||||||
|
spa_fallback: true
|
||||||
|
root: "./static"
|
||||||
|
index_file: "index.html"
|
||||||
|
"""
|
||||||
|
|
||||||
|
ORCHESTRATION_TEMPLATE = """\
|
||||||
|
# PyServe Process Orchestration Configuration
|
||||||
|
# Generated by: pyserve init --template orchestration
|
||||||
|
#
|
||||||
|
# This configuration runs multiple ASGI/WSGI apps as isolated processes
|
||||||
|
# with automatic health monitoring and restart.
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8080
|
||||||
|
backlog: 2048
|
||||||
|
proxy_timeout: 60.0
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
console_output: true
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: true
|
||||||
|
files:
|
||||||
|
- path: ./logs/pyserve.log
|
||||||
|
level: DEBUG
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: false
|
||||||
|
|
||||||
|
extensions:
|
||||||
|
# Process Orchestration - runs each app in its own process
|
||||||
|
- type: process_orchestration
|
||||||
|
config:
|
||||||
|
port_range: [9000, 9999]
|
||||||
|
health_check_enabled: true
|
||||||
|
proxy_timeout: 60.0
|
||||||
|
|
||||||
|
apps:
|
||||||
|
# Example: FastAPI application
|
||||||
|
- name: api
|
||||||
|
path: /api
|
||||||
|
app_path: myapp.api:app
|
||||||
|
module_path: "."
|
||||||
|
workers: 2
|
||||||
|
health_check_path: /health
|
||||||
|
health_check_interval: 10.0
|
||||||
|
health_check_timeout: 5.0
|
||||||
|
health_check_retries: 3
|
||||||
|
max_restart_count: 5
|
||||||
|
restart_delay: 1.0
|
||||||
|
strip_path: true
|
||||||
|
env:
|
||||||
|
APP_ENV: "production"
|
||||||
|
|
||||||
|
# Example: Flask application (WSGI)
|
||||||
|
# - name: admin
|
||||||
|
# path: /admin
|
||||||
|
# app_path: myapp.admin:app
|
||||||
|
# app_type: wsgi
|
||||||
|
# module_path: "."
|
||||||
|
# workers: 1
|
||||||
|
# health_check_path: /health
|
||||||
|
# strip_path: true
|
||||||
|
|
||||||
|
# Static files routing
|
||||||
|
- type: routing
|
||||||
|
config:
|
||||||
|
regex_locations:
|
||||||
|
"=/health":
|
||||||
|
return: "200 OK"
|
||||||
|
content_type: "text/plain"
|
||||||
|
|
||||||
|
"^/static/":
|
||||||
|
root: "./static"
|
||||||
|
strip_prefix: "/static"
|
||||||
|
"""
|
||||||
|
|
||||||
|
ASGI_TEMPLATE = """\
|
||||||
|
# PyServe ASGI Mount Configuration
|
||||||
|
# Generated by: pyserve init --template asgi
|
||||||
|
#
|
||||||
|
# This configuration mounts ASGI apps in-process (like ASGI Lifespan).
|
||||||
|
# More efficient but apps share the same process.
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8080
|
||||||
|
backlog: 100
|
||||||
|
proxy_timeout: 30.0
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
console_output: true
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: true
|
||||||
|
files:
|
||||||
|
- path: ./logs/pyserve.log
|
||||||
|
level: DEBUG
|
||||||
|
|
||||||
|
extensions:
|
||||||
|
- type: asgi_mount
|
||||||
|
config:
|
||||||
|
mounts:
|
||||||
|
# FastAPI app mounted at /api
|
||||||
|
- path: /api
|
||||||
|
app: myapp.api:app
|
||||||
|
# factory: false # Set to true if app is a factory function
|
||||||
|
|
||||||
|
# Starlette app mounted at /web
|
||||||
|
# - path: /web
|
||||||
|
# app: myapp.web:app
|
||||||
|
|
||||||
|
- type: routing
|
||||||
|
config:
|
||||||
|
regex_locations:
|
||||||
|
"=/health":
|
||||||
|
return: "200 OK"
|
||||||
|
content_type: "text/plain"
|
||||||
|
|
||||||
|
"^/static/":
|
||||||
|
root: "./static"
|
||||||
|
strip_prefix: "/static"
|
||||||
|
|
||||||
|
"__default__":
|
||||||
|
spa_fallback: true
|
||||||
|
root: "./static"
|
||||||
|
index_file: "index.html"
|
||||||
|
"""
|
||||||
|
|
||||||
|
FULL_TEMPLATE = """\
|
||||||
|
# PyServe Full Configuration
|
||||||
|
# Generated by: pyserve init --template full
|
||||||
|
#
|
||||||
|
# Comprehensive configuration showcasing all PyServe features.
|
||||||
|
|
||||||
|
http:
|
||||||
|
static_dir: ./static
|
||||||
|
templates_dir: ./templates
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8080
|
||||||
|
backlog: 2048
|
||||||
|
default_root: false
|
||||||
|
proxy_timeout: 60.0
|
||||||
|
redirect_instructions:
|
||||||
|
"/old-path": "/new-path"
|
||||||
|
|
||||||
|
ssl:
|
||||||
|
enabled: false
|
||||||
|
cert_file: ./ssl/cert.pem
|
||||||
|
key_file: ./ssl/key.pem
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: INFO
|
||||||
|
console_output: true
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: true
|
||||||
|
show_module: true
|
||||||
|
timestamp_format: "%Y-%m-%d %H:%M:%S"
|
||||||
|
console:
|
||||||
|
level: DEBUG
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: true
|
||||||
|
files:
|
||||||
|
# Main log file
|
||||||
|
- path: ./logs/pyserve.log
|
||||||
|
level: DEBUG
|
||||||
|
format:
|
||||||
|
type: standard
|
||||||
|
use_colors: false
|
||||||
|
|
||||||
|
# JSON logs for log aggregation
|
||||||
|
- path: ./logs/pyserve.json
|
||||||
|
level: INFO
|
||||||
|
format:
|
||||||
|
type: json
|
||||||
|
|
||||||
|
# Access logs
|
||||||
|
- path: ./logs/access.log
|
||||||
|
level: INFO
|
||||||
|
loggers: ["pyserve.access"]
|
||||||
|
max_bytes: 10485760 # 10MB
|
||||||
|
backup_count: 10
|
||||||
|
|
||||||
|
extensions:
|
||||||
|
# Process Orchestration for background services
|
||||||
|
- type: process_orchestration
|
||||||
|
config:
|
||||||
|
port_range: [9000, 9999]
|
||||||
|
health_check_enabled: true
|
||||||
|
proxy_timeout: 60.0
|
||||||
|
|
||||||
|
apps:
|
||||||
|
- name: api
|
||||||
|
path: /api
|
||||||
|
app_path: myapp.api:app
|
||||||
|
module_path: "."
|
||||||
|
workers: 2
|
||||||
|
health_check_path: /health
|
||||||
|
strip_path: true
|
||||||
|
env:
|
||||||
|
APP_ENV: "production"
|
||||||
|
|
||||||
|
# Advanced routing with regex
|
||||||
|
- type: routing
|
||||||
|
config:
|
||||||
|
regex_locations:
|
||||||
|
# API versioning
|
||||||
|
"~^/api/v(?P<version>\\\\d+)/":
|
||||||
|
proxy_pass: "http://localhost:9001"
|
||||||
|
headers:
|
||||||
|
- "API-Version: {version}"
|
||||||
|
- "X-Forwarded-For: $remote_addr"
|
||||||
|
|
||||||
|
# Static files with caching
|
||||||
|
"~*\\\\.(js|css|png|jpg|gif|ico|svg|woff2?)$":
|
||||||
|
root: "./static"
|
||||||
|
cache_control: "public, max-age=31536000"
|
||||||
|
headers:
|
||||||
|
- "Access-Control-Allow-Origin: *"
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
"=/health":
|
||||||
|
return: "200 OK"
|
||||||
|
content_type: "text/plain"
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
"^/static/":
|
||||||
|
root: "./static"
|
||||||
|
strip_prefix: "/static"
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
"__default__":
|
||||||
|
spa_fallback: true
|
||||||
|
root: "./static"
|
||||||
|
index_file: "index.html"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_template_content(template: str) -> str:
|
||||||
|
templates = {
|
||||||
|
"basic": BASIC_TEMPLATE,
|
||||||
|
"orchestration": ORCHESTRATION_TEMPLATE,
|
||||||
|
"asgi": ASGI_TEMPLATE,
|
||||||
|
"full": FULL_TEMPLATE,
|
||||||
|
}
|
||||||
|
return templates.get(template, BASIC_TEMPLATE)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("init")
|
||||||
|
@click.option(
|
||||||
|
"-t",
|
||||||
|
"--template",
|
||||||
|
"template",
|
||||||
|
type=click.Choice(list(TEMPLATES.keys())),
|
||||||
|
default="basic",
|
||||||
|
help="Configuration template to use",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
"output_file",
|
||||||
|
default="config.yaml",
|
||||||
|
help="Output file path (default: config.yaml)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-f",
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
help="Overwrite existing configuration",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--list-templates",
|
||||||
|
is_flag=True,
|
||||||
|
help="List available templates",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def init_cmd(
|
||||||
|
ctx,
|
||||||
|
template: str,
|
||||||
|
output_file: str,
|
||||||
|
force: bool,
|
||||||
|
list_templates: bool,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize a new pyserve project.
|
||||||
|
|
||||||
|
Creates a configuration file with sensible defaults and directory structure.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve init # Basic configuration
|
||||||
|
pyserve init -t orchestration # Process orchestration setup
|
||||||
|
pyserve init -t asgi # ASGI mount setup
|
||||||
|
pyserve init -t full # All features
|
||||||
|
pyserve init -o production.yaml # Custom output file
|
||||||
|
"""
|
||||||
|
from ..output import console, print_success, print_warning, print_info
|
||||||
|
|
||||||
|
if list_templates:
|
||||||
|
console.print("\n[bold]Available Templates:[/bold]\n")
|
||||||
|
for name, info in TEMPLATES.items():
|
||||||
|
console.print(f" [cyan]{name:15}[/cyan] - {info['description']}")
|
||||||
|
console.print()
|
||||||
|
return
|
||||||
|
|
||||||
|
output_path = Path(output_file)
|
||||||
|
|
||||||
|
if output_path.exists() and not force:
|
||||||
|
print_warning(f"Configuration file '{output_file}' already exists.")
|
||||||
|
if not click.confirm("Do you want to overwrite it?"):
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
dirs_to_create = ["static", "templates", "logs"]
|
||||||
|
if template == "orchestration":
|
||||||
|
dirs_to_create.append("apps")
|
||||||
|
|
||||||
|
for dir_name in dirs_to_create:
|
||||||
|
dir_path = Path(dir_name)
|
||||||
|
if not dir_path.exists():
|
||||||
|
dir_path.mkdir(parents=True)
|
||||||
|
print_info(f"Created directory: {dir_name}/")
|
||||||
|
|
||||||
|
state_dir = Path(".pyserve")
|
||||||
|
if not state_dir.exists():
|
||||||
|
state_dir.mkdir()
|
||||||
|
print_info("Created directory: .pyserve/")
|
||||||
|
|
||||||
|
content = get_template_content(template)
|
||||||
|
output_path.write_text(content)
|
||||||
|
|
||||||
|
print_success(f"Created configuration file: {output_file}")
|
||||||
|
print_info(f"Template: {template}")
|
||||||
|
|
||||||
|
gitignore_path = Path(".pyserve/.gitignore")
|
||||||
|
if not gitignore_path.exists():
|
||||||
|
gitignore_path.write_text("*\n!.gitignore\n")
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print("[bold]Next steps:[/bold]")
|
||||||
|
console.print(f" 1. Edit [cyan]{output_file}[/cyan] to configure your services")
|
||||||
|
console.print(" 2. Run [cyan]pyserve config validate[/cyan] to check configuration")
|
||||||
|
console.print(" 3. Run [cyan]pyserve up[/cyan] to start services")
|
||||||
|
console.print()
|
||||||
290
pyserve/ctl/commands/logs.py
Normal file
290
pyserve/ctl/commands/logs.py
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
pyserve logs - View service logs
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("logs")
|
||||||
|
@click.argument("services", nargs=-1)
|
||||||
|
@click.option(
|
||||||
|
"-f",
|
||||||
|
"--follow",
|
||||||
|
is_flag=True,
|
||||||
|
help="Follow log output",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--tail",
|
||||||
|
"tail",
|
||||||
|
default=100,
|
||||||
|
type=int,
|
||||||
|
help="Number of lines to show from the end",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--since",
|
||||||
|
"since",
|
||||||
|
default=None,
|
||||||
|
help="Show logs since timestamp (e.g., '10m', '1h', '2024-01-01')",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--until",
|
||||||
|
"until_time",
|
||||||
|
default=None,
|
||||||
|
help="Show logs until timestamp",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-t",
|
||||||
|
"--timestamps",
|
||||||
|
is_flag=True,
|
||||||
|
help="Show timestamps",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-color",
|
||||||
|
is_flag=True,
|
||||||
|
help="Disable colored output",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--filter",
|
||||||
|
"filter_pattern",
|
||||||
|
default=None,
|
||||||
|
help="Filter logs by pattern",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def logs_cmd(
|
||||||
|
ctx,
|
||||||
|
services: tuple,
|
||||||
|
follow: bool,
|
||||||
|
tail: int,
|
||||||
|
since: Optional[str],
|
||||||
|
until_time: Optional[str],
|
||||||
|
timestamps: bool,
|
||||||
|
no_color: bool,
|
||||||
|
filter_pattern: Optional[str],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
View service logs.
|
||||||
|
|
||||||
|
If no services are specified, shows logs from all services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve logs # All logs
|
||||||
|
pyserve logs api # Logs from api service
|
||||||
|
pyserve logs api admin # Logs from multiple services
|
||||||
|
pyserve logs -f # Follow logs
|
||||||
|
pyserve logs --tail 50 # Last 50 lines
|
||||||
|
pyserve logs --since "10m" # Logs from last 10 minutes
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_info
|
||||||
|
from ..state import StateManager
|
||||||
|
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
|
||||||
|
if services:
|
||||||
|
log_files = [
|
||||||
|
(name, state_manager.get_service_log_file(name)) for name in services
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
all_services = state_manager.get_all_services()
|
||||||
|
if not all_services:
|
||||||
|
main_log = Path("logs/pyserve.log")
|
||||||
|
if main_log.exists():
|
||||||
|
log_files = [("pyserve", main_log)]
|
||||||
|
else:
|
||||||
|
print_info("No logs available. Start services with 'pyserve up'")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
log_files = [
|
||||||
|
(name, state_manager.get_service_log_file(name))
|
||||||
|
for name in all_services
|
||||||
|
]
|
||||||
|
|
||||||
|
existing_logs = [(name, path) for name, path in log_files if path.exists()]
|
||||||
|
|
||||||
|
if not existing_logs:
|
||||||
|
print_info("No log files found")
|
||||||
|
return
|
||||||
|
|
||||||
|
since_time = _parse_time(since) if since else None
|
||||||
|
until_timestamp = _parse_time(until_time) if until_time else None
|
||||||
|
|
||||||
|
colors = ["cyan", "green", "yellow", "blue", "magenta"]
|
||||||
|
service_colors = {
|
||||||
|
name: colors[i % len(colors)] for i, (name, _) in enumerate(existing_logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if follow:
|
||||||
|
asyncio.run(
|
||||||
|
_follow_logs(
|
||||||
|
existing_logs,
|
||||||
|
service_colors,
|
||||||
|
timestamps,
|
||||||
|
no_color,
|
||||||
|
filter_pattern,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_read_logs(
|
||||||
|
existing_logs,
|
||||||
|
service_colors,
|
||||||
|
tail,
|
||||||
|
since_time,
|
||||||
|
until_timestamp,
|
||||||
|
timestamps,
|
||||||
|
no_color,
|
||||||
|
filter_pattern,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(time_str: str) -> Optional[float]:
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Relative time (e.g., "10m", "1h", "2d")
|
||||||
|
match = re.match(r"^(\d+)([smhd])$", time_str)
|
||||||
|
if match:
|
||||||
|
value = int(match.group(1))
|
||||||
|
unit = match.group(2)
|
||||||
|
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||||
|
return time.time() - (value * units[unit])
|
||||||
|
|
||||||
|
# Relative phrase (e.g., "10m ago")
|
||||||
|
match = re.match(r"^(\d+)([smhd])\s+ago$", time_str)
|
||||||
|
if match:
|
||||||
|
value = int(match.group(1))
|
||||||
|
unit = match.group(2)
|
||||||
|
units = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
||||||
|
return time.time() - (value * units[unit])
|
||||||
|
|
||||||
|
# ISO format
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(time_str)
|
||||||
|
return dt.timestamp()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_logs(
|
||||||
|
log_files,
|
||||||
|
service_colors,
|
||||||
|
tail: int,
|
||||||
|
since_time: Optional[float],
|
||||||
|
until_time: Optional[float],
|
||||||
|
timestamps: bool,
|
||||||
|
no_color: bool,
|
||||||
|
filter_pattern: Optional[str],
|
||||||
|
):
|
||||||
|
from ..output import console
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
all_lines = []
|
||||||
|
|
||||||
|
for service_name, log_path in log_files:
|
||||||
|
try:
|
||||||
|
with open(log_path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Take last N lines
|
||||||
|
lines = lines[-tail:] if tail else lines
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.rstrip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if filter_pattern and filter_pattern not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_time = None
|
||||||
|
timestamp_match = re.match(r"^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})", line)
|
||||||
|
if timestamp_match:
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
line_time = datetime.fromisoformat(
|
||||||
|
timestamp_match.group(1).replace(" ", "T")
|
||||||
|
).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if since_time and line_time and line_time < since_time:
|
||||||
|
continue
|
||||||
|
if until_time and line_time and line_time > until_time:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_lines.append((line_time or 0, service_name, line))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
console.print(f"[red]Error reading {log_path}: {e}[/red]")
|
||||||
|
|
||||||
|
all_lines.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
for _, service_name, line in all_lines:
|
||||||
|
if len(log_files) > 1:
|
||||||
|
# Multiple services - prefix with service name
|
||||||
|
if no_color:
|
||||||
|
console.print(f"{service_name} | {line}")
|
||||||
|
else:
|
||||||
|
color = service_colors.get(service_name, "white")
|
||||||
|
console.print(f"[{color}]{service_name}[/{color}] | {line}")
|
||||||
|
else:
|
||||||
|
console.print(line)
|
||||||
|
|
||||||
|
|
||||||
|
async def _follow_logs(
|
||||||
|
log_files,
|
||||||
|
service_colors,
|
||||||
|
timestamps: bool,
|
||||||
|
no_color: bool,
|
||||||
|
filter_pattern: Optional[str],
|
||||||
|
):
|
||||||
|
from ..output import console
|
||||||
|
|
||||||
|
positions = {}
|
||||||
|
for service_name, log_path in log_files:
|
||||||
|
if log_path.exists():
|
||||||
|
positions[service_name] = log_path.stat().st_size
|
||||||
|
else:
|
||||||
|
positions[service_name] = 0
|
||||||
|
|
||||||
|
console.print("[dim]Following logs... Press Ctrl+C to stop[/dim]\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
for service_name, log_path in log_files:
|
||||||
|
if not log_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_size = log_path.stat().st_size
|
||||||
|
if current_size > positions[service_name]:
|
||||||
|
with open(log_path) as f:
|
||||||
|
f.seek(positions[service_name])
|
||||||
|
new_content = f.read()
|
||||||
|
positions[service_name] = f.tell()
|
||||||
|
|
||||||
|
for line in new_content.splitlines():
|
||||||
|
if filter_pattern and filter_pattern not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if len(log_files) > 1:
|
||||||
|
if no_color:
|
||||||
|
console.print(f"{service_name} | {line}")
|
||||||
|
else:
|
||||||
|
color = service_colors.get(service_name, "white")
|
||||||
|
console.print(f"[{color}]{service_name}[/{color}] | {line}")
|
||||||
|
else:
|
||||||
|
console.print(line)
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[dim]Stopped following logs[/dim]")
|
||||||
89
pyserve/ctl/commands/scale.py
Normal file
89
pyserve/ctl/commands/scale.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""
|
||||||
|
pyserve scale - Scale services
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("scale")
|
||||||
|
@click.argument("scales", nargs=-1, required=True)
|
||||||
|
@click.option(
|
||||||
|
"--timeout",
|
||||||
|
"timeout",
|
||||||
|
default=60,
|
||||||
|
type=int,
|
||||||
|
help="Timeout in seconds for scaling operation",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-wait",
|
||||||
|
is_flag=True,
|
||||||
|
help="Don't wait for services to be ready",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def scale_cmd(ctx, scales: tuple, timeout: int, no_wait: bool):
|
||||||
|
"""
|
||||||
|
Scale services to specified number of workers.
|
||||||
|
|
||||||
|
Use SERVICE=NUM format to specify scaling.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve scale api=4 # Scale api to 4 workers
|
||||||
|
pyserve scale api=4 admin=2 # Scale multiple services
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_info, print_success
|
||||||
|
from ..state import StateManager
|
||||||
|
from .._runner import ServiceRunner
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
scale_map = {}
|
||||||
|
for scale in scales:
|
||||||
|
try:
|
||||||
|
service, num = scale.split("=")
|
||||||
|
scale_map[service] = int(num)
|
||||||
|
except ValueError:
|
||||||
|
print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
config_path = Path(ctx.config_file)
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
config = Config.from_yaml(str(config_path))
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
|
||||||
|
all_services = state_manager.get_all_services()
|
||||||
|
for service in scale_map:
|
||||||
|
if service not in all_services:
|
||||||
|
print_error(f"Service '{service}' not found")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
runner = ServiceRunner(config, state_manager)
|
||||||
|
|
||||||
|
console.print("[bold]Scaling services...[/bold]")
|
||||||
|
|
||||||
|
async def do_scale():
|
||||||
|
for service, workers in scale_map.items():
|
||||||
|
current = all_services[service].workers or 1
|
||||||
|
print_info(f"Scaling {service}: {current} → {workers} workers")
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = await runner.scale_service(
|
||||||
|
service, workers, timeout=timeout, wait=not no_wait
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
print_success(f"Scaled {service} to {workers} workers")
|
||||||
|
else:
|
||||||
|
print_error(f"Failed to scale {service}")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error scaling {service}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(do_scale())
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Scaling failed: {e}")
|
||||||
|
raise click.Abort()
|
||||||
190
pyserve/ctl/commands/service.py
Normal file
190
pyserve/ctl/commands/service.py
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
"""
|
||||||
|
pyserve start/stop/restart - Service management commands
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("start")
|
||||||
|
@click.argument("services", nargs=-1, required=True)
|
||||||
|
@click.option(
|
||||||
|
"--timeout",
|
||||||
|
"timeout",
|
||||||
|
default=60,
|
||||||
|
type=int,
|
||||||
|
help="Timeout in seconds for service startup",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def start_cmd(ctx, services: tuple, timeout: int):
|
||||||
|
"""
|
||||||
|
Start one or more services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve start api # Start api service
|
||||||
|
pyserve start api admin # Start multiple services
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_info, print_success
|
||||||
|
from ..state import StateManager
|
||||||
|
from .._runner import ServiceRunner
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
config_path = Path(ctx.config_file)
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
config = Config.from_yaml(str(config_path))
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
runner = ServiceRunner(config, state_manager)
|
||||||
|
|
||||||
|
console.print(f"[bold]Starting services: {', '.join(services)}[/bold]")
|
||||||
|
|
||||||
|
async def do_start():
|
||||||
|
results = {}
|
||||||
|
for service in services:
|
||||||
|
try:
|
||||||
|
success = await runner.start_service(service, timeout=timeout)
|
||||||
|
results[service] = success
|
||||||
|
if success:
|
||||||
|
print_success(f"Started {service}")
|
||||||
|
else:
|
||||||
|
print_error(f"Failed to start {service}")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error starting {service}: {e}")
|
||||||
|
results[service] = False
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = asyncio.run(do_start())
|
||||||
|
if not all(results.values()):
|
||||||
|
raise click.Abort()
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("stop")
|
||||||
|
@click.argument("services", nargs=-1, required=True)
|
||||||
|
@click.option(
|
||||||
|
"--timeout",
|
||||||
|
"timeout",
|
||||||
|
default=30,
|
||||||
|
type=int,
|
||||||
|
help="Timeout in seconds for graceful shutdown",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-f",
|
||||||
|
"--force",
|
||||||
|
is_flag=True,
|
||||||
|
help="Force stop (SIGKILL)",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def stop_cmd(ctx, services: tuple, timeout: int, force: bool):
|
||||||
|
"""
|
||||||
|
Stop one or more services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve stop api # Stop api service
|
||||||
|
pyserve stop api admin # Stop multiple services
|
||||||
|
pyserve stop api --force # Force stop
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_success
|
||||||
|
from ..state import StateManager
|
||||||
|
from .._runner import ServiceRunner
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
config_path = Path(ctx.config_file)
|
||||||
|
config = Config.from_yaml(str(config_path)) if config_path.exists() else Config()
|
||||||
|
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
runner = ServiceRunner(config, state_manager)
|
||||||
|
|
||||||
|
console.print(f"[bold]Stopping services: {', '.join(services)}[/bold]")
|
||||||
|
|
||||||
|
async def do_stop():
|
||||||
|
results = {}
|
||||||
|
for service in services:
|
||||||
|
try:
|
||||||
|
success = await runner.stop_service(service, timeout=timeout, force=force)
|
||||||
|
results[service] = success
|
||||||
|
if success:
|
||||||
|
print_success(f"Stopped {service}")
|
||||||
|
else:
|
||||||
|
print_error(f"Failed to stop {service}")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error stopping {service}: {e}")
|
||||||
|
results[service] = False
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = asyncio.run(do_stop())
|
||||||
|
if not all(results.values()):
|
||||||
|
raise click.Abort()
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("restart")
|
||||||
|
@click.argument("services", nargs=-1, required=True)
|
||||||
|
@click.option(
|
||||||
|
"--timeout",
|
||||||
|
"timeout",
|
||||||
|
default=60,
|
||||||
|
type=int,
|
||||||
|
help="Timeout in seconds for restart",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def restart_cmd(ctx, services: tuple, timeout: int):
|
||||||
|
"""
|
||||||
|
Restart one or more services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve restart api # Restart api service
|
||||||
|
pyserve restart api admin # Restart multiple services
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_success
|
||||||
|
from ..state import StateManager
|
||||||
|
from .._runner import ServiceRunner
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
config_path = Path(ctx.config_file)
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
config = Config.from_yaml(str(config_path))
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
runner = ServiceRunner(config, state_manager)
|
||||||
|
|
||||||
|
console.print(f"[bold]Restarting services: {', '.join(services)}[/bold]")
|
||||||
|
|
||||||
|
async def do_restart():
|
||||||
|
results = {}
|
||||||
|
for service in services:
|
||||||
|
try:
|
||||||
|
success = await runner.restart_service(service, timeout=timeout)
|
||||||
|
results[service] = success
|
||||||
|
if success:
|
||||||
|
print_success(f"Restarted {service}")
|
||||||
|
else:
|
||||||
|
print_error(f"Failed to restart {service}")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error restarting {service}: {e}")
|
||||||
|
results[service] = False
|
||||||
|
return results
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = asyncio.run(do_restart())
|
||||||
|
if not all(results.values()):
|
||||||
|
raise click.Abort()
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Error: {e}")
|
||||||
|
raise click.Abort()
|
||||||
153
pyserve/ctl/commands/status.py
Normal file
153
pyserve/ctl/commands/status.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
pyserve ps / status - Show service status
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("ps")
|
||||||
|
@click.argument("services", nargs=-1)
|
||||||
|
@click.option(
|
||||||
|
"-a",
|
||||||
|
"--all",
|
||||||
|
"show_all",
|
||||||
|
is_flag=True,
|
||||||
|
help="Show all services (including stopped)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-q",
|
||||||
|
"--quiet",
|
||||||
|
is_flag=True,
|
||||||
|
help="Only show service names",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--format",
|
||||||
|
"output_format",
|
||||||
|
type=click.Choice(["table", "json", "yaml"]),
|
||||||
|
default="table",
|
||||||
|
help="Output format",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--filter",
|
||||||
|
"filter_status",
|
||||||
|
default=None,
|
||||||
|
help="Filter by status (running, stopped, failed)",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def ps_cmd(
|
||||||
|
ctx,
|
||||||
|
services: tuple,
|
||||||
|
show_all: bool,
|
||||||
|
quiet: bool,
|
||||||
|
output_format: str,
|
||||||
|
filter_status: Optional[str],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Show status of services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve ps # Show running services
|
||||||
|
pyserve ps -a # Show all services
|
||||||
|
pyserve ps api admin # Show specific services
|
||||||
|
pyserve ps --format json # JSON output
|
||||||
|
pyserve ps --filter running # Filter by status
|
||||||
|
"""
|
||||||
|
from ..output import (
|
||||||
|
console,
|
||||||
|
create_services_table,
|
||||||
|
format_health,
|
||||||
|
format_status,
|
||||||
|
format_uptime,
|
||||||
|
print_info,
|
||||||
|
)
|
||||||
|
from ..state import StateManager
|
||||||
|
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
all_services = state_manager.get_all_services()
|
||||||
|
|
||||||
|
# Check if daemon is running
|
||||||
|
daemon_running = state_manager.is_daemon_running()
|
||||||
|
|
||||||
|
# Filter services
|
||||||
|
if services:
|
||||||
|
all_services = {k: v for k, v in all_services.items() if k in services}
|
||||||
|
|
||||||
|
if filter_status:
|
||||||
|
all_services = {
|
||||||
|
k: v for k, v in all_services.items() if v.state.lower() == filter_status.lower()
|
||||||
|
}
|
||||||
|
|
||||||
|
if not show_all:
|
||||||
|
# By default, show only running/starting/failed services
|
||||||
|
all_services = {
|
||||||
|
k: v
|
||||||
|
for k, v in all_services.items()
|
||||||
|
if v.state.lower() in ("running", "starting", "stopping", "failed", "restarting")
|
||||||
|
}
|
||||||
|
|
||||||
|
if not all_services:
|
||||||
|
if daemon_running:
|
||||||
|
print_info("No services found. Daemon is running but no services are configured.")
|
||||||
|
else:
|
||||||
|
print_info("No services running. Use 'pyserve up' to start services.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if quiet:
|
||||||
|
for name in all_services:
|
||||||
|
click.echo(name)
|
||||||
|
return
|
||||||
|
|
||||||
|
if output_format == "json":
|
||||||
|
data = {name: svc.to_dict() for name, svc in all_services.items()}
|
||||||
|
console.print(json.dumps(data, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
if output_format == "yaml":
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
data = {name: svc.to_dict() for name, svc in all_services.items()}
|
||||||
|
console.print(yaml.dump(data, default_flow_style=False))
|
||||||
|
return
|
||||||
|
|
||||||
|
table = create_services_table()
|
||||||
|
|
||||||
|
for name, service in sorted(all_services.items()):
|
||||||
|
ports = f"{service.port}" if service.port else "-"
|
||||||
|
uptime = format_uptime(service.uptime) if service.state == "running" else "-"
|
||||||
|
health = format_health(service.health.status if service.state == "running" else "-")
|
||||||
|
pid = str(service.pid) if service.pid else "-"
|
||||||
|
workers = f"{service.workers}" if service.workers else "-"
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
name,
|
||||||
|
format_status(service.state),
|
||||||
|
ports,
|
||||||
|
uptime,
|
||||||
|
health,
|
||||||
|
pid,
|
||||||
|
workers,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.print()
|
||||||
|
console.print(table)
|
||||||
|
console.print()
|
||||||
|
|
||||||
|
total = len(all_services)
|
||||||
|
running = sum(1 for s in all_services.values() if s.state == "running")
|
||||||
|
failed = sum(1 for s in all_services.values() if s.state == "failed")
|
||||||
|
|
||||||
|
summary_parts = [f"[bold]{total}[/bold] service(s)"]
|
||||||
|
if running:
|
||||||
|
summary_parts.append(f"[green]{running} running[/green]")
|
||||||
|
if failed:
|
||||||
|
summary_parts.append(f"[red]{failed} failed[/red]")
|
||||||
|
if total - running - failed > 0:
|
||||||
|
summary_parts.append(f"[dim]{total - running - failed} stopped[/dim]")
|
||||||
|
|
||||||
|
console.print(" | ".join(summary_parts))
|
||||||
|
console.print()
|
||||||
182
pyserve/ctl/commands/top.py
Normal file
182
pyserve/ctl/commands/top.py
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
"""
|
||||||
|
pyserve top - Live monitoring dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("top")
|
||||||
|
@click.argument("services", nargs=-1)
|
||||||
|
@click.option(
|
||||||
|
"--refresh",
|
||||||
|
"refresh_interval",
|
||||||
|
default=2,
|
||||||
|
type=float,
|
||||||
|
help="Refresh interval in seconds",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-color",
|
||||||
|
is_flag=True,
|
||||||
|
help="Disable colored output",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def top_cmd(ctx, services: tuple, refresh_interval: float, no_color: bool):
|
||||||
|
"""
|
||||||
|
Live monitoring dashboard for services.
|
||||||
|
|
||||||
|
Shows real-time CPU, memory usage, and request metrics.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve top # Monitor all services
|
||||||
|
pyserve top api admin # Monitor specific services
|
||||||
|
pyserve top --refresh 5 # Slower refresh rate
|
||||||
|
"""
|
||||||
|
from ..output import console, print_info
|
||||||
|
from ..state import StateManager
|
||||||
|
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
|
||||||
|
if not state_manager.is_daemon_running():
|
||||||
|
print_info("No services running. Start with 'pyserve up -d'")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
_run_dashboard(
|
||||||
|
state_manager,
|
||||||
|
list(services) if services else None,
|
||||||
|
refresh_interval,
|
||||||
|
no_color,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_dashboard(
|
||||||
|
state_manager,
|
||||||
|
filter_services: Optional[list],
|
||||||
|
refresh_interval: float,
|
||||||
|
no_color: bool,
|
||||||
|
):
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.layout import Layout
|
||||||
|
from rich.text import Text
|
||||||
|
from ..output import console, format_uptime, format_bytes
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psutil
|
||||||
|
except ImportError:
|
||||||
|
console.print("[yellow]psutil not installed. Install with: pip install psutil[/yellow]")
|
||||||
|
return
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
def make_dashboard():
|
||||||
|
all_services = state_manager.get_all_services()
|
||||||
|
|
||||||
|
if filter_services:
|
||||||
|
all_services = {k: v for k, v in all_services.items() if k in filter_services}
|
||||||
|
|
||||||
|
table = Table(
|
||||||
|
title=None,
|
||||||
|
show_header=True,
|
||||||
|
header_style="bold",
|
||||||
|
border_style="dim",
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
table.add_column("SERVICE", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("STATUS", no_wrap=True)
|
||||||
|
table.add_column("CPU%", justify="right")
|
||||||
|
table.add_column("MEM", justify="right")
|
||||||
|
table.add_column("PID", style="dim")
|
||||||
|
table.add_column("UPTIME", style="dim")
|
||||||
|
table.add_column("HEALTH", no_wrap=True)
|
||||||
|
|
||||||
|
total_cpu = 0.0
|
||||||
|
total_mem = 0
|
||||||
|
running_count = 0
|
||||||
|
total_count = len(all_services)
|
||||||
|
|
||||||
|
for name, service in sorted(all_services.items()):
|
||||||
|
status_style = {
|
||||||
|
"running": "[green]● RUN[/green]",
|
||||||
|
"stopped": "[dim]○ STOP[/dim]",
|
||||||
|
"failed": "[red]✗ FAIL[/red]",
|
||||||
|
"starting": "[yellow]◐ START[/yellow]",
|
||||||
|
"stopping": "[yellow]◑ STOP[/yellow]",
|
||||||
|
}.get(service.state, service.state)
|
||||||
|
|
||||||
|
cpu_str = "-"
|
||||||
|
mem_str = "-"
|
||||||
|
|
||||||
|
if service.pid and service.state == "running":
|
||||||
|
try:
|
||||||
|
proc = psutil.Process(service.pid)
|
||||||
|
cpu = proc.cpu_percent(interval=0.1)
|
||||||
|
mem = proc.memory_info().rss
|
||||||
|
cpu_str = f"{cpu:.1f}%"
|
||||||
|
mem_str = format_bytes(mem)
|
||||||
|
total_cpu += cpu
|
||||||
|
total_mem += mem
|
||||||
|
running_count += 1
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
health_style = {
|
||||||
|
"healthy": "[green]✓[/green]",
|
||||||
|
"unhealthy": "[red]✗[/red]",
|
||||||
|
"degraded": "[yellow]⚠[/yellow]",
|
||||||
|
"unknown": "[dim]?[/dim]",
|
||||||
|
}.get(service.health.status, "[dim]-[/dim]")
|
||||||
|
|
||||||
|
uptime = format_uptime(service.uptime) if service.state == "running" else "-"
|
||||||
|
pid = str(service.pid) if service.pid else "-"
|
||||||
|
|
||||||
|
table.add_row(
|
||||||
|
name,
|
||||||
|
status_style,
|
||||||
|
cpu_str,
|
||||||
|
mem_str,
|
||||||
|
pid,
|
||||||
|
uptime,
|
||||||
|
health_style,
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = format_uptime(time.time() - start_time)
|
||||||
|
summary = Text()
|
||||||
|
summary.append(f"Running: {running_count}/{total_count}", style="bold")
|
||||||
|
summary.append(" | ")
|
||||||
|
summary.append(f"CPU: {total_cpu:.1f}%", style="cyan")
|
||||||
|
summary.append(" | ")
|
||||||
|
summary.append(f"MEM: {format_bytes(total_mem)}", style="cyan")
|
||||||
|
summary.append(" | ")
|
||||||
|
summary.append(f"Session: {elapsed}", style="dim")
|
||||||
|
|
||||||
|
layout = Layout()
|
||||||
|
layout.split_column(
|
||||||
|
Layout(
|
||||||
|
Panel(
|
||||||
|
Text("PyServe Dashboard", style="bold cyan", justify="center"),
|
||||||
|
border_style="cyan",
|
||||||
|
),
|
||||||
|
size=3,
|
||||||
|
),
|
||||||
|
Layout(table),
|
||||||
|
Layout(Panel(summary, border_style="dim"), size=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
return layout
|
||||||
|
|
||||||
|
with Live(make_dashboard(), refresh_per_second=1 / refresh_interval, console=console) as live:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(refresh_interval)
|
||||||
|
live.update(make_dashboard())
|
||||||
174
pyserve/ctl/commands/up.py
Normal file
174
pyserve/ctl/commands/up.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
pyserve up - Start all services
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("up")
|
||||||
|
@click.argument("services", nargs=-1)
|
||||||
|
@click.option(
|
||||||
|
"-d",
|
||||||
|
"--detach",
|
||||||
|
is_flag=True,
|
||||||
|
help="Run in background (detached mode)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--build",
|
||||||
|
is_flag=True,
|
||||||
|
help="Build/reload applications before starting",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--force-recreate",
|
||||||
|
is_flag=True,
|
||||||
|
help="Recreate services even if configuration hasn't changed",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--scale",
|
||||||
|
"scales",
|
||||||
|
multiple=True,
|
||||||
|
help="Scale SERVICE to NUM workers (e.g., --scale api=4)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--timeout",
|
||||||
|
"timeout",
|
||||||
|
default=60,
|
||||||
|
type=int,
|
||||||
|
help="Timeout in seconds for service startup",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--wait",
|
||||||
|
is_flag=True,
|
||||||
|
help="Wait for services to be healthy before returning",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--remove-orphans",
|
||||||
|
is_flag=True,
|
||||||
|
help="Remove services not defined in configuration",
|
||||||
|
)
|
||||||
|
@click.pass_obj
|
||||||
|
def up_cmd(
|
||||||
|
ctx,
|
||||||
|
services: tuple,
|
||||||
|
detach: bool,
|
||||||
|
build: bool,
|
||||||
|
force_recreate: bool,
|
||||||
|
scales: tuple,
|
||||||
|
timeout: int,
|
||||||
|
wait: bool,
|
||||||
|
remove_orphans: bool,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Start services defined in configuration.
|
||||||
|
|
||||||
|
If no services are specified, all services will be started.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyserve up # Start all services
|
||||||
|
pyserve up -d # Start in background
|
||||||
|
pyserve up api admin # Start specific services
|
||||||
|
pyserve up --scale api=4 # Scale api to 4 workers
|
||||||
|
pyserve up --wait # Wait for healthy status
|
||||||
|
"""
|
||||||
|
from ..output import console, print_error, print_info, print_success, print_warning
|
||||||
|
from ..state import StateManager
|
||||||
|
from .._runner import ServiceRunner
|
||||||
|
|
||||||
|
config_path = Path(ctx.config_file)
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
print_error(f"Configuration file not found: {config_path}")
|
||||||
|
print_info("Run 'pyserve init' to create a configuration file")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
scale_map = {}
|
||||||
|
for scale in scales:
|
||||||
|
try:
|
||||||
|
service, num = scale.split("=")
|
||||||
|
scale_map[service] = int(num)
|
||||||
|
except ValueError:
|
||||||
|
print_error(f"Invalid scale format: {scale}. Use SERVICE=NUM")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ...config import Config
|
||||||
|
|
||||||
|
config = Config.from_yaml(str(config_path))
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Failed to load configuration: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
state_manager = StateManager(Path(".pyserve"), ctx.project)
|
||||||
|
|
||||||
|
if state_manager.is_daemon_running():
|
||||||
|
daemon_pid = state_manager.get_daemon_pid()
|
||||||
|
print_warning(f"PyServe daemon is already running (PID: {daemon_pid})")
|
||||||
|
if not click.confirm("Do you want to restart it?"):
|
||||||
|
raise click.Abort()
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
from typing import cast
|
||||||
|
# FIXME: Please fix the cast usage here
|
||||||
|
os.kill(cast(int, daemon_pid), signal.SIGTERM)
|
||||||
|
time.sleep(2)
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
state_manager.clear_daemon_pid()
|
||||||
|
|
||||||
|
runner = ServiceRunner(config, state_manager)
|
||||||
|
|
||||||
|
service_list = list(services) if services else None
|
||||||
|
|
||||||
|
if detach:
|
||||||
|
console.print("[bold]Starting PyServe in background...[/bold]")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = runner.start_daemon(
|
||||||
|
service_list,
|
||||||
|
scale_map=scale_map,
|
||||||
|
force_recreate=force_recreate,
|
||||||
|
)
|
||||||
|
state_manager.set_daemon_pid(pid)
|
||||||
|
print_success(f"PyServe started in background (PID: {pid})")
|
||||||
|
print_info("Use 'pyserve ps' to see service status")
|
||||||
|
print_info("Use 'pyserve logs -f' to follow logs")
|
||||||
|
print_info("Use 'pyserve down' to stop")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Failed to start daemon: {e}")
|
||||||
|
raise click.Abort()
|
||||||
|
else:
|
||||||
|
console.print("[bold]Starting PyServe...[/bold]")
|
||||||
|
|
||||||
|
def signal_handler(signum, frame):
|
||||||
|
console.print("\n[yellow]Received shutdown signal...[/yellow]")
|
||||||
|
runner.stop()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
runner.start(
|
||||||
|
service_list,
|
||||||
|
scale_map=scale_map,
|
||||||
|
force_recreate=force_recreate,
|
||||||
|
wait_healthy=wait,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
console.print("\n[yellow]Shutting down...[/yellow]")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Failed to start services: {e}")
|
||||||
|
if ctx.debug:
|
||||||
|
raise
|
||||||
|
raise click.Abort()
|
||||||
165
pyserve/ctl/main.py
Normal file
165
pyserve/ctl/main.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
PyServeCTL - Main entry point
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pyservectl [OPTIONS] COMMAND [ARGS]...
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = "config.yaml"
|
||||||
|
DEFAULT_STATE_DIR = ".pyserve"
|
||||||
|
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
def __init__(self):
|
||||||
|
self.config_file: str = DEFAULT_CONFIG
|
||||||
|
self.state_dir: Path = Path(DEFAULT_STATE_DIR)
|
||||||
|
self.verbose: bool = False
|
||||||
|
self.debug: bool = False
|
||||||
|
self.project: Optional[str] = None
|
||||||
|
self._config = None
|
||||||
|
self._state = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self):
|
||||||
|
if self._config is None:
|
||||||
|
from ..config import Config
|
||||||
|
|
||||||
|
if Path(self.config_file).exists():
|
||||||
|
self._config = Config.from_yaml(self.config_file)
|
||||||
|
else:
|
||||||
|
self._config = Config()
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self):
|
||||||
|
if self._state is None:
|
||||||
|
from .state import StateManager
|
||||||
|
|
||||||
|
self._state = StateManager(self.state_dir, self.project)
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
|
||||||
|
pass_context = click.make_pass_decorator(Context, ensure=True)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(invoke_without_command=True)
|
||||||
|
@click.option(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
"config_file",
|
||||||
|
default=DEFAULT_CONFIG,
|
||||||
|
envvar="PYSERVE_CONFIG",
|
||||||
|
help=f"Path to configuration file (default: {DEFAULT_CONFIG})",
|
||||||
|
type=click.Path(),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-p",
|
||||||
|
"--project",
|
||||||
|
"project",
|
||||||
|
default=None,
|
||||||
|
envvar="PYSERVE_PROJECT",
|
||||||
|
help="Project name for isolation",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"-v",
|
||||||
|
"--verbose",
|
||||||
|
is_flag=True,
|
||||||
|
help="Enable verbose output",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--debug",
|
||||||
|
is_flag=True,
|
||||||
|
help="Enable debug mode",
|
||||||
|
)
|
||||||
|
@click.version_option(version=__version__, prog_name="pyservectl")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, config_file: str, project: Optional[str], verbose: bool, debug: bool):
|
||||||
|
"""
|
||||||
|
PyServeCTL - Service management CLI for PyServe.
|
||||||
|
|
||||||
|
Docker-compose-like tool for managing PyServe services.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Quick Start:
|
||||||
|
pyservectl init # Initialize a new project
|
||||||
|
pyservectl up # Start all services
|
||||||
|
pyservectl ps # Show service status
|
||||||
|
pyservectl logs -f # Follow logs
|
||||||
|
pyservectl down # Stop all services
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
pyservectl up -d # Start in background
|
||||||
|
pyservectl up -c prod.yaml # Use custom config
|
||||||
|
pyservectl logs api -f --tail 100 # Follow api logs
|
||||||
|
pyservectl restart api admin # Restart specific services
|
||||||
|
pyservectl scale api=4 # Scale api to 4 workers
|
||||||
|
"""
|
||||||
|
ctx.ensure_object(Context)
|
||||||
|
ctx.obj.config_file = config_file
|
||||||
|
ctx.obj.verbose = verbose
|
||||||
|
ctx.obj.debug = debug
|
||||||
|
ctx.obj.project = project
|
||||||
|
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
click.echo(ctx.get_help())
|
||||||
|
|
||||||
|
|
||||||
|
from .commands import (
|
||||||
|
config_cmd,
|
||||||
|
down_cmd,
|
||||||
|
health_cmd,
|
||||||
|
init_cmd,
|
||||||
|
logs_cmd,
|
||||||
|
ps_cmd,
|
||||||
|
restart_cmd,
|
||||||
|
scale_cmd,
|
||||||
|
start_cmd,
|
||||||
|
stop_cmd,
|
||||||
|
top_cmd,
|
||||||
|
up_cmd,
|
||||||
|
)
|
||||||
|
|
||||||
|
cli.add_command(init_cmd, name="init")
|
||||||
|
cli.add_command(config_cmd, name="config")
|
||||||
|
cli.add_command(up_cmd, name="up")
|
||||||
|
cli.add_command(down_cmd, name="down")
|
||||||
|
cli.add_command(start_cmd, name="start")
|
||||||
|
cli.add_command(stop_cmd, name="stop")
|
||||||
|
cli.add_command(restart_cmd, name="restart")
|
||||||
|
cli.add_command(ps_cmd, name="ps")
|
||||||
|
cli.add_command(logs_cmd, name="logs")
|
||||||
|
cli.add_command(top_cmd, name="top")
|
||||||
|
cli.add_command(health_cmd, name="health")
|
||||||
|
cli.add_command(scale_cmd, name="scale")
|
||||||
|
|
||||||
|
# Alias 'status' -> 'ps'
|
||||||
|
cli.add_command(ps_cmd, name="status")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
cli(standalone_mode=False)
|
||||||
|
except click.ClickException as e:
|
||||||
|
e.show()
|
||||||
|
sys.exit(e.exit_code)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
click.echo("\nInterrupted by user")
|
||||||
|
sys.exit(130)
|
||||||
|
except Exception as e:
|
||||||
|
if "--debug" in sys.argv:
|
||||||
|
raise
|
||||||
|
click.secho(f"Error: {e}", fg="red", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
108
pyserve/ctl/output/__init__.py
Normal file
108
pyserve/ctl/output/__init__.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
PyServe CLI Output utilities
|
||||||
|
|
||||||
|
Rich-based formatters and helpers for CLI output.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.theme import Theme
|
||||||
|
|
||||||
|
pyserve_theme = Theme(
|
||||||
|
{
|
||||||
|
"info": "cyan",
|
||||||
|
"warning": "yellow",
|
||||||
|
"error": "red bold",
|
||||||
|
"success": "green",
|
||||||
|
"service.running": "green",
|
||||||
|
"service.stopped": "dim",
|
||||||
|
"service.failed": "red",
|
||||||
|
"service.starting": "yellow",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
console = Console(theme=pyserve_theme)
|
||||||
|
|
||||||
|
|
||||||
|
def print_error(message: str) -> None:
|
||||||
|
console.print(f"[error] {message}[/error]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_warning(message: str) -> None:
|
||||||
|
console.print(f"[warning] {message}[/warning]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_success(message: str) -> None:
|
||||||
|
console.print(f"[success] {message}[/success]")
|
||||||
|
|
||||||
|
def print_info(message: str) -> None:
|
||||||
|
console.print(f"[info] {message}[/info]")
|
||||||
|
|
||||||
|
def create_services_table() -> Table:
|
||||||
|
table = Table(
|
||||||
|
title=None,
|
||||||
|
show_header=True,
|
||||||
|
header_style="bold",
|
||||||
|
border_style="dim",
|
||||||
|
)
|
||||||
|
table.add_column("NAME", style="cyan", no_wrap=True)
|
||||||
|
table.add_column("STATUS", no_wrap=True)
|
||||||
|
table.add_column("PORTS", style="dim")
|
||||||
|
table.add_column("UPTIME", style="dim")
|
||||||
|
table.add_column("HEALTH", no_wrap=True)
|
||||||
|
table.add_column("PID", style="dim")
|
||||||
|
table.add_column("WORKERS", style="dim")
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def format_status(status: str) -> str:
|
||||||
|
status_styles = {
|
||||||
|
"running": "[service.running]● running[/service.running]",
|
||||||
|
"stopped": "[service.stopped]○ stopped[/service.stopped]",
|
||||||
|
"failed": "[service.failed]✗ failed[/service.failed]",
|
||||||
|
"starting": "[service.starting]◐ starting[/service.starting]",
|
||||||
|
"stopping": "[service.starting]◑ stopping[/service.starting]",
|
||||||
|
"restarting": "[service.starting]↻ restarting[/service.starting]",
|
||||||
|
"pending": "[service.stopped]○ pending[/service.stopped]",
|
||||||
|
}
|
||||||
|
return status_styles.get(status.lower(), status)
|
||||||
|
|
||||||
|
|
||||||
|
def format_health(health: str) -> str:
|
||||||
|
health_styles = {
|
||||||
|
"healthy": "[green] healthy[/green]",
|
||||||
|
"unhealthy": "[red] unhealthy[/red]",
|
||||||
|
"degraded": "[yellow] degraded[/yellow]",
|
||||||
|
"unknown": "[dim] unknown[/dim]",
|
||||||
|
"-": "[dim]-[/dim]",
|
||||||
|
}
|
||||||
|
return health_styles.get(health.lower(), health)
|
||||||
|
|
||||||
|
|
||||||
|
def format_uptime(seconds: float) -> str:
|
||||||
|
if seconds <= 0:
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{int(seconds)}s"
|
||||||
|
elif seconds < 3600:
|
||||||
|
minutes = int(seconds / 60)
|
||||||
|
secs = int(seconds % 60)
|
||||||
|
return f"{minutes}m {secs}s"
|
||||||
|
elif seconds < 86400:
|
||||||
|
hours = int(seconds / 3600)
|
||||||
|
minutes = int((seconds % 3600) / 60)
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
else:
|
||||||
|
days = int(seconds / 86400)
|
||||||
|
hours = int((seconds % 86400) / 3600)
|
||||||
|
return f"{days}d {hours}h"
|
||||||
|
|
||||||
|
|
||||||
|
def format_bytes(num_bytes: int) -> str:
|
||||||
|
value = float(num_bytes)
|
||||||
|
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||||
|
if abs(value) < 1024.0:
|
||||||
|
return f"{value:.1f}{unit}"
|
||||||
|
value /= 1024.0
|
||||||
|
return f"{value:.1f}PB"
|
||||||
234
pyserve/ctl/state/__init__.py
Normal file
234
pyserve/ctl/state/__init__.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
PyServe CLI State Management
|
||||||
|
|
||||||
|
Manages the state of running services.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceHealth:
|
||||||
|
status: str = "unknown" # healthy, unhealthy, degraded, unknown
|
||||||
|
last_check: Optional[float] = None
|
||||||
|
failures: int = 0
|
||||||
|
response_time_ms: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ServiceState:
|
||||||
|
name: str
|
||||||
|
state: str = "stopped" # pending, starting, running, stopping, stopped, failed, restarting
|
||||||
|
pid: Optional[int] = None
|
||||||
|
port: int = 0
|
||||||
|
workers: int = 0
|
||||||
|
started_at: Optional[float] = None
|
||||||
|
restart_count: int = 0
|
||||||
|
health: ServiceHealth = field(default_factory=ServiceHealth)
|
||||||
|
config_hash: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uptime(self) -> float:
|
||||||
|
if self.started_at is None:
|
||||||
|
return 0.0
|
||||||
|
return time.time() - self.started_at
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"state": self.state,
|
||||||
|
"pid": self.pid,
|
||||||
|
"port": self.port,
|
||||||
|
"workers": self.workers,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"restart_count": self.restart_count,
|
||||||
|
"health": asdict(self.health),
|
||||||
|
"config_hash": self.config_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "ServiceState":
|
||||||
|
health_data = data.pop("health", {})
|
||||||
|
health = ServiceHealth(**health_data) if health_data else ServiceHealth()
|
||||||
|
return cls(**data, health=health)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectState:
|
||||||
|
version: str = "1.0"
|
||||||
|
project: str = ""
|
||||||
|
config_file: str = ""
|
||||||
|
config_hash: str = ""
|
||||||
|
started_at: Optional[float] = None
|
||||||
|
daemon_pid: Optional[int] = None
|
||||||
|
services: Dict[str, ServiceState] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"version": self.version,
|
||||||
|
"project": self.project,
|
||||||
|
"config_file": self.config_file,
|
||||||
|
"config_hash": self.config_hash,
|
||||||
|
"started_at": self.started_at,
|
||||||
|
"daemon_pid": self.daemon_pid,
|
||||||
|
"services": {name: svc.to_dict() for name, svc in self.services.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict[str, Any]) -> "ProjectState":
|
||||||
|
services_data = data.pop("services", {})
|
||||||
|
services = {name: ServiceState.from_dict(svc) for name, svc in services_data.items()}
|
||||||
|
return cls(**data, services=services)
|
||||||
|
|
||||||
|
|
||||||
|
class StateManager:
|
||||||
|
STATE_FILE = "state.json"
|
||||||
|
PID_FILE = "pyserve.pid"
|
||||||
|
SOCKET_FILE = "pyserve.sock"
|
||||||
|
LOGS_DIR = "logs"
|
||||||
|
|
||||||
|
def __init__(self, state_dir: Path, project: Optional[str] = None):
|
||||||
|
self.state_dir = Path(state_dir)
|
||||||
|
self.project = project or self._detect_project()
|
||||||
|
self._state: Optional[ProjectState] = None
|
||||||
|
|
||||||
|
def _detect_project(self) -> str:
|
||||||
|
return Path.cwd().name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_file(self) -> Path:
|
||||||
|
return self.state_dir / self.STATE_FILE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pid_file(self) -> Path:
|
||||||
|
return self.state_dir / self.PID_FILE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def socket_file(self) -> Path:
|
||||||
|
return self.state_dir / self.SOCKET_FILE
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logs_dir(self) -> Path:
|
||||||
|
return self.state_dir / self.LOGS_DIR
|
||||||
|
|
||||||
|
def ensure_dirs(self) -> None:
|
||||||
|
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def load(self) -> ProjectState:
|
||||||
|
if self._state is not None:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
if self.state_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.state_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self._state = ProjectState.from_dict(data)
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
self._state = ProjectState(project=self.project)
|
||||||
|
else:
|
||||||
|
self._state = ProjectState(project=self.project)
|
||||||
|
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
if self._state is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.ensure_dirs()
|
||||||
|
|
||||||
|
with open(self.state_file, "w") as f:
|
||||||
|
json.dump(self._state.to_dict(), f, indent=2)
|
||||||
|
|
||||||
|
def get_state(self) -> ProjectState:
|
||||||
|
return self.load()
|
||||||
|
|
||||||
|
def update_service(self, name: str, **kwargs) -> ServiceState:
|
||||||
|
state = self.load()
|
||||||
|
|
||||||
|
if name not in state.services:
|
||||||
|
state.services[name] = ServiceState(name=name)
|
||||||
|
|
||||||
|
service = state.services[name]
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(service, key):
|
||||||
|
setattr(service, key, value)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
return service
|
||||||
|
|
||||||
|
def remove_service(self, name: str) -> None:
|
||||||
|
state = self.load()
|
||||||
|
if name in state.services:
|
||||||
|
del state.services[name]
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def get_service(self, name: str) -> Optional[ServiceState]:
|
||||||
|
state = self.load()
|
||||||
|
return state.services.get(name)
|
||||||
|
|
||||||
|
def get_all_services(self) -> Dict[str, ServiceState]:
|
||||||
|
state = self.load()
|
||||||
|
return state.services.copy()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._state = ProjectState(project=self.project)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def is_daemon_running(self) -> bool:
|
||||||
|
if not self.pid_file.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid = int(self.pid_file.read_text().strip())
|
||||||
|
# Check if process exists
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except (ValueError, ProcessLookupError, PermissionError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_daemon_pid(self) -> Optional[int]:
|
||||||
|
if not self.is_daemon_running():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(self.pid_file.read_text().strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_daemon_pid(self, pid: int) -> None:
|
||||||
|
self.ensure_dirs()
|
||||||
|
self.pid_file.write_text(str(pid))
|
||||||
|
|
||||||
|
state = self.load()
|
||||||
|
state.daemon_pid = pid
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def clear_daemon_pid(self) -> None:
|
||||||
|
if self.pid_file.exists():
|
||||||
|
self.pid_file.unlink()
|
||||||
|
|
||||||
|
state = self.load()
|
||||||
|
state.daemon_pid = None
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def get_service_log_file(self, service_name: str) -> Path:
|
||||||
|
self.ensure_dirs()
|
||||||
|
return self.logs_dir / f"{service_name}.log"
|
||||||
|
|
||||||
|
def compute_config_hash(self, config_file: str) -> str:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
path = Path(config_file)
|
||||||
|
if not path.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
content = path.read_bytes()
|
||||||
|
return hashlib.sha256(content).hexdigest()[:16]
|
||||||
Loading…
x
Reference in New Issue
Block a user