Agent creation gains a GrantDocsRead node (after CreateForgeUser) that adds the agent as a read collaborator on internal/docs, and forge::docs_grant runs a pass over the store's hive-agent-* roster at start and every five minutes that queues the same job for any agent that is not one. The pass only adds; an unreadable roster or forge read changes nothing. hive-c0re drops shared_docs_access, its call in sync_agent, and the SHARED_ORG / SHARED_DOCS_REPO constants only it used.
507 lines
21 KiB
Rust
507 lines
21 KiB
Rust
//! Optional Forgejo wiring — the `core/meta` repo and meta read-access
|
|
//! grants. Those writes, as `core`, run only beside a local `hive-forge`
|
|
//! container; reads of the forge go through [`reader_token`] on every hive.
|
|
//! The swarm-wide orgs, teams and repos, and each agent's read grant on
|
|
//! `internal/docs`, are swarm-controller's. Full design:
|
|
//! `docs/integrations/forge.md`.
|
|
|
|
mod pr_comment;
|
|
mod reader;
|
|
mod reconcile;
|
|
mod repos;
|
|
mod users;
|
|
|
|
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
|
|
pub use reader::reader_token;
|
|
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
|
pub use repos::{
|
|
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
|
|
fetch_config_main_into_applied, meta_read_access, push_meta,
|
|
};
|
|
pub use users::core_token;
|
|
|
|
use std::sync::OnceLock;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use anyhow::{Context, Result};
|
|
use forgejo_api::{Auth, Forgejo};
|
|
use url::Url;
|
|
|
|
use users::{ensure_core_avatar, ensure_core_user_and_token};
|
|
|
|
const FORGE_CONTAINER: &str = "hive-forge";
|
|
|
|
/// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL`
|
|
/// (set unconditionally by the hyperhive NixOS module on the `hive-c0re`
|
|
/// unit, to `http://<forge.domain>`).
|
|
///
|
|
/// # Panics
|
|
///
|
|
/// When `HIVE_FORGE_URL` is unset. That is deliberate: this daemon only
|
|
/// runs under the NixOS module, which always sets it, so an unset var
|
|
/// means the deployment is broken. There is no loopback fallback,
|
|
/// because a guess is wrong in exactly the cases that matter — the
|
|
/// forge may live on a different host from the daemon, and a fallback
|
|
/// turns "misconfigured" into "silently talking to the wrong machine"
|
|
/// or, worse, "connection refused" surfacing far from its cause.
|
|
pub(crate) fn forge_http_base() -> &'static str {
|
|
static BASE: OnceLock<String> = OnceLock::new();
|
|
BASE.get_or_init(|| {
|
|
std::env::var("HIVE_FORGE_URL").expect(
|
|
"HIVE_FORGE_URL is unset — the hyperhive NixOS module sets it \
|
|
unconditionally on the hive-c0re unit, so this process was \
|
|
started outside that unit",
|
|
)
|
|
})
|
|
}
|
|
|
|
/// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**.
|
|
///
|
|
/// The token does not go here. A URL is a process argument, and `argv` is
|
|
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child
|
|
/// lives, so a credentialed remote publishes the core admin token to every
|
|
/// local user on the host. Credentials travel in the environment instead, via
|
|
/// [`core_auth_header`] and [`crate::lifecycle::git_command_authed`] —
|
|
/// `/proc/<pid>/environ` is owner-only.
|
|
pub(crate) fn forge_git_url(repo: &str) -> String {
|
|
git_url_with_base(forge_http_base(), repo)
|
|
}
|
|
|
|
/// The pure half of [`forge_git_url`], split out so it can be tested without a
|
|
/// process-wide env var (which would race every other test in this binary).
|
|
fn git_url_with_base(base: &str, repo: &str) -> String {
|
|
format!("{base}/{repo}.git")
|
|
}
|
|
|
|
/// The `http.extraHeader` value authenticating as the forge core user.
|
|
pub(crate) fn core_auth_header(token: &str) -> String {
|
|
basic_auth_header("core", token)
|
|
}
|
|
|
|
/// The `http.extraHeader` value carrying `token`. Forgejo takes the account
|
|
/// from the token, so `login` only labels the request.
|
|
///
|
|
/// Basic auth over a header rather than userinfo in the URL, so the secret
|
|
/// reaches git through the environment (see [`forge_git_url`]). Pair with
|
|
/// [`crate::lifecycle::git_command_authed`], which is the only thing that
|
|
/// should ever hold the result.
|
|
fn basic_auth_header(login: &str, token: &str) -> String {
|
|
use base64::Engine as _;
|
|
let basic = base64::engine::general_purpose::STANDARD.encode(format!("{login}:{token}"));
|
|
format!("Authorization: Basic {basic}")
|
|
}
|
|
|
|
/// Forgejo org grouping every agent's config repo. Core is a site admin
|
|
/// and reads + writes every repo here. Each agent is a **write collaborator
|
|
/// on its own** `agent-configs/<name>` repo — the editable PR surface it
|
|
/// pushes config-change branches to. `main`'s branch protection is
|
|
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
|
|
/// so the agent can't push `main` or self-merge, and an operator's merge
|
|
/// deploys through swarm-controller. The repos remain private, so an agent
|
|
/// still can't reach *another* agent's config. hive-c0re only reads from it.
|
|
pub(crate) const CONFIG_ORG: &str = "agent-configs";
|
|
|
|
/// Leak `s` to get a `&'static str` warning `kind` for the small, bounded
|
|
/// set of boot warnings in [`ensure_all`] keyed by a runtime name (at
|
|
/// most a handful per process). [`crate::warnings::set_boot_warning`]
|
|
/// requires a `'static` kind so distinct orgs/repos don't clobber each
|
|
/// other's banner entry; leaking a few short strings once per boot is
|
|
/// cheap and bounded, unlike a per-request or per-loop-iteration leak.
|
|
fn static_kind(s: String) -> &'static str {
|
|
Box::leak(s.into_boxed_str())
|
|
}
|
|
|
|
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
|
|
/// `nixos-container list` is just a directory scan in /etc. Routed
|
|
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
|
|
/// unprivileged (privsep).
|
|
pub async fn is_present() -> bool {
|
|
let Ok(stdout) = crate::priv_client::list_containers().await else {
|
|
return false;
|
|
};
|
|
stdout.lines().any(|l| l.trim() == FORGE_CONTAINER)
|
|
}
|
|
|
|
/// Name a `forgejo admin` invocation for an error message without
|
|
/// reproducing its arguments: keep the leading verb path, stop at the
|
|
/// first flag. `["user", "create", "--username", "iris", "--password",
|
|
/// "…"]` becomes `forgejo admin user create`.
|
|
///
|
|
/// The verbs are an **allowlist**, and that is the whole point. Some
|
|
/// callers pass a live secret as an argument value (`--password`), so a
|
|
/// message built from the raw vector puts it in the log. Listing the
|
|
/// flags to *hide* instead would repeat the bug this guards against: a
|
|
/// newly added secret-bearing flag would leak until someone remembered
|
|
/// to extend the list. Verbs are a closed set this crate chooses
|
|
/// itself; argument values never are.
|
|
fn describe_forge_admin(args: &[&str]) -> String {
|
|
let verbs: Vec<&str> = args
|
|
.iter()
|
|
.take_while(|a| !a.starts_with('-'))
|
|
.copied()
|
|
.collect();
|
|
if verbs.is_empty() {
|
|
"forgejo admin".to_owned()
|
|
} else {
|
|
format!("forgejo admin {}", verbs.join(" "))
|
|
}
|
|
}
|
|
|
|
/// Run `forgejo admin <args>` inside the hive-forge container as the
|
|
/// forgejo user (the only uid with write access to the state dir).
|
|
/// Returns stdout on success; bails with stderr context on failure.
|
|
async fn forge_admin(args: &[&str]) -> Result<String> {
|
|
// Route through hive-priv (root helper) because `nixos-container run`
|
|
// uses nsenter to enter the container's namespaces, which requires root.
|
|
// hive-c0re runs as the unprivileged `hive-core` user and cannot call
|
|
// nsenter directly — doing so produces:
|
|
// nsenter: stat of /proc/<pid>/ns/user failed: Permission denied
|
|
let (stdout, _stderr) = crate::priv_client::run_forge_admin(args)
|
|
.await
|
|
.with_context(|| format!("{} (via hive-priv)", describe_forge_admin(args)))?;
|
|
Ok(stdout)
|
|
}
|
|
|
|
/// Typed Forgejo API client for the local forge ([`forge_http_base()`]),
|
|
/// authenticated as `token`. All Forgejo API calls that don't shell
|
|
/// out to `forgejo admin` go through clients built here — one place
|
|
/// for the base URL and auth. Tokens differ per call site (core admin
|
|
/// token vs per-agent tokens), so the token is passed per call; the
|
|
/// base URL is parsed once. Failures surface as
|
|
/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status
|
|
/// and the API's error message (e.g. the validation reason on a 422).
|
|
pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
|
static URL: OnceLock<Url> = OnceLock::new();
|
|
let url = URL
|
|
.get_or_init(|| Url::parse(forge_http_base()).expect("forge_http_base() is a valid URL"))
|
|
.clone();
|
|
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
|
}
|
|
|
|
/// Per-agent forge sync: grant read access to `core/meta`, and wire the
|
|
/// `meta` remote in its proposed repo. All operations are idempotent;
|
|
/// failures are logged as warnings but don't abort the caller.
|
|
///
|
|
/// `core_token` is `core_token()` — passed in so callers that already
|
|
/// fetched it don't re-read the file. Pass `None` to skip the
|
|
/// `meta_read_access` step (safe: the access grant is best-effort).
|
|
///
|
|
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
|
|
/// (per-rebuild) so the two paths stay equivalent.
|
|
/// Returns `true` if all steps succeeded, `false` if any step failed. The
|
|
/// caller can use the return value to aggregate per-agent failures into a
|
|
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
|
|
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
|
|
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
|
// The agent's forge account, token and `agent-configs/<name>` repo are
|
|
// swarm-controller's; this hive creates none of them and pushes nothing
|
|
// to the repo.
|
|
let mut ok = true;
|
|
// Grant read-only access to core/meta and wire the `meta` remote
|
|
// into the proposed repo so agents can fetch their deployment context.
|
|
if let Some(token) = core_token
|
|
&& let Err(e) = meta_read_access(name, token).await
|
|
{
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
|
ok = false;
|
|
}
|
|
if let Err(e) = ensure_meta_remote(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
|
ok = false;
|
|
}
|
|
ok
|
|
}
|
|
|
|
/// The `core_token.is_some()` half of [`ensure_all`]: the meta repo, the
|
|
/// core avatar, and removing this hive's own config-PR hook —
|
|
/// every step that needs an authenticated forge client. The swarm-wide
|
|
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
|
|
/// `internal/knowledge`, the `agent-configs` avatar) are the
|
|
/// swarm-controller's, not this hive's. Split out purely to keep
|
|
/// `ensure_all` under clippy's function-length limit; not meant to be called
|
|
/// from anywhere else.
|
|
async fn ensure_all_orgs_and_repos(token: &str) {
|
|
// Meta repo lives at core/meta — pushed from git_commit in
|
|
// meta.rs on every deploy/lock-update. Make sure it exists
|
|
// before the first push hits a 404.
|
|
if let Err(e) = ensure_repo("meta", token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
|
|
crate::warnings::set_boot_warning(
|
|
"forge_ensure_meta_repo",
|
|
"crit",
|
|
format!("forge: core/meta repo provisioning failed: {e}"),
|
|
);
|
|
}
|
|
if let Err(e) = ensure_core_avatar(token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
|
crate::warnings::set_boot_warning(
|
|
"forge_ensure_core_avatar",
|
|
"warn",
|
|
format!("forge: core avatar upload failed: {e}"),
|
|
);
|
|
}
|
|
if let Err(e) = remove_config_pr_webhook(token).await {
|
|
tracing::warn!(error = ?e, "forge: remove_config_pr_webhook failed");
|
|
}
|
|
}
|
|
|
|
/// Delete this hive's own `/webhook/config-pr` hook from the
|
|
/// `agent-configs` org. No hive serves that route, so such a hook fails on
|
|
/// every config-repo event; swarm-controller's hook is the one that acts on
|
|
/// config PRs.
|
|
///
|
|
/// Only a hook whose URL equals this hive's exactly is removed, never one
|
|
/// matching by path: the same path on another base is another hive's hook.
|
|
/// Idempotent, and a no-op when `HYPERHIVE_HIVE_DOMAIN` is unset.
|
|
async fn remove_config_pr_webhook(core_token: &str) -> Result<()> {
|
|
const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
let Some(domain) = std::env::var("HYPERHIVE_HIVE_DOMAIN")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let own_url = config_pr_hook_url(&domain);
|
|
let client = api(core_token)?;
|
|
let hooks = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send())
|
|
.await
|
|
.map_err(anyhow::Error::from)
|
|
.and_then(|r| r.map_err(anyhow::Error::from))
|
|
.with_context(|| format!("list webhooks for org {CONFIG_ORG}"))?;
|
|
for id in hook_ids_at(&hooks, &own_url) {
|
|
tokio::time::timeout(HTTP_TIMEOUT, client.org_delete_hook(CONFIG_ORG, id).send())
|
|
.await
|
|
.map_err(anyhow::Error::from)
|
|
.and_then(|r| r.map_err(anyhow::Error::from))
|
|
.with_context(|| format!("delete webhook {id} on org {CONFIG_ORG}"))?;
|
|
tracing::info!(%own_url, id, "forge: removed this hive's config-pr webhook");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The URL this hive's config-PR hook was registered under.
|
|
fn config_pr_hook_url(hive_domain: &str) -> String {
|
|
format!("https://{hive_domain}/webhook/config-pr")
|
|
}
|
|
|
|
/// Ids of the hooks whose URL is exactly `url`.
|
|
fn hook_ids_at(hooks: &[forgejo_api::structs::Hook], url: &str) -> Vec<i64> {
|
|
hooks
|
|
.iter()
|
|
.filter(|h| {
|
|
h.config
|
|
.as_ref()
|
|
.and_then(|c| c.get("url"))
|
|
.is_some_and(|u| u == url)
|
|
})
|
|
.filter_map(|h| h.id)
|
|
.collect()
|
|
}
|
|
|
|
/// Poll the local Forgejo API until it answers, with a timeout of 1 minute.
|
|
/// The whole boot provisioning below hits the API, and [`is_present`] only
|
|
/// confirms the container exists — not that Forgejo is *listening*. A
|
|
/// `nixos-rebuild` that restarts hive-forge and hive-c0re together races:
|
|
/// without this gate every `ensure_*` fires at a refused socket and leaves a
|
|
/// stale "provisioning failed" banner that never clears (the pass is one-shot).
|
|
/// Returns whether the endpoint became ready before the deadline; on timeout
|
|
/// the caller proceeds anyway so a genuinely-down forge still surfaces its real
|
|
/// errors. Uses the unauthenticated `get_version` endpoint (no token yet).
|
|
async fn wait_until_ready() -> bool {
|
|
const READY_TIMEOUT: Duration = Duration::from_mins(1);
|
|
const POLL_INTERVAL: Duration = Duration::from_millis(500);
|
|
let Ok(url) = Url::parse(forge_http_base()) else {
|
|
tracing::warn!("forge: HIVE_FORGE_URL is not a valid URL; skipping readiness wait");
|
|
return false;
|
|
};
|
|
let Ok(client) = Forgejo::new(Auth::None, url) else {
|
|
tracing::warn!("forge: could not build readiness client; skipping readiness wait");
|
|
return false;
|
|
};
|
|
let deadline = Instant::now() + READY_TIMEOUT;
|
|
let mut waited = false;
|
|
loop {
|
|
if client.get_version().await.is_ok() {
|
|
if waited {
|
|
tracing::info!("forge: endpoint ready, proceeding with provisioning");
|
|
}
|
|
return true;
|
|
}
|
|
if Instant::now() >= deadline {
|
|
tracing::warn!(
|
|
"forge: endpoint not ready after {READY_TIMEOUT:?}; provisioning anyway"
|
|
);
|
|
return false;
|
|
}
|
|
waited = true;
|
|
tokio::time::sleep(POLL_INTERVAL).await;
|
|
}
|
|
}
|
|
|
|
/// Sweep the forge at startup: seed the `core` admin user (hive-c0re's own
|
|
/// identity for pushing the meta repo + driving the API) and its token, the
|
|
/// `core/meta` repo and the local knowledge clone, then run [`sync_agent`]
|
|
/// over every existing container (manager + sub-agents). Agent accounts,
|
|
/// tokens and `agent-configs/<name>` repos are the swarm-controller's, as is
|
|
/// the `agent-configs` org; this sweep pushes nothing to them.
|
|
/// Called once at hive-c0re startup. Per-step failures are logged
|
|
/// but don't abort the sweep.
|
|
pub async fn ensure_all() {
|
|
if !is_present().await {
|
|
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
|
|
return;
|
|
}
|
|
// The container exists, but its HTTP may not be up yet (a rebuild restarts
|
|
// hive-forge + hive-c0re together). Wait for the API to answer before the
|
|
// provisioning pass so a boot race doesn't leave stale failure banners.
|
|
wait_until_ready().await;
|
|
let core_token = match ensure_core_user_and_token().await {
|
|
Ok(t) => Some(t),
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
|
|
crate::warnings::set_boot_warning(
|
|
"forge_ensure_core_user",
|
|
"crit",
|
|
format!("forge: core user/token provisioning failed: {e}"),
|
|
);
|
|
None
|
|
}
|
|
};
|
|
if let Some(token) = core_token.as_deref() {
|
|
ensure_all_orgs_and_repos(token).await;
|
|
}
|
|
let Ok(containers) = crate::lifecycle::list().await else {
|
|
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
|
|
crate::warnings::set_boot_warning(
|
|
"forge_container_list",
|
|
"crit",
|
|
"forge: nixos-container list failed; per-agent forge sync skipped this boot",
|
|
);
|
|
return;
|
|
};
|
|
let mut sync_failed: Vec<String> = Vec::new();
|
|
for c in containers {
|
|
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
|
|
continue;
|
|
};
|
|
if !sync_agent(name, core_token.as_deref()).await {
|
|
sync_failed.push(name.to_owned());
|
|
}
|
|
}
|
|
if !sync_failed.is_empty() {
|
|
// Use static_kind to mint a `&'static str` key from the failed-agent
|
|
// list (bounded leak: one per hive-c0re boot, not per request).
|
|
let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_")));
|
|
crate::warnings::set_boot_warning(
|
|
key,
|
|
"warn",
|
|
format!(
|
|
"forge: per-agent sync failed for: {} (see journal for per-step detail)",
|
|
sync_failed.join(", ")
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{config_pr_hook_url, describe_forge_admin, git_url_with_base, hook_ids_at};
|
|
|
|
fn hook(id: i64, url: &str) -> forgejo_api::structs::Hook {
|
|
serde_json::from_value(serde_json::json!({ "id": id, "config": { "url": url } }))
|
|
.expect("hook json")
|
|
}
|
|
|
|
/// The same path on another base is another hive's hook, and removing it
|
|
/// would cut that hive off. Only an exact URL match is this hive's.
|
|
#[test]
|
|
fn only_this_hives_own_config_pr_hook_is_removed() {
|
|
let own = config_pr_hook_url("hive.example");
|
|
let hooks = [
|
|
hook(1, &own),
|
|
hook(2, "https://other-hive.example/webhook/config-pr"),
|
|
hook(3, "http://127.0.0.1:7000/webhook/config-pr"),
|
|
hook(4, "https://swarm.example/webhook/forge/config-pr"),
|
|
];
|
|
assert_eq!(hook_ids_at(&hooks, &own), vec![1]);
|
|
assert!(hook_ids_at(&hooks[1..], &own).is_empty());
|
|
}
|
|
|
|
/// The remote carries **no credential**. `argv` is world-readable through
|
|
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
|
|
/// every local user for the life of the git child.
|
|
///
|
|
/// Deliberately not via `forge_git_url`, which reads `HIVE_FORGE_URL` —
|
|
/// setting that here would race every other test in this binary.
|
|
#[test]
|
|
fn forge_git_url_carries_no_credential() {
|
|
let url = git_url_with_base("http://forge.example.test", "a/iris");
|
|
assert_eq!(url, "http://forge.example.test/a/iris.git");
|
|
assert!(!url.contains('@'), "no userinfo: {url}");
|
|
}
|
|
|
|
#[test]
|
|
fn forge_git_url_preserves_https() {
|
|
// The scheme is carried through rather than assumed: a swarm
|
|
// whose forge is behind TLS must not be downgraded to http.
|
|
let url = git_url_with_base("https://forge.example.test", "a/iris");
|
|
assert!(url.starts_with("https://"), "https must survive: {url}");
|
|
}
|
|
|
|
/// The credential goes in an `Authorization` header instead — decodable
|
|
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
|
|
/// not a silent downgrade to anonymous.
|
|
#[test]
|
|
fn core_auth_header_is_basic_core_token() {
|
|
use base64::Engine as _;
|
|
let header = super::core_auth_header("s3cret");
|
|
let b64 = header
|
|
.strip_prefix("Authorization: Basic ")
|
|
.expect("basic auth header");
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
.decode(b64)
|
|
.expect("valid base64");
|
|
assert_eq!(String::from_utf8_lossy(&decoded), "core:s3cret");
|
|
assert!(
|
|
!header.contains("s3cret"),
|
|
"token not in the clear: {header}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn describe_keeps_the_verb_path_and_drops_every_value() {
|
|
assert_eq!(
|
|
describe_forge_admin(&[
|
|
"user",
|
|
"create",
|
|
"--username",
|
|
"iris",
|
|
"--email",
|
|
"iris@hyperhive.local",
|
|
"--random-password",
|
|
]),
|
|
"forgejo admin user create"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn describe_does_not_reproduce_a_password_argument() {
|
|
let described = describe_forge_admin(&[
|
|
"user",
|
|
"change-password",
|
|
"--username",
|
|
"iris",
|
|
"--password",
|
|
"correct-horse-battery-staple",
|
|
]);
|
|
assert_eq!(described, "forgejo admin user change-password");
|
|
assert!(!described.contains("correct-horse-battery-staple"));
|
|
}
|
|
|
|
#[test]
|
|
fn describe_survives_a_leading_flag_and_an_empty_vector() {
|
|
assert_eq!(describe_forge_admin(&["--help"]), "forgejo admin");
|
|
assert_eq!(describe_forge_admin(&[]), "forgejo admin");
|
|
}
|
|
}
|