Watch
0
0
Fork
You've already forked hyperhive
0

icons for the swarm's own forge accounts, and a default agent icon

The builder, ci-metrics and review-classifier forge accounts get an avatar: an
icon each in a hexagonal frame, the hyperhive logo when one is missing. The
controller sets them with a short-lived write:user token it mints for the
account, since only an account can set its own avatar.

An agent with no icon of its own shows a default agent icon in the swarm UI, a
robot head in a round frame, instead of the dimmed hyperhive mark.
This commit is contained in:
iris 2026-11-15 16:06:17 +01:00 • committed by mara
commit 91cf4d2ae1
15 changed files with 389 additions and 8 deletions

View file

@ -0,0 +1,24 @@
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
<title>Agent</title>
<desc>The default agent icon: a robot head in line art, in a round frame</desc>
<defs>
<clipPath id="clip-a"><circle cx="150" cy="150" r="140"/></clipPath>
<filter id="glow-a" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<radialGradient id="core-a"><stop offset="0" stop-color="#7aa7ff" stop-opacity="0.35"/><stop offset="1" stop-color="#7aa7ff" stop-opacity="0"/></radialGradient>
</defs>
<g clip-path="url(#clip-a)">
<rect x="0" y="0" width="300" height="300" fill="#060a1c"/>
<circle cx="150" cy="150" r="80" fill="url(#core-a)"/>
</g>
<circle cx="150" cy="150" r="140" fill="none" stroke="#7aa7ff" stroke-width="2.5"/>
<circle cx="150" cy="150" r="118" fill="none" stroke="#7aa7ff" stroke-width="0.8" opacity="0.4"/>
<g filter="url(#glow-a)" fill="none" stroke="#7aa7ff" stroke-width="3" stroke-linejoin="round" stroke-linecap="round">
<rect x="108" y="112" width="84" height="72" rx="14"/><path d="M150,112 L150,94"/><circle cx="150" cy="87" r="6"/>
<circle cx="130" cy="144" r="7"/><circle cx="170" cy="144" r="7"/><path d="M132,166 L168,166"/>
<rect x="98" y="132" width="10" height="28" rx="3"/><rect x="192" y="132" width="10" height="28" rx="3"/>
<path d="M104,214 Q150,184 196,214"/>
</g>
<g stroke="#7aa7ff" stroke-width="1.5" fill="none" opacity="0.8">
<path d="M44,44 L20,44 L20,68"/><path d="M256,44 L280,44 L280,68"/><path d="M44,256 L20,256 L20,232"/><path d="M256,256 L280,256 L280,232"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,22 @@
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
<title>builder</title>
<desc>The swarm builder service: a hammer over a cube, in a hexagonal frame</desc>
<defs>
<clipPath id="clip-b"><polygon points="150.0,10.0 271.2,80.0 271.2,220.0 150.0,290.0 28.8,220.0 28.8,80.0"/></clipPath>
<filter id="glow-b" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<radialGradient id="core-b"><stop offset="0" stop-color="#ffb454" stop-opacity="0.35"/><stop offset="1" stop-color="#ffb454" stop-opacity="0"/></radialGradient>
</defs>
<g clip-path="url(#clip-b)">
<rect x="0" y="0" width="300" height="300" fill="#060a1c"/>
<circle cx="150" cy="150" r="80" fill="url(#core-b)"/>
</g>
<polygon points="150.0,10.0 271.2,80.0 271.2,220.0 150.0,290.0 28.8,220.0 28.8,80.0" fill="none" stroke="#ffb454" stroke-width="2.5" stroke-linejoin="round"/>
<polygon points="150.0,32.0 252.2,91.0 252.2,209.0 150.0,268.0 47.8,209.0 47.8,91.0" fill="none" stroke="#ffb454" stroke-width="0.8" opacity="0.4" stroke-linejoin="round"/>
<g filter="url(#glow-b)" fill="none" stroke="#ffb454" stroke-width="3" stroke-linejoin="round" stroke-linecap="round">
<g transform="rotate(-35 138 150)"><path d="M138,214 L138,118"/><rect x="110" y="96" width="56" height="26" rx="4"/><path d="M166,104 L182,98 M166,114 L182,120"/></g>
<path d="M196,196 l22,-12 l22,12 l0,26 l-22,12 l-22,-12 z M196,196 l22,12 l22,-12 M218,208 l0,26" stroke-width="2.2"/>
</g>
<g stroke="#ffb454" stroke-width="1.5" fill="none" opacity="0.8">
<path d="M44,44 L20,44 L20,68"/><path d="M256,44 L280,44 L280,68"/><path d="M44,256 L20,256 L20,232"/><path d="M256,256 L280,256 L280,232"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,23 @@
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
<title>ci-metrics</title>
<desc>The CI metrics service: a bar chart with a trend line, in a hexagonal frame</desc>
<defs>
<clipPath id="clip-m"><polygon points="150.0,10.0 271.2,80.0 271.2,220.0 150.0,290.0 28.8,220.0 28.8,80.0"/></clipPath>
<filter id="glow-m" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<radialGradient id="core-m"><stop offset="0" stop-color="#3ddcc8" stop-opacity="0.35"/><stop offset="1" stop-color="#3ddcc8" stop-opacity="0"/></radialGradient>
</defs>
<g clip-path="url(#clip-m)">
<rect x="0" y="0" width="300" height="300" fill="#060a1c"/>
<circle cx="150" cy="150" r="80" fill="url(#core-m)"/>
</g>
<polygon points="150.0,10.0 271.2,80.0 271.2,220.0 150.0,290.0 28.8,220.0 28.8,80.0" fill="none" stroke="#3ddcc8" stroke-width="2.5" stroke-linejoin="round"/>
<polygon points="150.0,32.0 252.2,91.0 252.2,209.0 150.0,268.0 47.8,209.0 47.8,91.0" fill="none" stroke="#3ddcc8" stroke-width="0.8" opacity="0.4" stroke-linejoin="round"/>
<g filter="url(#glow-m)" fill="none" stroke="#3ddcc8" stroke-width="3" stroke-linejoin="round" stroke-linecap="round">
<path d="M92,210 L208,210"/>
<rect x="100" y="170" width="20" height="40" rx="2"/><rect x="130" y="148" width="20" height="62" rx="2"/><rect x="160" y="160" width="20" height="50" rx="2"/><rect x="190" y="122" width="20" height="88" rx="2"/>
<path d="M98,150 L134,128 L168,140 L206,100" stroke-width="2.2"/><circle cx="206" cy="100" r="4" fill="#3ddcc8"/>
</g>
<g stroke="#3ddcc8" stroke-width="1.5" fill="none" opacity="0.8">
<path d="M44,44 L20,44 L20,68"/><path d="M256,44 L280,44 L280,68"/><path d="M44,256 L20,256 L20,232"/><path d="M256,256 L280,256 L280,232"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,23 @@
<svg width="300" height="300" viewBox="0 0 300 300" role="img" xmlns="http://www.w3.org/2000/svg">
<title>review-classifier</title>
<desc>The review classifier service: a pair of scales, in a hexagonal frame</desc>
<defs>
<clipPath id="clip-c"><polygon points="150.0,10.0 271.2,80.0 271.2,220.0 150.0,290.0 28.8,220.0 28.8,80.0"/></clipPath>
<filter id="glow-c" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="3" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
<radialGradient id="core-c"><stop offset="0" stop-color="#b48cff" stop-opacity="0.35"/><stop offset="1" stop-color="#b48cff" stop-opacity="0"/></radialGradient>
</defs>
<g clip-path="url(#clip-c)">
<rect x="0" y="0" width="300" height="300" fill="#060a1c"/>
<circle cx="150" cy="150" r="80" fill="url(#core-c)"/>
</g>
<polygon points="150.0,10.0 271.2,80.0 271.2,220.0 150.0,290.0 28.8,220.0 28.8,80.0" fill="none" stroke="#b48cff" stroke-width="2.5" stroke-linejoin="round"/>
<polygon points="150.0,32.0 252.2,91.0 252.2,209.0 150.0,268.0 47.8,209.0 47.8,91.0" fill="none" stroke="#b48cff" stroke-width="0.8" opacity="0.4" stroke-linejoin="round"/>
<g filter="url(#glow-c)" fill="none" stroke="#b48cff" stroke-width="3" stroke-linejoin="round" stroke-linecap="round">
<path d="M150,96 L150,206 M120,206 L180,206 M98,116 L202,116"/><circle cx="150" cy="96" r="5"/>
<path d="M98,116 L82,164 M98,116 L114,164 M202,116 L186,164 M202,116 L218,164"/>
<path d="M78,164 L118,164 Q98,190 78,164 Z M182,164 L222,164 Q202,190 182,164 Z"/>
</g>
<g stroke="#b48cff" stroke-width="1.5" fill="none" opacity="0.8">
<path d="M44,44 L20,44 L20,68"/><path d="M256,44 L280,44 L280,68"/><path d="M44,256 L20,256 L20,232"/><path d="M256,256 L280,256 L280,232"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -258,8 +258,8 @@ once per start, because a config change reaches an agent by restarting its
container. An agent with no icon deletes its key. Swarm-side, container. An agent with no icon deletes its key. Swarm-side,
`GET /api/agents/<name>/icon` serves `GET /api/agents/<name>/icon` serves
the stored bytes, and 404 means the agent has no icon. swarm-ui's agent cards the stored bytes, and 404 means the agent has no icon. swarm-ui's agent cards
load it as an `<img>` and show the dimmed hyperhive mark for an agent without load it as an `<img>` and show the dimmed default agent icon (a robot head in a
one, as the hive dashboard does. round frame, `branding/agent-default.svg`) for an agent without one.
The fourth is its **`AGENTS.md`**, which goes into the `agents-md` bucket under The fourth is its **`AGENTS.md`**, which goes into the `agents-md` bucket under
the key `<agent>`, writable by the agent's own credential alone the key `<agent>`, writable by the agent's own credential alone
@ -363,6 +363,17 @@ one per hive. It ensures them at start and every five minutes after
- the `agent-configs` org avatar - the `agent-configs` org avatar
(`deploy.swarm-controller.configOrgAvatarPng`). (`deploy.swarm-controller.configOrgAvatarPng`).
The swarm's own forge accounts (`builder`, `ci-metrics` and `review-classifier`)
get an avatar too, from a separate pass in the controller (`forge/service_avatars.rs`).
Each has an icon in a hexagonal frame (`branding/service-<login>.svg`, rendered to a PNG
in the assets package) and falls back to the hyperhive logo. Only an account can set its own
avatar, so the pass mints a short-lived `write:user` token for it with the admin API, uploads
the image, and deletes the token. A marker per account in the state directory
(`forge-service-avatar-<login>`) holds the image's SHA-256, so a changed icon uploads again
and an unchanged one doesn't. The directory with the icons is
`SWARM_CONTROLLER_SERVICE_ICON_DIR`, set by the nix module. An account that doesn't exist yet
gets passed over and shows up on the next pass, every five minutes.
A pass that can't finish logs a A pass that can't finish logs a
`warn` line per object plus `swarm forge objects: pass incomplete` in `warn` line per object plus `swarm forge objects: pass incomplete` in
`journalctl -u swarm-controller`, and retries on the next tick. While the `journalctl -u swarm-controller`, and retries on the next tick. While the

View file

@ -178,7 +178,7 @@ you flip it follow it.
A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of
different agents tell apart. A subagent's tab shows its agent's icon. An agent different agents tell apart. A subagent's tab shows its agent's icon. An agent
with no icon keeps the hyperhive mark. with no icon keeps the default agent icon.
The tab also has an agent badge, with the agent's icon and name, ahead of the The tab also has an agent badge, with the agent's icon and name, ahead of the
connection badge. It opens the agent's details in a popover under the badge: connection badge. It opens the agent's details in a popover under the badge:

View file

@ -20,6 +20,10 @@
filter: grayscale(1); filter: grayscale(1);
opacity: 0.4; opacity: 0.4;
} }
/* The default agent icon: dimmed, but in its colour. */
.ui-agent-icon-default {
opacity: 0.6;
}
/* Beside a line of text, inside a badge. */ /* Beside a line of text, inside a badge. */
.ui-agent-icon-small { .ui-agent-icon-small {
width: 1.3em; width: 1.3em;

View file

@ -1,7 +1,8 @@
// <AgentIcon> — the agent's icon from swarm-controller's // <AgentIcon> — the agent's icon from swarm-controller's
// `GET /api/agents/{name}/icon`, the same square and fallback as the hive // `GET /api/agents/{name}/icon`: an agent with no icon (404) or an unreachable
// dashboard's container row: an agent with no icon (404) or an unreachable // controller shows the default agent icon, dimmed, instead of a broken image.
// controller shows the dimmed hyperhive mark instead of a broken image. // It is a robot head in a round frame, so it is not mistaken for a swarm
// service, whose icons sit in a hexagon.
// Always an `<img>`: the body is an agent-authored SVG, and only an image load // Always an `<img>`: the body is an agent-authored SVG, and only an image load
// keeps its script from running. // keeps its script from running.
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@ -21,14 +22,14 @@ export function AgentIcon({
class={ class={
"ui-agent-icon" + "ui-agent-icon" +
(small ? " ui-agent-icon-small" : "") + (small ? " ui-agent-icon-small" : "") +
(fallback ? " ui-agent-icon-none" : "") (fallback ? " ui-agent-icon-default" : "")
} }
> >
<img <img
alt="" alt=""
src={ src={
fallback fallback
? "/favicon.svg" ? "/agent-default.svg"
: `/api/agents/${encodeURIComponent(name)}/icon` : `/api/agents/${encodeURIComponent(name)}/icon`
} }
onError={() => setFallback(true)} onError={() => setFallback(true)}

View file

@ -210,6 +210,8 @@ let
"${deployCfg.swarm-controller.configOrgAvatarPng}" "${deployCfg.swarm-controller.configOrgAvatarPng}"
else else
"${config.services.hyperhive.deploy.hive-controller.assets}/share/hyperhive/branding/agent-configs.png"; "${config.services.hyperhive.deploy.hive-controller.assets}/share/hyperhive/branding/agent-configs.png";
# The swarm services' own forge accounts' avatars (`forge/service_avatars.rs`).
SWARM_CONTROLLER_SERVICE_ICON_DIR = "${config.services.hyperhive.deploy.hive-controller.assets}/share/hyperhive/branding";
}; };
# How the forge must address this controller to deliver a swarm-wide # How the forge must address this controller to deliver a swarm-wide

View file

@ -10,6 +10,7 @@
# #
# Output layout: # Output layout:
# $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png} # $out/share/hyperhive/branding/{hyperhive,agent-configs}.{svg,png}
# $out/share/hyperhive/branding/service-*.{svg,png} (the swarm services' forge avatars)
# $out/share/hyperhive/prompts/{system.md, claude-settings.json} # $out/share/hyperhive/prompts/{system.md, claude-settings.json}
# #
# The repo docs/ tree is a SEPARATE derivation (./reference-docs.nix) # The repo docs/ tree is a SEPARATE derivation (./reference-docs.nix)
@ -42,6 +43,9 @@ stdenv.mkDerivation {
rsvg-convert --width 300 --height 300 \ rsvg-convert --width 300 --height 300 \
-o branding/agent-configs.png \ -o branding/agent-configs.png \
branding/agent-configs.svg branding/agent-configs.svg
for svg in branding/service-*.svg; do
rsvg-convert --width 300 --height 300 -o "''${svg%.svg}.png" "$svg"
done
runHook postBuild runHook postBuild
''; '';

View file

@ -228,6 +228,7 @@ in
swarm-ui = pkgs.callPackage ./swarm-ui.nix { swarm-ui = pkgs.callPackage ./swarm-ui.nix {
branding-svg = ../../branding/hyperhive.svg; branding-svg = ../../branding/hyperhive.svg;
branding-svg-maskable = ../../branding/hyperhive-maskable.svg; branding-svg-maskable = ../../branding/hyperhive-maskable.svg;
agent-default-svg = ../../branding/agent-default.svg;
}; };
# Bundled browser assets — see ./frontend.nix. Output is # Bundled browser assets — see ./frontend.nix. Output is

View file

@ -2,6 +2,7 @@
buildNpmPackage, buildNpmPackage,
branding-svg, branding-svg,
branding-svg-maskable, branding-svg-maskable,
agent-default-svg,
librsvg, librsvg,
}: }:
@ -66,6 +67,8 @@ buildNpmPackage {
mkdir -p $out mkdir -p $out
cp -r packages/swarm-ui/dist/. $out/ cp -r packages/swarm-ui/dist/. $out/
cp ${branding-svg} $out/favicon.svg cp ${branding-svg} $out/favicon.svg
# What an agent with no icon of its own shows (`AgentIcon.tsx`).
cp ${agent-default-svg} $out/agent-default.svg
rsvg-convert -w 192 -h 192 ${branding-svg} -o $out/icon-192.png rsvg-convert -w 192 -h 192 ${branding-svg} -o $out/icon-192.png
rsvg-convert -w 512 -h 512 ${branding-svg} -o $out/icon-512.png rsvg-convert -w 512 -h 512 ${branding-svg} -o $out/icon-512.png

View file

@ -61,6 +61,7 @@ pub mod pr_report;
pub mod pull_diff; pub mod pull_diff;
pub mod review_request; pub mod review_request;
pub mod runner_token; pub mod runner_token;
pub mod service_avatars;
pub mod site_admin; pub mod site_admin;
/// The longest commit status description [`Client::create_commit_status`] /// The longest commit status description [`Client::create_commit_status`]

View file

@ -0,0 +1,261 @@
//! The avatars of the swarm's own forge accounts: [`builder_account::LOGIN`],
//! [`ci_metrics_account::LOGIN`] and [`classifier_account::LOGIN`], which the
//! controller creates itself. Without one the forge shows an identicon.
//!
//! Each gets `service-<login>.png` from [`ICON_DIR_ENV`]'s directory, the
//! hyperhive logo (`hyperhive.png`) when that file is missing. The images are
//! the `branding/` SVGs rendered by `nix/packages/assets.nix`.
//!
//! **Only the account itself may set its avatar**, so each pass mints a
//! short-lived token for it with the admin API, scoped to `write:user` and
//! nothing else, sets the avatar, and deletes the token again. The accounts'
//! own tokens never carry that scope.
//!
//! A marker per account under the state directory records the fingerprint of
//! the image last uploaded, so a pass only uploads when the image changed. An
//! account that does not exist yet is skipped; the next pass finds it.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use base64::Engine as _;
use forgejo_api::structs::{CreateAccessTokenOption, UpdateUserAvatarOption};
use forgejo_api::{Auth, Forgejo};
use sha2::{Digest as _, Sha256};
use super::agent_token::is_not_found;
use super::{Client, builder_account, ci_metrics_account, classifier_account};
/// The directory holding the rendered icons, set by `swarm-controller.nix`.
const ICON_DIR_ENV: &str = "SWARM_CONTROLLER_SERVICE_ICON_DIR";
/// The accounts that get an avatar.
const SERVICES: [&str; 3] = [
builder_account::LOGIN,
ci_metrics_account::LOGIN,
classifier_account::LOGIN,
];
/// What a service without an icon of its own shows.
const FALLBACK_ICON: &str = "hyperhive.png";
/// The name of the short-lived token.
const TOKEN_NAME: &str = "swarm-avatar";
/// What the short-lived token may do: edit the account's own profile.
const TOKEN_SCOPES: [&str; 1] = ["write:user"];
/// How often the pass re-runs. Avatars rarely change; this is about how long
/// an account created after the first pass waits for its avatar.
const INTERVAL: Duration = Duration::from_mins(5);
/// The file `login`'s icon is read from.
fn icon_file(login: &str) -> String {
format!("service-{login}.png")
}
/// The image to upload for `login`: its own icon, else the fallback.
fn pick_icon(dir: &Path, login: &str) -> PathBuf {
let own = dir.join(icon_file(login));
if own.is_file() {
own
} else {
dir.join(FALLBACK_ICON)
}
}
/// What the marker holds: the SHA-256 of the image, in hex.
fn fingerprint(image: &[u8]) -> String {
use std::fmt::Write as _;
Sha256::digest(image)
.iter()
.fold(String::new(), |mut hex, b| {
let _ = write!(hex, "{b:02x}");
hex
})
}
/// Where `login`'s marker lives.
fn marker(state_dir: &Path, login: &str) -> PathBuf {
state_dir.join(format!("forge-service-avatar-{login}"))
}
impl Client {
/// Set the avatar of every service account whose image changed since the
/// last upload. Returns how many were set.
///
/// # Errors
/// The first account's failure, after every account has been tried.
pub async fn ensure_service_avatars(&self, icon_dir: &Path, state_dir: &Path) -> Result<usize> {
let mut set = 0;
let mut first_error = None;
for login in SERVICES {
match self.ensure_service_avatar(login, icon_dir, state_dir).await {
Ok(true) => set += 1,
Ok(false) => {}
Err(e) => {
tracing::warn!(%login, error = %format!("{e:#}"), "service avatars: setting the avatar failed");
first_error.get_or_insert(e);
}
}
}
match first_error {
Some(e) => Err(e),
None => Ok(set),
}
}
/// One account. `Ok(true)` when the avatar was set, `Ok(false)` when it is
/// current or the account does not exist yet.
async fn ensure_service_avatar(
&self,
login: &str,
icon_dir: &Path,
state_dir: &Path,
) -> Result<bool> {
let path = pick_icon(icon_dir, login);
let image = tokio::fs::read(&path)
.await
.with_context(|| format!("reading {}", path.display()))?;
let print = fingerprint(&image);
let marker = marker(state_dir, login);
if std::fs::read_to_string(&marker).is_ok_and(|seen| seen.trim() == print) {
return Ok(false);
}
if !self.set_user_avatar(login, &image).await? {
tracing::debug!(%login, "service avatars: the account does not exist yet");
return Ok(false);
}
if let Err(e) = std::fs::write(&marker, &print) {
tracing::warn!(error = %e, marker = %marker.display(), "service avatars: avatar set, but its marker could not be written");
}
tracing::info!(%login, "service avatars: set the avatar");
Ok(true)
}
/// Set `login`'s avatar to `image` as the account itself, with a token that
/// exists only for this call. `Ok(false)` when the account does not exist.
async fn set_user_avatar(&self, login: &str, image: &[u8]) -> Result<bool> {
match self
.api
.call(|api| {
api.admin_delete_user_access_token(login, TOKEN_NAME)
.into_future()
})
.await
{
Ok(()) => {}
Err(e) if is_not_found(&e) => {}
Err(e) => {
return Err(e).with_context(|| format!("clearing {login}'s {TOKEN_NAME} token"));
}
}
let created = match self
.api
.call(|api| {
api.admin_create_user_access_token(
login,
CreateAccessTokenOption {
name: TOKEN_NAME.to_owned(),
repositories: None,
scopes: Some(TOKEN_SCOPES.iter().map(|&s| s.to_owned()).collect()),
},
)
.into_future()
})
.await
{
Ok(created) => created,
Err(e) if is_not_found(&e) => return Ok(false),
Err(e) => {
return Err(e).with_context(|| format!("creating {login}'s {TOKEN_NAME} token"));
}
};
let token = created
.sha1
.filter(|v| !v.is_empty())
.with_context(|| format!("the forge created {login}'s token but returned no value"))?;
let uploaded = Forgejo::new(Auth::Token(&token), self.url.clone())
.context("building a forge client with the avatar token")?
.user_update_avatar(UpdateUserAvatarOption {
image: Some(base64::engine::general_purpose::STANDARD.encode(image)),
})
.await
.with_context(|| format!("setting {login}'s avatar"));
// The token goes whether or not the upload worked.
if let Err(e) = self
.api
.call(|api| {
api.admin_delete_user_access_token(login, TOKEN_NAME)
.into_future()
})
.await
{
tracing::warn!(%login, error = %e, "service avatars: could not delete the avatar token");
}
uploaded.map(|()| true)
}
}
/// Set the service accounts' avatars now and every [`INTERVAL`]. Does nothing
/// when [`ICON_DIR_ENV`] is unset, as on a host without the assets.
pub fn spawn(client: Arc<Client>) {
let Some(icon_dir) = std::env::var_os(ICON_DIR_ENV).map(PathBuf::from) else {
tracing::warn!(
"{ICON_DIR_ENV} unset; the service accounts keep the forge's default avatar"
);
return;
};
let state_dir = crate::webhook::state_dir();
tokio::spawn(async move {
let mut ticker = tokio::time::interval(INTERVAL);
loop {
ticker.tick().await;
let _ = super::within_forge_deadline(
"service avatars",
client.ensure_service_avatars(&icon_dir, &state_dir),
)
.await;
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// A service with its own icon file uses it; one without falls back to the
/// logo, so a newly added service still gets an avatar.
#[test]
fn a_service_without_its_own_icon_gets_the_logo() {
let dir = std::env::temp_dir().join(format!("svc-avatars-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
std::fs::write(dir.join("service-builder.png"), b"x").expect("icon");
assert_eq!(pick_icon(&dir, "builder"), dir.join("service-builder.png"));
assert_eq!(pick_icon(&dir, "ci-metrics"), dir.join("hyperhive.png"));
std::fs::remove_dir_all(&dir).ok();
}
/// The marker changes with the image, so a re-rendered icon is uploaded
/// again, and only then.
#[test]
fn the_fingerprint_follows_the_image() {
assert_eq!(fingerprint(b"a"), fingerprint(b"a"));
assert_ne!(fingerprint(b"a"), fingerprint(b"b"));
assert_eq!(fingerprint(b"").len(), 64);
}
#[test]
fn every_service_has_its_own_marker_and_icon_name() {
let state = Path::new("/state");
let markers: std::collections::BTreeSet<_> =
SERVICES.iter().map(|l| marker(state, l)).collect();
assert_eq!(markers.len(), SERVICES.len());
assert_eq!(
icon_file("review-classifier"),
"service-review-classifier.png"
);
}
}

View file

@ -271,6 +271,7 @@ fn spawn_forge_workers(
// pin when that is newer. The timer's first tick is this boot run. // pin when that is newer. The timer's first tick is this boot run.
crate::jobq::spawn_meta_update_timer(Arc::clone(jobq)); crate::jobq::spawn_meta_update_timer(Arc::clone(jobq));
forge::objects::spawn(Arc::clone(&client)); forge::objects::spawn(Arc::clone(&client));
forge::service_avatars::spawn(Arc::clone(&client));
if let (Some(bridge), Some(queue)) = (auth, queue.clone()) { if let (Some(bridge), Some(queue)) = (auth, queue.clone()) {
forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue); forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue);
} }