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 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"
|
||||
replace_exact(
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
--gold: #9a661f;
|
||||
--focus-ring: rgba(24, 127, 139, 0.38);
|
||||
--focus-glow: rgba(24, 127, 139, 0.12);
|
||||
--hermes-violet: #7065a8;
|
||||
--hermes-grid: rgba(24, 127, 139, 0.035);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
@ -43,6 +45,9 @@
|
||||
--main-bg: rgba(7, 10, 18, 0.72);
|
||||
--focus-ring: rgba(72, 207, 204, 0.38);
|
||||
--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;
|
||||
--success: #65c9a6;
|
||||
--warning: #f0b66b;
|
||||
@ -51,7 +56,8 @@
|
||||
|
||||
:root.dark body {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -65,6 +71,36 @@
|
||||
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 {
|
||||
display: block;
|
||||
width: 22px;
|
||||
@ -78,9 +114,87 @@
|
||||
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 {
|
||||
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. */
|
||||
@ -99,7 +213,31 @@
|
||||
}
|
||||
|
||||
.app-titlebar-icon img,
|
||||
:root.dark .composer-box:focus-within {
|
||||
:root.dark .composer-box:focus-within,
|
||||
.suggestion {
|
||||
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`.
|
||||
Jenkins builds the newest main containing that commit, publishes a final
|
||||
immutable release tag only after its evidence passes, and Flux applies the
|
||||
resulting digest. Candidate tags and failed builds never deploy. Follow the
|
||||
returned queue item with `jenkins_build_evidence.py`, then verify the Flux
|
||||
revision, pod image digest, rollout health, and public SSO redirect before
|
||||
reporting the release complete.
|
||||
resulting digest. Candidate tags and failed builds never deploy. Run the
|
||||
safe `follow_command` returned by the trigger and do not report completion
|
||||
until it says `converged`: that proves the exact reviewed source is the
|
||||
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
|
||||
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
|
||||
- jenkins_build_evidence.py=scripts/jenkins_build_evidence.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_supervisor.py=scripts/kanban_supervisor.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
|
||||
return {
|
||||
"component": component,
|
||||
"follow_command": (
|
||||
"/opt/coordinator/hermes_image_release_status.py "
|
||||
f"--component {component} --revision {revision} --wait"
|
||||
),
|
||||
"job": job["job"],
|
||||
"queue_path": queue_path,
|
||||
"source_revision": revision,
|
||||
|
||||
@ -8,5 +8,7 @@ Hermes WebUI image pinned by `dockerfiles/Dockerfile.hermes-webui`:
|
||||
- OCI source revision: `7a94e34a6d639576576baa9131acf6765f6d2b98`
|
||||
- Full upstream `static/index.html` SHA-256: `6e218d42f6e047168a774c59aa9fc98a55b608cdc070cf1049ad414a597a722c`
|
||||
|
||||
The fixture stays intentionally narrow, but tests execute the shipped
|
||||
`hermes-webui-atlas-patch.py` against it; they do not duplicate its patch logic.
|
||||
The fixture stays intentionally narrow, but tests execute the shipped patchers
|
||||
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>
|
||||
<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 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="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"ci/scripts/hermes_image_release.py",
|
||||
"dockerfiles/hermes-kaniko-heredoc-runner.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",
|
||||
"ci/scripts/publish_test_metrics.py",
|
||||
"ci/scripts/publish_test_metrics_quality.py",
|
||||
@ -107,6 +108,7 @@
|
||||
"ci/scripts/hermes_image_release.py",
|
||||
"dockerfiles/hermes-kaniko-heredoc-runner.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",
|
||||
"ci/scripts/publish_test_metrics.py",
|
||||
"ci/scripts/publish_test_metrics_quality.py",
|
||||
@ -341,6 +343,7 @@
|
||||
"ci/scripts/hermes_image_release.py",
|
||||
"dockerfiles/hermes-kaniko-heredoc-runner.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",
|
||||
"ci/scripts/publish_test_metrics.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 'id="appTitlebarTitle">Hermes Chat</span>' 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-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 "--voice-accent: 72, 207, 204" 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
|
||||
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
||||
assert "transition: none !important" in reduced
|
||||
assert "transform: none !important" in reduced
|
||||
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:
|
||||
"""The immutable image, not a runtime coordinator path, owns PWA assets."""
|
||||
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user