difftreelog
refactor declare configuration using flake parts
in: trunk
29 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1432,6 +1432,7 @@
name = "nix-eval"
version = "0.1.0"
dependencies = [
+ "anyhow",
"better-command",
"futures",
"itertools",
README.adocdiffbeforeafterboth--- a/README.adoc
+++ b/README.adoc
@@ -24,44 +24,33 @@
url = "github:CertainLach/fleet";
inputs.nixpkgs.follows = "nixpkgs";
};
+ flake-parts.url = "github:hercules-ci/flake-parts";
lanzaboote = {
url = "github:nix-community/lanzaboote/v0.3.0";
inputs.nixpkgs.follows = "nixpkgs";
};
};
- outputs = {
- nixpkgs,
- fleet,
- lanzaboote,
- ...
- }: {
- # TODO: This section of documentation needs to use flake-utils.
- formatter.x86_64-linux = let
- pkgs = import nixpkgs {system = "x86_64-linux";};
- in
- pkgs.alejandra;
+ outputs = inputs: flake-parts.lib.mkFlake { inherit inputs; } {
+ imports = [inputs.fleet.flakeModules.default];
- devShell.x86_64-linux = let
- pkgs = import nixpkgs {
- system = "x86_64-linux";
- };
- in
- pkgs.mkShell {
- buildInputs = with pkgs; [
- fleet.packages.x86_64-linux.fleet
+ perSystem = {pkgs, system, ...}: {
+ _module.args.pkgs = import nixpkgs { inherit system; };
+
+ formatter = pkgs.alejandra;
+ devShells.default = pkgs.mkShell {
+ packages = [
+ inputs.fleet.packages.${system}.fleet
];
};
+ };
# Single flake may contain multiple fleet configurations, default one is called... `default`
- fleetConfigurations.default = fleet.lib.fleetConfiguration {
+ fleetConfigurations.default = {
# nixpkgs used to build the systems
- inherit nixpkgs;
- # fleet wants to pass some data, like secrets, to do that - fleet writes all the encrypted secrets to fleet.nix
- # treat the contents of this file as implementation detail
- data = import ./fleet.nix;
+ nixpkgs.buildUsing = nixpkgs;
- # nixosModules section of fleet config declares modules, which are used for all configured nixos hosts.
- nixosModules = [
+ # nixos option section of fleet config declares module, which is used for all configured nixos hosts.
+ nixos.imports = [
lanzaboote.nixosModules.lanzaboote
{
# Make `nix shell nixpkgs#thing` use the same nixpkgs, as used to build the system.
@@ -77,7 +66,7 @@
# Is I.e wiring up the mesh VPN, or deploying kubernetes, or other things.
#
# Modules use the same semantics as standard nixos module system, they are just configuring all the hosts at once.
- fleetModules = [
+ imports = [
./wireguard
# Multi-instancible modules example
(import ./kubernetes {hosts = ["a" "b"];})
@@ -89,7 +78,7 @@
# Every host has some system, for which the system configuration needs to be built
system = "x86_64-linux";
# And nixos modules
- nixosModules = [
+ nixos.imports = [
./controlplane-1/hardware-configuration.nix
./controlplane-1/configuration.nix
# Configuration may also be specified inline, as in any nixos config.
cmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -254,13 +254,8 @@
let host = config.host(&host).await?;
// let action = Action::from(self.subcommand.clone());
let fleet_config = &config.config_field;
- let drv = nix_go!(
- fleet_config.hosts[{ &host.name }]
- .nixosSystem
- .config
- .system
- .build[{ build_attr }]
- );
+ let nixos = host.nixos_config().await?;
+ let drv = nix_go!(nixos.system.build[{ build_attr }]);
let outputs = drv.build().await.inspect_err(|_| {
if build_attr == "sdImage" {
info!("sd-image build failed");
@@ -335,6 +330,7 @@
let config = config.clone();
let span = info_span!("deploy", host = field::display(&host.name));
let hostname = host.name.clone();
+ let local_host = config.local_host();
// FIXME: Fix repl concurrency (see build-systems)
set.spawn_local(
(async move {
@@ -354,7 +350,10 @@
// at least for the first deployment, to provide trusted store key.
//
// It is much slower, yet doesn't require root on the deployer machine.
- let mut sign = MyCommand::new("nix");
+ let Ok(mut sign) = local_host.cmd("nix").await else {
+ error!("failed to setup local");
+ return;
+ };
// Private key for host machine is registered in nix-sign.nix
sign.arg("store")
.arg("sign")
@@ -362,7 +361,7 @@
.arg("-r")
.arg(&built);
if let Err(e) = sign.sudo().run_nix().await {
- warn!("Failed to sign store paths: {e}");
+ warn!("failed to sign store paths: {e}");
};
}
let mut tries = 0;
cmds/fleet/src/cmds/info.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/info.rs
+++ b/cmds/fleet/src/cmds/info.rs
@@ -38,9 +38,9 @@
InfoCmd::ListHosts { ref tagged } => {
'host: for host in config.list_hosts().await? {
if !tagged.is_empty() {
- let config = &config.config_unchecked_field;
+ let config = &config.config_field;
let tags: Vec<String> =
- nix_go_json!(config.hosts[{ host.name }].nixosSystem.config.tags);
+ nix_go_json!(config.hosts[{ host.name }].tags);
for tag in tagged {
if !tags.contains(tag) {
continue 'host;
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -598,7 +598,7 @@
return Ok(());
}
- let config_field = &config.config_unchecked_field;
+ let config_field = &config.config_field;
let field = nix_go!(config_field.sharedSecrets[{ name }]);
let updated = update_owner_set(
@@ -623,7 +623,7 @@
.collect::<HashSet<_>>();
let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();
for missing in expected_shared_set.difference(&shared_set) {
- let config_field = &config.config_unchecked_field;
+ let config_field = &config.config_field;
let secret = nix_go!(config_field.sharedSecrets[{ missing }]);
let expected_owners: Option<Vec<String>> =
nix_go_json!(secret.expectedOwners);
@@ -675,7 +675,7 @@
for name in &config.list_shared() {
info!("updating secret: {name}");
let data = config.shared_secret(name)?;
- let config_field = &config.config_unchecked_field;
+ let config_field = &config.config_field;
let expected_owners: Vec<String> =
nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);
if expected_owners.is_empty() {
cmds/fleet/src/command.rsdiffbeforeafterboth--- a/cmds/fleet/src/command.rs
+++ b/cmds/fleet/src/command.rs
@@ -9,6 +9,8 @@
use tokio_util::codec::{BytesCodec, FramedRead, LinesCodec};
use tracing::debug;
+use crate::host::EscalationStrategy;
+
fn escape_bash(input: &str, out: &mut String) {
const TO_ESCAPE: &str = "$ !\"#&'()*,;<>?[\\]^`{|}";
if input.chars().all(|c| !TO_ESCAPE.contains(c)) {
@@ -27,32 +29,51 @@
fn ostoutf8(os: impl AsRef<OsStr>) -> String {
os.as_ref().to_str().expect("non-utf8 data").to_owned()
}
-#[derive(Clone)]
+
+#[derive(Clone, Debug)]
pub struct MyCommand {
command: String,
args: Vec<String>,
env: Vec<(String, String)>,
ssh_session: Option<Arc<Session>>,
+ escalation: EscalationStrategy,
+ escalate: bool,
}
impl MyCommand {
- pub fn new_on(cmd: impl AsRef<OsStr>, session: Arc<Session>) -> Self {
+ pub fn new_on(
+ escalation: EscalationStrategy,
+ cmd: impl AsRef<OsStr>,
+ session: Arc<Session>,
+ ) -> Self {
assert!(!cmd.as_ref().is_empty());
Self {
command: ostoutf8(cmd),
args: vec![],
env: vec![],
ssh_session: Some(session),
+ escalation,
+ escalate: false,
}
}
- pub fn new(cmd: impl AsRef<OsStr>) -> Self {
+ pub fn new(escalation: EscalationStrategy, cmd: impl AsRef<OsStr>) -> Self {
assert!(!cmd.as_ref().is_empty());
Self {
command: ostoutf8(cmd),
args: vec![],
env: vec![],
ssh_session: None,
+ escalation,
+ escalate: false,
}
}
+ fn new_here(&self, cmd: impl AsRef<OsStr>) -> Self {
+ if let Some(ssh_session) = self.ssh_session.clone() {
+ Self::new_on(self.escalation, cmd, ssh_session)
+ } else {
+ Self::new(self.escalation, cmd)
+ }
+ }
+
fn into_args(self) -> Vec<String> {
let mut out = Vec::new();
if !self.env.is_empty() {
@@ -76,8 +97,7 @@
if self.env.is_empty() {
return self;
}
- let mut out = Self::new("env");
- out.ssh_session = self.ssh_session;
+ let mut out = self.new_here("env");
for (k, v) in self.env {
assert!(!k.contains('='));
out.arg(format!("{k}={v}"));
@@ -160,26 +180,46 @@
self
}
pub fn sudo(mut self) -> Self {
- // TODO: Multiple escalation strategies.
- // Maybe escalation should be moved to ConfigHost, to also support cases
- // when there is no sudo on remote machine, but instead we can reconnect
- // as root using ssh?
- if std::env::var_os("NO_SUDO").is_some() {
- let mut out = Self::new("su");
- out.ssh_session = self.ssh_session.take();
- out.arg("-c").arg(self.into_string());
- out
- } else {
- let mut out = Self::new("sudo");
- out.ssh_session = self.ssh_session.take();
- out.args(self.into_args());
- out
+ self.escalate = true;
+ self
+ }
+ fn wrap_sudo_if_needed(self) -> Self {
+ if !self.escalate {
+ return self;
+ }
+ match self.escalation {
+ EscalationStrategy::Su => {
+ let mut out = self.new_here("su");
+ out.arg("-c").arg(self.into_string());
+ out
+ }
+ EscalationStrategy::Sudo => {
+ let mut out = self.new_here("sudo");
+ out.args(self.into_args());
+ out
+ }
+ EscalationStrategy::Run0 => {
+ // run0 wants interactive authentication by default.
+ let mut run0 = self.new_here("run0");
+ let mut out = self.new_here("script");
+
+ // Red backgrounds messes with fleet formatting
+ run0.arg("--background=");
+ run0.args(self.into_args());
+
+ out.arg("-q");
+ out.arg("/dev/null");
+ out.arg("-c");
+ out.arg(run0.into_string());
+ dbg!(&out);
+ out
+ }
}
}
pub async fn run(self) -> Result<()> {
let str = self.clone().into_string();
- let cmd = self.into_command_new()?;
+ let cmd = self.wrap_sudo_if_needed().into_command_new()?;
match cmd {
Either::Left(cmd) => run_nix_inner(str, cmd, &mut PlainHandler).await?,
Either::Right(cmd) => run_nix_inner_ssh(str, cmd, &mut PlainHandler).await?,
@@ -192,7 +232,7 @@
}
pub async fn run_bytes(self) -> Result<Vec<u8>> {
let str = self.clone().into_string();
- let cmd = self.into_command_new()?;
+ let cmd = self.wrap_sudo_if_needed().into_command_new()?;
let v = match cmd {
Either::Left(cmd) => run_nix_inner_stdout(str, cmd, &mut PlainHandler).await?,
Either::Right(cmd) => run_nix_inner_stdout_ssh(str, cmd, &mut PlainHandler).await?,
@@ -200,17 +240,17 @@
Ok(v)
}
- pub async fn run_nix_string(self) -> Result<String> {
+ pub async fn run_nix_string(mut self) -> Result<String> {
let str = self.clone().into_string();
- let mut cmd = self.into_command();
- cmd.arg("--log-format").arg("internal-json");
+ self.arg("--log-format").arg("internal-json");
+ let mut cmd = self.wrap_sudo_if_needed().into_command();
let bytes = run_nix_inner_stdout(str, cmd, &mut NixHandler::default()).await?;
Ok(String::from_utf8(bytes)?)
}
- pub async fn run_nix(self) -> Result<()> {
+ pub async fn run_nix(mut self) -> Result<()> {
let str = self.clone().into_string();
- let mut cmd = self.into_command();
- cmd.arg("--log-format").arg("internal-json");
+ self.arg("--log-format").arg("internal-json");
+ let mut cmd = self.wrap_sudo_if_needed().into_command();
cmd.stdout(Stdio::inherit());
run_nix_inner(str, cmd, &mut NixHandler::default()).await
}
cmds/fleet/src/host.rsdiffbeforeafterboth1use std::{2 cell::OnceCell,3 collections::BTreeMap,4 env::current_dir,5 ffi::{OsStr, OsString},6 fmt::Display,7 io::Write,8 ops::Deref,9 path::PathBuf,10 str::FromStr,11 sync::{Arc, Mutex, MutexGuard, OnceLock},12};1314use anyhow::{anyhow, bail, ensure, Context, Result};15use clap::Parser;16use fleet_shared::SecretData;17use nix_eval::{nix_go, nix_go_json, NixSessionPool, Value};18use nom::{19 bytes::complete::take_while1,20 character::complete::char,21 combinator::{map, opt},22 multi::separated_list1,23 sequence::{preceded, separated_pair},24};25use openssh::SessionBuilder;26use serde::de::DeserializeOwned;27use tempfile::NamedTempFile;2829use crate::{30 command::MyCommand,31 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},32};3334pub struct FleetConfigInternals {35 pub local_system: String,36 pub directory: PathBuf,37 pub opts: FleetOpts,38 pub data: Mutex<FleetData>,39 pub nix_args: Vec<OsString>,40 /// fleet_config.config41 pub config_field: Value,42 /// fleet_config.unchecked.config43 pub config_unchecked_field: Value,4445 /// import nixpkgs {system = local};46 pub default_pkgs: Value,47}4849#[derive(Clone)]50pub struct Config(Arc<FleetConfigInternals>);5152impl Deref for Config {53 type Target = FleetConfigInternals;5455 fn deref(&self) -> &Self::Target {56 &self.057 }58}5960pub struct ConfigHost {61 config: Config,62 pub name: String,63 pub local: bool,64 pub session: OnceLock<Arc<openssh::Session>>,65 groups: OnceCell<Vec<String>>,6667 pub nixos_config: Option<Value>,68}69impl ConfigHost {70 pub async fn tags(&self) -> Result<Vec<String>> {71 if let Some(v) = self.groups.get() {72 return Ok(v.clone());73 }74 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,75 // assuming getting tags always returns the same value.76 let Some(nixos_config) = &self.nixos_config else {77 return Ok(vec![]);78 };79 let tags: Vec<String> = nix_go_json!(nixos_config.tags);8081 let _ = self.groups.set(tags.clone());8283 Ok(tags)84 }85 async fn open_session(&self) -> Result<Arc<openssh::Session>> {86 assert!(!self.local, "do not open ssh connection to local session");87 // FIXME: TOCTOU88 if let Some(session) = &self.session.get() {89 return Ok((*session).clone());90 };91 let session = SessionBuilder::default();9293 let session = session94 .connect(&self.name)95 .await96 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;97 let session = Arc::new(session);98 self.session.set(session.clone()).expect("TOCTOU happened");99 Ok(session)100 }101 pub async fn mktemp_dir(&self) -> Result<String> {102 let mut cmd = self.cmd("mktemp").await?;103 cmd.arg("-d");104 let path = cmd.run_string().await?;105 Ok(path.trim_end().to_owned())106 }107 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {108 let mut cmd = self.cmd("cat").await?;109 cmd.arg(path);110 cmd.run_bytes().await111 }112 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {113 let mut cmd = self.cmd("cat").await?;114 cmd.arg(path);115 cmd.run_string().await116 }117 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {118 let mut cmd = self.cmd("ls").await?;119 cmd.arg(path);120 let out = cmd.run_string().await?;121 let mut lines = out.split('\n');122 if let Some(last) = lines.next_back() {123 ensure!(last.is_empty(), "output of ls should end with newline");124 }125 Ok(lines.map(ToOwned::to_owned).collect())126 }127 #[allow(dead_code)]128 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {129 let text = self.read_file_text(path).await?;130 Ok(serde_json::from_str(&text)?)131 }132 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>133 where134 <D as FromStr>::Err: Display,135 {136 let text = self.read_file_text(path).await?;137 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))138 }139 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {140 if self.local {141 Ok(MyCommand::new(cmd))142 } else {143 let session = self.open_session().await?;144 Ok(MyCommand::new_on(cmd, session))145 }146 }147148 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {149 ensure!(data.encrypted, "secret is not encrypted");150 let mut cmd = self.cmd("fleet-install-secrets").await?;151 cmd.arg("decrypt").eqarg("--secret", data.to_string());152 let encoded = cmd153 .sudo()154 .run_string()155 .await156 .context("failed to call remote host for decrypt")?;157 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;158 ensure!(!data.encrypted, "secret came out encrypted");159 Ok(data.data)160 }161 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {162 ensure!(data.encrypted, "secret is not encrypted");163 let mut cmd = self.cmd("fleet-install-secrets").await?;164 cmd.arg("reencrypt").eqarg("--secret", data.to_string());165 for target in targets {166 let key = self.config.key(&target).await?;167 cmd.eqarg("--targets", key);168 }169 let encoded = cmd170 .sudo()171 .run_string()172 .await173 .context("failed to call remote host for decrypt")?;174 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;175 ensure!(data.encrypted, "secret came out not encrypted");176 Ok(data)177 }178 /// Returns path for futureproofing, as path might change i.e on conversion to CA179 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {180 if self.local {181 // Path is located locally, thus already trusted.182 return Ok(path.to_owned());183 }184 let mut nix = MyCommand::new("nix");185 nix.arg("copy")186 .arg("--substitute-on-destination")187 .comparg("--to", format!("ssh-ng://{}", self.name))188 .arg(path);189 nix.run_nix().await.context("nix copy")?;190 Ok(path.to_owned())191 }192 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {193 let mut cmd = self.cmd("systemctl").await?;194 cmd.arg("stop").arg(name);195 cmd.sudo().run().await196 }197 pub async fn systemctl_start(&self, name: &str) -> Result<()> {198 let mut cmd = self.cmd("systemctl").await?;199 cmd.arg("start").arg(name);200 cmd.sudo().run().await201 }202203 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {204 let mut cmd = self.cmd("rm").await?;205 cmd.arg("-f").arg(path);206 if sudo {207 cmd = cmd.sudo()208 }209 cmd.run().await210 }211212 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {213 let Some(nixos) = &self.nixos_config else {214 return Ok(vec![]);215 };216 let secrets = nix_go!(nixos.secrets);217 let mut out = Vec::new();218 for name in secrets.list_fields().await? {219 let secret = nix_go!(secrets[{ name }]);220 let is_shared: bool = nix_go_json!(secret.shared);221 if is_shared {222 continue;223 }224 out.push(name);225 }226 Ok(out)227 }228 pub async fn secret_field(&self, name: &str) -> Result<Value> {229 let Some(nixos) = &self.nixos_config else {230 bail!("host is virtual and has no secrets");231 };232 Ok(nix_go!(nixos.secrets[{ name }]))233 }234235 /// Packages for this host, resolved with nixpkgs overlays236 pub async fn pkgs(&self) -> Result<Value> {237 let Some(nixos) = &self.nixos_config else {238 return Ok(self.config.default_pkgs.clone());239 };240 Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))241 }242}243244impl Config {245 pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {246 if !self.opts.skip.is_empty() && self.opts.skip.iter().any(|h| h as &str == host.name) {247 return Ok(true);248 }249 if self.opts.only.is_empty() {250 return Ok(false);251 }252 let mut have_group_matches = false;253 for item in self.opts.only.iter() {254 match item {255 HostItem::Host { name, .. } if *name == host.name => {256 return Ok(false);257 }258 HostItem::Tag { .. } => {259 have_group_matches = true;260 }261 _ => {}262 }263 }264 if have_group_matches {265 let host_tags = host.tags().await?;266 for item in self.opts.only.iter() {267 match item {268 HostItem::Tag { name, .. } if host_tags.contains(name) => {269 return Ok(false);270 }271 _ => {}272 }273 }274 }275 Ok(true)276 }277 pub async fn action_attr(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {278 if self.opts.only.is_empty() {279 return Ok(None);280 }281 let mut have_group_matches = false;282 for item in self.opts.only.iter() {283 match item {284 HostItem::Host { name, attrs }285 if *name == host.name && attrs.contains_key(attr) =>286 {287 return Ok(attrs.get(attr).cloned());288 }289 HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {290 have_group_matches = true;291 }292 _ => {}293 }294 }295 if have_group_matches {296 let host_tags = host.tags().await?;297 for item in self.opts.only.iter() {298 match item {299 HostItem::Tag { name, attrs }300 if host_tags.contains(name) && attrs.contains_key(attr) =>301 {302 return Ok(attrs.get(attr).cloned());303 }304 _ => {}305 }306 }307 }308 Ok(None)309 }310 pub fn is_local(&self, host: &str) -> bool {311 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)312 }313314 pub fn local_host(&self) -> ConfigHost {315 ConfigHost {316 config: self.clone(),317 name: "<virtual localhost>".to_owned(),318 local: true,319 session: OnceLock::new(),320 nixos_config: None,321 groups: {322 let cell = OnceCell::new();323 let _ = cell.set(vec![]);324 cell325 },326 }327 }328329 pub async fn host(&self, name: &str) -> Result<ConfigHost> {330 let config = &self.config_unchecked_field;331 let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);332 Ok(ConfigHost {333 config: self.clone(),334 name: name.to_owned(),335 local: self.is_local(name),336 session: OnceLock::new(),337 nixos_config: Some(nixos_config),338 groups: OnceCell::new(),339 })340 }341 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {342 let config = &self.config_unchecked_field;343 let names = nix_go!(config.hosts).list_fields().await?;344 let mut out = vec![];345 for name in names {346 out.push(self.host(&name).await?);347 }348 Ok(out)349 }350 pub async fn system_config(&self, host: &str) -> Result<Value> {351 let fleet_field = &self.config_unchecked_field;352 Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))353 }354355 pub(super) fn data(&self) -> MutexGuard<FleetData> {356 self.data.lock().unwrap()357 }358 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {359 self.data.lock().unwrap()360 }361 /// Shared secrets configured in fleet.nix or in flake362 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {363 let config_field = &self.config_unchecked_field;364 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)365 }366 /// Shared secrets configured in fleet.nix367 pub fn list_shared(&self) -> Vec<String> {368 let data = self.data();369 data.shared_secrets.keys().cloned().collect()370 }371 pub fn has_shared(&self, name: &str) -> bool {372 let data = self.data();373 data.shared_secrets.contains_key(name)374 }375 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {376 let mut data = self.data_mut();377 data.shared_secrets.insert(name.to_owned(), shared);378 }379 pub fn remove_shared(&self, secret: &str) {380 let mut data = self.data_mut();381 data.shared_secrets.remove(secret);382 }383384 pub fn list_secrets(&self, host: &str) -> Vec<String> {385 let data = self.data();386 let Some(secrets) = data.host_secrets.get(host) else {387 return Vec::new();388 };389 secrets.keys().cloned().collect()390 }391392 pub fn has_secret(&self, host: &str, secret: &str) -> bool {393 let data = self.data();394 let Some(host_secrets) = data.host_secrets.get(host) else {395 return false;396 };397 host_secrets.contains_key(secret)398 }399 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {400 let mut data = self.data_mut();401 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();402 host_secrets.insert(secret, value);403 }404405 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {406 let data = self.data();407 let Some(host_secrets) = data.host_secrets.get(host) else {408 bail!("no secrets for machine {host}");409 };410 let Some(secret) = host_secrets.get(secret) else {411 bail!("machine {host} has no secret {secret}");412 };413 Ok(secret.clone())414 }415 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {416 let data = self.data();417 let Some(secret) = data.shared_secrets.get(secret) else {418 bail!("no shared secret {secret}");419 };420 Ok(secret.clone())421 }422 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {423 let config_field = &self.config_unchecked_field;424 Ok(nix_go_json!(425 config_field.sharedSecrets[{ secret }].expectedOwners426 ))427 }428429 pub fn save(&self) -> Result<()> {430 let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;431 let data = nixlike::serialize(&self.data() as &FleetData)?;432 tempfile.write_all(433 format!(434 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",435 data436 )437 .as_bytes(),438 )?;439 let mut fleet_data_path = self.directory.clone();440 fleet_data_path.push("fleet.nix");441 tempfile.persist(fleet_data_path)?;442 Ok(())443 }444}445446#[derive(Clone)]447enum HostItem {448 Host {449 name: String,450 attrs: BTreeMap<String, String>,451 },452 Tag {453 name: String,454 attrs: BTreeMap<String, String>,455 },456}457fn host_item_parser(input: &str) -> Result<HostItem, String> {458 fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {459 err.to_string()460 }461462 let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;463 let (input, name) = map(464 take_while1(|v| v != ',' && v != '?' && v != '@'),465 str::to_owned,466 )(input)467 .map_err(err_to_string)?;468469 let kw_item = separated_pair(470 map(take_while1(|v| v != '&' && v != '='), str::to_owned),471 char('='),472 map(take_while1(|v| v != '&'), str::to_owned),473 );474 let kw = map(separated_list1(char('&'), kw_item), |vec| {475 vec.into_iter().collect::<BTreeMap<_, _>>()476 });477 let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);478479 let (input, attrs) = opt_kw(input).map_err(err_to_string)?;480481 if !input.is_empty() {482 return Err(format!("unexpected trailing input: {input:?}"));483 }484 Ok(if is_tag {485 HostItem::Tag { name, attrs }486 } else {487 HostItem::Host { name, attrs }488 })489}490491#[derive(Parser, Clone)]492pub struct FleetOpts {493 /// All hosts except those would be skipped494 #[clap(long, number_of_values = 1, value_parser = host_item_parser)]495 only: Vec<HostItem>,496497 /// Hosts to skip498 #[clap(long, number_of_values = 1)]499 skip: Vec<String>,500501 /// Host, which should be threaten as current machine502 #[clap(long)]503 pub localhost: Option<String>,504505 /// Override detected system for host, to perform builds via506 /// binfmt-declared qemu instead of trying to crosscompile507 #[clap(long, default_value = "detect")]508 pub local_system: String,509}510511impl FleetOpts {512 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {513 if self.localhost.is_none() {514 self.localhost515 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());516 }517 let directory = current_dir()?;518519 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;520 let root_field = pool.get().await?;521522 let builtins_field = Value::binding(root_field.clone(), "builtins").await?;523 if self.local_system == "detect" {524 self.local_system = nix_go_json!(builtins_field.currentSystem);525 }526 let local_system = self.local_system.clone();527528 let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;529 let fleet_field = nix_go!(fleet_root.default);530531 let config_field = nix_go!(fleet_field.config);532 let config_unchecked_field = nix_go!(fleet_field.unchecked.config);533534 let import = nix_go!(builtins_field.import);535 let overlays = nix_go!(config_unchecked_field.overlays);536 let nixpkgs = nix_go!(fleet_field.nixpkgs | import);537538 let default_pkgs = nix_go!(nixpkgs(Obj {539 overlays,540 system: { self.local_system.clone() },541 }));542543 let mut fleet_data_path = directory.clone();544 fleet_data_path.push("fleet.nix");545 let bytes = std::fs::read_to_string(fleet_data_path)?;546 let data = nixlike::parse_str(&bytes)?;547548 Ok(Config(Arc::new(FleetConfigInternals {549 opts: self,550 directory,551 data,552 local_system,553 nix_args,554 config_field,555 config_unchecked_field,556 default_pkgs,557 })))558 }559}1use std::{2 cell::{LazyCell, OnceCell},3 collections::BTreeMap,4 env::current_dir,5 ffi::{OsStr, OsString},6 fmt::Display,7 io::Write,8 ops::Deref,9 path::PathBuf,10 str::FromStr,11 sync::{Arc, Mutex, MutexGuard, OnceLock},12};1314use anyhow::{anyhow, bail, ensure, Context, Result};15use clap::Parser;16use fleet_shared::SecretData;17use nix_eval::{nix_go, nix_go_json, util::assert_warn, NixSessionPool, Value};18use nom::{19 bytes::complete::take_while1,20 character::complete::char,21 combinator::{map, opt},22 multi::separated_list1,23 sequence::{preceded, separated_pair},24};25use openssh::SessionBuilder;26use serde::de::DeserializeOwned;27use tempfile::NamedTempFile;28use tracing::error;2930use crate::{31 command::MyCommand,32 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},33};3435pub struct FleetConfigInternals {36 pub local_system: String,37 pub directory: PathBuf,38 pub opts: FleetOpts,39 pub data: Mutex<FleetData>,40 pub nix_args: Vec<OsString>,41 /// fleet_config.config42 pub config_field: Value,4344 /// import nixpkgs {system = local};45 pub default_pkgs: Value,46}4748#[derive(Clone)]49pub struct Config(Arc<FleetConfigInternals>);5051impl Deref for Config {52 type Target = FleetConfigInternals;5354 fn deref(&self) -> &Self::Target {55 &self.056 }57}5859#[derive(Clone, Copy, Debug)]60pub enum EscalationStrategy {61 Sudo,62 Run0,63 Su,64}6566pub struct ConfigHost {67 config: Config,68 pub name: String,69 pub local: bool,70 pub session: OnceLock<Arc<openssh::Session>>,71 groups: OnceCell<Vec<String>>,7273 pub host_config: Option<Value>,74 pub nixos_config: OnceCell<Value>,75}76impl ConfigHost {77 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {78 // Prefer sudo, as run0 has some gotchas with polkit79 // and too many repeating prompts.80 if let Ok(_) = self.find_in_path("sudo").await {81 return Ok(EscalationStrategy::Sudo);82 }83 if let Ok(_) = self.find_in_path("run0").await {84 return Ok(EscalationStrategy::Run0);85 }86 Ok(EscalationStrategy::Su)87 }88 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,89 // assuming getting tags always returns the same value.90 pub async fn tags(&self) -> Result<Vec<String>> {91 if let Some(v) = self.groups.get() {92 return Ok(v.clone());93 }94 let Some(host_config) = &self.host_config else {95 return Ok(vec![]);96 };97 let tags: Vec<String> = nix_go_json!(host_config.tags);9899 let _ = self.groups.set(tags.clone());100101 Ok(tags)102 }103 pub async fn nixos_config(&self) -> Result<Value> {104 if let Some(v) = self.nixos_config.get() {105 return Ok(v.clone());106 }107 let Some(host_config) = &self.host_config else {108 bail!("local host has no nixos_config");109 };110 let nixos_config = nix_go!(host_config.nixos.config);111 assert_warn("nixos config evaluation", &nixos_config).await?;112113 let _ = self.nixos_config.set(nixos_config.clone());114115 Ok(nixos_config)116 }117 async fn open_session(&self) -> Result<Arc<openssh::Session>> {118 assert!(!self.local, "do not open ssh connection to local session");119 // FIXME: TOCTOU120 if let Some(session) = &self.session.get() {121 return Ok((*session).clone());122 };123 let mut session = SessionBuilder::default();124 let session = session125 .connect(&self.name)126 .await127 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;128 let session = Arc::new(session);129 self.session.set(session.clone()).expect("TOCTOU happened");130 Ok(session)131 }132 pub async fn mktemp_dir(&self) -> Result<String> {133 let mut cmd = self.cmd("mktemp").await?;134 cmd.arg("-d");135 let path = cmd.run_string().await?;136 Ok(path.trim_end().to_owned())137 }138 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {139 let mut cmd = self.cmd("cat").await?;140 cmd.arg(path);141 cmd.run_bytes().await142 }143 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {144 let mut cmd = self.cmd("cat").await?;145 cmd.arg(path);146 cmd.run_string().await147 }148 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {149 let mut cmd = self.cmd("ls").await?;150 cmd.arg(path);151 let out = cmd.run_string().await?;152 let mut lines = out.split('\n');153 if let Some(last) = lines.next_back() {154 ensure!(last.is_empty(), "output of ls should end with newline");155 }156 Ok(lines.map(ToOwned::to_owned).collect())157 }158 #[allow(dead_code)]159 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {160 let text = self.read_file_text(path).await?;161 Ok(serde_json::from_str(&text)?)162 }163 pub async fn read_env(&self, env: &str) -> Result<String> {164 let mut cmd = self.cmd("printenv").await?;165 cmd.arg(env);166 Ok(cmd.run_string().await?)167 }168 pub async fn find_in_path(&self, command: &str) -> Result<String> {169 // // `which` is not a part of coreutils, and it might not exist on machine.170 // let path = self.read_env("PATH").await?;171 // // Assuming delimiter is :, we don't work with windows host, this check will be much172 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)173 // for ele in path.split(':') {174 // let test_path = format!("{ele}/{cmd}");175 // test -x etc176 // }177 // let mut cmd = self.cmd("printenv").await?;178 // cmd.arg(env);179 // Ok(cmd.run_string().await?)180 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.181 let mut cmd = self182 .cmd_escalation(183 // Not used184 EscalationStrategy::Su,185 "which",186 )187 .await?;188 cmd.arg(command);189 cmd.run_string().await190 }191 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>192 where193 <D as FromStr>::Err: Display,194 {195 let text = self.read_file_text(path).await?;196 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))197 }198 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {199 self.cmd_escalation(self.escalation_strategy().await?, cmd)200 .await201 }202 pub async fn cmd_escalation(203 &self,204 escalation: EscalationStrategy,205 cmd: impl AsRef<OsStr>,206 ) -> Result<MyCommand> {207 if self.local {208 Ok(MyCommand::new(escalation, cmd))209 } else {210 let session = self.open_session().await?;211 Ok(MyCommand::new_on(escalation, cmd, session))212 }213 }214215 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {216 ensure!(data.encrypted, "secret is not encrypted");217 let mut cmd = self.cmd("fleet-install-secrets").await?;218 cmd.arg("decrypt").eqarg("--secret", data.to_string());219 let encoded = cmd220 .sudo()221 .run_string()222 .await223 .context("failed to call remote host for decrypt")?;224 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;225 ensure!(!data.encrypted, "secret came out encrypted");226 Ok(data.data)227 }228 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {229 ensure!(data.encrypted, "secret is not encrypted");230 let mut cmd = self.cmd("fleet-install-secrets").await?;231 cmd.arg("reencrypt").eqarg("--secret", data.to_string());232 for target in targets {233 let key = self.config.key(&target).await?;234 cmd.eqarg("--targets", key);235 }236 let encoded = cmd237 .sudo()238 .run_string()239 .await240 .context("failed to call remote host for decrypt")?;241 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;242 ensure!(data.encrypted, "secret came out not encrypted");243 Ok(data)244 }245 /// Returns path for futureproofing, as path might change i.e on conversion to CA246 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {247 if self.local {248 // Path is located locally, thus already trusted.249 return Ok(path.to_owned());250 }251 let mut nix = MyCommand::new(252 // Not used253 EscalationStrategy::Su,254 "nix",255 );256 nix.arg("copy")257 .arg("--substitute-on-destination")258 .comparg("--to", format!("ssh-ng://{}", self.name))259 .arg(path);260 nix.run_nix().await.context("nix copy")?;261 Ok(path.to_owned())262 }263 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {264 let mut cmd = self.cmd("systemctl").await?;265 cmd.arg("stop").arg(name);266 cmd.sudo().run().await267 }268 pub async fn systemctl_start(&self, name: &str) -> Result<()> {269 let mut cmd = self.cmd("systemctl").await?;270 cmd.arg("start").arg(name);271 cmd.sudo().run().await272 }273274 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {275 let mut cmd = self.cmd("rm").await?;276 cmd.arg("-f").arg(path);277 if sudo {278 cmd = cmd.sudo()279 }280 cmd.run().await281 }282283 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {284 let nixos = self.nixos_config().await?;285 let secrets = nix_go!(nixos.secrets);286 let mut out = Vec::new();287 for name in secrets.list_fields().await? {288 let secret = nix_go!(secrets[{ name }]);289 let is_shared: bool = nix_go_json!(secret.shared);290 if is_shared {291 continue;292 }293 out.push(name);294 }295 Ok(out)296 }297 pub async fn secret_field(&self, name: &str) -> Result<Value> {298 let nixos = self.nixos_config().await?;299 Ok(nix_go!(nixos.secrets[{ name }]))300 }301302 /// Packages for this host, resolved with nixpkgs overlays303 pub async fn pkgs(&self) -> Result<Value> {304 let nixos = self.nixos_config().await?;305 Ok(nix_go!(nixos._resolvedPkgs))306 }307}308309impl Config {310 pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {311 if !self.opts.skip.is_empty() && self.opts.skip.iter().any(|h| h as &str == host.name) {312 return Ok(true);313 }314 if self.opts.only.is_empty() {315 return Ok(false);316 }317 let mut have_group_matches = false;318 for item in self.opts.only.iter() {319 match item {320 HostItem::Host { name, .. } if *name == host.name => {321 return Ok(false);322 }323 HostItem::Tag { .. } => {324 have_group_matches = true;325 }326 _ => {}327 }328 }329 if have_group_matches {330 let host_tags = host.tags().await?;331 for item in self.opts.only.iter() {332 match item {333 HostItem::Tag { name, .. } if host_tags.contains(name) => {334 return Ok(false);335 }336 _ => {}337 }338 }339 }340 Ok(true)341 }342 pub async fn action_attr(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {343 if self.opts.only.is_empty() {344 return Ok(None);345 }346 let mut have_group_matches = false;347 for item in self.opts.only.iter() {348 match item {349 HostItem::Host { name, attrs }350 if *name == host.name && attrs.contains_key(attr) =>351 {352 return Ok(attrs.get(attr).cloned());353 }354 HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {355 have_group_matches = true;356 }357 _ => {}358 }359 }360 if have_group_matches {361 let host_tags = host.tags().await?;362 for item in self.opts.only.iter() {363 match item {364 HostItem::Tag { name, attrs }365 if host_tags.contains(name) && attrs.contains_key(attr) =>366 {367 return Ok(attrs.get(attr).cloned());368 }369 _ => {}370 }371 }372 }373 Ok(None)374 }375 pub fn is_local(&self, host: &str) -> bool {376 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)377 }378379 pub fn local_host(&self) -> ConfigHost {380 ConfigHost {381 config: self.clone(),382 name: "<virtual localhost>".to_owned(),383 local: true,384 session: OnceLock::new(),385 host_config: None,386 nixos_config: OnceCell::new(),387 groups: {388 let cell = OnceCell::new();389 let _ = cell.set(vec![]);390 cell391 },392 }393 }394395 pub async fn host(&self, name: &str) -> Result<ConfigHost> {396 let config = &self.config_field;397 let host_config = nix_go!(config.hosts[{ name }]);398399400 Ok(ConfigHost {401 config: self.clone(),402 name: name.to_owned(),403 local: self.is_local(name),404 session: OnceLock::new(),405 host_config: Some(host_config),406 nixos_config: OnceCell::new(),407 groups: OnceCell::new(),408 })409 }410 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {411 let config = &self.config_field;412 let names = nix_go!(config.hosts).list_fields().await?;413 let mut out = vec![];414 for name in names {415 out.push(self.host(&name).await?);416 }417 Ok(out)418 }419 pub async fn system_config(&self, host: &str) -> Result<Value> {420 let fleet_field = &self.config_field;421 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))422 }423424 pub(super) fn data(&self) -> MutexGuard<FleetData> {425 self.data.lock().unwrap()426 }427 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {428 self.data.lock().unwrap()429 }430 /// Shared secrets configured in fleet.nix or in flake431 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {432 let config_field = &self.config_field;433 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)434 }435 /// Shared secrets configured in fleet.nix436 pub fn list_shared(&self) -> Vec<String> {437 let data = self.data();438 data.shared_secrets.keys().cloned().collect()439 }440 pub fn has_shared(&self, name: &str) -> bool {441 let data = self.data();442 data.shared_secrets.contains_key(name)443 }444 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {445 let mut data = self.data_mut();446 data.shared_secrets.insert(name.to_owned(), shared);447 }448 pub fn remove_shared(&self, secret: &str) {449 let mut data = self.data_mut();450 data.shared_secrets.remove(secret);451 }452453 pub fn list_secrets(&self, host: &str) -> Vec<String> {454 let data = self.data();455 let Some(secrets) = data.host_secrets.get(host) else {456 return Vec::new();457 };458 secrets.keys().cloned().collect()459 }460461 pub fn has_secret(&self, host: &str, secret: &str) -> bool {462 let data = self.data();463 let Some(host_secrets) = data.host_secrets.get(host) else {464 return false;465 };466 host_secrets.contains_key(secret)467 }468 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {469 let mut data = self.data_mut();470 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();471 host_secrets.insert(secret, value);472 }473474 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {475 let data = self.data();476 let Some(host_secrets) = data.host_secrets.get(host) else {477 bail!("no secrets for machine {host}");478 };479 let Some(secret) = host_secrets.get(secret) else {480 bail!("machine {host} has no secret {secret}");481 };482 Ok(secret.clone())483 }484 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {485 let data = self.data();486 let Some(secret) = data.shared_secrets.get(secret) else {487 bail!("no shared secret {secret}");488 };489 Ok(secret.clone())490 }491 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {492 let config_field = &self.config_field;493 Ok(nix_go_json!(494 config_field.sharedSecrets[{ secret }].expectedOwners495 ))496 }497498 pub fn save(&self) -> Result<()> {499 let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;500 let data = nixlike::serialize(&self.data() as &FleetData)?;501 tempfile.write_all(502 format!(503 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",504 data505 )506 .as_bytes(),507 )?;508 let mut fleet_data_path = self.directory.clone();509 fleet_data_path.push("fleet.nix");510 tempfile.persist(fleet_data_path)?;511 Ok(())512 }513}514515#[derive(Clone)]516enum HostItem {517 Host {518 name: String,519 attrs: BTreeMap<String, String>,520 },521 Tag {522 name: String,523 attrs: BTreeMap<String, String>,524 },525}526fn host_item_parser(input: &str) -> Result<HostItem, String> {527 fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {528 err.to_string()529 }530531 let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;532 let (input, name) = map(533 take_while1(|v| v != ',' && v != '?' && v != '@'),534 str::to_owned,535 )(input)536 .map_err(err_to_string)?;537538 let kw_item = separated_pair(539 map(take_while1(|v| v != '&' && v != '='), str::to_owned),540 char('='),541 map(take_while1(|v| v != '&'), str::to_owned),542 );543 let kw = map(separated_list1(char('&'), kw_item), |vec| {544 vec.into_iter().collect::<BTreeMap<_, _>>()545 });546 let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);547548 let (input, attrs) = opt_kw(input).map_err(err_to_string)?;549550 if !input.is_empty() {551 return Err(format!("unexpected trailing input: {input:?}"));552 }553 Ok(if is_tag {554 HostItem::Tag { name, attrs }555 } else {556 HostItem::Host { name, attrs }557 })558}559560#[derive(Parser, Clone)]561pub struct FleetOpts {562 /// All hosts except those would be skipped563 #[clap(long, number_of_values = 1, value_parser = host_item_parser)]564 only: Vec<HostItem>,565566 /// Hosts to skip567 #[clap(long, number_of_values = 1)]568 skip: Vec<String>,569570 /// Host, which should be threaten as current machine571 #[clap(long)]572 pub localhost: Option<String>,573574 /// Override detected system for host, to perform builds via575 /// binfmt-declared qemu instead of trying to crosscompile576 #[clap(long, default_value = "detect")]577 pub local_system: String,578}579580impl FleetOpts {581 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {582 if self.localhost.is_none() {583 self.localhost584 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());585 }586 let directory = current_dir()?;587588 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;589 let root_field = pool.get().await?;590591 let builtins_field = Value::binding(root_field.clone(), "builtins").await?;592 if self.local_system == "detect" {593 self.local_system = nix_go_json!(builtins_field.currentSystem);594 }595 let local_system = self.local_system.clone();596597 let mut fleet_data_path = directory.clone();598 fleet_data_path.push("fleet.nix");599 let bytes = std::fs::read_to_string(fleet_data_path)?;600 let data: Mutex<FleetData> = nixlike::parse_str(&bytes)?;601602 let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;603 let fleet_field = nix_go!(fleet_root.default({ data }));604605 let config_field = nix_go!(fleet_field.config);606607 assert_warn("fleet config evaluation", &config_field).await?;608609 let import = nix_go!(builtins_field.import);610 let overlays = nix_go!(config_field.nixpkgs.overlays);611 let nixpkgs = nix_go!(fleet_field.nixpkgs.buildUsing | import);612613 let default_pkgs = nix_go!(nixpkgs(Obj {614 overlays,615 system: { self.local_system.clone() },616 }));617618 Ok(Config(Arc::new(FleetConfigInternals {619 opts: self,620 directory,621 data,622 local_system,623 nix_args,624 config_field,625 default_pkgs,626 })))627 }628}cmds/fleet/src/main.rsdiffbeforeafterboth--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -58,7 +58,7 @@
path.push("file://");
path.push(entry.path());
- let mut status = MyCommand::new("nix");
+ let mut status = config.local_host().cmd("nix").await?;
status.args(&config.nix_args);
status.arg("store").arg("prefetch-file").arg(path);
status.run_nix_string().instrument(span).await?;
crates/nix-eval/Cargo.tomldiffbeforeafterboth--- a/crates/nix-eval/Cargo.toml
+++ b/crates/nix-eval/Cargo.toml
@@ -5,6 +5,7 @@
build = "build.rs"
[dependencies]
+anyhow.workspace = true
better-command.workspace = true
futures = "0.3.30"
itertools = "0.13.0"
crates/nix-eval/src/lib.rsdiffbeforeafterboth--- a/crates/nix-eval/src/lib.rs
+++ b/crates/nix-eval/src/lib.rs
@@ -17,6 +17,7 @@
// Contains macros helpers
#[doc(hidden)]
pub mod macros;
+pub mod util;
// #[allow(non_upper_case_globals, non_camel_case_types, non_snake_case)]
// mod nix_raw {
// include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
crates/nix-eval/src/util.rsdiffbeforeafterboth--- /dev/null
+++ b/crates/nix-eval/src/util.rs
@@ -0,0 +1,30 @@
+use anyhow::bail;
+use tracing::{debug, warn};
+use std::time::Instant;
+
+use crate::{nix_go_json, Value};
+
+pub async fn assert_warn(action: &str, val: &Value) -> anyhow::Result<()> {
+ let before_errors = Instant::now();
+ let errors: Vec<String> = nix_go_json!(val.errors);
+ debug!("errors evaluation took {:?}", before_errors.elapsed());
+ if !errors.is_empty() {
+ bail!(
+ "{action} failed with error{}{}",
+ (errors.len() != 1).then_some("s:\n- ").unwrap_or(": "),
+ errors.join("\n- "),
+ );
+ }
+
+ let before_errors = Instant::now();
+ let warnings: Vec<String> = nix_go_json!(val.warnings);
+ debug!("warnings evaluation took {:?}", before_errors.elapsed());
+ if !warnings.is_empty() {
+ warn!(
+ "{action} completed with warning{}{}",
+ (warnings.len() != 1).then_some("s:\n- ").unwrap_or(": "),
+ warnings.join("\n- "),
+ );
+ }
+ Ok(())
+}
crates/nix-eval/src/value.rsdiffbeforeafterboth--- a/crates/nix-eval/src/value.rs
+++ b/crates/nix-eval/src/value.rs
@@ -44,14 +44,14 @@
let v = nixlike::format_identifier(k.as_str());
write!(f, ".{v}")
}
- Index::Apply(o) => {
- write!(f, "<apply>({o})")
+ Index::Apply(_) => {
+ write!(f, "<apply>(...)")
}
Index::Expr(e) => {
write!(f, "[{}]", e.out)
}
- Index::ExprApply(e) => {
- write!(f, "<apply>({})", e.out)
+ Index::ExprApply(_) => {
+ write!(f, "<apply>(...)")
}
Index::Pipe(e) => {
write!(f, "<map>({})", e.out)
flake.nixdiffbeforeafterboth--- a/flake.nix
+++ b/flake.nix
@@ -25,18 +25,23 @@
flake-parts.lib.mkFlake {
inherit inputs;
} {
- flake = let
- inherit (inputs.nixpkgs.lib) mapAttrs;
- in {
- lib = import ./lib {
- fleetPkgsForPkgs = pkgs:
- import ./pkgs {
- inherit (pkgs) callPackage;
- craneLib = crane.mkLib pkgs;
- };
- };
+ flake = rec {
+ lib =
+ (import ./lib {
+ inherit (inputs.nixpkgs) lib;
+ })
+ // {
+ fleetConfiguration = throw "function-based interface is deprecated, use flake-parts syntax instead";
+ };
+ flakeModules.default = (import ./lib/flakePart.nix {
+ inherit crane;
+ });
+ flakeModule = flakeModules.default;
+
# To be used with https://github.com/NixOS/nix/pull/8892
- schemas = {
+ schemas = let
+ inherit (inputs.nixpkgs.lib) mapAttrs;
+ in {
fleetConfigurations = {
version = 1;
doc = ''
@@ -69,7 +74,8 @@
pkgs,
...
}: let
- inherit (lib) mapAttrs' elem;
+ inherit (lib.attrSets) mapAttrs';
+ inherit (lib.lists) elem;
# Can also be built for darwin, through it is not usual to deploy nixos systems from macos machines.
# I have no hardware for such testing, thus only adding machines I actually have and use.
#
@@ -108,6 +114,7 @@
pkg-config
openssl
bacon
+ nil
];
};
};
lib/default.nixdiffbeforeafterboth--- a/lib/default.nix
+++ b/lib/default.nix
@@ -1,60 +1,121 @@
-{fleetPkgsForPkgs}: {
- fleetConfiguration = {
- # TODO: Provide by fleet, instead of requesting user to provide it.
- # This is not good that user needs to provide it, as it becomes a flake data, and fleet arbitrarily rewriting it
- # always dirnets the flake. Instead, fleetConfiguration should return function, parameters of which should be filled
- # by fleet itself, which is possible since fleet moving to nix repl execution.
- data,
- nixpkgs,
- overlays ? [],
- hosts,
- fleetModules,
- nixosModules ? [],
- extraFleetLib ? {},
- }: let
- hostNames = nixpkgs.lib.attrNames hosts;
- fleetLib =
- (import ./fleetLib.nix {
- inherit nixpkgs hostNames;
- })
- // extraFleetLib;
- in let
- root = nixpkgs.lib.evalModules {
- modules =
- (import ../modules/fleet/_modules.nix)
- ++ [
- data
- ({...}: {
- inherit nixosModules hosts;
- overlays = [(final: prev: (fleetPkgsForPkgs final))] ++ overlays;
- })
- ]
- ++ fleetModules;
- specialArgs = {
- inherit nixpkgs fleetLib;
+# Shared functions for fleet configuration, available as `fleet` module argument
+{lib}: let
+ inherit (lib.trivial) isFunction;
+ inherit (lib.options) mkOption mergeOneOption;
+ inherit (lib.modules) mkOverride;
+ inherit (lib.types) listOf submodule attrsOf mkOptionType;
+ inherit (lib.strings) optionalString;
+in rec {
+ types = {
+ overlay = mkOptionType {
+ name = "nixpkgs-overlay";
+ description = "nixpkgs overlay";
+ check = isFunction;
+ merge = mergeOneOption;
+ };
+ listOfOverlay = listOf types.overlay;
+
+ mkHostsType = module: attrsOf (submodule module);
+ };
+
+ options = {
+ mkHostsOption = module:
+ mkOption {
+ type = types.mkHostsType module;
};
- };
- failedAssertions = map (x: x.message) (nixpkgs.lib.filter (x: !x.assertion) root.config.assertions);
- checkedRoot =
- if failedAssertions != []
- then throw "Fleet failed assertions:\n${nixpkgs.lib.concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}"
- else nixpkgs.lib.showWarnings root.config.warnings root;
- withData = {
- root,
- data,
- }: {
- config = root.config;
- };
- defaultData = withData {
- inherit data;
- root = checkedRoot;
- };
- uncheckedData = withData {inherit data root;};
- in {
- inherit nixpkgs overlays;
- inherit (defaultData) config;
- unchecked = {
- inherit (uncheckedData) config;
- };
};
+
+ inherit (options) mkHostsOption;
+
+ modules = {
+ # mkDefault = mkOverride 1000
+ # For places, where fleet knows better than nixpkgs defaults.
+ mkFleetDefault = mkOverride 999;
+ # Some generators use mkDefault, but optionDefault is set by nixpkgs.
+ mkFleetGeneratorDefault = mkOverride 1001;
+ };
+
+ inherit (modules) mkFleetDefault mkFleetGeneratorDefault;
+
+ secrets = {
+ mkPassword = {size ? 32}: {
+ coreutils,
+ mkSecretGenerator,
+ ...
+ }:
+ mkSecretGenerator {
+ script = ''
+ mkdir $out
+ gh generate password -o $out/secret --size ${toString size}
+ '';
+ };
+
+ mkEd25519 = {
+ noEmbedPublic ? false,
+ encoding ? null,
+ }: {mkSecretGenerator, ...}:
+ mkSecretGenerator {
+ script = ''
+ mkdir $out
+ gh generate ed25519 -p $out/public -s $out/secret \
+ ${optionalString noEmbedPublic "--no-embed-public"} \
+ ${optionalString (encoding != null) "--encoding=${encoding}"}
+ '';
+ };
+
+ mkX25519 = {encoding ? null}: {mkSecretGenerator, ...}:
+ mkSecretGenerator {
+ script = ''
+ mkdir $out
+ gh generate x25519 -p $out/public -s $out/secret \
+ ${optionalString (encoding != null) "--encoding=${encoding}"}
+ '';
+ };
+
+ mkRsa = {size ? 4096}: {
+ openssl,
+ mkSecretGenerator,
+ ...
+ }:
+ mkSecretGenerator {
+ script = ''
+ mkdir $out
+
+ ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
+ ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
+
+ cat rsa_private.key | gh private -o $out/secret
+ cat rsa_public.key | gh public -o $out/public
+ '';
+ };
+
+ mkBytes = {
+ count ? 32,
+ encoding,
+ noNuls ? false,
+ }: {mkSecretGenerator, ...}:
+ mkSecretGenerator {
+ script = ''
+ mkdir $out
+ gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
+ ${optionalString noNuls "--no-nuls"}
+ '';
+ };
+ mkHexBytes = {count ? 32}:
+ mkBytes {
+ inherit count;
+ encoding = "hex";
+ };
+ mkBase64Bytes = {count ? 32}:
+ mkBytes {
+ inherit count;
+ encoding = "base64";
+ };
+
+ # Wireguard
+ # mkWireguard = {}: mkX25519 {encoding = "base64";};
+ # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
+ };
+
+ inherit (secrets) mkPassword mkEd25519 mkX25519 mkRsa mkBytes mkHexBytes mkBase64Bytes;
}
lib/flakePart.nixdiffbeforeafterboth--- /dev/null
+++ b/lib/flakePart.nix
@@ -0,0 +1,70 @@
+{crane}: {
+ fleetLib,
+ lib,
+ config,
+ ...
+}: let
+ inherit (lib.options) mkOption;
+ inherit (lib.attrsets) mapAttrs;
+ inherit (lib.types) lazyAttrsOf deferredModule unspecified;
+ inherit (fleetLib.options) mkHostsOption;
+in {
+ options.fleetModules = mkOption {
+ type = lazyAttrsOf unspecified;
+ default = {};
+ };
+ options.fleetConfigurations = mkOption {
+ type = lazyAttrsOf deferredModule;
+ apply = nameToModule:
+ mapAttrs (
+ name: module: data: let
+ # To use user-provided nixpkgs, we first need to extract wanted nixpkgs attribute,
+ # to do that, evaluate all the modules with only needed option declared.
+ bootstrapEval = lib.evalModules {
+ modules = [
+ module
+ {
+ options.nixpkgs.buildUsing = mkOption {
+ description = ''
+ Nixpkgs to use for fleetConfiguration evaluation.
+ '';
+ };
+ config._module.check = false;
+ }
+ ];
+ };
+ bootstrapNixpkgs = bootstrapEval.config.nixpkgs.buildUsing;
+ normalEval = bootstrapNixpkgs.lib.evalModules {
+ modules =
+ (import ../modules/fleet/_modules.nix)
+ ++ [
+ data
+ module
+ {
+ options.hosts = mkHostsOption {
+ nixos.nixpkgs.overlays = [
+ (final: prev: {
+ # FIXME: make this name not conflicting
+ craneLib = crane.mkLib prev;
+ })
+ ];
+ };
+ }
+ ];
+ specialArgs.fleetLib = import ../lib {
+ inherit (bootstrapNixpkgs) lib;
+ };
+ };
+ in
+ normalEval
+ )
+ nameToModule;
+ };
+ config = {
+ _module.args.fleetLib = import ../lib {inherit lib;};
+ flake.fleetConfigurations = config.fleetConfigurations;
+ flake.fleetModules = config.fleetModules;
+ };
+
+ _file = ./flakePart.nix;
+}
lib/fleetLib.nixdiffbeforeafterboth--- a/lib/fleetLib.nix
+++ /dev/null
@@ -1,144 +0,0 @@
-# Shared functions for fleet configuration, available as `fleet` module argument
-{
- nixpkgs,
- hostNames,
-}: let
- inherit (nixpkgs) lib;
- inherit (lib) listToAttrs remove unique crossLists sort elemAt mkOptionType mkOverride optionalString;
- inherit (lib.types) listOf coercedTo oneOf submodule;
-in rec {
- hostsToAttrs = f:
- listToAttrs (
- map (name: {
- inherit name;
- value = f name;
- })
- hostNames
- );
- hostsCartesian = remove null (
- unique (
- crossLists
- (
- a: b:
- if a == b
- then null
- else hostsPair a b
- ) [hostNames hostNames]
- )
- );
- hostsPair = this: other: let
- sorted = sort (a: b: a < b) [this other];
- in {
- a = elemAt sorted 0;
- b = elemAt sorted 1;
- };
- hostPairName = this: other:
- if this < other
- then "${this}-${other}"
- else "${other}-${this}";
-
- types = rec {
- anyModule = mkOptionType {
- name = "submodule";
- inherit (submodule {}) check;
- merge = lib.options.mergeOneOption;
- description = "Nixos module";
- };
- listOfAnyModuleStrict =
- listOf anyModule;
- listOfAnyModule =
- coercedTo (oneOf [listOfAnyModuleStrict anyModule]) (
- v:
- if builtins.isAttrs v
- then [v]
- else if builtins.isFunction v
- then [v]
- else v
- )
- listOfAnyModuleStrict;
- };
-
- # mkDefault = mkOverride 1000
- # For places, where fleet knows better than nixpkgs defaults.
- mkFleetDefault = mkOverride 999;
- # Some generators use mkDefault, but optionDefault is set by nixpkgs.
- mkFleetGeneratorDefault = mkOverride 1001;
-
- mkPassword = {size ? 32}: {
- coreutils,
- mkSecretGenerator,
- ...
- }:
- mkSecretGenerator {
- script = ''
- mkdir $out
- gh generate password -o $out/secret --size ${toString size}
- '';
- };
-
- mkEd25519 = {
- noEmbedPublic ? false,
- encoding ? null,
- }: {mkSecretGenerator, ...}:
- mkSecretGenerator {
- script = ''
- mkdir $out
- gh generate ed25519 -p $out/public -s $out/secret \
- ${optionalString noEmbedPublic "--no-embed-public"} \
- ${optionalString (encoding != null) "--encoding=${encoding}"}
- '';
- };
-
- mkX25519 = {encoding ? null}: {mkSecretGenerator, ...}:
- mkSecretGenerator {
- script = ''
- mkdir $out
- gh generate x25519 -p $out/public -s $out/secret \
- ${optionalString (encoding != null) "--encoding=${encoding}"}
- '';
- };
-
- mkRsa = {size ? 4096}: {
- openssl,
- mkSecretGenerator,
- ...
- }:
- mkSecretGenerator {
- script = ''
- mkdir $out
-
- ${openssl}/bin/openssl genrsa -out rsa_private.key ${toString size}
- ${openssl}/bin/openssl rsa -in rsa_private.key -pubout -out rsa_public.key
-
- cat rsa_private.key | gh private -o $out/secret
- cat rsa_public.key | gh public -o $out/public
- '';
- };
-
- mkBytes = {
- count ? 32,
- encoding,
- noNuls ? false,
- }: {mkSecretGenerator, ...}:
- mkSecretGenerator {
- script = ''
- mkdir $out
- gh generate bytes --count=${toString count} --encoding=${encoding} -o $out/secret \
- ${optionalString noNuls "--no-nuls"}
- '';
- };
- mkHexBytes = {count ? 32}:
- mkBytes {
- inherit count;
- encoding = "hex";
- };
- mkBase64Bytes = {count ? 32}:
- mkBytes {
- inherit count;
- encoding = "base64";
- };
-
- # Wireguard
- # mkWireguard = {}: mkX25519 {encoding = "base64";};
- # mkWireguardPsk = {}: mkBase64Bytes {count = 32;};
-}
modules/fleet/_modules.nixdiffbeforeafterboth--- a/modules/fleet/_modules.nix
+++ b/modules/fleet/_modules.nix
@@ -1,5 +1,9 @@
[
./assertions.nix
+ ./fleetLib.nix
+ ./hosts.nix
./meta.nix
+ ./nixos.nix
+ ./nixpkgs.nix
./secrets.nix
]
modules/fleet/assertions.nixdiffbeforeafterboth--- a/modules/fleet/assertions.nix
+++ b/modules/fleet/assertions.nix
@@ -1,6 +1,11 @@
-{lib, ...}: let
- inherit (lib) mkOption;
+{
+ lib,
+ config,
+ ...
+}: let
+ inherit (lib.options) mkOption;
inherit (lib.types) listOf unspecified str;
+ inherit (lib.lists) map filter;
in {
options = {
assertions = mkOption {
@@ -30,6 +35,15 @@
the evaluation of the system configuration.
'';
};
+ errors = mkOption {
+ type = listOf str;
+ internal = true;
+ description = ''
+ Similar to warnings, however build will fail if any error exists.
+ '';
+ };
};
- # impl of assertions is in <fleet/lib/default.nix>
+ config.errors =
+ map (v: v.message)
+ (filter (v: !v.assertion) config.assertions);
}
modules/fleet/fleetLib.nixdiffbeforeafterboth--- /dev/null
+++ b/modules/fleet/fleetLib.nix
@@ -0,0 +1,9 @@
+{
+ lib,
+ config,
+ ...
+}: {
+ _module.args.fleetLib = import ../../lib {
+ inherit lib;
+ };
+}
modules/fleet/hosts.nixdiffbeforeafterboth--- /dev/null
+++ b/modules/fleet/hosts.nix
@@ -0,0 +1,40 @@
+{
+ lib,
+ fleetLib,
+ ...
+}: let
+ inherit (fleetLib.modules) mkFleetGeneratorDefault;
+ inherit (fleetLib.types) mkHostsType;
+ inherit (lib.options) mkOption;
+ inherit (lib.types) str listOf;
+in {
+ options = {
+ hosts = mkOption {
+ type = mkHostsType ({config, ...}: {
+ options = {
+ system = mkOption {
+ type = str;
+ description = "Type of the system.";
+ };
+ # TODO: This is part of fleet.nix, move it to separate toplevel data config option.
+ encryptionKey = mkOption {
+ type = str;
+ description = "Rage SSH encryption key for secrets.";
+ };
+ tags = mkOption {
+ type = listOf str;
+ description = "Host tag. In CLI, you can refer to all hosts having this tag using @tag syntax.";
+ };
+ };
+ config = {
+ nixos.networking.hostName = mkFleetGeneratorDefault config._module.args.name;
+ tags = ["all"];
+ };
+ _file = ./meta.nix;
+ });
+ default = {};
+ description = "Configurations of individual hosts";
+ };
+ };
+ _file = ./meta.nix;
+}
modules/fleet/meta.nixdiffbeforeafterboth--- a/modules/fleet/meta.nix
+++ b/modules/fleet/meta.nix
@@ -1,89 +1,8 @@
-{
- lib,
- fleetLib,
- config,
- nixpkgs,
- ...
-}: let
- inherit (fleetLib) hostsToAttrs mkFleetGeneratorDefault;
- inherit (fleetLib.types) listOfAnyModule;
- inherit (lib) mkOption mkOptionType;
- inherit (lib.types) str unspecified attrsOf listOf submodule;
- hostModule = {...} @ hostConfig: let
- hostName = hostConfig.config._module.args.name;
- in {
- options = {
- nixosModules = mkOption {
- # Not too strict, but nixos module system will fix everything.
- type =
- listOfAnyModule;
-
- description = "List of nixos modules";
- default = [];
- };
- system = mkOption {
- type = str;
- description = "Type of system";
- };
- encryptionKey = mkOption {
- type = str;
- description = "Encryption key";
- };
- nixosSystem = mkOption {
- type = unspecified;
- description = "Nixos configuration";
- };
- nixpkgs = mkOption {
- type = unspecified;
- description = "Nixpkgs override";
- default = nixpkgs;
- };
- };
- config = {
- nixosSystem = hostConfig.config.nixpkgs.lib.nixosSystem {
- inherit (hostConfig.config) system;
- modules = hostConfig.config.nixosModules;
- specialArgs = {
- inherit fleetLib;
- fleet = hostsToAttrs (host: config.hosts.${host}.nixosSystem.config);
- };
- };
- nixosModules.networking.hostName = mkFleetGeneratorDefault hostName;
- };
- };
- overlayType = mkOptionType {
- name = "nixpkgs-overlay";
- description = "nixpkgs overlay";
- check = lib.isFunction;
- merge = lib.mergeOneOption;
- };
+{lib, ...}: let
+ inherit (lib.modules) mkRemovedOptionModule;
in {
- options = {
- hosts = mkOption {
- type = attrsOf (submodule hostModule);
- default = {};
- description = "Configurations of individual hosts";
- };
- nixosModules = mkOption {
- type = listOfAnyModule;
- description = "Modules, which should be added to every system";
- default = [];
- };
- overlays = mkOption {
- default = [];
- type = listOf overlayType;
- };
- };
- config = {
- hosts = hostsToAttrs (host: {
- nixosModules =
- config.nixosModules
- ++ [
- {
- nixpkgs.overlays = config.overlays;
- }
- ];
- });
- nixosModules = import ../../nixos/modules/module-list.nix;
- };
+ imports = [
+ (mkRemovedOptionModule ["fleetModules"] "replaced with imports.")
+ (mkRemovedOptionModule ["data"] "data is now provided by fleet itself, you can remove your import.")
+ ];
}
modules/fleet/nixos.nixdiffbeforeafterboth--- /dev/null
+++ b/modules/fleet/nixos.nix
@@ -0,0 +1,55 @@
+{
+ lib,
+ fleetLib,
+ config,
+ ...
+}: let
+ inherit (lib.attrsets) mapAttrs;
+ inherit (lib.options) mkOption;
+ inherit (lib.types) deferredModule deferredModuleWith;
+ inherit (lib.modules) mkRemovedOptionModule;
+ inherit (fleetLib.options) mkHostsOption;
+
+ _file = ./nixos.nix;
+in {
+ options = {
+ nixos = mkOption {
+ description = ''
+ Nixos configuration for all hosts.
+ '';
+ type = deferredModule;
+ };
+ hosts = mkHostsOption (hostArgs: {
+ inherit _file;
+ options = {
+ nixos = mkOption {
+ description = ''
+ Nixos configuration for the current host.
+ '';
+ type = deferredModuleWith {
+ staticModules = import ../../nixos/modules/module-list.nix;
+ };
+ apply = module:
+ config.nixpkgs.buildUsing.lib.nixosSystem {
+ inherit (hostArgs.config) system;
+ modules = [module];
+ };
+ };
+ };
+ config = {
+ # imports = [
+ # (mkRemovedOptionModule ["nixosModules"] "replaced with hosts.*.nixos.imports.")
+ # ];
+ nixos = {
+ imports = [
+ config.nixos
+ ];
+ config._module.args.fleet = mapAttrs (_: value: value.nixos.config) config.hosts;
+ };
+ };
+ });
+ };
+ imports = [
+ (mkRemovedOptionModule ["nixosModules"] "replaced with nixos.imports.")
+ ];
+}
modules/fleet/nixpkgs.nixdiffbeforeafterboth--- /dev/null
+++ b/modules/fleet/nixpkgs.nix
@@ -0,0 +1,58 @@
+{
+ lib,
+ fleetLib,
+ config,
+ ...
+}: let
+ inherit (lib.options) mkOption;
+ inherit (lib.types) path;
+ inherit (lib.modules) mkRemovedOptionModule;
+ inherit (fleetLib.options) mkHostsOption;
+ inherit (fleetLib.types) listOfOverlay;
+
+ _file = ./nixpkgs.lib;
+in {
+ options = {
+ nixpkgs = {
+ buildUsing = mkOption {
+ description = ''
+ Default nixpkgs to use for building the systems.
+ '';
+ type = path;
+ };
+ overlays = mkOption {
+ description = ''
+ Package overlays to apply for all the hosts, gets propagated into
+ `hosts.*.nixosModules.nixpkgs.overlays`.
+ '';
+ type = listOfOverlay;
+ };
+ };
+ hosts = mkHostsOption {
+ inherit _file;
+ options.nixpkgs.buildUsing = mkOption {
+ description = ''
+ Nixpkgs to use for building the system.
+
+ Note that this option is defined at the host level, not the nixosModules level,
+ nixosModules will be evaluated using this flake input.
+ '';
+ type = path;
+ default = config.nixpkgs.buildUsing;
+ };
+ # imports = [
+ # (mkRemovedOptionModule ["nixpkgs" "overlays"] "this option needs to be specified at nixosModules level")
+ # ];
+ config.nixos = {
+ inherit _file;
+ nixpkgs.overlays = config.nixpkgs.overlays;
+ imports = [
+ (mkRemovedOptionModule ["nixpkgs" "buildUsing"] "this option should be specified at the host level, not the nixosModules level")
+ ];
+ };
+ };
+ };
+ config.nixpkgs.overlays = [
+ (final: prev: import ../../pkgs {inherit (final) callPackage craneLib;})
+ ];
+}
modules/fleet/secrets.nixdiffbeforeafterboth--- a/modules/fleet/secrets.nix
+++ b/modules/fleet/secrets.nix
@@ -4,9 +4,12 @@
config,
...
}: let
- inherit (fleetLib) hostsToAttrs;
- inherit (lib) mkOption mapAttrsToList mapAttrs filterAttrs concatStringsSep;
+ inherit (fleetLib.options) mkHostsOption;
+ inherit (lib.options) mkOption;
inherit (lib.types) lazyAttrsOf unspecified nullOr listOf str bool attrsOf submodule;
+ inherit (lib.lists) sort elem;
+ inherit (lib.attrsets) mapAttrsToList mapAttrs filterAttrs;
+ inherit (lib.strings) toJSON concatStringsSep;
sharedSecret = {config, ...}: {
freeformType = lazyAttrsOf unspecified;
@@ -82,11 +85,11 @@
};
};
};
+ inherit (config) hostSecrets sharedSecrets;
in {
options = {
version = mkOption {
type = str;
- default = "";
internal = true;
};
sharedSecrets = mkOption {
@@ -100,36 +103,34 @@
description = "Host secrets. Imported from fleet.nix";
internal = true;
};
+ hosts = mkHostsOption ({config, ...}: {
+ nixos = {
+ secrets = let
+ host = config._module.args.name;
+ processSecret = v:
+ (removeAttrs v ["createdAt" "expiresAt" "expectedOwners" "owners" "regenerateOnOwnerAdded" "regenerateOnOwnerRemoved"])
+ // {
+ shared = true;
+ };
+ in
+ (
+ mapAttrs (_: processSecret)
+ (filterAttrs (_: v: elem host v.owners) sharedSecrets)
+ )
+ // (mapAttrs (_: processSecret) (hostSecrets.${host} or {}));
+ _file = ./secrets.nix;
+ };
+ });
};
config = {
assertions =
mapAttrsToList
(name: secret: {
- assertion = secret.expectedOwners == null || builtins.sort (a: b: a < b) secret.owners == builtins.sort (a: b: a < b) secret.expectedOwners;
- message = "Shared secret ${name} is expected to be encrypted for ${builtins.toJSON secret.expectedOwners}, but it is encrypted for ${builtins.toJSON secret.owners}. Run fleet secrets regenerate to fix";
+ assertion = secret.expectedOwners == null || sort (a: b: a < b) secret.owners == sort (a: b: a < b) secret.expectedOwners;
+ message = "Shared secret ${name} is expected to be encrypted for ${toJSON secret.expectedOwners}, but it is encrypted for ${toJSON secret.owners}. Run fleet secrets regenerate to fix";
})
config.sharedSecrets;
- hosts = hostsToAttrs (host: {
- nixosModules = let
- # processPart
- processSecret = v:
- (removeAttrs v ["createdAt" "expiresAt" "expectedOwners" "owners" "regenerateOnOwnerAdded" "regenerateOnOwnerRemoved"])
- // {
- shared = true;
- };
- in [
- {
- secrets =
- (
- mapAttrs (_: processSecret)
- (filterAttrs (_: v: builtins.elem host v.owners) config.sharedSecrets)
- )
- // (mapAttrs (_: processSecret) (config.hostSecrets.${host} or {}));
- }
- ];
- });
- # TODO: Should this attribute be moved to `nixpkgs.overlays`?
- overlays = [
+ nixpkgs.overlays = [
(final: prev: {
mkSecretGenerators = {recipients}: rec {
# TODO: Merge both generators to one with consistent options syntax?
nixos/assertions.nixdiffbeforeafterboth--- /dev/null
+++ b/nixos/assertions.nix
@@ -0,0 +1,24 @@
+# Similar module exists for fleet, however it also defines assertions and warnings,
+# which are already defined for nixos.
+{
+ lib,
+ config,
+ ...
+}: let
+ inherit (lib.options) mkOption;
+ inherit (lib.lists) map filter;
+ inherit (lib.types) listOf str;
+in {
+ options = {
+ errors = mkOption {
+ type = listOf str;
+ internal = true;
+ description = ''
+ Similar to warnings, however build will fail if any error exists.
+ '';
+ };
+ };
+ config.errors =
+ map (v: v.message)
+ (filter (v: !v.assertion) config.assertions);
+}
nixos/meta.nixdiffbeforeafterboth--- a/nixos/meta.nix
+++ b/nixos/meta.nix
@@ -3,18 +3,16 @@
pkgs,
...
}: let
- inherit (lib) mkOption;
+ inherit (lib.options) mkOption;
inherit (lib.types) listOf str submodule;
+ inherit (lib.modules) mkRemovedOptionModule;
in {
options = {
- nixpkgs.resolvedPkgs = mkOption {
+ # TODO: Give a real name.
+ # Previously it was nixpkgs.resolvedPkgs, which was erroreously merged with nixpkgs override attribute.
+ _resolvedPkgs = mkOption {
type = lib.types.pkgs // {description = "nixpkgs.pkgs";};
description = "Value of pkgs";
- };
- tags = mkOption {
- type = listOf str;
- description = "Host tags";
- default = [];
};
network = mkOption {
type = submodule {
@@ -34,9 +32,11 @@
description = "Network definition of host";
};
};
+ imports = [
+ (mkRemovedOptionModule ["tags"] "tags are now defined at the host level, not the nixos system level for fast filtering without evaluating unnecessary hosts.")
+ ];
config = {
- tags = ["all"];
network = {};
- nixpkgs.resolvedPkgs = pkgs;
+ _resolvedPkgs = pkgs;
};
}
nixos/modules/module-list.nixdiffbeforeafterboth--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -1,4 +1,5 @@
[
+ ../assertions.nix
../meta.nix
../secrets.nix
../rollback.nix
nixos/nix-sign.nixdiffbeforeafterboth--- a/nixos/nix-sign.nix
+++ b/nixos/nix-sign.nix
@@ -1,7 +1,12 @@
# Required for nix copy in build_systems.rs
-{config, ...}: {
+{lib, config, ...}:
+let
+ inherit (lib.modules) mkIf;
+ hasPersistentHostname = config.networking.hostName != "";
+in
+{
# https://github.com/NixOS/nix/issues/3023
- systemd.services.generate-nix-cache-key = {
+ systemd.services.generate-nix-cache-key = mkIf hasPersistentHostname {
wantedBy = ["multi-user.target"];
serviceConfig.Type = "oneshot";
path = [config.nix.package];
@@ -10,5 +15,5 @@
nix-store --generate-binary-cache-key ${config.networking.hostName}-1 /etc/nix/private-key /etc/nix/public-key
'';
};
- nix.settings.secret-key-files = "/etc/nix/private-key";
+ nix.settings.secret-key-files = mkIf hasPersistentHostname "/etc/nix/private-key";
}
nixos/secrets.nixdiffbeforeafterboth--- a/nixos/secrets.nix
+++ b/nixos/secrets.nix
@@ -5,7 +5,11 @@
...
}: let
inherit (lib.strings) hasPrefix removePrefix;
- inherit (lib) mkOption mkOptionDefault mapAttrs stringAfter;
+ inherit (lib.stringsWithDeps) stringAfter;
+ inherit (lib.options) mkOption;
+ inherit (lib.lists) optional;
+ inherit (lib.attrsets) mapAttrs;
+ inherit (lib.modules) mkOptionDefault mkIf;
inherit (lib.types) submodule str attrsOf nullOr unspecified lazyAttrsOf;
plaintextPrefix = "<PLAINTEXT>";
plaintextNewlinePrefix = "<PLAINTEXT-NL>";
@@ -110,6 +114,7 @@
builtins.toJSON (mapAttrs (_: processSecret)
config.secrets);
};
+ useSysusers = (config.systemd ? sysusers && config.systemd.sysusers.enable) || (config ? userborn && config.userborn.enable);
in {
options = {
secrets = mkOption {
@@ -120,21 +125,44 @@
};
config = {
environment.systemPackages = [pkgs.fleet-install-secrets];
+
+ systemd.services.fleet-install-secrets = mkIf useSysusers {
+ wantedBy = ["sysinit.target"];
+ after = ["systemd-sysusers.service"];
+ restartTriggers = [
+ secretsFile
+ ];
+ aliases = [
+ "sops-install-secrets"
+ "agenix-install-secrets"
+ ];
+
+ unitConfig.DefaultDependencies = false;
+
+ serviceConfig = {
+ Type = "oneshot";
+ RemainAfterExit = true;
+ ExecStart = "${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}";
+ };
+ };
system.activationScripts.decryptSecrets =
- stringAfter (
- [
- # secrets are owned by user/group, thus we need to refer to those
- "users"
- "groups"
- "specialfs"
- ]
- # nixos-impermanence compatibility: secrets are encrypted by host-key,
- # but with impermanence we expect that the host-key is installed by
- # persist-file activation script.
- ++ (lib.optional (config.system.activationScripts ? "persist-files") "persist-files")
- ) ''
- 1>&2 echo "setting up secrets"
- ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}
- '';
+ mkIf (!useSysusers)
+ (
+ stringAfter (
+ [
+ # secrets are owned by user/group, thus we need to refer to those
+ "users"
+ "groups"
+ "specialfs"
+ ]
+ # nixos-impermanence compatibility: secrets are encrypted by host-key,
+ # but with impermanence we expect that the host-key is installed by
+ # persist-file activation script.
+ ++ (optional (config.system.activationScripts ? "persist-files") "persist-files")
+ ) ''
+ 1>&2 echo "setting up secrets"
+ ${pkgs.fleet-install-secrets}/bin/fleet-install-secrets install ${secretsFile}
+ ''
+ );
};
}