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
15 changed files with 389 additions and 8 deletions
24
branding/agent-default.svg
Normal file
24
branding/agent-default.svg
Normal 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 |
22
branding/service-builder.svg
Normal file
22
branding/service-builder.svg
Normal 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 |
23
branding/service-ci-metrics.svg
Normal file
23
branding/service-ci-metrics.svg
Normal 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 |
23
branding/service-review-classifier.svg
Normal file
23
branding/service-review-classifier.svg
Normal 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 |
|
|
@ -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,
|
||||
`GET /api/agents/<name>/icon` serves
|
||||
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
|
||||
one, as the hive dashboard does.
|
||||
load it as an `<img>` and show the dimmed default agent icon (a robot head in a
|
||||
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 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
|
||||
(`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
|
||||
`warn` line per object plus `swarm forge objects: pass incomplete` in
|
||||
`journalctl -u swarm-controller`, and retries on the next tick. While the
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
connection badge. It opens the agent's details in a popover under the badge:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@
|
|||
filter: grayscale(1);
|
||||
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. */
|
||||
.ui-agent-icon-small {
|
||||
width: 1.3em;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// <AgentIcon> — the agent's icon from swarm-controller's
|
||||
// `GET /api/agents/{name}/icon`, the same square and fallback as the hive
|
||||
// dashboard's container row: an agent with no icon (404) or an unreachable
|
||||
// controller shows the dimmed hyperhive mark instead of a broken image.
|
||||
// `GET /api/agents/{name}/icon`: an agent with no icon (404) or an unreachable
|
||||
// controller shows the default agent icon, dimmed, 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
|
||||
// keeps its script from running.
|
||||
import { useState } from "preact/hooks";
|
||||
|
|
@ -21,14 +22,14 @@ export function AgentIcon({
|
|||
class={
|
||||
"ui-agent-icon" +
|
||||
(small ? " ui-agent-icon-small" : "") +
|
||||
(fallback ? " ui-agent-icon-none" : "")
|
||||
(fallback ? " ui-agent-icon-default" : "")
|
||||
}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
src={
|
||||
fallback
|
||||
? "/favicon.svg"
|
||||
? "/agent-default.svg"
|
||||
: `/api/agents/${encodeURIComponent(name)}/icon`
|
||||
}
|
||||
onError={() => setFallback(true)}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@ let
|
|||
"${deployCfg.swarm-controller.configOrgAvatarPng}"
|
||||
else
|
||||
"${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
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
#
|
||||
# Output layout:
|
||||
# $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}
|
||||
#
|
||||
# The repo docs/ tree is a SEPARATE derivation (./reference-docs.nix)
|
||||
|
|
@ -42,6 +43,9 @@ stdenv.mkDerivation {
|
|||
rsvg-convert --width 300 --height 300 \
|
||||
-o branding/agent-configs.png \
|
||||
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
|
||||
'';
|
||||
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ in
|
|||
swarm-ui = pkgs.callPackage ./swarm-ui.nix {
|
||||
branding-svg = ../../branding/hyperhive.svg;
|
||||
branding-svg-maskable = ../../branding/hyperhive-maskable.svg;
|
||||
agent-default-svg = ../../branding/agent-default.svg;
|
||||
};
|
||||
|
||||
# Bundled browser assets — see ./frontend.nix. Output is
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
buildNpmPackage,
|
||||
branding-svg,
|
||||
branding-svg-maskable,
|
||||
agent-default-svg,
|
||||
librsvg,
|
||||
}:
|
||||
|
||||
|
|
@ -66,6 +67,8 @@ buildNpmPackage {
|
|||
mkdir -p $out
|
||||
cp -r packages/swarm-ui/dist/. $out/
|
||||
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 512 -h 512 ${branding-svg} -o $out/icon-512.png
|
||||
|
||||
|
|
|
|||
|
|
@ -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