Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl: --swarm works after the subcommand, takes a bare domain, and login defaults to the host's swarm

`swarmctl login --swarm X` failed with "unexpected argument", and
`login --help` listed no `--swarm`: the flag was only accepted before the
verb. `--swarm` and `--ca-file` are now global, so they parse on either side
of the subcommand and show in every subcommand's help.

`--swarm` / `SWARMCTL_SWARM` accept a bare domain and prepend `https://`.
A value with a scheme is parsed as before, so `http://` is still refused.

On a swarm-controller host, the swarmctl wrapper sets
`SWARMCTL_LOGIN_DEFAULT_SWARM=https://<swarm.domain>` (`--set-default`).
Only `login` reads it, after `--swarm` and `SWARMCTL_SWARM`. The wrapper
does not set `SWARMCTL_SWARM`: that would send every socket verb (agent
create, schedule, inference, forge) through the gateway and make each need
a login. Other verbs resolve exactly as before.

No CA file is wired. The only CA material the module names is the
controller's assembled trust bundle (system CAs plus the hive anchor).
`--ca-file` hands every certificate in it to rustls-platform-verifier as
an extra root, and that fails the whole client on any one it cannot
parse, so pointing it at the system CAs is a risk nothing here can test.
The controller daemon trusts the same CA through `SSL_CERT_FILE`, which
skips unparsable certificates; using that for swarmctl is left open.

Machines that are not swarm-controller hosts (an operator laptop) get no
default; that case waits on an answer in the issue.

Fixes #5239
This commit is contained in:
atlas 2026-10-14 02:42:11 +02:00 • committed by mara
commit 7d95973d83
6 changed files with 176 additions and 15 deletions

View file

@ -56,29 +56,55 @@ struct Cli {
command: Verb,
}
/// Where a verb that can reach a swarm looks for it when `--swarm` is absent.
const SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM"];
/// The same for `login`. The swarm-controller nix module sets
/// `SWARMCTL_LOGIN_DEFAULT_SWARM` to its host's own swarm; setting
/// `SWARMCTL_SWARM` there instead would send every other verb through the
/// gateway rather than the local socket.
const LOGIN_SWARM_ENVS: &[&str] = &["SWARMCTL_SWARM", "SWARMCTL_LOGIN_DEFAULT_SWARM"];
/// Which swarm to reach through its domain rather than the local socket.
#[derive(Args)]
struct RemoteArgs {
/// The swarm's domain, as `https://<domain>`. Given, the `agent`,
/// `schedule` and `forge` verbs go through its gateway as the account
/// `swarmctl login` logged in, instead of over the controller's socket.
/// Falls back to `SWARMCTL_SWARM`.
#[arg(long, value_name = "URL")]
/// The swarm's domain, as `https://<domain>` or the bare domain. Given,
/// the `agent`, `schedule` and `forge` verbs go through its gateway as the
/// account `swarmctl login` logged in, instead of over the controller's
/// socket. Falls back to `SWARMCTL_SWARM`; `login` then falls back to
/// `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller nix module
/// sets to its host's own swarm.
#[arg(long, value_name = "URL", global = true)]
swarm: Option<String>,
/// A PEM CA certificate to trust beside the system roots, for a swarm
/// whose gateway serves a certificate from the swarm's own CA. Falls back
/// to `SWARMCTL_CA_FILE`.
#[arg(long, value_name = "PATH")]
#[arg(long, value_name = "PATH", global = true)]
ca_file: Option<PathBuf>,
}
impl RemoteArgs {
/// `--swarm`, else the first of `envs` that `env` finds set and
/// non-empty. An empty `--swarm` names no swarm and skips the env.
fn swarm_url(&self, envs: &[&str], env: impl Fn(&str) -> Option<String>) -> Option<String> {
match &self.swarm {
Some(flag) => Some(flag.clone()),
None => envs
.iter()
.find_map(|name| env(name).filter(|u| !u.is_empty())),
}
.filter(|u| !u.is_empty())
}
fn swarm(&self) -> Result<Option<remote::Swarm>> {
let url = self
.swarm
.clone()
.or_else(|| std::env::var("SWARMCTL_SWARM").ok())
.filter(|u| !u.is_empty());
self.swarm_from(SWARM_ENVS)
}
fn login_swarm(&self) -> Result<Option<remote::Swarm>> {
self.swarm_from(LOGIN_SWARM_ENVS)
}
fn swarm_from(&self, envs: &[&str]) -> Result<Option<remote::Swarm>> {
let url = self.swarm_url(envs, |name| std::env::var(name).ok());
let ca_file = self
.ca_file
.clone()
@ -186,6 +212,10 @@ enum Verb {
},
/// Log in to the swarm named by `--swarm` as your own SSO account.
///
/// With no `--swarm` or `SWARMCTL_SWARM`, logs in to
/// `SWARMCTL_LOGIN_DEFAULT_SWARM`: on a swarm-controller host, the swarm
/// that host belongs to.
///
/// Opens the swarm's SSO login in your browser (and prints the URL) and
/// receives the answer on a loopback port, so run it where that browser
/// runs. swarmctl keeps the login, mode 0600, under
@ -583,7 +613,7 @@ fn main() -> Result<()> {
Verb::Schedule { command } => schedule(&remote, command),
Verb::Login => login::login(
&remote
.swarm()?
.login_swarm()?
.context("`swarmctl login` needs `--swarm` or `SWARMCTL_SWARM`")?,
),
// Resolved lazily, inside the one arm that actually touches the
@ -1037,6 +1067,80 @@ mod tests {
Cli::command().debug_assert();
}
#[test]
fn swarm_parses_before_and_after_the_subcommand() {
for argv in [
["swarmctl", "--swarm", "s.test", "login"],
["swarmctl", "login", "--swarm", "s.test"],
] {
let cli = Cli::try_parse_from(argv).expect("parses");
assert_eq!(cli.remote.swarm.as_deref(), Some("s.test"), "{argv:?}");
}
let cli = Cli::try_parse_from(["swarmctl", "schedule", "list", "--ca-file", "/ca.pem"])
.expect("parses");
assert_eq!(cli.remote.ca_file.as_deref(), Some(Path::new("/ca.pem")));
}
#[test]
fn login_help_lists_swarm_and_its_env_fallback() {
let Err(err) = Cli::try_parse_from(["swarmctl", "login", "--help"]) else {
panic!("--help parsed as a command");
};
assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
let help = err.to_string();
assert!(help.contains("--swarm <URL>"), "{help}");
assert!(help.contains("SWARMCTL_SWARM"), "{help}");
}
/// `--swarm` over `SWARMCTL_SWARM` over the login-only default, and only
/// `login` reads the default: anywhere else it would turn the socket verbs
/// into gateway calls.
#[test]
fn the_swarm_resolves_flag_then_env_then_the_login_default() {
let env = |set: &'static [(&'static str, &'static str)]| {
move |name: &str| {
set.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_owned())
}
};
let both = env(&[
("SWARMCTL_SWARM", "env.test"),
("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"),
]);
let only_default = env(&[
("SWARMCTL_SWARM", ""),
("SWARMCTL_LOGIN_DEFAULT_SWARM", "default.test"),
]);
let flag =
Cli::try_parse_from(["swarmctl", "login", "--swarm", "flag.test"]).expect("parses");
assert_eq!(
flag.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(),
Some("flag.test")
);
let bare = Cli::try_parse_from(["swarmctl", "login"]).expect("parses");
assert_eq!(
bare.remote.swarm_url(LOGIN_SWARM_ENVS, both).as_deref(),
Some("env.test")
);
assert_eq!(
bare.remote.swarm_url(SWARM_ENVS, both).as_deref(),
Some("env.test")
);
assert_eq!(
bare.remote
.swarm_url(LOGIN_SWARM_ENVS, only_default)
.as_deref(),
Some("default.test")
);
assert_eq!(bare.remote.swarm_url(SWARM_ENVS, only_default), None);
let empty = Cli::try_parse_from(["swarmctl", "login", "--swarm", ""]).expect("parses");
assert_eq!(empty.remote.swarm_url(LOGIN_SWARM_ENVS, both), None);
}
/// A schedule with no agent would fire nowhere, so `--agent` is required
/// at parse time, and repeats collect into the target list.
#[test]

View file

@ -44,9 +44,15 @@ pub(crate) struct Swarm {
}
impl Swarm {
/// `url` with no scheme is taken as a bare domain, under `https://`.
pub(crate) fn parse(url: &str, ca_file: Option<PathBuf>, state_dir: PathBuf) -> Result<Self> {
let full = if url.contains("://") {
url.to_owned()
} else {
format!("https://{url}")
};
let parsed =
url::Url::parse(url).with_context(|| format!("--swarm {url:?} is not a URL"))?;
url::Url::parse(&full).with_context(|| format!("--swarm {url:?} is not a URL"))?;
if parsed.scheme() != "https" || parsed.host_str().is_none() {
bail!("--swarm {url:?} must be an https:// URL naming the swarm's domain");
}
@ -382,6 +388,21 @@ mod tests {
assert!(Swarm::parse("http://swarm.example.test", None, "/s".into()).is_err());
}
/// A port is no scheme: `host:8443` would otherwise parse as a URL whose
/// scheme is `host`.
#[test]
fn a_bare_domain_is_taken_as_https() {
let origin = |url| Swarm::parse(url, None, "/s".into()).expect("parses").origin;
assert_eq!(
origin("swarm.example.test").as_str(),
"https://swarm.example.test/"
);
assert_eq!(
origin("swarm.example.test:8443").as_str(),
"https://swarm.example.test:8443/"
);
}
/// authelia's token endpoint and the gateway's `/api/`, as far as these
/// tests need them: a refresh rotates both tokens, a used refresh token
/// revokes the login (as authelia does), and `/api/` admits only the