Eric Callahan bfeb096f31
jsonrpc: share one instance among all transports
This change refactors the APIDefiniton into a dataclass, allowing
defs to be shared directly among HTTP and RPC requests.  In
addition, all transports now share one instance of JSONRPC,
removing duplicate registration.  API Defintiions are registered
with the RPC Dispatcher, and it validates the Transport type.
In addition tranports may perform their own validation prior
to request execution.

Signed-off-by:  Eric Callahan <arksine.code@gmail.com>
2023-12-16 16:21:21 -05:00

649 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
# Moonraker - HTTP/Websocket API Server for Klipper
#
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license
from __future__ import annotations
import pathlib
import sys
import argparse
import importlib
import os
import io
import time
import socket
import logging
import signal
import asyncio
import uuid
import traceback
from . import confighelper
from .eventloop import EventLoop
from .app import MoonrakerApp
from .klippy_connection import KlippyConnection
from .utils import ServerError, Sentinel, get_software_info, json_wrapper
from .loghelper import LogManager
from .common import RequestType
from .websockets import WebsocketManager
# Annotation imports
from typing import (
TYPE_CHECKING,
Any,
Optional,
Callable,
Coroutine,
Dict,
List,
Tuple,
Union,
TypeVar,
)
if TYPE_CHECKING:
from .common import WebRequest
from .components.file_manager.file_manager import FileManager
from .components.machine import Machine
from .components.extensions import ExtensionManager
FlexCallback = Callable[..., Optional[Coroutine]]
_T = TypeVar("_T", Sentinel, Any)
API_VERSION = (1, 4, 0)
CORE_COMPONENTS = [
'dbus_manager', 'database', 'file_manager', 'klippy_apis',
'machine', 'data_store', 'shell_command', 'proc_stats',
'job_state', 'job_queue', 'http_client', 'announcements',
'webcam', 'extensions',
]
class Server:
error = ServerError
config_error = confighelper.ConfigError
def __init__(self,
args: Dict[str, Any],
log_manager: LogManager,
event_loop: EventLoop
) -> None:
self.event_loop = event_loop
self.log_manager = log_manager
self.app_args = args
self.events: Dict[str, List[FlexCallback]] = {}
self.components: Dict[str, Any] = {}
self.failed_components: List[str] = []
self.warnings: Dict[str, str] = {}
self._is_configured: bool = False
self.config = config = self._parse_config()
self.host: str = config.get('host', "0.0.0.0")
self.port: int = config.getint('port', 7125)
self.ssl_port: int = config.getint('ssl_port', 7130)
self.exit_reason: str = ""
self.server_running: bool = False
# Configure Debug Logging
config.getboolean('enable_debug_logging', False, deprecate=True)
self.debug = args["debug"]
log_level = logging.DEBUG if args["verbose"] else logging.INFO
logging.getLogger().setLevel(log_level)
self.event_loop.set_debug(args["asyncio_debug"])
self.klippy_connection = KlippyConnection(self)
# Tornado Application/Server
self.moonraker_app = app = MoonrakerApp(config)
self.register_endpoint = app.register_endpoint
self.register_debug_endpoint = app.register_debug_endpoint
self.register_static_file_handler = app.register_static_file_handler
self.register_upload_handler = app.register_upload_handler
self.log_manager.set_server(self)
self.websocket_manager = WebsocketManager(config)
for warning in args.get("startup_warnings", []):
self.add_warning(warning)
self.register_endpoint(
"/server/info", RequestType.GET, self._handle_info_request
)
self.register_endpoint(
"/server/config", RequestType.GET, self._handle_config_request
)
self.register_endpoint(
"/server/restart", RequestType.POST, self._handle_server_restart
)
self.register_notification("server:klippy_ready")
self.register_notification("server:klippy_shutdown")
self.register_notification("server:klippy_disconnect",
"klippy_disconnected")
self.register_notification("server:gcode_response")
def get_app_args(self) -> Dict[str, Any]:
return dict(self.app_args)
def get_event_loop(self) -> EventLoop:
return self.event_loop
def get_api_version(self) -> Tuple[int, int, int]:
return API_VERSION
def get_warnings(self) -> List[str]:
return list(self.warnings.values())
def is_running(self) -> bool:
return self.server_running
def is_configured(self) -> bool:
return self._is_configured
def is_debug_enabled(self) -> bool:
return self.debug
def is_verbose_enabled(self) -> bool:
return self.app_args["verbose"]
def _parse_config(self) -> confighelper.ConfigHelper:
config = confighelper.get_configuration(self, self.app_args)
# log config file
cfg_files = "\n".join(config.get_config_files())
strio = io.StringIO()
config.write_config(strio)
cfg_item = f"\n{'#'*20} Moonraker Configuration {'#'*20}\n\n"
cfg_item += strio.getvalue()
cfg_item += "#"*65
cfg_item += f"\nAll Configuration Files:\n{cfg_files}\n"
cfg_item += "#"*65
strio.close()
self.add_log_rollover_item('config', cfg_item)
return config
async def server_init(self, start_server: bool = True) -> None:
self.event_loop.add_signal_handler(
signal.SIGTERM, self._handle_term_signal)
# Perform asynchronous init after the event loop starts
optional_comps: List[Coroutine] = []
for name, component in self.components.items():
if not hasattr(component, "component_init"):
continue
if name in CORE_COMPONENTS:
# Process core components in order synchronously
await self._initialize_component(name, component)
else:
optional_comps.append(
self._initialize_component(name, component))
# Asynchronous Optional Component Initialization
if optional_comps:
await asyncio.gather(*optional_comps)
if not self.warnings:
await self.event_loop.run_in_thread(self.config.create_backup)
machine: Machine = self.lookup_component("machine")
if await machine.validate_installation():
return
if start_server:
await self.start_server()
async def start_server(self, connect_to_klippy: bool = True) -> None:
# Open Unix Socket Server
extm: ExtensionManager = self.lookup_component("extensions")
await extm.start_unix_server()
# Start HTTP Server
logging.info(
f"Starting Moonraker on ({self.host}, {self.port}), "
f"Hostname: {socket.gethostname()}")
self.moonraker_app.listen(self.host, self.port, self.ssl_port)
self.server_running = True
if connect_to_klippy:
self.klippy_connection.connect()
def add_log_rollover_item(
self, name: str, item: str, log: bool = True
) -> None:
self.log_manager.set_rollover_info(name, item)
if log and item is not None:
logging.info(item)
def add_warning(
self, warning: str, warn_id: Optional[str] = None, log: bool = True
) -> str:
if warn_id is None:
warn_id = str(id(warning))
self.warnings[warn_id] = warning
if log:
logging.warning(warning)
return warn_id
def remove_warning(self, warn_id: str) -> None:
self.warnings.pop(warn_id, None)
# ***** Component Management *****
async def _initialize_component(self, name: str, component: Any) -> None:
logging.info(f"Performing Component Post Init: [{name}]")
try:
ret = component.component_init()
if ret is not None:
await ret
except Exception as e:
logging.exception(f"Component [{name}] failed post init")
self.add_warning(f"Component '{name}' failed to load with "
f"error: {e}")
self.set_failed_component(name)
def load_components(self) -> None:
config = self.config
cfg_sections = set([s.split()[0] for s in config.sections()])
cfg_sections.remove('server')
# load core components
for component in CORE_COMPONENTS:
self.load_component(config, component)
if component in cfg_sections:
cfg_sections.remove(component)
# load remaining optional components
for section in cfg_sections:
self.load_component(config, section, None)
self.klippy_connection.configure(config)
config.validate_config()
self._is_configured = True
def load_component(
self,
config: confighelper.ConfigHelper,
component_name: str,
default: _T = Sentinel.MISSING
) -> Union[_T, Any]:
if component_name in self.components:
return self.components[component_name]
if self.is_configured():
raise self.error(
"Cannot load components after configuration", 500
)
if component_name in self.failed_components:
raise self.error(
f"Component {component_name} previously failed to load", 500
)
try:
full_name = f"moonraker.components.{component_name}"
module = importlib.import_module(full_name)
is_core = component_name in CORE_COMPONENTS
fallback: Optional[str] = "server" if is_core else None
config = config.getsection(component_name, fallback)
load_func = getattr(module, "load_component")
component = load_func(config)
except Exception:
msg = f"Unable to load component: ({component_name})"
logging.exception(msg)
if component_name not in self.failed_components:
self.failed_components.append(component_name)
if default is Sentinel.MISSING:
raise
return default
self.components[component_name] = component
logging.info(f"Component ({component_name}) loaded")
return component
def lookup_component(
self, component_name: str, default: _T = Sentinel.MISSING
) -> Union[_T, Any]:
component = self.components.get(component_name, default)
if component is Sentinel.MISSING:
raise ServerError(f"Component ({component_name}) not found")
return component
def set_failed_component(self, component_name: str) -> None:
if component_name not in self.failed_components:
self.failed_components.append(component_name)
def register_component(self, component_name: str, component: Any) -> None:
if component_name in self.components:
raise self.error(
f"Component '{component_name}' already registered")
self.components[component_name] = component
def register_notification(
self, event_name: str, notify_name: Optional[str] = None
) -> None:
self.websocket_manager.register_notification(event_name, notify_name)
def register_event_handler(
self, event: str, callback: FlexCallback
) -> None:
self.events.setdefault(event, []).append(callback)
def send_event(self, event: str, *args) -> asyncio.Future:
fut = self.event_loop.create_future()
self.event_loop.register_callback(
self._process_event, fut, event, *args)
return fut
async def _process_event(
self, fut: asyncio.Future, event: str, *args
) -> None:
events = self.events.get(event, [])
coroutines: List[Coroutine] = []
for func in events:
try:
ret = func(*args)
except Exception:
logging.exception(f"Error processing callback in event {event}")
else:
if ret is not None:
coroutines.append(ret)
if coroutines:
results = await asyncio.gather(*coroutines, return_exceptions=True)
for val in results:
if isinstance(val, Exception):
if sys.version_info < (3, 10):
exc_info = "".join(traceback.format_exception(
type(val), val, val.__traceback__
))
else:
exc_info = "".join(traceback.format_exception(val))
logging.info(
f"\nError processing callback in event {event}\n{exc_info}"
)
if not fut.done():
fut.set_result(None)
def register_remote_method(
self, method_name: str, cb: FlexCallback
) -> None:
self.klippy_connection.register_remote_method(method_name, cb)
def get_host_info(self) -> Dict[str, Any]:
return {
'hostname': socket.gethostname(),
'address': self.host,
'port': self.port,
'ssl_port': self.ssl_port
}
def get_klippy_info(self) -> Dict[str, Any]:
return self.klippy_connection.klippy_info
def _handle_term_signal(self) -> None:
logging.info("Exiting with signal SIGTERM")
self.event_loop.register_callback(self._stop_server, "terminate")
async def _stop_server(self, exit_reason: str = "restart") -> None:
self.server_running = False
# Call each component's "on_exit" method
for name, component in self.components.items():
if hasattr(component, "on_exit"):
func: FlexCallback = getattr(component, "on_exit")
try:
ret = func()
if ret is not None:
await ret
except Exception:
logging.exception(
f"Error executing 'on_exit()' for component: {name}")
# Sleep for 100ms to allow connected websockets to write out
# remaining data
await asyncio.sleep(.1)
try:
await self.moonraker_app.close()
await self.websocket_manager.close()
except Exception:
logging.exception("Error Closing App")
# Disconnect from Klippy
try:
await asyncio.wait_for(
asyncio.shield(self.klippy_connection.close(
wait_closed=True)), 2.)
except Exception:
logging.exception("Klippy Disconnect Error")
# Close all components
for name, component in self.components.items():
if name in ["application", "websockets", "klippy_connection"]:
# These components have already been closed
continue
if hasattr(component, "close"):
func = getattr(component, "close")
try:
ret = func()
if ret is not None:
await ret
except Exception:
logging.exception(
f"Error executing 'close()' for component: {name}")
# Allow cancelled tasks a chance to run in the eventloop
await asyncio.sleep(.001)
self.exit_reason = exit_reason
self.event_loop.remove_signal_handler(signal.SIGTERM)
self.event_loop.stop()
async def _handle_server_restart(self, web_request: WebRequest) -> str:
self.event_loop.register_callback(self._stop_server)
return "ok"
async def _handle_info_request(self, web_request: WebRequest) -> Dict[str, Any]:
raw = web_request.get_boolean("raw", False)
file_manager: Optional[FileManager] = self.lookup_component(
'file_manager', None)
reg_dirs = []
if file_manager is not None:
reg_dirs = file_manager.get_registered_dirs()
mreqs = self.klippy_connection.missing_requirements
if raw:
warnings = list(self.warnings.values())
else:
warnings = [
w.replace("\n", "<br/>") for w in self.warnings.values()
]
return {
'klippy_connected': self.klippy_connection.is_connected(),
'klippy_state': str(self.klippy_connection.state),
'components': list(self.components.keys()),
'failed_components': self.failed_components,
'registered_directories': reg_dirs,
'warnings': warnings,
'websocket_count': self.websocket_manager.get_count(),
'moonraker_version': self.app_args['software_version'],
'missing_klippy_requirements': mreqs,
'api_version': API_VERSION,
'api_version_string': ".".join([str(v) for v in API_VERSION])
}
async def _handle_config_request(self, web_request: WebRequest) -> Dict[str, Any]:
cfg_file_list: List[Dict[str, Any]] = []
cfg_parent = pathlib.Path(
self.app_args["config_file"]
).expanduser().resolve().parent
for fname, sections in self.config.get_file_sections().items():
path = pathlib.Path(fname)
try:
rel_path = str(path.relative_to(str(cfg_parent)))
except ValueError:
rel_path = fname
cfg_file_list.append({"filename": rel_path, "sections": sections})
return {
'config': self.config.get_parsed_config(),
'orig': self.config.get_orig_config(),
'files': cfg_file_list
}
def main(from_package: bool = True) -> None:
def get_env_bool(key: str) -> bool:
return os.getenv(key, "").lower() in ["y", "yes", "true"]
# Parse start arguments
parser = argparse.ArgumentParser(
description="Moonraker - Klipper API Server")
parser.add_argument(
"-d", "--datapath",
default=os.getenv("MOONRAKER_DATA_PATH"),
metavar='<data path>',
help="Location of Moonraker Data File Path"
)
parser.add_argument(
"-c", "--configfile",
default=os.getenv("MOONRAKER_CONFIG_PATH"),
metavar='<configfile>',
help="Path to Moonraker's configuration file"
)
parser.add_argument(
"-l", "--logfile",
default=os.getenv("MOONRAKER_LOG_PATH"),
metavar='<logfile>',
help="Path to Moonraker's log file"
)
parser.add_argument(
"-u", "--unixsocket",
default=os.getenv("MOONRAKER_UDS_PATH"),
metavar="<unixsocket>",
help="Path to Moonraker's unix domain socket"
)
parser.add_argument(
"-n", "--nologfile",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_DISABLE_FILE_LOG"),
help="disable logging to a file"
)
parser.add_argument(
"-v", "--verbose",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_VERBOSE_LOGGING"),
help="Enable verbose logging"
)
parser.add_argument(
"-g", "--debug",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_ENABLE_DEBUG"),
help="Enable Moonraker debug features"
)
parser.add_argument(
"-o", "--asyncio-debug",
action='store_const',
const=True,
default=get_env_bool("MOONRAKER_ASYNCIO_DEBUG"),
help="Enable asyncio debug flag"
)
cmd_line_args = parser.parse_args()
startup_warnings: List[str] = []
dp: str = cmd_line_args.datapath or "~/printer_data"
data_path = pathlib.Path(dp).expanduser().resolve()
if not data_path.exists():
try:
data_path.mkdir()
except Exception:
startup_warnings.append(
f"Unable to create data path folder at {data_path}"
)
uuid_path = data_path.joinpath(".moonraker.uuid")
if not uuid_path.is_file():
instance_uuid = uuid.uuid4().hex
uuid_path.write_text(instance_uuid)
else:
instance_uuid = uuid_path.read_text().strip()
if cmd_line_args.configfile is not None:
cfg_file: str = cmd_line_args.configfile
else:
cfg_file = str(data_path.joinpath("config/moonraker.conf"))
if cmd_line_args.unixsocket is not None:
unix_sock: str = cmd_line_args.unixsocket
else:
comms_dir = data_path.joinpath("comms")
if not comms_dir.exists():
comms_dir.mkdir()
unix_sock = str(comms_dir.joinpath("moonraker.sock"))
app_args = {
"data_path": str(data_path),
"is_default_data_path": cmd_line_args.datapath is None,
"config_file": cfg_file,
"startup_warnings": startup_warnings,
"verbose": cmd_line_args.verbose,
"debug": cmd_line_args.debug,
"asyncio_debug": cmd_line_args.asyncio_debug,
"is_backup_config": False,
"is_python_package": from_package,
"instance_uuid": instance_uuid,
"unix_socket_path": unix_sock
}
# Setup Logging
app_args.update(get_software_info())
if cmd_line_args.nologfile:
app_args["log_file"] = ""
elif cmd_line_args.logfile:
app_args["log_file"] = os.path.normpath(
os.path.expanduser(cmd_line_args.logfile))
else:
app_args["log_file"] = str(data_path.joinpath("logs/moonraker.log"))
app_args["python_version"] = sys.version.replace("\n", " ")
app_args["msgspec_enabled"] = json_wrapper.MSGSPEC_ENABLED
app_args["uvloop_enabled"] = EventLoop.UVLOOP_ENABLED
log_manager = LogManager(app_args, startup_warnings)
# Start asyncio event loop and server
event_loop = EventLoop()
alt_config_loaded = False
estatus = 0
while True:
try:
server = Server(app_args, log_manager, event_loop)
server.load_components()
except confighelper.ConfigError as e:
backup_cfg = confighelper.find_config_backup(cfg_file)
logging.exception("Server Config Error")
if alt_config_loaded or backup_cfg is None:
estatus = 1
break
app_args["config_file"] = backup_cfg
app_args["is_backup_config"] = True
warn_list = list(startup_warnings)
app_args["startup_warnings"] = warn_list
warn_list.append(
f"Server configuration error: {e}\n"
f"Loaded server from most recent working configuration:"
f" '{app_args['config_file']}'\n"
f"Please fix the issue in moonraker.conf and restart "
f"the server."
)
alt_config_loaded = True
continue
except Exception:
logging.exception("Moonraker Error")
estatus = 1
break
try:
event_loop.register_callback(server.server_init)
event_loop.start()
except Exception:
logging.exception("Server Running Error")
estatus = 1
break
if server.exit_reason == "terminate":
break
# Restore the original config and clear the warning
# before the server restarts
if alt_config_loaded:
app_args["config_file"] = cfg_file
app_args["startup_warnings"] = startup_warnings
app_args["is_backup_config"] = False
alt_config_loaded = False
event_loop.close()
# Since we are running outside of the the server
# it is ok to use a blocking sleep here
time.sleep(.5)
logging.info("Attempting Server Restart...")
event_loop.reset()
event_loop.close()
logging.info("Server Shutdown")
log_manager.stop_logging()
exit(estatus)