git.delta.rocks / jrsonnet / refs/commits / cc4ecd613a27

difftreelog

refactor declare configuration using flake parts

Yaroslav Bolyukin2024-08-14parent: #f779c26.patch.diff
in: trunk

29 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1432,6 +1432,7 @@
 name = "nix-eval"
 version = "0.1.0"
 dependencies = [
+ "anyhow",
  "better-command",
  "futures",
  "itertools",
modifiedREADME.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.
modifiedcmds/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;
modifiedcmds/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;
modifiedcmds/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() {
modifiedcmds/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
 	}
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
before · cmds/fleet/src/host.rs
1use 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}
after · cmds/fleet/src/host.rs
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}
modifiedcmds/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?;
modifiedcrates/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"
modifiedcrates/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"));
addedcrates/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(())
+}
modifiedcrates/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)
modifiedflake.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
             ];
           };
         };
modifiedlib/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;
 }
addedlib/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;
+}
deletedlib/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;};
-}
modifiedmodules/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
 ]
modifiedmodules/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);
 }
addedmodules/fleet/fleetLib.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/fleet/fleetLib.nix
@@ -0,0 +1,9 @@
+{
+  lib,
+  config,
+  ...
+}: {
+  _module.args.fleetLib = import ../../lib {
+    inherit lib;
+  };
+}
addedmodules/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;
+}
modifiedmodules/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.")
+  ];
 }
addedmodules/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.")
+  ];
+}
addedmodules/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;})
+  ];
+}
modifiedmodules/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?
addednixos/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);
+}
modifiednixos/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;
   };
 }
modifiednixos/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
modifiednixos/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";
 }
modifiednixos/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}
+        ''
+      );
   };
 }