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:
parent
51479d9fe8
commit
91cf4d2ae1
3 changed files with 389 additions and 8 deletions
|
|
@ -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`]
|
||||
|
|
|
|||
261
swarm-controller/src/forge/service_avatars.rs
Normal file
261
swarm-controller/src/forge/service_avatars.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue