Watch
0
0
Fork
You've already forked hyperhive
0

require both attic options, follow attic's cache-name rule, read the token from a file

This commit is contained in:
atlas 2026-10-27 10:46:36 +01:00
commit bd214b9f3e
5 changed files with 156 additions and 41 deletions

View file

@ -267,12 +267,16 @@ inside the 61 minutes the controller waits. `attic` checks the server's certific
against the system roots and pins nothing, so a Let's Encrypt certificate against the system roots and pins nothing, so a Let's Encrypt certificate
works. works.
Set both options or neither: evaluation refuses a domain without a cache name.
The cache name follows attic's rule, 1 to 50 letters, digits, `-`, `_` or `+`,
starting with a letter or digit, and evaluation refuses any other name.
The push token is the operator's to write. The builder reads it on every push The push token is the operator's to write. The builder reads it on every push
from `secret/swarm/services/attic/push-token` (field `value`), writes an `attic` from `secret/swarm/services/attic/push-token` (field `value`), writes it to
config file holding it in the unit's private runtime directory (`/run/swarm-builder`, a file and an `attic` config naming that file (`token-file`) in the unit's private
mode `0700`, the file `0600`), points `attic` at it with `XDG_CONFIG_HOME` and runtime directory (`/run/swarm-builder`, mode `0700`, both files `0600`), points
removes it after the push. The token is in no argument or variable, so no `attic` at the config with `XDG_CONFIG_HOME` and removes both after the push.
process listing shows it. The token is in no argument or variable, so no process listing shows it.
The reply's `cached` says whether the push finished. A push that fails, times out The reply's `cached` says whether the push finished. A push that fails, times out
or can't read the token leaves the build green with `cached` false and logs a or can't read the token leaves the build green with `cached` false and logs a

View file

@ -12,7 +12,8 @@
Name of the attic server, with no scheme: the swarm builder pushes to Name of the attic server, with no scheme: the swarm builder pushes to
`https://<domain>`, and trusts the certificate the system roots do, so `https://<domain>`, and trusts the certificate the system roots do, so
a Let's Encrypt certificate works and none is pinned. Set together with a Let's Encrypt certificate works and none is pinned. Set together with
{option}`services.hyperhive.swarm.attic.cache`. {option}`services.hyperhive.swarm.attic.cache`; evaluation fails when only
one is set.
Null pushes nothing: the swarm builder does not push, and the closure Null pushes nothing: the swarm builder does not push, and the closure
record says it is not cached. record says it is not cached.
@ -24,13 +25,15 @@
}; };
cache = lib.mkOption { cache = lib.mkOption {
type = lib.types.nullOr lib.types.str; # attic's own rule for a cache name; the builder checks the same at start.
type = lib.types.nullOr (lib.types.strMatching "^[A-Za-z0-9][A-Za-z0-9_+-]{0,49}$");
default = null; default = null;
example = "grid"; example = "grid";
description = '' description = ''
Name of the cache on {option}`services.hyperhive.swarm.attic.domain` Name of the cache on {option}`services.hyperhive.swarm.attic.domain`
that the swarm builder pushes closures to. The push token must be that the swarm builder pushes closures to. The push token must be
allowed to push to it. allowed to push to it. The name follows attic's rule: 1 to 50 letters,
digits, `-`, `_` or `+`, starting with a letter or digit.
''; '';
}; };
}; };

View file

@ -90,8 +90,8 @@ let
hyperhiveFlake = hyperhiveCfg.c0re.hyperhiveFlake; hyperhiveFlake = hyperhiveCfg.c0re.hyperhiveFlake;
nixpkgsFlake = hyperhiveCfg.c0re.nixpkgsFlake; nixpkgsFlake = hyperhiveCfg.c0re.nixpkgsFlake;
# Either half alone reaches the daemon too, which refuses to start on it. # The assertion below keeps the two options together.
pushesToCache = atticCfg.domain != null || atticCfg.cache != null; pushesToCache = atticCfg.domain != null;
privateNetwork = true; privateNetwork = true;
in in
@ -133,6 +133,14 @@ in
host's /var/lib/swarm-bao-pki/ and name them in those two options. host's /var/lib/swarm-bao-pki/ and name them in those two options.
''; '';
} }
{
assertion = (atticCfg.domain == null) == (atticCfg.cache == null);
message = ''
services.hyperhive.swarm.attic.domain and services.hyperhive.swarm.attic.cache
must be set together: the swarm builder pushes to a cache on a server, and
either alone names nothing to push to.
'';
}
{ {
assertion = lib.all oneWord (lib.attrValues storeFiles); assertion = lib.all oneWord (lib.attrValues storeFiles);
message = '' message = ''
@ -257,10 +265,8 @@ in
SWARM_BUILDER_FORGE_URL = "https://${forgeCfg.domain}"; SWARM_BUILDER_FORGE_URL = "https://${forgeCfg.domain}";
SWARM_BUILDER_CONFIG_ORG = "agent-configs"; SWARM_BUILDER_CONFIG_ORG = "agent-configs";
} }
// lib.optionalAttrs (atticCfg.domain != null) { // lib.optionalAttrs pushesToCache {
SWARM_BUILDER_ATTIC_DOMAIN = atticCfg.domain; SWARM_BUILDER_ATTIC_DOMAIN = atticCfg.domain;
}
// lib.optionalAttrs (atticCfg.cache != null) {
SWARM_BUILDER_ATTIC_CACHE = atticCfg.cache; SWARM_BUILDER_ATTIC_CACHE = atticCfg.cache;
} }
// lib.optionalAttrs (storeFiles ? builder-bao-server-ca) { // lib.optionalAttrs (storeFiles ? builder-bao-server-ca) {
@ -276,7 +282,7 @@ in
DynamicUser = true; DynamicUser = true;
CacheDirectory = unit; CacheDirectory = unit;
# /run/swarm-builder, mode 0700 and owned by the dynamic user: the # /run/swarm-builder, mode 0700 and owned by the dynamic user: the
# push writes its token-bearing `attic` config under it. # push writes its token file and `attic` config under it.
RuntimeDirectory = unit; RuntimeDirectory = unit;
RuntimeDirectoryMode = "0700"; RuntimeDirectoryMode = "0700";
# Bare ids: the system credentials nspawn handed in. # Bare ids: the system credentials nspawn handed in.

View file

@ -39,6 +39,21 @@ let
m: m:
lib.any (a: !a.assertion && lib.hasInfix "deploy.builder.enable requires" a.message) m.assertions; lib.any (a: !a.assertion && lib.hasInfix "deploy.builder.enable requires" a.message) m.assertions;
# The one fixture that fires the cache assertion: a domain with no cache name.
halfCache = hive {
deploy.bao.enable = true;
deploy.builder.enable = true;
swarm.attic.domain = "cache.example.org";
};
refusedHalfCache =
m:
lib.any (
a:
!a.assertion && lib.hasInfix "swarm.attic.domain and services.hyperhive.swarm.attic.cache" a.message
) m.assertions;
# Only the option is read, so the host around it is never evaluated.
cacheNamed = name: (hive { swarm.attic.cache = name; }).services.hyperhive.swarm.attic.cache;
natsHere = shared.natsQueueOnly; natsHere = shared.natsQueueOnly;
autheliaHere = hive { autheliaHere = hive {
@ -139,6 +154,30 @@ let
&& !(withoutCache.environment ? SWARM_BUILDER_ATTIC_DOMAIN) && !(withoutCache.environment ? SWARM_BUILDER_ATTIC_DOMAIN)
&& !(withoutCache.environment ? SWARM_BUILDER_ATTIC_CACHE); && !(withoutCache.environment ? SWARM_BUILDER_ATTIC_CACHE);
} }
{
# A domain with no cache name would crash-loop the builder at start, so
# evaluation refuses it. `withBuilder`, with both set, is the control: the
# same assertion list holds the entry and it passes.
name = "the builder refuses a cache domain without a cache name";
ok =
refusedHalfCache halfCache
&& !(refusedHalfCache withBuilder)
&& lib.any (a: lib.hasInfix "swarm.attic.domain and" a.message) withBuilder.assertions;
}
{
# attic refuses a cache name outside its rule, which would fail every push.
# `grid` is the control that the lookup reads a name.
name = "the cache name must follow attic's rule";
ok =
cacheNamed "grid" == "grid"
&& cacheNamed "A-b_c+d" == "A-b_c+d"
&& lib.all (n: !(builtins.tryEval (cacheNamed n)).success) [
"grid.v2"
"-grid"
""
(lib.concatStrings (lib.genList (_: "a") 51))
];
}
{ {
# The minting unit goes inactive after each run, so a start that pulls it # The minting unit goes inactive after each run, so a start that pulls it
# runs it again and mints a leaf that is missing; an active # runs it again and mints a leaf that is missing; an active

View file

@ -1,10 +1,11 @@
//! The push of a built closure to the binary cache, with `attic push`. //! The push of a built closure to the binary cache, with `attic push`.
//! //!
//! The cache is an attic instance outside the swarm. Its push token is read //! The cache is an attic instance outside the swarm. Its push token is read
//! from the secret store per push and written to an `attic` config file in a //! from the secret store per push and written to a file in a
//! directory only the builder's unit can enter, which `attic` finds through //! directory only the builder's unit can enter, beside a config that names it with
//! `XDG_CONFIG_HOME`. `attic login <name> <url> <token>` is not used: it puts //! `token-file`. `attic` finds the config through `XDG_CONFIG_HOME`.
//! the token in the process list. `attic` verifies the server's certificate //! `attic login <name> <url> <token>` is not used: it puts the token in the
//! process list. `attic` verifies the server's certificate
//! against the system roots. //! against the system roots.
//! //!
//! A push that does not happen or does not finish leaves the build green with //! A push that does not happen or does not finish leaves the build green with
@ -54,13 +55,15 @@ impl Remote {
(Some(domain), Some(cache)) => (domain, cache), (Some(domain), Some(cache)) => (domain, cache),
_ => bail!("the cache domain and the cache name must be set together"), _ => bail!("the cache domain and the cache name must be set together"),
}; };
let plain = |s: &str| { let plain_domain = !domain.is_empty()
!s.is_empty() && domain
&& s.bytes() .bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b':')) .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b':'));
}; if !plain_domain {
if !plain(&domain) || !plain(&cache) { bail!("the cache domain {domain:?} is not a host name with an optional port");
bail!("the cache domain {domain:?} and name {cache:?} must be plain names"); }
if !cache_name_ok(&cache) {
bail!("the cache name {cache:?} is not one attic accepts");
} }
let Some(state_dir) = state_dir else { let Some(state_dir) = state_dir else {
bail!("a cache is set, but the unit has no runtime directory to hold its config"); bail!("a cache is set, but the unit has no runtime directory to hold its config");
@ -106,7 +109,7 @@ impl Remote {
} }
/// `attic push` of `out_path`, reading its config from `config_home`. The /// `attic push` of `out_path`, reading its config from `config_home`. The
/// token is in the file there and in no argument or variable. /// token is in a file beside it and in no argument or variable.
fn command(&self, config_home: &Path, out_path: &str) -> tokio::process::Command { fn command(&self, config_home: &Path, out_path: &str) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new(&self.attic); let mut cmd = tokio::process::Command::new(&self.attic);
cmd.arg("push") cmd.arg("push")
@ -121,6 +124,16 @@ impl Remote {
} }
} }
/// Whether `name` is a cache name attic accepts, which is
/// `^[A-Za-z0-9][A-Za-z0-9-_+]{0,49}$`. `services.hyperhive.swarm.attic.cache`
/// states the same rule in nix.
fn cache_name_ok(name: &str) -> bool {
let mut bytes = name.bytes();
bytes.next().is_some_and(|b| b.is_ascii_alphanumeric())
&& name.len() <= 50
&& bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+'))
}
/// Numbers the config directories of one process's pushes. /// Numbers the config directories of one process's pushes.
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@ -128,14 +141,14 @@ static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0)
struct ConfigHome(PathBuf); struct ConfigHome(PathBuf);
impl ConfigHome { impl ConfigHome {
/// `<parent>/push-<n>/attic/config.toml`, readable by this user alone. /// `<parent>/push-<n>/attic/config.toml`, which names `token` beside it, both
/// readable by this user alone.
fn write(parent: &Path, domain: &str, token: &str) -> std::io::Result<Self> { fn write(parent: &Path, domain: &str, token: &str) -> std::io::Result<Self> {
use std::io::Write as _; use std::io::Write as _;
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _}; use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _};
// A quote or a control character would end the TOML string early. if token.is_empty() || token.bytes().any(|b| b < b' ') {
if token.is_empty() || token.bytes().any(|b| b == b'"' || b == b'\\' || b < b' ') {
return Err(std::io::Error::other( return Err(std::io::Error::other(
"the push token is empty or holds a character the config cannot", "the push token is empty or holds a control character",
)); ));
} }
let home = Self(parent.join(format!( let home = Self(parent.join(format!(
@ -144,19 +157,30 @@ impl ConfigHome {
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
))); )));
let dir = home.0.join("attic"); let dir = home.0.join("attic");
let token_file = dir.join("token");
let token_path = token_file.to_string_lossy();
// A quote or backslash in the path would end the TOML string early.
if token_path.contains(['"', '\\']) {
return Err(std::io::Error::other(
"the runtime directory's path holds a quote or a backslash",
));
}
std::fs::DirBuilder::new() std::fs::DirBuilder::new()
.recursive(true) .recursive(true)
.mode(0o700) .mode(0o700)
.create(&dir)?; .create(&dir)?;
let mut file = std::fs::OpenOptions::new() let private = |name: &str| {
.write(true) std::fs::OpenOptions::new()
.create_new(true) .write(true)
.mode(0o600) .create_new(true)
.open(dir.join("config.toml"))?; .mode(0o600)
.open(dir.join(name))
};
private("token")?.write_all(token.as_bytes())?;
write!( write!(
file, private("config.toml")?,
"default-server = \"{SERVER}\"\n\n[servers.{SERVER}]\n\ "default-server = \"{SERVER}\"\n\n[servers.{SERVER}]\n\
endpoint = \"https://{domain}\"\ntoken = \"{token}\"\n" endpoint = \"https://{domain}\"\ntoken-file = \"{token_path}\"\n"
)?; )?;
Ok(home) Ok(home)
} }
@ -378,19 +402,26 @@ mod tests {
} }
#[test] #[test]
fn the_token_is_in_a_private_config_file_that_is_removed_after() { fn the_token_is_in_a_private_file_the_config_names_and_both_are_removed_after() {
let parent = std::env::temp_dir().join(format!("push-test-{}", std::process::id())); let parent = std::env::temp_dir().join(format!("push-test-{}", std::process::id()));
std::fs::create_dir_all(&parent).expect("parent"); std::fs::create_dir_all(&parent).expect("parent");
let home = ConfigHome::write(&parent, "cache.example.org", TOKEN).expect("written"); let home = ConfigHome::write(&parent, "cache.example.org", TOKEN).expect("written");
let file = home.0.join("attic/config.toml"); let file = home.0.join("attic/config.toml");
let text = std::fs::read_to_string(&file).expect("reads"); let text = std::fs::read_to_string(&file).expect("reads");
assert!(text.contains(&format!("token = \"{TOKEN}\"")), "{text}"); let token_file = home.0.join("attic/token");
assert!(
text.contains(&format!("token-file = \"{}\"", token_file.display())),
"{text}"
);
assert!(!text.contains(TOKEN), "{text}");
assert_eq!(std::fs::read_to_string(&token_file).expect("reads"), TOKEN);
assert!( assert!(
text.contains("endpoint = \"https://cache.example.org\""), text.contains("endpoint = \"https://cache.example.org\""),
"{text}" "{text}"
); );
let mode = |p: &Path| std::fs::metadata(p).expect("stat").permissions().mode() & 0o777; let mode = |p: &Path| std::fs::metadata(p).expect("stat").permissions().mode() & 0o777;
assert_eq!(mode(&file), 0o600); assert_eq!(mode(&file), 0o600);
assert_eq!(mode(&token_file), 0o600);
assert_eq!(mode(&home.0), 0o700); assert_eq!(mode(&home.0), 0o700);
let root = home.0.clone(); let root = home.0.clone();
drop(home); drop(home);
@ -399,13 +430,42 @@ mod tests {
} }
#[test] #[test]
fn a_token_that_would_end_the_config_string_is_refused() { fn an_empty_token_or_one_with_a_control_character_is_refused() {
let parent = std::env::temp_dir(); let parent = std::env::temp_dir();
for bad in ["", "a\"b", "a\\b", "a\nb"] { for bad in ["", "a\nb", "a\0b"] {
assert!(ConfigHome::write(&parent, "h", bad).is_err(), "{bad:?}"); assert!(ConfigHome::write(&parent, "h", bad).is_err(), "{bad:?}");
} }
} }
#[test]
fn a_path_that_would_end_the_config_string_is_refused() {
let parent = std::env::temp_dir().join("a\"b");
assert!(ConfigHome::write(&parent, "h", TOKEN).is_err());
assert!(!parent.exists());
}
#[test]
fn the_cache_name_rule_is_attics() {
let fifty = "a".repeat(50);
for ok in ["grid", "a", "A-b_c+d", "0", fifty.as_str()] {
assert!(cache_name_ok(ok), "{ok:?}");
}
let fifty_one = "a".repeat(51);
for bad in [
"",
"grid.v2",
"-x",
"_x",
"+x",
"a:b",
"a b",
"a/b",
fifty_one.as_str(),
] {
assert!(!cache_name_ok(bad), "{bad:?}");
}
}
#[tokio::test] #[tokio::test]
async fn a_path_that_is_not_a_store_path_is_not_pushed() { async fn a_path_that_is_not_a_store_path_is_not_pushed() {
let cache = cache_with("true"); let cache = cache_with("true");
@ -432,6 +492,9 @@ mod tests {
assert!(Remote::from_settings(None, some("grid"), state()).is_err()); assert!(Remote::from_settings(None, some("grid"), state()).is_err());
assert!(Remote::from_settings(some("https://cache"), some("grid"), state()).is_err()); assert!(Remote::from_settings(some("https://cache"), some("grid"), state()).is_err());
assert!(Remote::from_settings(some("cache"), some("a\"b"), state()).is_err()); assert!(Remote::from_settings(some("cache"), some("a\"b"), state()).is_err());
assert!(Remote::from_settings(some("cache"), some("grid.v2"), state()).is_err());
assert!(Remote::from_settings(some("cache"), some("-grid"), state()).is_err());
assert!(Remote::from_settings(some("cache"), some("g_r+i-d"), state()).is_ok());
assert!(Remote::from_settings(some("cache"), some("grid"), None).is_err()); assert!(Remote::from_settings(some("cache"), some("grid"), None).is_err());
} }
} }