§
    øžyj0  ã                  ó(  — d Z ddlmZ ddlZddlZddlZddlmZmZ ddlm	Z	 ddl
mZmZ ddlmZ ddlmZ  ej        e¦  «        Zd	Z ej        d
¬¦  «        d#d„¦   «         Zd$d%d„Zddddœd&d„Zddœd'd„Zd(d„Zddœd'd„Zddœd'd „Zddœd)d"„ZdS )*u¸  External drain-control marker contract (dashboard â†’ gateway).

Task 2.2 of the safe-shutdown plan (decisions.md Q-B, option A): the dashboard
has no way to call into a running gateway â€” there is no HTTP control channel
into the gateway process (guardrails: "there is NO external control channel
into a running gateway"). Restart/drain is driven only by the gateway reacting
to its own inputs: slash commands, process signals, and file markers it writes
itself (``.restart_notify.json``).

So the begin/cancel-drain dashboard endpoint communicates with the running
gateway the same way: it writes (or removes) a marker file, and a gateway
background watcher reacts to it. This module owns that marker contract so both
sides â€” the dashboard endpoint (writer) and the gateway watcher (reader) â€”
share one definition and can never disagree.

Contract (presence-based, mirroring ``.restart_notify.json``):

  * begin-drain  â†’ write ``{HERMES_HOME}/.drain_request.json`` with
    ``{"action": "drain", "requested_at": <iso>, "principal": <str>,
    "epoch": <instantiation-epoch>, "suppress_notification": <bool>}``.
  * cancel-drain â†’ remove the marker.
  * The gateway watcher treats **presence of a marker stamped with the current
    instantiation epoch** as "external drain active": flip
    ``gateway_state -> "draining"`` and stop accepting new turns. Absence (or a
    marker from a *prior* instantiation) means "not draining" (revert to
    ``running`` if we had flipped it).

Why the epoch (NS-570). ``HERMES_HOME`` is a **durable** store â€” on Hermes
Cloud it is a persistent Fly volume (``/opt/data``). A begin-drain marker
written there *survives a machine restart*. But the disruptive lifecycle
actions a drain protects (auto-update / image migrate / env edit / profile
change) all **restart the machine**, which is exactly the signal that the drain
is over. Without the epoch, a freshly-restarted gateway re-reads the orphaned
marker on boot and parks itself right back in ``draining`` forever (NS-570: an
auto-updated instance refused every turn for ~52 min). Stamping the marker with
an identity of *this* container/VM instantiation, and ignoring a marker whose
epoch doesn't match, makes "a deliberate restart clears the drain" true by
construction â€” while a marker written during the *current* instantiation (the
live drain) still matches, and an s6 respawn of just the gateway (PID 1 / init
unchanged) still honours an in-flight drain.

Reading the marker never raises: a malformed/half-written file reads as
"present but contentless", which the watcher still treats as drain-active
(fail-safe toward quiescing â€” a corrupt begin marker must not be ignored). The
epoch check is deliberately **lenient**: it ignores a marker only on a
*definite* epoch mismatch. A marker with no epoch (legacy/corrupt/contentless),
or an environment where the epoch cannot be computed (non-Linux, no ``/proc``),
both degrade to the original presence-only behaviour â€” never fail-closed.
é    )ÚannotationsN)ÚdatetimeÚtimezone)ÚPath)ÚAnyÚOptional)Úget_hermes_home)Úatomic_json_writez.drain_request.jsoné   )ÚmaxsizeÚreturnÚstrc                 ó–  — d} 	 t          d¦  «                             d¬¦  «                             ¦   «         } n# t          $ r Y nw xY wd}	 t          d¦  «                             d¬¦  «        }|                     dd¦  «        d                              ¦   «         }|d         }n# t          t          f$ r Y nw xY w| s|sdS | › d	|› �S )
u!  Identity of THIS container / VM instantiation.

    Stable for the life of the PID-1 init process â€” so an s6 respawn of just
    the gateway keeps the same epoch and an in-flight drain is honoured â€” but
    changes when the machine/container is recreated (a fresh PID 1 â†’ a fresh
    epoch). Composed from two ``/proc`` facts:

      * the kernel **boot id** (``/proc/sys/kernel/random/boot_id``) â€” changes
        on a VM / microVM reboot (e.g. a Fly Firecracker machine restart);
      * **PID 1's start time** (field 22 of ``/proc/1/stat``) â€” changes on a
        plain ``docker restart`` (the host kernel, hence boot_id, is unchanged,
        but ``/init`` is a brand-new process).

    Together they discriminate every restart mode that matters:

      | event                          | boot_id | pid1 start | epoch  | marker |
      |--------------------------------|---------|------------|--------|--------|
      | Fly microVM reboot (auto-upd.) | changes | changes    | NEW    | reject |
      | plain ``docker restart``       | same    | changes    | NEW    | reject |
      | s6 respawn of the gateway only | same    | same       | SAME   | honour |
      | host ``hermes gateway restart``| same    | same(init) | SAME   | honour |

    The last row is intentional: a host install has no durable-volume drain
    bug, and honouring a drain across a deliberate process restart is the
    intended reversible behaviour (D4a) â€” PID 1 there is the long-lived init
    (systemd/launchd), so the epoch is stable.

    Returns ``""`` when neither identity source is readable (non-Linux, no
    ``/proc``). An empty epoch disables the staleness check downstream,
    degrading to the released presence-only behaviour â€” never fail-closed.
    Memoised: the epoch is constant for the life of the process.
    Ú z/proc/sys/kernel/random/boot_idúutf-8©Úencodingz/proc/1/statú)r   é   ú:)r   Ú	read_textÚstripÚOSErrorÚrsplitÚsplitÚ
IndexError)Úboot_idÚ
pid1_startÚstatÚtails       ú;/home/ragecks/.hermes/hermes-agent/gateway/drain_control.pyÚcurrent_instantiation_epochr"   C   s  € ðD €GðåÐ2Ñ3Ô3ßŠY ˆYÑ(Ô(ßŠU‰WŒWð 	ˆøõ
 ð ð ð Øˆðøøøð €Jð
õ �NÑ#Ô#×-Ò-°wÐ-Ñ?Ô?ˆØ�{Š{˜3 Ñ"Ô" 1Ô%×+Ò+Ñ-Ô-ˆØ˜"”Xˆ
ˆ
øÝ•ZÐ ð ð ð Øˆðøøøð ð ˜:ð ØˆrØÐ$Ð$˜
Ð$Ð$Ð$s"   „5: º
AÁAÁAB' Â'B;Â:B;ÚhomeúOptional[Path]r   c                óT   — | �| nt          ¦   «         }t          |¦  «        t          z  S )zBAbsolute path to the drain-request marker, respecting HERMES_HOME.)r	   r   Ú_DRAIN_REQUEST_FILENAME)r#   Úbases     r!   Údrain_request_pathr(   �   s(   € àÐ#ˆ4ˆ4­Ñ):Ô):€DÝ�‰:Œ:Õ/Ñ/Ð/ó    zdrain-controlF)Ú	principalÚsuppress_notificationr#   r*   r+   Úboolúdict[str, Any]c                óÞ   — dt          j        t          j        ¦  «                             ¦   «         | t          ¦   «         t          |¦  «        dœ}t          t          |¦  «        |¦  «         |S )u
  Write the begin-drain marker. Returns the payload written.

    Atomic write so the gateway watcher never reads a half-written file.
    Idempotent: re-writing while a drain is already in progress just refreshes
    ``requested_at`` (harmless â€” the watcher keys off presence, not content).

    Stamps the marker with :func:`current_instantiation_epoch` so a marker that
    later survives a machine restart on the durable HERMES_HOME volume can be
    recognised as stale and ignored (NS-570).

    ``suppress_notification`` is a generic "be quiet on the shutdown that ends
    this drain" flag. When the drain culminates in a process exit (e.g. NAS
    recreates the machine for an auto-update image migration), the gateway's
    shutdown path reads it via :func:`drain_notification_suppressed` and skips
    the *home-channel* "gateway shutting down" broadcast â€” the operator-flavoured
    ping that would otherwise fire on every routine auto-update, potentially
    dozens of times a day. It NEVER suppresses the per-active-session interrupt
    ping. The gateway stays agnostic about *why* the drain is quiet; the policy
    of which drain causes set the flag lives entirely in the caller (NAS). The
    field defaults False so legacy/operator drains behave exactly as before.
    Údrain)ÚactionÚrequested_atr*   Úepochr+   )	r   Únowr   ÚutcÚ	isoformatr"   r,   r
   r(   )r*   r+   r#   Úpayloads       r!   Úwrite_drain_requestr7   ‡   sf   € ð8 Ý œ¥X¤\Ñ2Ô2×<Ò<Ñ>Ô>ØÝ,Ñ.Ô.Ý!%Ð&;Ñ!<Ô!<ðð €Gõ Õ(¨Ñ.Ô.°Ñ8Ô8Ð8Ø€Nr)   ©r#   c                óÎ   — t          | ¦  «        }	 |                     ¦   «          dS # t          $ r Y dS t          $ r'}t                               d||¦  «         Y d}~dS d}~ww xY w)z’Remove the drain marker (cancel-drain). Returns True if one existed.

    Best-effort: a missing file is not an error (cancel is idempotent).
    TFz&drain-control: failed to remove %s: %sN)r(   ÚunlinkÚFileNotFoundErrorr   Ú_logÚwarning)r#   ÚpathÚes      r!   Úclear_drain_requestr@   ­   s„   € õ
 ˜dÑ#Ô#€DðØ�Š‰ŒˆØˆtøÝð ð ð ØˆuˆuÝð ð ð Ý�ŠÐ=¸tÀQÑGÔGÐGØˆuˆuˆuˆuˆuøøøøðøøøs   ‘' §
A$´	A$½AÁA$Úbodyc                ód   — t          ¦   «         }|sdS |                      d¦  «        }|sdS ||k    S )u   True iff ``body``'s epoch is a *definite* mismatch with this process.

    Lenient by design â€” returns False (i.e. "not stale, honour it") whenever it
    can't be sure:
      * the current epoch can't be computed ("" fallback, no /proc), OR
      * the marker carries no epoch (legacy marker, or a corrupt/contentless
        ``{}`` body).
    Only a marker whose epoch is present AND differs from the current
    instantiation epoch is considered stale. This preserves the
    fail-safe-toward-quiescing contract for malformed markers.
    Fr2   )r"   Úget)rA   ÚcurrentÚmarker_epochs      r!   Ú_marker_epoch_is_stalerF   ½   sE   € õ *Ñ+Ô+€GØð ØˆuØ—8’8˜GÑ$Ô$€LØð ØˆuØ˜7Ò"Ð"r)   c                óP   — t          | ¬¦  «        }|€dS t          |¦  «        rdS dS )ud  True iff a begin-drain marker for THIS instantiation is present.

    A marker whose ``epoch`` does not match the current instantiation epoch is
    treated as absent: it survived a container/VM restart (HERMES_HOME is a
    durable Fly volume on Hermes Cloud) and the lifecycle action that triggered
    the drain has already completed â€” honouring it would wedge the
    freshly-restarted gateway in ``draining`` (NS-570). The staleness check is
    lenient (see :func:`_marker_epoch_is_stale`): a legacy/corrupt marker with
    no epoch, or an environment without ``/proc``, still reads as drain-active.
    r8   NFT)Úread_drain_requestrF   ©r#   rA   s     r!   Údrain_requestedrJ   Ò   s8   € õ  4Ð(Ñ(Ô(€DØ€|ØˆuÝ˜dÑ#Ô#ð ØˆuØˆ4r)   c                ó�   — t          | ¬¦  «        }|€dS t          |¦  «        rdS t          |                     d¦  «        ¦  «        S )u¿  True iff an ACTIVE drain marker asks to suppress the shutdown broadcast.

    "Active" means exactly what :func:`drain_requested` means â€” a marker present
    AND stamped with the current instantiation epoch. A stale (other-epoch)
    marker that survived a machine restart on the durable HERMES_HOME volume is
    ignored here just as it is for drain state (NS-570): we must never let an
    orphaned marker's flag silence a *fresh* gateway's legitimate shutdown
    broadcast.

    Only honours the flag when it is explicitly truthy in the marker body. A
    legacy marker without the field, a corrupt/contentless ``{}`` body, or an
    absent marker all read as "not suppressed" (False) â€” fail toward the louder,
    more-visible behaviour, consistent with :func:`read_drain_request`'s
    never-raise contract. The gateway's shutdown path uses this to skip ONLY the
    home-channel broadcast; the per-active-session interrupt ping is unaffected.
    r8   NFr+   )rH   rF   r,   rC   rI   s     r!   Údrain_notification_suppressedrL   å   sN   € õ"  4Ð(Ñ(Ô(€DØ€|ØˆuÝ˜dÑ#Ô#ð ØˆuÝ�—’Ð0Ñ1Ô1Ñ2Ô2Ð2r)   úOptional[dict[str, Any]]c                ó`  — t          | ¦  «        }	 |                     d¬¦  «        }n@# t          $ r Y dS t          $ r'}t                               d||¦  «         Y d}~dS d}~ww xY w	 t          j        |¦  «        }n# t          t          f$ r i cY S w xY wt          |t          ¦  «        r|ni S )a  Return the marker payload, or ``None`` if absent.

    A present-but-unparseable marker returns ``{}`` (truthy-presence preserved
    via :func:`drain_requested`; callers that need the body get an empty dict
    rather than an exception). Never raises.
    r   r   Nz$drain-control: failed to read %s: %s)r(   r   r;   r   r<   r=   ÚjsonÚloadsÚ
ValueErrorÚ	TypeErrorÚ
isinstanceÚdict)r#   r>   Úrawr?   Údatas        r!   rH   rH   þ   sÝ   € õ ˜dÑ#Ô#€DðØ�nŠn gˆnÑ.Ô.ˆˆøÝð ð ð ØˆtˆtÝð ð ð Ý�ŠÐ;¸TÀ1ÑEÔEÐEØˆtˆtˆtˆtˆtøøøøðøøøðÝŒz˜#‰ŒˆˆøÝ�	Ð"ð ð ð Øˆ	ˆ	ˆ	ðøøøå˜d¥DÑ)Ô)Ð1ˆ4ˆ4¨rÐ1s+   ‘( ¨
A%µ	A%¾A Á A%Á)A> Á>BÂB)r   r   )N)r#   r$   r   r   )r*   r   r+   r,   r#   r$   r   r-   )r#   r$   r   r,   )rA   r-   r   r,   )r#   r$   r   rM   )Ú__doc__Ú
__future__r   Ú	functoolsrO   Úloggingr   r   Úpathlibr   Útypingr   r   Úhermes_constantsr	   Úutilsr
   Ú	getLoggerÚ__name__r<   r&   Ú	lru_cacher"   r(   r7   r@   rF   rJ   rL   rH   © r)   r!   ú<module>rc      sà  ðð0ð 0ðb #Ð "Ð "Ð "Ð "Ð "à Ð Ð Ð Ø €€€Ø €€€Ø 'Ð 'Ð 'Ð 'Ð 'Ð 'Ð 'Ð 'Ø Ð Ð Ð Ð Ð Ø  Ð  Ð  Ð  Ð  Ð  Ð  Ð  à ,Ð ,Ð ,Ð ,Ð ,Ð ,Ø #Ð #Ð #Ð #Ð #Ð #à€wÔ˜Ñ"Ô"€à/Ð ð €Ô˜QÐÑÔð:%ð :%ð :%ñ  Ôð:%ðz0ð 0ð 0ð 0ð 0ð %Ø"'Øð	#ð #ð #ð #ð #ð #ðL 37ð ð ð ð ð ð ð #ð #ð #ð #ð* /3ð ð ð ð ð ð ð& =Að 3ð 3ð 3ð 3ð 3ð 3ð2 26ð 2ð 2ð 2ð 2ð 2ð 2ð 2ð 2r)   