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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
'';
|
'';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`]
|
||||||
|
|
|
||||||
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.
|
// 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue