#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""Sign every web user out ahead of a shutdown: run **under sudo** by `logout-all` and `make logout-web`.Each running per-user instance keeps its own countdown (:mod:`euler_webshell.user.logout`); thisstarts them, waits for them all, then revokes every session and remember-me token, which is whatsigns out a user with no tab open. Only instances already running are called: dialling a socketis what starts a stopped one. `--extend` adds time and returns, leaving the run that is waitingto finish the job; `--now` closes every shell at once, notice or no notice, and finishes it here.Invocation:: sudo <venv-python> -m euler_webshell.logout_all [reason] [--seconds N] [--limit N] sudo <venv-python> -m euler_webshell.logout_all --extend N sudo <venv-python> -m euler_webshell.logout_all --now"""from__future__importannotations__all__=['DEFAULT_LIMIT','DEFAULT_REASON','DEFAULT_SECONDS','main']importargparseimporthttp.clientimportsubprocessimportsysimporttimefrompathlibimportPathfromtypingimportAnyfromeuler_site.auth.adminimportrevoke_allfromeuler_site.auth.clientimportrequestfromsolver.config.envimportload_specDEFAULT_REASON:str='the server is shutting down'#: The countdown, and how long running work may hold the shell past it in all.DEFAULT_SECONDS:int=5DEFAULT_LIMIT:int=300_ROUTE:str='/internal/logout-notice'_POLL_SECONDS:float=2.0defrunning_instances()->list[str]:"""The slugs of the per-user instances running now."""listed=subprocess.run(['systemctl','list-units','euler-user@*.service','--state=active','--plain','--no-legend'],capture_output=True,text=True,check=False)units=[line.split()[0]forlineinlisted.stdout.splitlines()ifline.strip()]return[unit.removeprefix('euler-user@').removesuffix('.service')forunitinunits]def_call(slug:str,method:str,body:dict[str,Any]|None=None)->dict[str,Any]|None:"""One call to *slug*'s notice route; None if the instance is gone or predates the route."""socket_path=Path(load_spec('auth').raw('user_socket_dir'))/f'user-{slug}.sock'try:status,data=request(str(socket_path),method,_ROUTE,body=body,timeout=5.0)except(OSError,http.client.HTTPException):returnNonereturndataifstatus==200andisinstance(data,dict)elseNonedef_push(slugs:list[str],body:dict[str,Any])->list[str]:"""Send *body* to every instance; return the ones that took it, naming the rest."""reached=[slugforsluginslugsif_call(slug,'POST',body)isnotNone]forsluginsorted(set(slugs)-set(reached)):print(f'note: {slug} did not answer; its shell closes when the instance stops')returnreacheddef_wait(slugs:list[str])->None:"""Block until no instance has a notice standing, reporting who is still holding it up."""waiting=list(slugs)shown:list[str]=[]whilewaiting:states={slug:_call(slug,'GET')forsluginwaiting}waiting=[slugforslug,stateinstates.items()ifstateisnotNoneandstate.get('active')]held=[slugforsluginwaitingif(states[slug]or{}).get('running')]ifheld!=shown:print(f'waiting on running work: {", ".join(held)}'ifheldelse'no running work left')shown=heldifwaiting:time.sleep(_POLL_SECONDS)defmain(argv:list[str])->int:parser=argparse.ArgumentParser(prog='logout_all',description=__doc__.splitlines()[0]if__doc__else'')parser.add_argument('reason',nargs='?',default=DEFAULT_REASON)parser.add_argument('--seconds',type=int,default=DEFAULT_SECONDS)parser.add_argument('--limit',type=int,default=DEFAULT_LIMIT)parser.add_argument('--extend',type=int,default=0)parser.add_argument('--now',action='store_true')args=parser.parse_args(argv)slugs=running_instances()ifargs.extend:print(f'added {args.extend}s on {len(_push(slugs,{"extend":args.extend}))} instance(s)')return0body={'now':True}ifargs.nowelse{'reason':args.reason,'seconds':args.seconds,'limit':args.limit}reached=_push(slugs,body)print(f'closing the shells on {len(reached)} instance(s)'ifargs.nowelsef'notice on {len(reached)} instance(s): {args.reason}')try:_wait(reached)exceptKeyboardInterrupt:print('stopped waiting: the countdowns go on, but nobody is signed out until `--now` runs',file=sys.stderr)return130try:print(f'revoked {revoke_all()} session(s) and remember-me token(s)')except(OSError,RuntimeError)asexc:print(f'error: could not revoke the sessions ({exc})',file=sys.stderr)return1return0if__name__=='__main__':raiseSystemExit(main(sys.argv[1:]))