feat(hermes): brand chat and prove image rollout
This commit is contained in:
parent
ddc46aa845
commit
b87f33530b
@ -92,6 +92,15 @@ replace_exact(
|
|||||||
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>',
|
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes</span>',
|
||||||
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes Chat</span>',
|
' <span class="app-titlebar-title" id="appTitlebarTitle">Hermes Chat</span>',
|
||||||
)
|
)
|
||||||
|
replace_between_exact(
|
||||||
|
index,
|
||||||
|
' <div class="empty-logo"><svg',
|
||||||
|
' <h2 data-i18n="empty_title">',
|
||||||
|
' <div class="empty-logo">\n'
|
||||||
|
' <img class="hermes-agent-portrait" '
|
||||||
|
'src="static/hermes-agent-192.png" alt="Hermes Agent">\n'
|
||||||
|
' </div>\n',
|
||||||
|
)
|
||||||
|
|
||||||
service_worker = ROOT / "static/sw.js"
|
service_worker = ROOT / "static/sw.js"
|
||||||
replace_exact(
|
replace_exact(
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
--gold: #9a661f;
|
--gold: #9a661f;
|
||||||
--focus-ring: rgba(24, 127, 139, 0.38);
|
--focus-ring: rgba(24, 127, 139, 0.38);
|
||||||
--focus-glow: rgba(24, 127, 139, 0.12);
|
--focus-glow: rgba(24, 127, 139, 0.12);
|
||||||
|
--hermes-violet: #7065a8;
|
||||||
|
--hermes-grid: rgba(24, 127, 139, 0.035);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root.dark {
|
:root.dark {
|
||||||
@ -43,6 +45,9 @@
|
|||||||
--main-bg: rgba(7, 10, 18, 0.72);
|
--main-bg: rgba(7, 10, 18, 0.72);
|
||||||
--focus-ring: rgba(72, 207, 204, 0.38);
|
--focus-ring: rgba(72, 207, 204, 0.38);
|
||||||
--focus-glow: rgba(72, 207, 204, 0.12);
|
--focus-glow: rgba(72, 207, 204, 0.12);
|
||||||
|
--hermes-violet: #9d8ee0;
|
||||||
|
--hermes-violet-muted: rgba(157, 142, 224, 0.12);
|
||||||
|
--hermes-grid: rgba(72, 207, 204, 0.025);
|
||||||
--error: #f08b79;
|
--error: #f08b79;
|
||||||
--success: #65c9a6;
|
--success: #65c9a6;
|
||||||
--warning: #f0b66b;
|
--warning: #f0b66b;
|
||||||
@ -51,7 +56,8 @@
|
|||||||
|
|
||||||
:root.dark body {
|
:root.dark body {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 78% 8%, rgba(72, 164, 205, 0.07), transparent 31rem),
|
radial-gradient(circle at 78% 8%, rgba(72, 164, 205, 0.085), transparent 31rem),
|
||||||
|
radial-gradient(circle at 28% 100%, rgba(157, 142, 224, 0.045), transparent 34rem),
|
||||||
linear-gradient(145deg, #070a12, #080d17 55%, #071017);
|
linear-gradient(145deg, #070a12, #080d17 55%, #071017);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,6 +71,36 @@
|
|||||||
background-color: rgba(13, 20, 32, 0.94);
|
background-color: rgba(13, 20, 32, 0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A quiet workstation canvas: enough technical texture to feel intentional,
|
||||||
|
but low-contrast enough to remain behind the work. */
|
||||||
|
:root.dark .main-view,
|
||||||
|
:root.dark .messages {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .messages {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--hermes-grid) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--hermes-grid) 1px, transparent 1px),
|
||||||
|
radial-gradient(circle at 76% 22%, rgba(76, 164, 205, 0.055), transparent 30rem),
|
||||||
|
radial-gradient(circle at 22% 78%, var(--hermes-violet-muted), transparent 35rem);
|
||||||
|
background-size: 32px 32px, 32px 32px, auto, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .rail {
|
||||||
|
background-image: linear-gradient(180deg, rgba(72, 207, 204, 0.035), transparent 38%, rgba(157, 142, 224, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .sidebar {
|
||||||
|
box-shadow: inset -1px 0 rgba(72, 207, 204, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .session-item.active {
|
||||||
|
border-color: rgba(72, 207, 204, 0.17);
|
||||||
|
background: linear-gradient(90deg, rgba(72, 207, 204, 0.11), rgba(157, 142, 224, 0.045));
|
||||||
|
box-shadow: inset 2px 0 rgba(72, 207, 204, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
.app-titlebar-icon img {
|
.app-titlebar-icon img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
@ -78,9 +114,87 @@
|
|||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The established Hermes-agent character is the new-chat persona. */
|
||||||
|
.empty-logo {
|
||||||
|
isolation: isolate;
|
||||||
|
min-width: 126px;
|
||||||
|
min-height: 126px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-logo::before {
|
||||||
|
width: 224px;
|
||||||
|
height: 224px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle, rgba(72, 207, 204, 0.15), rgba(76, 164, 205, 0.07) 42%, rgba(112, 101, 168, 0.035) 58%, transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-logo::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
inset: -7px;
|
||||||
|
border: 1px solid rgba(24, 127, 139, 0.2);
|
||||||
|
border-radius: 29px;
|
||||||
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.12), transparent 38%, rgba(112, 101, 168, 0.08));
|
||||||
|
box-shadow: 0 16px 42px rgba(18, 55, 65, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-logo .hermes-agent-portrait {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: block;
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(24, 127, 139, 0.28);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow: 0 10px 30px rgba(18, 55, 65, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .empty-logo::after {
|
||||||
|
border-color: rgba(72, 207, 204, 0.28);
|
||||||
|
background: linear-gradient(145deg, rgba(72, 207, 204, 0.1), transparent 40%, rgba(157, 142, 224, 0.09));
|
||||||
|
box-shadow: 0 0 0 1px rgba(72, 207, 204, 0.035), 0 18px 48px rgba(0, 0, 0, 0.36), 0 0 34px rgba(76, 164, 205, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .empty-logo .hermes-agent-portrait {
|
||||||
|
border-color: rgba(72, 207, 204, 0.32);
|
||||||
|
box-shadow: 0 12px 34px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .empty-state h2 {
|
||||||
|
color: var(--strong);
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
text-shadow: 0 0 24px rgba(72, 207, 204, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .empty-state p {
|
||||||
|
color: #9cafba;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .suggestion {
|
||||||
|
border-color: rgba(174, 218, 224, 0.15);
|
||||||
|
background: linear-gradient(105deg, rgba(17, 27, 41, 0.88), rgba(13, 20, 32, 0.74));
|
||||||
|
box-shadow: inset 2px 0 rgba(72, 207, 204, 0.12), 0 8px 22px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .suggestion:hover,
|
||||||
|
:root.dark .suggestion:focus-visible {
|
||||||
|
color: var(--strong);
|
||||||
|
border-color: rgba(72, 207, 204, 0.4);
|
||||||
|
background: linear-gradient(105deg, rgba(72, 207, 204, 0.11), rgba(157, 142, 224, 0.065));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.dark .suggestion:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
:root.dark .composer-box:focus-within {
|
:root.dark .composer-box:focus-within {
|
||||||
border-color: rgba(72, 207, 204, 0.66);
|
border-color: rgba(72, 207, 204, 0.66);
|
||||||
box-shadow: 0 0 0 2px var(--focus-glow), 0 10px 34px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 0 0 2px var(--focus-glow), 0 10px 34px rgba(0, 0, 0, 0.28), 0 0 28px rgba(157, 142, 224, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep the conversation instrument inside the same cyan/blue/gold family. */
|
/* Keep the conversation instrument inside the same cyan/blue/gold family. */
|
||||||
@ -99,7 +213,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-titlebar-icon img,
|
.app-titlebar-icon img,
|
||||||
:root.dark .composer-box:focus-within {
|
:root.dark .composer-box:focus-within,
|
||||||
|
.suggestion {
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.suggestion:hover,
|
||||||
|
.suggestion:focus-visible {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.empty-logo {
|
||||||
|
min-width: 104px;
|
||||||
|
min-height: 104px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-logo::before {
|
||||||
|
width: 176px;
|
||||||
|
height: 176px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-logo .hermes-agent-portrait {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 19px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -376,10 +376,18 @@ data:
|
|||||||
private voice services, passing a full commit already contained by `main`.
|
private voice services, passing a full commit already contained by `main`.
|
||||||
Jenkins builds the newest main containing that commit, publishes a final
|
Jenkins builds the newest main containing that commit, publishes a final
|
||||||
immutable release tag only after its evidence passes, and Flux applies the
|
immutable release tag only after its evidence passes, and Flux applies the
|
||||||
resulting digest. Candidate tags and failed builds never deploy. Follow the
|
resulting digest. Candidate tags and failed builds never deploy. Run the
|
||||||
returned queue item with `jenkins_build_evidence.py`, then verify the Flux
|
safe `follow_command` returned by the trigger and do not report completion
|
||||||
revision, pod image digest, rollout health, and public SSO redirect before
|
until it says `converged`: that proves the exact reviewed source is the
|
||||||
reporting the release complete.
|
selected immutable tag/digest, Flux is Ready, every consumer has the digest
|
||||||
|
in desired state, and all corresponding pods are Ready on that digest. If
|
||||||
|
main advanced before Jenkins checked it out, the status command reports
|
||||||
|
`different_revision_selected`; independently prove the requested commit is
|
||||||
|
an ancestor of that exact source, then follow the exact selected revision.
|
||||||
|
Use `jenkins_build_evidence.py` for a terminal failure cause when its
|
||||||
|
bounded controller evidence path is available. Candidate-only, queued, or
|
||||||
|
merged code is not a completed release. Finally verify the public SSO
|
||||||
|
redirect and the changed behavior before reporting the release complete.
|
||||||
|
|
||||||
The terminal PATH contains the pinned operator tools. Start cluster work
|
The terminal PATH contains the pinned operator tools. Start cluster work
|
||||||
with `kubectl config current-context`, read-only status/events/logs, and the
|
with `kubectl config current-context`, read-only status/events/logs, and the
|
||||||
|
|||||||
@ -143,6 +143,7 @@ configMapGenerator:
|
|||||||
- install_agent_tools.sh=scripts/install_agent_tools.sh
|
- install_agent_tools.sh=scripts/install_agent_tools.sh
|
||||||
- jenkins_build_evidence.py=scripts/jenkins_build_evidence.py
|
- jenkins_build_evidence.py=scripts/jenkins_build_evidence.py
|
||||||
- jenkins_image_build_trigger.py=scripts/jenkins_image_build_trigger.py
|
- jenkins_image_build_trigger.py=scripts/jenkins_image_build_trigger.py
|
||||||
|
- hermes_image_release_status.py=scripts/hermes_image_release_status.py
|
||||||
- kanban_status_recovery.py=scripts/kanban_status_recovery.py
|
- kanban_status_recovery.py=scripts/kanban_status_recovery.py
|
||||||
- kanban_supervisor.py=scripts/kanban_supervisor.py
|
- kanban_supervisor.py=scripts/kanban_supervisor.py
|
||||||
- supervisor_policy.py=scripts/supervisor_policy.py
|
- supervisor_policy.py=scripts/supervisor_policy.py
|
||||||
|
|||||||
275
services/hermes/scripts/hermes_image_release_status.py
Executable file
275
services/hermes/scripts/hermes_image_release_status.py
Executable file
@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Report whether a validated Hermes image has converged through Flux."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
|
||||||
|
NAMESPACE = "hermes"
|
||||||
|
FLUX_NAMESPACE = "flux-system"
|
||||||
|
AUTOMATION = "hermes"
|
||||||
|
FLUX_KUSTOMIZATION = "hermes"
|
||||||
|
REVISION_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||||
|
RELEASE_TAG_PATTERN = re.compile(
|
||||||
|
r"^git-(?P<revision>[0-9a-f]{40})-build-(?P<build>[1-9][0-9]*)-release$"
|
||||||
|
)
|
||||||
|
COMPONENTS = {
|
||||||
|
"agent": {
|
||||||
|
"policy": "hermes-agent-release",
|
||||||
|
"repository": "registry.bstein.dev/bstein/hermes-agent",
|
||||||
|
"workloads": (("deployment", "hermes-agent", "hermes-agent"),),
|
||||||
|
},
|
||||||
|
"webui": {
|
||||||
|
"policy": "hermes-webui-release",
|
||||||
|
"repository": "registry.bstein.dev/bstein/hermes-webui",
|
||||||
|
"workloads": (
|
||||||
|
("deployment", "hermes", "hermes"),
|
||||||
|
("statefulset", "hermes-chat-tenant", "hermes-chat-tenant"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"stt": {
|
||||||
|
"policy": "hermes-stt-release",
|
||||||
|
"repository": "registry.bstein.dev/bstein/hermes-jetson-stt",
|
||||||
|
"workloads": (("deployment", "hermes-stt", "hermes-stt"),),
|
||||||
|
},
|
||||||
|
"tts": {
|
||||||
|
"policy": "hermes-tts-release",
|
||||||
|
"repository": "registry.bstein.dev/bstein/hermes-jetson-tts",
|
||||||
|
"workloads": (("deployment", "hermes-tts", "hermes-tts"),),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WorkloadStatus:
|
||||||
|
"""Desired and observed state for one image-consuming workload."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
desired_replicas: int
|
||||||
|
ready_replicas: int
|
||||||
|
desired_image_matches: bool
|
||||||
|
ready_pods: int
|
||||||
|
matching_pods: int
|
||||||
|
converged: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _kubectl_json(*command: str) -> dict:
|
||||||
|
"""Read one Kubernetes object as JSON without shell or credential output."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["kubectl", *command, "-o", "json"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
value = json.loads(result.stdout)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("kubectl returned a non-object JSON value")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_ready(resource: dict) -> bool:
|
||||||
|
"""Return true only for an explicit current Ready condition."""
|
||||||
|
generation = resource.get("metadata", {}).get("generation")
|
||||||
|
for condition in resource.get("status", {}).get("conditions", []):
|
||||||
|
if condition.get("type") != "Ready" or condition.get("status") != "True":
|
||||||
|
continue
|
||||||
|
observed = condition.get("observedGeneration")
|
||||||
|
return generation is None or observed is None or int(observed) >= int(generation)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _release_ref(policy: dict) -> tuple[str | None, str | None, str | None, int | None]:
|
||||||
|
"""Extract the immutable release tag, digest, source revision, and build."""
|
||||||
|
ref = policy.get("status", {}).get("latestRef", {})
|
||||||
|
tag = ref.get("tag")
|
||||||
|
digest = ref.get("digest")
|
||||||
|
match = RELEASE_TAG_PATTERN.fullmatch(tag or "")
|
||||||
|
if not match or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest or ""):
|
||||||
|
return tag, digest, None, None
|
||||||
|
return tag, digest, match.group("revision"), int(match.group("build"))
|
||||||
|
|
||||||
|
|
||||||
|
def _desired_images(workload: dict) -> list[str]:
|
||||||
|
"""Return pod-template container and init-container image references."""
|
||||||
|
spec = workload.get("spec", {}).get("template", {}).get("spec", {})
|
||||||
|
containers = list(spec.get("initContainers", [])) + list(spec.get("containers", []))
|
||||||
|
return [item.get("image", "") for item in containers]
|
||||||
|
|
||||||
|
|
||||||
|
def _rollout_ready(kind: str, workload: dict) -> tuple[int, int, bool]:
|
||||||
|
"""Evaluate generation and replica convergence for a workload controller."""
|
||||||
|
metadata = workload.get("metadata", {})
|
||||||
|
spec = workload.get("spec", {})
|
||||||
|
status = workload.get("status", {})
|
||||||
|
desired = int(spec.get("replicas", 1))
|
||||||
|
ready = int(status.get("readyReplicas", 0))
|
||||||
|
generation_ready = int(status.get("observedGeneration", 0)) >= int(
|
||||||
|
metadata.get("generation", 1)
|
||||||
|
)
|
||||||
|
if kind == "deployment":
|
||||||
|
controller_ready = (
|
||||||
|
int(status.get("updatedReplicas", 0)) >= desired
|
||||||
|
and int(status.get("availableReplicas", 0)) >= desired
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
controller_ready = (
|
||||||
|
int(status.get("currentReplicas", 0)) >= desired
|
||||||
|
and int(status.get("updatedReplicas", 0)) >= desired
|
||||||
|
and status.get("currentRevision") == status.get("updateRevision")
|
||||||
|
)
|
||||||
|
return desired, ready, generation_ready and controller_ready and ready >= desired
|
||||||
|
|
||||||
|
|
||||||
|
def _pod_image_status(label: str, repository: str, digest: str) -> tuple[int, int]:
|
||||||
|
"""Count ready pods whose relevant running containers use the exact digest."""
|
||||||
|
pod_list = _kubectl_json(
|
||||||
|
"-n", NAMESPACE, "get", "pods", "-l", f"app={label}"
|
||||||
|
)
|
||||||
|
matching = 0
|
||||||
|
ready = 0
|
||||||
|
for pod in pod_list.get("items", []):
|
||||||
|
statuses = list(pod.get("status", {}).get("initContainerStatuses", []))
|
||||||
|
statuses.extend(pod.get("status", {}).get("containerStatuses", []))
|
||||||
|
relevant = [item for item in statuses if item.get("image", "").startswith(repository)]
|
||||||
|
if not relevant:
|
||||||
|
continue
|
||||||
|
if all((item.get("imageID") or "").endswith(f"@{digest}") for item in relevant):
|
||||||
|
matching += 1
|
||||||
|
conditions = pod.get("status", {}).get("conditions", [])
|
||||||
|
if any(
|
||||||
|
item.get("type") == "Ready" and item.get("status") == "True"
|
||||||
|
for item in conditions
|
||||||
|
):
|
||||||
|
ready += 1
|
||||||
|
return matching, ready
|
||||||
|
|
||||||
|
|
||||||
|
def _workload_status(
|
||||||
|
kind: str, name: str, label: str, repository: str, digest: str
|
||||||
|
) -> WorkloadStatus:
|
||||||
|
"""Read desired image, controller rollout, and running-pod digest evidence."""
|
||||||
|
workload = _kubectl_json("-n", NAMESPACE, "get", kind, name)
|
||||||
|
desired, ready_replicas, controller_ready = _rollout_ready(kind, workload)
|
||||||
|
relevant_images = [
|
||||||
|
image for image in _desired_images(workload) if image.startswith(repository)
|
||||||
|
]
|
||||||
|
desired_matches = bool(relevant_images) and all(
|
||||||
|
image.endswith(f"@{digest}") for image in relevant_images
|
||||||
|
)
|
||||||
|
matching_pods, ready_pods = _pod_image_status(label, repository, digest)
|
||||||
|
converged = (
|
||||||
|
desired_matches
|
||||||
|
and controller_ready
|
||||||
|
and matching_pods >= desired
|
||||||
|
and ready_pods >= desired
|
||||||
|
)
|
||||||
|
return WorkloadStatus(
|
||||||
|
kind=kind,
|
||||||
|
name=name,
|
||||||
|
desired_replicas=desired,
|
||||||
|
ready_replicas=ready_replicas,
|
||||||
|
desired_image_matches=desired_matches,
|
||||||
|
ready_pods=ready_pods,
|
||||||
|
matching_pods=matching_pods,
|
||||||
|
converged=converged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_release(component: str, revision: str) -> dict:
|
||||||
|
"""Return safe end-to-end release state for one exact reviewed revision."""
|
||||||
|
if component not in COMPONENTS:
|
||||||
|
raise ValueError("component must be agent, webui, stt, or tts")
|
||||||
|
if not REVISION_PATTERN.fullmatch(revision):
|
||||||
|
raise ValueError("revision must be a lowercase full 40-character commit")
|
||||||
|
config = COMPONENTS[component]
|
||||||
|
policy = _kubectl_json(
|
||||||
|
"-n", NAMESPACE, "get", "imagepolicy", config["policy"]
|
||||||
|
)
|
||||||
|
tag, digest, selected_revision, build = _release_ref(policy)
|
||||||
|
result = {
|
||||||
|
"component": component,
|
||||||
|
"requested_revision": revision,
|
||||||
|
"selected_revision": selected_revision,
|
||||||
|
"release_tag": tag,
|
||||||
|
"digest": digest,
|
||||||
|
"build": build,
|
||||||
|
"stage": "image_policy_pending",
|
||||||
|
"converged": False,
|
||||||
|
"workloads": [],
|
||||||
|
}
|
||||||
|
if selected_revision != revision or digest is None:
|
||||||
|
if selected_revision is not None:
|
||||||
|
result["stage"] = "different_revision_selected"
|
||||||
|
return result
|
||||||
|
automation = _kubectl_json(
|
||||||
|
"-n", NAMESPACE, "get", "imageupdateautomation", AUTOMATION
|
||||||
|
)
|
||||||
|
flux = _kubectl_json(
|
||||||
|
"-n", FLUX_NAMESPACE, "get", "kustomization", FLUX_KUSTOMIZATION
|
||||||
|
)
|
||||||
|
result["image_policy_ready"] = _condition_ready(policy)
|
||||||
|
result["image_automation_ready"] = _condition_ready(automation)
|
||||||
|
result["flux_ready"] = _condition_ready(flux)
|
||||||
|
result["flux_revision"] = flux.get("status", {}).get("lastAppliedRevision")
|
||||||
|
workloads = [
|
||||||
|
_workload_status(kind, name, label, config["repository"], digest)
|
||||||
|
for kind, name, label in config["workloads"]
|
||||||
|
]
|
||||||
|
result["workloads"] = [asdict(item) for item in workloads]
|
||||||
|
if not all((result["image_policy_ready"], result["image_automation_ready"])):
|
||||||
|
result["stage"] = "image_automation_pending"
|
||||||
|
elif not result["flux_ready"] or not all(item.desired_image_matches for item in workloads):
|
||||||
|
result["stage"] = "flux_apply_pending"
|
||||||
|
elif not all(item.converged for item in workloads):
|
||||||
|
result["stage"] = "rollout_pending"
|
||||||
|
else:
|
||||||
|
result["stage"] = "converged"
|
||||||
|
result["converged"] = True
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
"""Parse one bounded release-follow request."""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--component", required=True, choices=sorted(COMPONENTS))
|
||||||
|
parser.add_argument("--revision", required=True)
|
||||||
|
parser.add_argument("--wait", action="store_true")
|
||||||
|
parser.add_argument("--timeout", type=int, default=1800)
|
||||||
|
parser.add_argument("--poll", type=int, default=15)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""Print safe JSON immediately or wait for exact live convergence."""
|
||||||
|
args = parse_args(argv)
|
||||||
|
if args.timeout < 1 or args.poll < 1:
|
||||||
|
print("timeout and poll must be positive", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
deadline = time.monotonic() + args.timeout
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
status = inspect_release(args.component, args.revision)
|
||||||
|
if status["converged"]:
|
||||||
|
print(json.dumps(status, indent=2, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
if not args.wait or time.monotonic() >= deadline:
|
||||||
|
status["timed_out"] = bool(args.wait)
|
||||||
|
print(json.dumps(status, indent=2, sort_keys=True))
|
||||||
|
return 2 if args.wait else 1
|
||||||
|
time.sleep(args.poll)
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"Hermes release status failed: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@ -112,6 +112,10 @@ def trigger_build(
|
|||||||
queue_path = parsed_queue.path
|
queue_path = parsed_queue.path
|
||||||
return {
|
return {
|
||||||
"component": component,
|
"component": component,
|
||||||
|
"follow_command": (
|
||||||
|
"/opt/coordinator/hermes_image_release_status.py "
|
||||||
|
f"--component {component} --revision {revision} --wait"
|
||||||
|
),
|
||||||
"job": job["job"],
|
"job": job["job"],
|
||||||
"queue_path": queue_path,
|
"queue_path": queue_path,
|
||||||
"source_revision": revision,
|
"source_revision": revision,
|
||||||
|
|||||||
@ -8,5 +8,7 @@ Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`:
|
|||||||
- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
|
- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
|
||||||
- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
|
- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
|
||||||
|
|
||||||
The fixture stays intentionally narrow, but tests execute the shipped
|
The fixture stays intentionally narrow, but tests execute the shipped patchers
|
||||||
`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic.
|
against it; large inline artwork bodies are reduced while their exact unique
|
||||||
|
boundary tags are retained so fail-closed replacements are exercised without
|
||||||
|
copying generated SVG payloads into the fixture.
|
||||||
|
|||||||
@ -30,6 +30,11 @@
|
|||||||
</select>
|
</select>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
|
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tts_voice">Preferred voice. Populated from your browser's available voices.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="empty-state" id="emptyState">
|
||||||
|
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus"><defs><linearGradient id="hermes-mark"><stop class="hm-g0"/><stop class="hm-g1"/></linearGradient></defs><path fill="url(#hermes-mark)" d="M32 4v56"/></svg></div>
|
||||||
|
<h2 data-i18n="empty_title">What can I help with?</h2>
|
||||||
|
</div>
|
||||||
|
<div class="messages-inner" id="msgInner"></div>
|
||||||
<div class="composer-box" id="composerBox">
|
<div class="composer-box" id="composerBox">
|
||||||
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
"ci/scripts/hermes_image_release.py",
|
"ci/scripts/hermes_image_release.py",
|
||||||
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
||||||
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
||||||
|
"services/hermes/scripts/hermes_image_release_status.py",
|
||||||
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
||||||
"ci/scripts/publish_test_metrics.py",
|
"ci/scripts/publish_test_metrics.py",
|
||||||
"ci/scripts/publish_test_metrics_quality.py",
|
"ci/scripts/publish_test_metrics_quality.py",
|
||||||
@ -107,6 +108,7 @@
|
|||||||
"ci/scripts/hermes_image_release.py",
|
"ci/scripts/hermes_image_release.py",
|
||||||
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
||||||
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
||||||
|
"services/hermes/scripts/hermes_image_release_status.py",
|
||||||
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
||||||
"ci/scripts/publish_test_metrics.py",
|
"ci/scripts/publish_test_metrics.py",
|
||||||
"ci/scripts/publish_test_metrics_quality.py",
|
"ci/scripts/publish_test_metrics_quality.py",
|
||||||
@ -341,6 +343,7 @@
|
|||||||
"ci/scripts/hermes_image_release.py",
|
"ci/scripts/hermes_image_release.py",
|
||||||
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
"dockerfiles/hermes-kaniko-heredoc-runner.py",
|
||||||
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
"services/harbor/scripts/harbor_hermes_agent_immutability_ensure.py",
|
||||||
|
"services/hermes/scripts/hermes_image_release_status.py",
|
||||||
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
"services/hermes/scripts/jenkins_image_build_trigger.py",
|
||||||
"ci/scripts/publish_test_metrics.py",
|
"ci/scripts/publish_test_metrics.py",
|
||||||
"ci/scripts/publish_test_metrics_quality.py",
|
"ci/scripts/publish_test_metrics_quality.py",
|
||||||
|
|||||||
331
testing/tests/test_hermes_release_followthrough.py
Normal file
331
testing/tests/test_hermes_release_followthrough.py
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
"""Contracts for Hermes' read-only image release follow-through helper."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
STATUS = ROOT / "services/hermes/scripts/hermes_image_release_status.py"
|
||||||
|
TRIGGER = ROOT / "services/hermes/scripts/jenkins_image_build_trigger.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(path: Path, name: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _condition(ready: bool = True, generation: int = 3) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "Ready",
|
||||||
|
"status": "True" if ready else "False",
|
||||||
|
"observedGeneration": generation,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _policy(module, revision: str, digest: str, *, ready: bool = True) -> dict:
|
||||||
|
return {
|
||||||
|
"metadata": {"generation": 3},
|
||||||
|
"status": {
|
||||||
|
"conditions": _condition(ready),
|
||||||
|
"latestRef": {
|
||||||
|
"tag": f"git-{revision}-build-17-release",
|
||||||
|
"digest": digest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _controller(kind: str, repository: str, digest: str, *, ready: bool = True) -> dict:
|
||||||
|
status = {
|
||||||
|
"observedGeneration": 4,
|
||||||
|
"readyReplicas": 1 if ready else 0,
|
||||||
|
"updatedReplicas": 1,
|
||||||
|
"availableReplicas": 1,
|
||||||
|
"currentReplicas": 1,
|
||||||
|
"currentRevision": "revision-2",
|
||||||
|
"updateRevision": "revision-2",
|
||||||
|
}
|
||||||
|
if kind == "statefulset" and not ready:
|
||||||
|
status["currentRevision"] = "revision-1"
|
||||||
|
return {
|
||||||
|
"metadata": {"generation": 4},
|
||||||
|
"spec": {
|
||||||
|
"replicas": 1,
|
||||||
|
"template": {
|
||||||
|
"spec": {
|
||||||
|
"initContainers": [{"image": "busybox:1"}],
|
||||||
|
"containers": [{"image": f"{repository}@{digest}"}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _pods(repository: str, digest: str, *, ready: bool = True) -> dict:
|
||||||
|
return {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"status": {
|
||||||
|
"conditions": [
|
||||||
|
{"type": "Ready", "status": "True" if ready else "False"}
|
||||||
|
],
|
||||||
|
"containerStatuses": [
|
||||||
|
{
|
||||||
|
"image": f"{repository}@{digest}",
|
||||||
|
"imageID": f"{repository}@{digest}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _healthy_objects(module, component: str, revision: str, digest: str) -> dict:
|
||||||
|
config = module.COMPONENTS[component]
|
||||||
|
objects = {
|
||||||
|
("hermes", "imagepolicy", config["policy"]): _policy(
|
||||||
|
module, revision, digest
|
||||||
|
),
|
||||||
|
("hermes", "imageupdateautomation", "hermes"): {
|
||||||
|
"metadata": {"generation": 3},
|
||||||
|
"status": {"conditions": _condition()},
|
||||||
|
},
|
||||||
|
("flux-system", "kustomization", "hermes"): {
|
||||||
|
"metadata": {"generation": 3},
|
||||||
|
"status": {
|
||||||
|
"conditions": _condition(),
|
||||||
|
"lastAppliedRevision": "main@sha1:" + "f" * 40,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for kind, name, label in config["workloads"]:
|
||||||
|
objects[("hermes", kind, name)] = _controller(
|
||||||
|
kind, config["repository"], digest
|
||||||
|
)
|
||||||
|
objects[("hermes", "pods", f"app={label}")] = _pods(
|
||||||
|
config["repository"], digest
|
||||||
|
)
|
||||||
|
return objects
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake(module, monkeypatch: pytest.MonkeyPatch, objects: dict) -> None:
|
||||||
|
def fake(*command):
|
||||||
|
namespace = command[1]
|
||||||
|
kind = command[3]
|
||||||
|
if kind == "pods":
|
||||||
|
key = (namespace, kind, command[5])
|
||||||
|
else:
|
||||||
|
key = (namespace, kind, command[4])
|
||||||
|
return objects[key]
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "_kubectl_json", fake)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("component", ["agent", "webui", "stt", "tts"])
|
||||||
|
def test_exact_release_convergence_covers_every_component(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, component: str
|
||||||
|
) -> None:
|
||||||
|
"""Every lane binds policy source/digest to Ready desired and running state."""
|
||||||
|
module = _load(STATUS, f"release_status_{component}")
|
||||||
|
revision = "a" * 40
|
||||||
|
digest = "sha256:" + "b" * 64
|
||||||
|
_install_fake(module, monkeypatch, _healthy_objects(module, component, revision, digest))
|
||||||
|
|
||||||
|
result = module.inspect_release(component, revision)
|
||||||
|
|
||||||
|
assert result["converged"] is True
|
||||||
|
assert result["stage"] == "converged"
|
||||||
|
assert result["selected_revision"] == revision
|
||||||
|
assert result["digest"] == digest
|
||||||
|
assert result["build"] == 17
|
||||||
|
assert all(item["converged"] for item in result["workloads"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_and_different_policy_never_claim_deployment(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""No or different immutable source remains visibly incomplete."""
|
||||||
|
module = _load(STATUS, "release_status_policy_pending")
|
||||||
|
revision = "a" * 40
|
||||||
|
digest = "sha256:" + "b" * 64
|
||||||
|
config = module.COMPONENTS["tts"]
|
||||||
|
objects = _healthy_objects(module, "tts", revision, digest)
|
||||||
|
policy_key = ("hermes", "imagepolicy", config["policy"])
|
||||||
|
objects[policy_key]["status"]["latestRef"] = {}
|
||||||
|
_install_fake(module, monkeypatch, objects)
|
||||||
|
assert module.inspect_release("tts", revision)["stage"] == "image_policy_pending"
|
||||||
|
|
||||||
|
objects[policy_key] = _policy(module, "c" * 40, digest)
|
||||||
|
result = module.inspect_release("tts", revision)
|
||||||
|
assert result["stage"] == "different_revision_selected"
|
||||||
|
assert result["selected_revision"] == "c" * 40
|
||||||
|
assert result["converged"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("mutation", "expected"),
|
||||||
|
[
|
||||||
|
("automation", "image_automation_pending"),
|
||||||
|
("flux", "flux_apply_pending"),
|
||||||
|
("desired", "flux_apply_pending"),
|
||||||
|
("pod", "rollout_pending"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_followthrough_reports_the_first_incomplete_delivery_stage(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, mutation: str, expected: str
|
||||||
|
) -> None:
|
||||||
|
"""The helper separates promotion, GitOps apply, and live rollout gaps."""
|
||||||
|
module = _load(STATUS, f"release_status_stage_{mutation}")
|
||||||
|
revision = "d" * 40
|
||||||
|
digest = "sha256:" + "e" * 64
|
||||||
|
objects = _healthy_objects(module, "stt", revision, digest)
|
||||||
|
if mutation == "automation":
|
||||||
|
objects[("hermes", "imageupdateautomation", "hermes")]["status"][
|
||||||
|
"conditions"
|
||||||
|
] = _condition(False)
|
||||||
|
elif mutation == "flux":
|
||||||
|
objects[("flux-system", "kustomization", "hermes")]["status"][
|
||||||
|
"conditions"
|
||||||
|
] = _condition(False)
|
||||||
|
elif mutation == "desired":
|
||||||
|
objects[("hermes", "deployment", "hermes-stt")]["spec"]["template"][
|
||||||
|
"spec"
|
||||||
|
]["containers"][0]["image"] = "registry.bstein.dev/bstein/hermes-jetson-stt@sha256:" + "1" * 64
|
||||||
|
else:
|
||||||
|
objects[("hermes", "pods", "app=hermes-stt")] = _pods(
|
||||||
|
module.COMPONENTS["stt"]["repository"], digest, ready=False
|
||||||
|
)
|
||||||
|
_install_fake(module, monkeypatch, objects)
|
||||||
|
|
||||||
|
result = module.inspect_release("stt", revision)
|
||||||
|
|
||||||
|
assert result["stage"] == expected
|
||||||
|
assert result["converged"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_helpers_fail_closed_on_stale_generation_and_malformed_references() -> None:
|
||||||
|
"""Malformed release evidence and stale Ready conditions are not accepted."""
|
||||||
|
module = _load(STATUS, "release_status_helpers")
|
||||||
|
assert module._condition_ready(
|
||||||
|
{"metadata": {"generation": 4}, "status": {"conditions": _condition(True, 3)}}
|
||||||
|
) is False
|
||||||
|
assert module._condition_ready({}) is False
|
||||||
|
assert module._release_ref({"status": {"latestRef": {"tag": "latest"}}}) == (
|
||||||
|
"latest",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert module._rollout_ready("deployment", _controller("deployment", "r", "d"))[2]
|
||||||
|
stale = _controller("deployment", "r", "d")
|
||||||
|
stale["status"]["observedGeneration"] = 1
|
||||||
|
assert module._rollout_ready("deployment", stale)[2] is False
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
module.inspect_release("invalid", "a" * 40)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
module.inspect_release("agent", "main")
|
||||||
|
|
||||||
|
|
||||||
|
def test_kubectl_reader_is_bounded_and_requires_an_object(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Status uses argument arrays, a timeout, and object-shaped JSON only."""
|
||||||
|
module = _load(STATUS, "release_status_kubectl")
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def run(command, **kwargs):
|
||||||
|
seen["command"] = command
|
||||||
|
seen["kwargs"] = kwargs
|
||||||
|
return subprocess.CompletedProcess(command, 0, '{"ok": true}', "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.subprocess, "run", run)
|
||||||
|
assert module._kubectl_json("get", "pod", "demo") == {"ok": True}
|
||||||
|
assert seen["command"] == ["kubectl", "get", "pod", "demo", "-o", "json"]
|
||||||
|
assert seen["kwargs"]["timeout"] == 30
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, "[]", ""),
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="non-object"):
|
||||||
|
module._kubectl_json("get", "pods")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_waits_for_convergence_and_returns_safe_json(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Wait mode polls bounded status and terminates only at convergence."""
|
||||||
|
module = _load(STATUS, "release_status_main")
|
||||||
|
revision = "7" * 40
|
||||||
|
states = [
|
||||||
|
{"stage": "image_policy_pending", "converged": False},
|
||||||
|
{"stage": "converged", "converged": True, "digest": "sha256:" + "8" * 64},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(module, "inspect_release", lambda *_args: states.pop(0))
|
||||||
|
monkeypatch.setattr(module.time, "sleep", lambda _seconds: None)
|
||||||
|
assert module.main(
|
||||||
|
["--component", "agent", "--revision", revision, "--wait", "--poll", "1"]
|
||||||
|
) == 0
|
||||||
|
assert json.loads(capsys.readouterr().out)["converged"] is True
|
||||||
|
|
||||||
|
assert module.main(
|
||||||
|
["--component", "agent", "--revision", revision, "--timeout", "0"]
|
||||||
|
) == 2
|
||||||
|
assert "must be positive" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_trigger_returns_a_token_free_exact_follow_command(tmp_path: Path) -> None:
|
||||||
|
"""A successful trigger tells Hermes how to prove the approved release landed."""
|
||||||
|
module = _load(TRIGGER, "release_status_trigger")
|
||||||
|
revision = "9" * 40
|
||||||
|
token = tmp_path / "token"
|
||||||
|
token.write_text("never-print-me\n", encoding="utf-8")
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
status = 201
|
||||||
|
headers = {"Location": "/queue/item/91/"}
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = module.trigger_build(
|
||||||
|
revision,
|
||||||
|
component="webui",
|
||||||
|
token_file=token,
|
||||||
|
opener=lambda *_args, **_kwargs: Response(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["follow_command"] == (
|
||||||
|
"/opt/coordinator/hermes_image_release_status.py --component webui "
|
||||||
|
f"--revision {revision} --wait"
|
||||||
|
)
|
||||||
|
assert "never-print-me" not in json.dumps(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_bundle_and_guidance_require_live_convergence() -> None:
|
||||||
|
"""The helper ships to Worker and merged code alone is explicitly insufficient."""
|
||||||
|
kustomization = (ROOT / "services/hermes/kustomization.yaml").read_text()
|
||||||
|
guidance = (ROOT / "services/hermes/agent-configmap.yaml").read_text()
|
||||||
|
assert "hermes_image_release_status.py=scripts/hermes_image_release_status.py" in kustomization
|
||||||
|
assert "safe `follow_command`" in guidance
|
||||||
|
assert "Merged code is not a completed release" in guidance.replace(
|
||||||
|
"merged code", "Merged code"
|
||||||
|
)
|
||||||
@ -142,6 +142,11 @@ def test_production_patchers_apply_title_icons_theme_and_cache_contract(
|
|||||||
assert '<meta name="theme-color" content="#0D1420"' in index
|
assert '<meta name="theme-color" content="#0D1420"' in index
|
||||||
assert 'id="appTitlebarTitle">Hermes Chat</span>' in index
|
assert 'id="appTitlebarTitle">Hermes Chat</span>' in index
|
||||||
assert '<img src="static/hermes-agent-192.png" alt="">' in index
|
assert '<img src="static/hermes-agent-192.png" alt="">' in index
|
||||||
|
assert (
|
||||||
|
'<img class="hermes-agent-portrait" '
|
||||||
|
'src="static/hermes-agent-192.png" alt="Hermes Agent">'
|
||||||
|
) in index
|
||||||
|
assert 'aria-label="Hermes caduceus"' not in index
|
||||||
assert "favicon.svg" not in index
|
assert "favicon.svg" not in index
|
||||||
assert "favicon-32.png" not in index
|
assert "favicon-32.png" not in index
|
||||||
|
|
||||||
@ -291,12 +296,35 @@ def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
|
|||||||
assert "--accent: #48cfcc" in dark
|
assert "--accent: #48cfcc" in dark
|
||||||
assert "--voice-accent: 72, 207, 204" in css
|
assert "--voice-accent: 72, 207, 204" in css
|
||||||
assert "--voice-accent-secondary: 76, 164, 205" in css
|
assert "--voice-accent-secondary: 76, 164, 205" in css
|
||||||
|
assert "--hermes-violet: #9d8ee0" in dark
|
||||||
|
assert "--hermes-grid: rgba(72, 207, 204, 0.025)" in dark
|
||||||
|
assert ".empty-logo .hermes-agent-portrait" in css
|
||||||
|
assert "width: 112px" in css
|
||||||
|
assert ":root.dark .messages" in css
|
||||||
|
assert "background-size: 32px 32px, 32px 32px, auto, auto" in css
|
||||||
|
assert ":root.dark .session-item.active" in css
|
||||||
|
assert ":root.dark .suggestion:focus-visible" in css
|
||||||
assert "@media (prefers-reduced-motion: reduce)" in css
|
assert "@media (prefers-reduced-motion: reduce)" in css
|
||||||
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
||||||
assert "transition: none !important" in reduced
|
assert "transition: none !important" in reduced
|
||||||
|
assert "transform: none !important" in reduced
|
||||||
assert "animation:" not in css
|
assert "animation:" not in css
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_state_uses_canonical_character_without_inline_staff(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The new-chat identity reuses the established full character artwork."""
|
||||||
|
index = (_patched_fixture(tmp_path) / "static/index.html").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
empty_state = index.split('<div class="empty-state" id="emptyState">', 1)[1]
|
||||||
|
empty_state = empty_state.split('<div class="messages-inner"', 1)[0]
|
||||||
|
assert empty_state.count('class="hermes-agent-portrait"') == 1
|
||||||
|
assert empty_state.count('src="static/hermes-agent-192.png"') == 1
|
||||||
|
assert "<svg" not in empty_state.split('<h2 data-i18n="empty_title">', 1)[0]
|
||||||
|
|
||||||
|
|
||||||
def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None:
|
def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None:
|
||||||
"""The immutable image, not a runtime coordinator path, owns PWA assets."""
|
"""The immutable image, not a runtime coordinator path, owns PWA assets."""
|
||||||
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user