swarmctl: forge make-admin
Calls POST /api/forge/users/{name}/admin and prints what it found. It
fails with the controller's message when the user has not logged in via
SSO yet, and when the name is an agent's.
Refs #3782
This commit is contained in:
parent
f1f59ea165
commit
d56d8f2b36
4 changed files with 157 additions and 2 deletions
|
|
@ -13,6 +13,8 @@ This document contains the help content for the `swarmctl` command-line program.
|
||||||
* [`swarmctl user add`↴](#swarmctl-user-add)
|
* [`swarmctl user add`↴](#swarmctl-user-add)
|
||||||
* [`swarmctl user update`↴](#swarmctl-user-update)
|
* [`swarmctl user update`↴](#swarmctl-user-update)
|
||||||
* [`swarmctl user list`↴](#swarmctl-user-list)
|
* [`swarmctl user list`↴](#swarmctl-user-list)
|
||||||
|
* [`swarmctl forge`↴](#swarmctl-forge)
|
||||||
|
* [`swarmctl forge make-admin`↴](#swarmctl-forge-make-admin)
|
||||||
* [`swarmctl completions`↴](#swarmctl-completions)
|
* [`swarmctl completions`↴](#swarmctl-completions)
|
||||||
|
|
||||||
## `swarmctl`
|
## `swarmctl`
|
||||||
|
|
@ -25,6 +27,7 @@ swarm-level operator CLI
|
||||||
|
|
||||||
* `agent` — Manage agents across the swarm
|
* `agent` — Manage agents across the swarm
|
||||||
* `user` — Manage subjects in the swarm's SSO provider
|
* `user` — Manage subjects in the swarm's SSO provider
|
||||||
|
* `forge` — Manage human accounts on the swarm's forge
|
||||||
* `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
|
* `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
|
||||||
|
|
||||||
###### **Options:**
|
###### **Options:**
|
||||||
|
|
@ -191,6 +194,38 @@ Read-only: it never writes the file. Shows every subject in it, including agent
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## `swarmctl forge`
|
||||||
|
|
||||||
|
Manage human accounts on the swarm's forge
|
||||||
|
|
||||||
|
**Usage:** `swarmctl forge <COMMAND>`
|
||||||
|
|
||||||
|
###### **Subcommands:**
|
||||||
|
|
||||||
|
* `make-admin` — Make an existing forge user a site admin
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## `swarmctl forge make-admin`
|
||||||
|
|
||||||
|
Make an existing forge user a site admin.
|
||||||
|
|
||||||
|
The forge creates a human's account on their first SSO login, and this fails until that login has happened. Running it on a site admin changes nothing. Refused for an agent.
|
||||||
|
|
||||||
|
**Usage:** `swarmctl forge make-admin [OPTIONS] <NAME>`
|
||||||
|
|
||||||
|
###### **Arguments:**
|
||||||
|
|
||||||
|
* `<NAME>` — Forge username, the same as the user's SSO username
|
||||||
|
|
||||||
|
###### **Options:**
|
||||||
|
|
||||||
|
* `--controller-socket <PATH>` — swarm-controller's unix socket.
|
||||||
|
|
||||||
|
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## `swarmctl completions`
|
## `swarmctl completions`
|
||||||
|
|
||||||
Generate a shell completion script for `swarmctl` and print it to stdout.
|
Generate a shell completion script for `swarmctl` and print it to stdout.
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ pub(crate) fn create(socket: &Path, name: &str, hive: &str) -> Result<()> {
|
||||||
/// Parse a CLI-supplied name into a validated identifier, naming which
|
/// Parse a CLI-supplied name into a validated identifier, naming which
|
||||||
/// argument was wrong — `invalid hive` and `invalid agent name` send the
|
/// argument was wrong — `invalid hive` and `invalid agent name` send the
|
||||||
/// operator to different flags.
|
/// operator to different flags.
|
||||||
fn parse_ident(value: &str, what: &str) -> Result<String> {
|
pub(crate) fn parse_ident(value: &str, what: &str) -> Result<String> {
|
||||||
hive_types::Ident::parse(value)
|
hive_types::Ident::parse(value)
|
||||||
.map(hive_types::Ident::into_string)
|
.map(hive_types::Ident::into_string)
|
||||||
.map_err(|reason| anyhow::anyhow!("invalid {what} {value:?}: {reason}"))
|
.map_err(|reason| anyhow::anyhow!("invalid {what} {value:?}: {reason}"))
|
||||||
|
|
@ -186,7 +186,7 @@ async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgen
|
||||||
/// `what` names the request in error messages — the operator needs to know
|
/// `what` names the request in error messages — the operator needs to know
|
||||||
/// which call failed, and every other part of this function is identical
|
/// which call failed, and every other part of this function is identical
|
||||||
/// between the two verbs.
|
/// between the two verbs.
|
||||||
async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
pub(crate) async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
uri: &str,
|
uri: &str,
|
||||||
request: &Req,
|
request: &Req,
|
||||||
|
|
|
||||||
66
swarmctl/src/forge.rs
Normal file
66
swarmctl/src/forge.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
//! `swarmctl forge make-admin` — make an existing human forge account a site
|
||||||
|
//! admin, through the swarm-controller over the forge's admin API.
|
||||||
|
//!
|
||||||
|
//! Unlike the `agent` verbs this one waits: the controller answers with what
|
||||||
|
//! it found. Same round trip as [`crate::agent`], over the controller's unix
|
||||||
|
//! socket; the shape below mirrors the controller's own private
|
||||||
|
//! `MakeForgeAdminResponse` for the reason that module's doc comment gives.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::agent::{parse_ident, post};
|
||||||
|
|
||||||
|
/// Success body of `POST /api/forge/users/{name}/admin`.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct MakeForgeAdminResponse {
|
||||||
|
change: AdminChange,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
enum AdminChange {
|
||||||
|
Promoted,
|
||||||
|
AlreadyAdmin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `swarmctl forge make-admin`.
|
||||||
|
///
|
||||||
|
/// Synchronous for the same reason `agent create` is.
|
||||||
|
pub(crate) fn make_admin(socket: &Path, name: &str) -> Result<()> {
|
||||||
|
let name = parse_ident(name, "user name")?;
|
||||||
|
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_io()
|
||||||
|
.build()
|
||||||
|
.context("starting a tokio runtime for the controller request")?;
|
||||||
|
let resp: MakeForgeAdminResponse = rt.block_on(post(
|
||||||
|
socket,
|
||||||
|
&format!("/api/forge/users/{name}/admin"),
|
||||||
|
&serde_json::json!({}),
|
||||||
|
"forge-make-admin",
|
||||||
|
))?;
|
||||||
|
|
||||||
|
match resp.change {
|
||||||
|
AdminChange::Promoted => println!("forge: {name:?} is now a site admin"),
|
||||||
|
AdminChange::AlreadyAdmin => println!("forge: {name:?} was already a site admin"),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{AdminChange, MakeForgeAdminResponse};
|
||||||
|
|
||||||
|
/// The controller's two answers, as it spells them.
|
||||||
|
#[test]
|
||||||
|
fn both_changes_decode() {
|
||||||
|
for (wire, want) in [("promoted", true), ("already_admin", false)] {
|
||||||
|
let resp: MakeForgeAdminResponse =
|
||||||
|
serde_json::from_str(&format!(r#"{{"change":"{wire}"}}"#)).expect("decodes");
|
||||||
|
assert_eq!(matches!(resp.change, AdminChange::Promoted), want);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
//! for the same reason `hivectl` does not link `hive-c0re`.
|
//! for the same reason `hivectl` does not link `hive-c0re`.
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
|
mod forge;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
use std::fs::{self, File, Permissions};
|
use std::fs::{self, File, Permissions};
|
||||||
|
|
@ -123,6 +124,11 @@ enum Verb {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: UserVerb,
|
command: UserVerb,
|
||||||
},
|
},
|
||||||
|
/// Manage human accounts on the swarm's forge.
|
||||||
|
Forge {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: ForgeVerb,
|
||||||
|
},
|
||||||
/// Emit the full CLI reference as `CommonMark` to stdout.
|
/// Emit the full CLI reference as `CommonMark` to stdout.
|
||||||
///
|
///
|
||||||
/// Hidden tooling command used by the docs build to keep the published
|
/// Hidden tooling command used by the docs build to keep the published
|
||||||
|
|
@ -255,6 +261,29 @@ struct AgentMintIdentityArgs {
|
||||||
controller_socket: Option<PathBuf>,
|
controller_socket: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ForgeVerb {
|
||||||
|
/// Make an existing forge user a site admin.
|
||||||
|
///
|
||||||
|
/// The forge creates a human's account on their first SSO login, and
|
||||||
|
/// this fails until that login has happened. Running it on a site admin
|
||||||
|
/// changes nothing. Refused for an agent.
|
||||||
|
MakeAdmin(ForgeMakeAdminArgs),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct ForgeMakeAdminArgs {
|
||||||
|
/// Forge username, the same as the user's SSO username.
|
||||||
|
name: String,
|
||||||
|
/// swarm-controller's unix socket.
|
||||||
|
///
|
||||||
|
/// Supplied by the nix module that installs this binary, from the same
|
||||||
|
/// `socketPath` option the daemon binds; falls back to
|
||||||
|
/// `SWARM_CONTROLLER_SOCKET`.
|
||||||
|
#[arg(long, value_name = "PATH")]
|
||||||
|
controller_socket: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum UserVerb {
|
enum UserVerb {
|
||||||
/// Add a user, generating a password for them.
|
/// Add a user, generating a password for them.
|
||||||
|
|
@ -337,6 +366,13 @@ fn main() -> Result<()> {
|
||||||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||||
agent::mint_forge_token(&socket, &args.name)
|
agent::mint_forge_token(&socket, &args.name)
|
||||||
}
|
}
|
||||||
|
// Same socket-resolution reasoning as `Create` above.
|
||||||
|
Verb::Forge {
|
||||||
|
command: ForgeVerb::MakeAdmin(args),
|
||||||
|
} => {
|
||||||
|
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||||
|
forge::make_admin(&socket, &args.name)
|
||||||
|
}
|
||||||
// Resolved lazily, inside the one arm that actually touches the
|
// Resolved lazily, inside the one arm that actually touches the
|
||||||
// deployment env vars — see the `MarkdownDocs` doc comment above
|
// deployment env vars — see the `MarkdownDocs` doc comment above
|
||||||
// for why an unconditional resolve up front would be wrong.
|
// for why an unconditional resolve up front would be wrong.
|
||||||
|
|
@ -751,6 +787,24 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_make_admin_verb_takes_a_user_name() {
|
||||||
|
let cli = Cli::try_parse_from(["swarmctl", "forge", "make-admin", "mara"])
|
||||||
|
.expect("the minimal form parses");
|
||||||
|
let Verb::Forge {
|
||||||
|
command: ForgeVerb::MakeAdmin(args),
|
||||||
|
} = cli.command
|
||||||
|
else {
|
||||||
|
panic!("expected `forge make-admin`");
|
||||||
|
};
|
||||||
|
assert_eq!(args.name, "mara");
|
||||||
|
assert!(args.controller_socket.is_none());
|
||||||
|
assert!(
|
||||||
|
Cli::try_parse_from(["swarmctl", "forge", "make-admin"]).is_err(),
|
||||||
|
"the user must be named"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_authelia_hash_output() {
|
fn parses_authelia_hash_output() {
|
||||||
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue