
    `gjre                       d Z ddlmZ ddlZddlZddlZddlmZ ddlm	Z	 ddl
mZmZ  ej                  e      Z edh      Z edd	h      Zd
Zed   Z ed       G d d             Zddd	 	 	 	 	 	 	 	 	 ddZ	 	 	 	 	 	 	 	 d dZd!dZd"dZd#dZd#dZd$dZd%dZd&dZ	 	 	 	 	 	 d'dZdZ d(dZ!edk(  r e" e!             y))a  Container-boot reconciliation of per-profile gateway s6 services.

Service directories under /run/service/ live on **tmpfs** and are wiped
on every container restart. Profile directories under
``$HERMES_HOME/profiles/<name>/`` live on the persistent VOLUME, and
each one records its gateway's last state in ``gateway_state.json``.
This module bridges the two: on every container boot, walk the
persistent profiles, recreate the s6 service slots, and auto-start
only those whose last recorded state was ``running``.

Wired into the image as /etc/cont-init.d/02-reconcile-profiles by the
Dockerfile (Phase 4 Task 4.0). Runs as root after 01-hermes-setup
(the stage2 hook) has chowned the volume and seeded $HERMES_HOME, but
before s6-rc starts user services.

Without this module, every ``docker restart`` would silently wipe
every per-profile gateway, even though the user's profiles still
exist on disk.
    )annotationsN)	dataclass)Path)LiteralSequencerunningdrainingdegraded)zgateway.pidzprocesses.json)started
registeredskippedT)frozenc                  0    e Zd ZU dZded<   ded<   ded<   y)	ReconcileActionz8One profile's outcome from a single reconciliation pass.strprofile
str | Noneprior_stateReconcileActionLabelactionN)__name__
__module____qualname____doc____annotations__     L/root/.hermes/venv/lib/python3.12/site-packages/hermes_cli/container_boot.pyr   r   P   s    BL  r   r   F)dry_runcontainer_argvc                   g }ddl m}  |t        j                  j	                  d            }t        | ||      }|xs t        |       }|t        v }	|st        |        t        |d|	       |j                  t        d||	rdnd	             | d
z  }
|
j                         rt        |
j                               D ]  }|j                         s|dz  j                         s(|j                   dk(  rt"        j%                  d       Mt        |      }| xr |t        v }|s#t        |       t        ||j                   |       |j                  t        |j                   ||rdnd	              |st'        | |       |S )ub  Recreate s6 service registrations for every persistent profile.

    Always registers a ``gateway-default`` slot for the root profile
    (the implicit profile that lives at the top of ``$HERMES_HOME``,
    not under ``profiles/``). The dispatcher in ``hermes_cli.gateway``
    maps an empty profile suffix to ``gateway-default``, so this slot
    is what ``hermes gateway start`` (no ``-p``) targets. Without it,
    bare ``hermes gateway start`` inside the container would land on
    ``s6-svc -u /run/service/gateway-default`` → uncaught
    ``CalledProcessError`` → traceback to the user (PR #30136 review).

    The default slot's prior state is read from
    ``$HERMES_HOME/gateway_state.json`` (sibling to the profile root,
    not under ``profiles/``); stale runtime files there are swept the
    same way as for named profiles.

    Args:
        hermes_home: The container's HERMES_HOME (typically /opt/data).
            Profiles live under ``<hermes_home>/profiles/<name>/``;
            the default profile lives at ``<hermes_home>`` itself.
        scandir: The s6 dynamic scandir (typically /run/service). Service
            directories are created at ``<scandir>/gateway-<profile>/``.
        dry_run: When True, walk and return the action list without
            touching the filesystem. For tests and `--dry-run` debug.
        container_argv: Optional container PID 1 argv override. Production
            reads ``/proc/1/cmdline``; tests inject it directly.

    Returns:
        One :class:`ReconcileAction` per profile, in this order:
        ``default`` first, then named profiles in directory order.
    r   )is_truthy_valueGATEWAY_MULTIPLEX_PROFILES)r    r   default)startr   r   )r   r   r   profileszSOUL.mdu_   profiles/default/ exists — skipping to avoid colliding with the reserved root-profile s6 slot)utilsr"   osenvironget'_maybe_migrate_legacy_gateway_run_state_read_desired_state_AUTOSTART_STATES_cleanup_stale_runtime_files_register_serviceappendr   is_dirsortediterdirexistsnamelogwarning_write_reconcile_log)hermes_homescandirr   r    actionsr"   multiplex_profileslegacy_default_statedefault_prior_statedefault_should_startprofiles_rootentryr   should_starts                 r   reconcile_profile_gatewaysrC   X   s   L &(G &(


34 C%
 /R2Ek2R.2CC$[1'94HINN?'0yl   *,MM1134 #	E<<>
 I%--/ zzY&= -e4K&&K;:K+K  ,U3!'5::\JNN?

'$0yl ?#	J ['2Nr   c          
     j   | dz  }|j                         ryt        j                  j                  dd      j	                         dv ry|t        |      n	t               }t        |      sy|sGddl}|j                  t        j                  ddt        |j                               dd	      d
z          y)a  Seed root gateway_state for pre-s6 `gateway run` containers.

    The tini image let Docker users run the gateway as the container
    command (`docker run ... gateway run`). After the s6 migration,
    profile gateways are restored from persisted gateway_state.json; a
    legacy container with no state file would therefore register the
    default service down and never start. Only synthesize state when no
    root gateway_state.json exists so explicit stopped/failed states keep
    winning across restarts.
    gateway_state.jsonNHERMES_GATEWAY_NO_SUPERVISE )1trueyesr   r   zlegacy-container-cmd)gateway_statedesired_state	timestampmigrated_from
)r4   r(   r)   r*   lowertuple_read_container_argv_is_legacy_gateway_run_requesttime
write_textjsondumpsint)r9   r    r   
state_fileargvrT   s         r   r+   r+      s      33J	zz~~3R8>>@DXX$2$>5 DXDZD)$/djj&&TYY[)3	*
 
  	 r   c                    	 t        d      j                         } t        d | j                  d      D              }t	        d |D              r|S 	 	 t        d      }|j                         D ]h  }|j                  j                         s	 |dz  j                         } t        d | j                  d      D              }t	        d |D              sf|c S  	 y	# t
        $ r Y w xY w# t
        $ r Y w xY w# t
        $ r Y y	w xY w)
a  Best-effort read of the container's main program argv.

    Under s6-overlay v2, PID 1 is ``/init`` and its argv contains the
    ``main-wrapper.sh`` path.  Under s6-overlay v3, PID 1 is
    ``s6-svscan`` and the actual command (``rc.init top main-wrapper.sh
    ...``) lives on a different PID.  We try PID 1 first (fast path,
    covers v2 and pre-s6 images), then fall back to scanning
    ``/proc/*/cmdline`` for a process whose argv contains
    ``main-wrapper.sh`` (the rc.init-launched PID in v3).
    z/proc/1/cmdlinec              3  F   K   | ]  }|s|j                  d d        ywutf-8replaceNdecode.0parts     r   	<genexpr>z'_read_container_argv.<locals>.<genexpr>  s"      
04DDKK+
s   !!    c              3  $   K   | ]  }d |v  
 ywzmain-wrapper.shNr   rb   s     r   re   z'_read_container_argv.<locals>.<genexpr>  s     :T D(:   z/proccmdlinec              3  D   K   | ]  }|r|j                  d d        ywr]   r`   rb   s     r   re   z'_read_container_argv.<locals>.<genexpr>  s&       GY/s    c              3  $   K   | ]  }d |v  
 ywrh   r   rb   s     r   re   z'_read_container_argv.<locals>.<genexpr>  s     >$,>ri   r   )	r   
read_bytesrQ   splitanyOSErrorr3   r5   isdigit)rawrZ   proc_dirrA   s       r   rR   rR      s   $%002 
8;		%8H
 
 :T::K ;=%%' 	E::%%'y(446  IIe, D
 >>>	" 1      sT   AC 9C8 C)3C8 C8 C8 	C&%C&)	C52C8 4C55C8 8	DDc                    t        |       }t        d t        |      D        d      }|	||dz   d }n"|r t        |d         j                  dk(  r|dd }|r t        |d         j                  dk(  r|dd }|S )u|  Strip the s6/wrapper prefix off the container argv, leaving the hermes args.

    Two container-command argv shapes are handled:

    * **s6-overlay v2 / tini:** PID 1 argv is
      ``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]``.
    * **s6-overlay v3:** PID 1 is ``s6-svscan`` and the command lives on the
      rc.init-launched process as ``/bin/sh -e
      /run/s6/basedir/scripts/rc.init top /opt/hermes/docker/main-wrapper.sh
      <subcommand> [args...]`` (see :func:`_read_container_argv`).

    Rather than peel each leading token positionally (which silently breaks
    the moment s6 changes its launcher shape again — exactly what happened
    in the v2→v3 bump), drop everything up to and including the
    ``main-wrapper.sh`` token: that wrapper path is the stable boundary the
    image owns, and the subcommand always follows it. Pre-s6 / direct
    ``hermes`` invocations carry no wrapper, so fall back to peeling a bare
    ``init`` prefix. The wrapper re-execs ``hermes <subcommand>``, so an
    explicit leading ``hermes`` is peeled too. Shared by the legacy-gateway
    and dashboard role detectors.
    c              3  J   K   | ]  \  }}|j                  d       s|  ywrh   )endswith)rc   ias      r   re   z/_strip_container_argv_prefix.<locals>.<genexpr>=  s      Htq!!**5F*GHs   ##N   r   inithermes)listnext	enumerater   r5   )rZ   argswrapper_idxs      r   _strip_container_argv_prefixr   !  s    , :D
 HyHK K!O%&	$tAw-$$.ABx T!W""h.ABxKr   c                f    t        |       }d|v ryt        |      dk\  xr |d   dk(  xr |d   dk(  S )z<Return True for Docker commands equivalent to `gateway run`.z--no-superviseF   r   gatewayry   run)r   lenrZ   r   s     r   rS   rS   L  sA    '-D4t9>Gd1g2GtAw%7GGr   c                B    t        |       }t        |      xr |d   dk(  S )u  Return True when the container's command is the dashboard.

    A dashboard-only container (``hermes dashboard ...``) never spawns or
    supervises per-profile gateways — that is the gateway container's job.
    Reconciling profile gateway s6 slots there is not just wasted work: when
    the gateway and dashboard containers share a bind-mounted HERMES_HOME,
    both race to ``flock()`` the same ``logs/gateways/<profile>/lock`` files,
    producing "Resource busy" failures and an s6-log restart storm. So the
    dashboard container skips reconciliation entirely.

    Detected from PID 1 argv (``/proc/1/cmdline``) rather than an operator
    flag: the role is a fact about the container's command, not a tunable,
    and a flag can be forgotten in a hand-written compose/k8s manifest —
    reintroducing the exact storm this prevents. Mirrors the argv handling
    in :func:`_is_legacy_gateway_run_request`.
    r   	dashboard)r   boolr   s     r   _is_dashboard_containerr   T  s&    " (-D:0$q'[00r   c                B   | dz  }|j                         sy	 t        j                  |j                               }|j	                  d      }||S |j	                  d      }|t
        v ry|S # t        t        j                  f$ r t        j                  d|       Y yw xY w)u  Read the persisted gateway desired state for reconciliation.

    Newer state files carry ``desired_state``: operator intent written by
    s6 lifecycle commands. Older files only carry ``gateway_state``; keep
    that as a compatibility fallback so existing running/stopped profiles
    preserve their behavior until the next explicit start/stop.

    When falling back to ``gateway_state`` (no explicit ``desired_state``),
    a transient running sub-state (``draining``) is normalised to ``running``
    — see ``_TRANSIENT_RUNNING_STATES``. A gateway hard-killed mid-drain
    leaves ``draining`` as its last persisted value; without this it would be
    treated as a non-autostart state and the gateway would stay DOWN forever.
    An explicit ``desired_state`` is always honoured verbatim (it is the
    operator's durable intent), so this normalisation only affects the
    legacy/transient fallback path.

    Missing or unparseable files count as "no desired state" so we don't
    bork the whole reconciliation on a corrupt file.
    rE   NrL   rK   r   z-could not read %s; treating as no prior state)
r4   rV   loads	read_textr*   _TRANSIENT_RUNNING_STATESrp   JSONDecodeErrorr6   r7   )profile_dirrY   datarL   rK   s        r   r,   r,   i  s    ( 33Jzz*..011$  155T))* ;Z	
 	s   7A, A, *A, ,/BBc                D    t         D ]  }| |z  j                  d        y)u   Remove gateway.pid and processes.json — they reference PIDs in
    the dead container's process namespace and would otherwise confuse
    the newly-started gateway's process-mismatch checks.T)
missing_okN)_STALE_RUNTIME_FILESunlink)r   r5   s     r   r.   r.     s)     % 5	t	##t#45r   c               f   ddl }ddlm}m}m}  ||       | d| z  }|j                  d|j                  z   dz         }|j                         r|j                  |d       |j                  d	       	 |d
z  j                  d       |dz  }	|	j                  |j                  |i              |	j                  d       |dz  }
|
j                  |j                                |
j                  d       |dz  }|j                          |dz  }|j                  |j                  |             |j                  d       |s|dz  j                           ||       |j                         r|j                  |       |j!                  |       y# t"        $ r |j                  |d        w xY w)u  Recreate the s6 service slot for one profile.

    Mirrors the rendering in :func:`S6ServiceManager.register_profile_gateway`,
    but here we control the start state directly via the ``down`` marker
    file (s6-svscan honors it on rescan). Cannot use the manager
    directly because the cont-init.d phase runs as root before
    s6-svscan starts scanning the dynamic scandir — the manager's
    ``s6-svscanctl -a`` call would fail with no control socket.

    Atomicity: build the new layout in a sibling temp directory and
    rename it into place via :meth:`Path.replace`. This matches
    :meth:`S6ServiceManager.register_profile_gateway` (PR #30136
    review item O4) — even though cont-init.d runs before s6-svscan
    starts scanning, an atomic publication keeps the contract uniform
    between the two registration paths and protects against a
    half-populated dir if the script is interrupted mid-write.
    r   N)S6ServiceManager_seed_supervise_skeletonvalidate_profile_namezgateway-.z.tmpT)ignore_errors)parentstypezlongrun
r   )	extra_envi  finishr6   down)shutilhermes_cli.service_managerr   r   r   	with_namer5   r4   rmtreemkdirrU   _render_run_scriptchmod_render_finish_script_render_log_runtouchr_   	Exception)r:   r   r%   r   r   r   r   service_dirtmp_dirr   r   
log_subdirlog_runs                r   r/   r/     s   $   '"hwi00K ##C+*:*:$:V$CDG ~~gT2MM$M3	6	%%k2 o'::7b:QR		%8#*@@BCU u_
u$+;;GDEe v$$& 	!) MM+&$ gT2s   6DF F0c                $   ddl }| dz  }|j                  dd       |dz  }	 |j                         r5|j                         j                  t
        k\  r|j                  |dz         |j                  d	      }|j                  d
d      5 }|D ]=  }|j                  | d|j                   d|j                   d|j                   d       ? 	 ddd       y# t        $ r!}t        j                  d||       Y d}~d}~ww xY w# 1 sw Y   yxY w)u  Append one line per profile to $HERMES_HOME/logs/container-boot.log.

    Operators inspect this to debug "why didn't my profile come back
    up". Keeping a separate log file (vs. mixing into agent.log) lets
    troubleshooters grep for "profile=foo" without wading through
    unrelated activity.

    Size-bounded: when the file exceeds ``_LOG_ROTATE_BYTES``
    (defaults to 256 KiB ≈ 3000 reconcile lines), the current file
    is renamed to ``container-boot.log.1`` (replacing any previous
    rotation) before the new entries are appended. This gives long-
    lived containers a soft cap of ~512 KiB across the two files
    without pulling in logrotate or s6-log machinery just for this
    one append-only file (PR #30136 review item O3).
    r   NlogsT)r   exist_okzcontainer-boot.logzcontainer-boot.log.1zcould not rotate %s: %sz%Y-%m-%dT%H:%M:%S%zrx   r^   )encodingz	 profile= prior_state= action=rO   )rT   r   r4   statst_size_LOG_ROTATE_BYTESr_   rp   r6   r7   strftimeopenwriter   r   r   )	r9   r;   rT   log_dirlog_pathexctsfrx   s	            r   r8   r8     s   $ F"GMM$M.--H>??!8!8<M!MW'==> 
,	-B	sW	-  	AGG$i		{- G((2'	   > 	-x==> s%   AC AD	D"C>>DDi   c            	     r   t        t                     rt        d       yt        t        j
                  j                  dd            } t        t        j
                  j                  dd            }t        | |      }|D ]4  }t        d|j                   d	|j                   d
|j                          6 y)z@Entry point invoked from /etc/cont-init.d/02-reconcile-profiles.uP   reconcile: skipping (dashboard container — does not need per-profile gateways)r   HERMES_HOMEz	/opt/dataS6_PROFILE_GATEWAY_SCANDIRz/run/service)r9   r:   zreconcile: profile=r   r   )r   rR   printr   r(   r)   r*   rC   r   r   r   )r9   r:   r;   rx   s       r   mainr   ,  s     356$	
 rzz~~m[ABK2::>>">OPG(G  
!!)) -==/!((=	


 r   __main__)
r9   r   r:   r   r   r   r    Sequence[str] | Nonereturnlist[ReconcileAction])r9   r   r    r   r   r   r   r   )r   ztuple[str, ...])rZ   Sequence[str]r   z	list[str])rZ   r   r   r   )r   r   r   r   )r   r   r   None)r:   r   r   r   r%   r   r   r   )r9   r   r;   r   r   r   )r   rX   )#r   
__future__r   rV   loggingr(   dataclassesr   pathlibr   typingr   r   	getLoggerr   r6   	frozensetr-   r   r   r   r   rC   r+   rR   r   rS   r   r,   r.   r/   r8   r   r   
SystemExitr   r   r   <module>r      sf  & #   	 !  $g! yk* < &z:&>?  9 AB  $! ! ! +/rr r 	r
 )r rj## )# 	#
 #L+\(VH1*$N5_D'' 5'	'^  > z
TV
 r   