3636from ..storage .indy import IndyStorage
3737from ..config .ledger import ledger_config
3838
39+
3940LOGGER = logging .getLogger (__name__ )
4041
4142
@@ -51,11 +52,23 @@ class AdminStatusSchema(Schema):
5152 """Schema for the status endpoint."""
5253
5354
55+ class AdminStatusLivelinessSchema (Schema ):
56+ """Schema for the liveliness endpoint."""
57+
58+ alive = fields .Boolean (description = "Liveliness status" , example = True )
59+
60+
61+ class AdminStatusReadinessSchema (Schema ):
62+ """Schema for the liveliness endpoint."""
63+
64+ ready = fields .Boolean (description = "Readiness status" , example = True )
65+
66+
5467class AdminResponder (BaseResponder ):
5568 """Handle outgoing messages from message handlers."""
5669
5770 def __init__ (
58- self , context : InjectionContext , send : Coroutine , webhook : Coroutine , ** kwargs
71+ self , context : InjectionContext , send : Coroutine , webhook : Coroutine , ** kwargs ,
5972 ):
6073 """
6174 Initialize an instance of `AdminResponder`.
@@ -93,10 +106,10 @@ class WebhookTarget:
93106 """Class for managing webhook target information."""
94107
95108 def __init__ (
96- self ,
97- endpoint : str ,
98- topic_filter : Sequence [str ] = None ,
99- max_attempts : int = None ,
109+ self ,
110+ endpoint : str ,
111+ topic_filter : Sequence [str ] = None ,
112+ max_attempts : int = None ,
100113 ):
101114 """Initialize the webhook target."""
102115 self .endpoint = endpoint
@@ -118,18 +131,45 @@ def topic_filter(self, val: Sequence[str]):
118131 self ._topic_filter = filter
119132
120133
134+ @web .middleware
135+ async def ready_middleware (request : web .BaseRequest , handler : Coroutine ):
136+ """Only continue if application is ready to take work."""
137+
138+ if str (request .rel_url ).rstrip ("/" ) in (
139+ "/status/live" ,
140+ "/status/ready" ,
141+ ) or request .app ._state .get ("ready" ):
142+ return await handler (request )
143+
144+ raise web .HTTPServiceUnavailable (reason = "Shutdown in progress" )
145+
146+
147+ @web .middleware
148+ async def debug_middleware (request : web .BaseRequest , handler : Coroutine ):
149+ """Show request detail in debug log."""
150+
151+ if LOGGER .isEnabledFor (logging .DEBUG ):
152+ LOGGER .debug (f"Incoming request: { request .method } { request .path_qs } " )
153+ LOGGER .debug (f"Match info: { request .match_info } " )
154+ body = await request .text ()
155+ LOGGER .debug (f"Body: { body } " )
156+
157+ return await handler (request )
158+
159+
121160class AdminServer (BaseAdminServer ):
122161 """Admin HTTP server class."""
123162
124163 def __init__ (
125- self ,
126- host : str ,
127- port : int ,
128- context : InjectionContext ,
129- outbound_message_router : Coroutine ,
130- webhook_router : Callable ,
131- task_queue : TaskQueue = None ,
132- conductor_stats : Coroutine = None ,
164+ self ,
165+ host : str ,
166+ port : int ,
167+ context : InjectionContext ,
168+ outbound_message_router : Coroutine ,
169+ webhook_router : Callable ,
170+ conductor_stop : Coroutine ,
171+ task_queue : TaskQueue = None ,
172+ conductor_stats : Coroutine = None ,
133173 ):
134174 """
135175 Initialize an AdminServer instance.
@@ -140,6 +180,7 @@ def __init__(
140180 context: The application context instance
141181 outbound_message_router: Coroutine for delivering outbound messages
142182 webhook_router: Callable for delivering webhooks
183+ conductor_stop: Conductor (graceful) stop for shutdown API call
143184 task_queue: An optional task queue for handlers
144185 """
145186 self .app = None
@@ -149,6 +190,7 @@ def __init__(
149190 )
150191 self .host = host
151192 self .port = port
193+ self .conductor_stop = conductor_stop
152194 self .conductor_stats = conductor_stats
153195 self .loaded_modules = []
154196 self .task_queue = task_queue
@@ -159,14 +201,14 @@ def __init__(
159201
160202 self .context = context .start_scope ("admin" )
161203 self .responder = AdminResponder (
162- self .context , outbound_message_router , self .send_webhook
204+ self .context , outbound_message_router , self .send_webhook ,
163205 )
164206 self .context .injector .bind_instance (BaseResponder , self .responder )
165207
166208 async def make_application (self ) -> web .Application :
167209 """Get the aiohttp application instance."""
168210
169- middlewares = [validation_middleware ]
211+ middlewares = [ready_middleware , debug_middleware , validation_middleware ]
170212
171213 # admin-token and admin-token are mutually exclusive and required.
172214 # This should be enforced during parameter parsing but to be sure,
@@ -225,22 +267,17 @@ async def collect_stats(request, handler):
225267 @web .middleware
226268 async def agency_middleware (request , handler ):
227269 omit_list = ["/create_wallet" ]
228-
229270 if request .rel_url .path not in omit_list :
230271 wallet_key = request .headers .get ("wallet-key" )
231272 wallet_name = request .headers .get ("wallet-name" )
232-
233273 self .context .settings .set_value ("wallet.key" , wallet_key )
234274 self .context .settings .set_value ("wallet.name" , wallet_name )
235-
236275 self .context .injector .clear_binding (BaseWallet )
237276 self .context .injector .clear_binding (BaseStorage )
238-
239277 wallet_instance : BaseWallet = await agency_wallet .get (wallet_name , wallet_key )
240278 if wallet_instance is None :
241279 raise web .HTTPUnauthorized ()
242280 self .context .injector .bind_instance (BaseWallet , wallet_instance )
243-
244281 storage = IndyStorage (wallet_instance )
245282 self .context .injector .bind_instance (BaseStorage , storage )
246283 await wallet_config (self .context )
@@ -255,6 +292,9 @@ async def agency_middleware(request, handler):
255292 web .get ("/plugins" , self .plugins_handler , allow_head = False ),
256293 web .get ("/status" , self .status_handler , allow_head = False ),
257294 web .post ("/status/reset" , self .status_reset_handler ),
295+ web .get ("/status/live" , self .liveliness_handler , allow_head = False ),
296+ web .get ("/status/ready" , self .readiness_handler , allow_head = False ),
297+ web .get ("/shutdown" , self .shutdown_handler , allow_head = False ),
258298 web .get ("/ws" , self .websocket_handler , allow_head = False ),
259299 web .post ('/create_wallet' , agency_wallet .create ),
260300 ]
@@ -307,10 +347,20 @@ async def start(self) -> None:
307347 if plugin_registry :
308348 plugin_registry .post_process_routes (self .app )
309349
350+ # order tags alphabetically, parameters deterministically and pythonically
351+ swagger_dict = self .app ._state ["swagger_dict" ]
352+ swagger_dict .get ("tags" , []).sort (key = lambda t : t ["name" ])
353+ for path in swagger_dict ["paths" ].values ():
354+ for method_spec in path .values ():
355+ method_spec ["parameters" ].sort (
356+ key = lambda p : (p ["in" ], not p ["required" ], p ["name" ])
357+ )
358+
310359 self .site = web .TCPSite (runner , host = self .host , port = self .port )
311360
312361 try :
313362 await self .site .start ()
363+ self .app ._state ["ready" ] = True
314364 except OSError :
315365 raise AdminSetupError (
316366 "Unable to start webserver with host "
@@ -319,6 +369,7 @@ async def start(self) -> None:
319369
320370 async def stop (self ) -> None :
321371 """Stop the webserver."""
372+ self .app ._state ["ready" ] = False # in case call does not come through OpenAPI
322373 for queue in self .websocket_queues .values ():
323374 queue .stop ()
324375 if self .site :
@@ -367,6 +418,7 @@ async def status_handler(self, request: web.BaseRequest):
367418
368419 """
369420 status = {"version" : __version__ }
421+ status ["label" ] = self .context .settings .get ("default_label" )
370422 collector : Collector = await self .context .inject (Collector , required = False )
371423 if collector :
372424 status ["timing" ] = collector .results
@@ -396,6 +448,54 @@ async def redirect_handler(self, request: web.BaseRequest):
396448 """Perform redirect to documentation."""
397449 raise web .HTTPFound ("/api/doc" )
398450
451+ @docs (tags = ["server" ], summary = "Liveliness check" )
452+ @response_schema (AdminStatusLivelinessSchema (), 200 )
453+ async def liveliness_handler (self , request : web .BaseRequest ):
454+ """
455+ Request handler for liveliness check.
456+
457+ Args:
458+ request: aiohttp request object
459+
460+ Returns:
461+ The web response, always indicating True
462+
463+ """
464+ return web .json_response ({"alive" : True })
465+
466+ @docs (tags = ["server" ], summary = "Readiness check" )
467+ @response_schema (AdminStatusReadinessSchema (), 200 )
468+ async def readiness_handler (self , request : web .BaseRequest ):
469+ """
470+ Request handler for liveliness check.
471+
472+ Args:
473+ request: aiohttp request object
474+
475+ Returns:
476+ The web response, indicating readiness for further calls
477+
478+ """
479+ return web .json_response ({"ready" : self .app ._state ["ready" ]})
480+
481+ @docs (tags = ["server" ], summary = "Shut down server" )
482+ async def shutdown_handler (self , request : web .BaseRequest ):
483+ """
484+ Request handler for server shutdown.
485+
486+ Args:
487+ request: aiohttp request object
488+
489+ Returns:
490+ The web response (empty production)
491+
492+ """
493+ self .app ._state ["ready" ] = False
494+ loop = asyncio .get_event_loop ()
495+ asyncio .ensure_future (self .conductor_stop (), loop = loop )
496+
497+ return web .json_response ({})
498+
399499 async def websocket_handler (self , request ):
400500 """Send notifications to admin client over websocket."""
401501
@@ -477,6 +577,7 @@ async def websocket_handler(self, request):
477577 if msg :
478578 await ws .send_json (msg )
479579 send = loop .create_task (queue .dequeue (timeout = 5.0 ))
580+
480581 except asyncio .CancelledError :
481582 closed = True
482583
@@ -491,10 +592,10 @@ async def websocket_handler(self, request):
491592 return ws
492593
493594 def add_webhook_target (
494- self ,
495- target_url : str ,
496- topic_filter : Sequence [str ] = None ,
497- max_attempts : int = None ,
595+ self ,
596+ target_url : str ,
597+ topic_filter : Sequence [str ] = None ,
598+ max_attempts : int = None ,
498599 ):
499600 """Add a webhook target."""
500601 self .webhook_targets [target_url ] = WebhookTarget (
0 commit comments