swarm-controller: refuse a new agent name the forge would reject

Every agent becomes a Forgejo user of the same name, and nothing upstream
of `CreateForgeUser` knew what Forgejo refuses: `admin`, `api`, `foo-` or a
41-character name passed name validation and failed one node into
provisioning with Forgejo's 422.

`nix/reserved-names.nix` gains the 22 reserved usernames of Forgejo
v16.0.5 (`models/user/user.go:639-680`) that `[a-z0-9-]` can spell, and
the bare `-` (`models/repo/repo.go:67`). The dot and underscore entries are
left out, since our charset cannot produce them. The header's admission
rule grows a third class — a username the forge refuses — because that is
a failure behind the refusal.

The shape rules are not literals, so they live in
`hive_types::forge_username_violation`: no leading `-`, no `--`, no
trailing `-`, at most 40 characters. Beside `is_reserved_name`, not in
`Ident::parse`: an `Ident` is also a hive, label, account and subagent
name, and parsing runs on every read of an existing name.

`create_agent` used to WARN on a reserved name, deliberately: an operator
with agents already created under a colliding name would otherwise be
unable to re-run creation. That reason is kept, and narrowed to what it
protects. A name breaking either rule is now refused with a 400 naming the
rule when the name is NOT in the swarm roster, and still only warned
about when it is, so re-creating an existing agent keeps working. The
roster is read only for a rule-breaking name; when it cannot be read, a new
name and an existing one look alike, and this warns as before. The
hive-collision warning is unchanged.
This commit is contained in:
atlas 2026-09-24 14:30:31 +02:00 • committed by mara
commit 88e571a463
3 changed files with 327 additions and 43 deletions

View file

@ -64,6 +64,35 @@ pub fn is_reserved_name(name: &str, reserved: &[&str]) -> bool {
reserved.contains(&name)
}
/// Forgejo's cap on a username (`MaxSize(40)` on the admin create-user body,
/// `modules/structs/admin_user.go:14` in v16.0.5) — tighter than
/// [`Ident::MAX_LEN`], so an agent name can be a valid [`Ident`] and still
/// never get a forge account.
pub const FORGE_USERNAME_MAX_LEN: usize = 40;
/// The Forgejo username shape rule an [`Ident`]-valid `name` breaks, or
/// `None`. Forgejo v16.0.5 `modules/validation/helpers.go:97-108`.
///
/// The shape half of the forge's refusals; its reserved literals are in
/// `nix/reserved-names.nix`. Creation sites only, for the reason given at
/// [`is_reserved_name`] — and not inside [`Ident::parse`], because an
/// `Ident` is also a hive, label, account and subagent name that never
/// becomes a forge user.
#[must_use]
pub fn forge_username_violation(name: &str) -> Option<&'static str> {
if name.starts_with('-') {
Some("must not start with '-'")
} else if name.contains("--") {
Some("must not contain '--'")
} else if name.ends_with('-') {
Some("must not end with '-'")
} else if name.len() > FORGE_USERNAME_MAX_LEN {
Some("must be 40 characters or fewer")
} else {
None
}
}
/// A validated hive identifier: 1-63 chars of `[a-z0-9-]`.
///
/// The single ident type for agent names, forge labels, and matrix / github
@ -156,7 +185,10 @@ impl<'de> serde::Deserialize<'de> for Ident {
#[cfg(test)]
mod ident_tests {
use super::{Ident, is_reserved_name, parse_reserved_names};
use super::{
FORGE_USERNAME_MAX_LEN, Ident, forge_username_violation, is_reserved_name,
parse_reserved_names,
};
#[test]
fn accepts_canonical_shapes() {
@ -240,6 +272,36 @@ mod ident_tests {
}
}
/// Each Forgejo shape rule is named on its own, so a refusal tells the
/// operator which character to change. The 63-char case is an `Ident`
/// that is still no forge username: the gap between the two caps.
#[test]
fn forge_shape_rules_are_each_refused() {
let over = "a".repeat(FORGE_USERNAME_MAX_LEN + 1);
let longest_ident = "a".repeat(Ident::MAX_LEN);
for (bad, rule) in [
("-", "must not start with '-'"),
("-agent", "must not start with '-'"),
("my--agent", "must not contain '--'"),
("agent-", "must not end with '-'"),
(over.as_str(), "must be 40 characters or fewer"),
(longest_ident.as_str(), "must be 40 characters or fewer"),
] {
assert!(Ident::parse(bad).is_ok(), "{bad:?} must be a valid Ident");
assert_eq!(forge_username_violation(bad), Some(rule), "{bad:?}");
}
}
/// The accept arm: without it, a predicate refusing everything passes
/// the test above. 40 characters is the boundary, not past it.
#[test]
fn forge_shape_rules_accept_ordinary_names() {
let at_cap = "a".repeat(FORGE_USERNAME_MAX_LEN);
for ok in ["my-agent", "a", "v3", "a-b-c", at_cap.as_str()] {
assert_eq!(forge_username_violation(ok), None, "{ok:?}");
}
}
#[test]
fn round_trips_and_serde_validates() {
let id = Ident::parse("damocles").unwrap();