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
3 changed files with 389 additions and 8 deletions

View file

@ -61,6 +61,7 @@ pub mod pr_report;
pub mod pull_diff;
pub mod review_request;
pub mod runner_token;
pub mod service_avatars;
pub mod site_admin;
/// 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.
crate::jobq::spawn_meta_update_timer(Arc::clone(jobq));
forge::objects::spawn(Arc::clone(&client));
forge::service_avatars::spawn(Arc::clone(&client));
if let (Some(bridge), Some(queue)) = (auth, queue.clone()) {
forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue);
}