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

difftreelog

refactor split build-systems and deploy commands

Yaroslav Bolyukin2024-01-05parent: #718d88b.patch.diff
in: trunk

10 files changed

modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,3 +5,5 @@
 [workspace.dependencies]
 nixlike = { path = "./crates/nixlike" }
 better-command = { path = "./crates/better-command" }
+uuid = { version = "1.3.3", features = ["v4"] }
+tokio = { version = "1.33.0", features = ["fs", "rt", "macros", "sync", "time", "rt-multi-thread"] }
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -8,6 +8,7 @@
 [dependencies]
 nixlike.workspace = true
 better-command.workspace = true
+tokio.workspace = true
 anyhow = "1.0"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
@@ -27,7 +28,6 @@
 	"wrap_help",
 	"unicode",
 ] }
-tokio = { version = "1.33.0", features = ["full"] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
 tokio-util = { version = "0.7.10", features = ["codec"] }
modifiedcmds/fleet/src/better_nix_eval.rsdiffbeforeafterboth
--- a/cmds/fleet/src/better_nix_eval.rs
+++ b/cmds/fleet/src/better_nix_eval.rs
@@ -428,6 +428,7 @@
 		self.used_fields.extend(e.used_fields);
 	}
 
+	#[allow(dead_code)]
 	pub fn session(&self) -> NixSession {
 		let mut session = None;
 		for ele in &self.used_fields {
@@ -444,6 +445,7 @@
 		}
 		session.expect("expr without fields used")
 	}
+	#[allow(dead_code)]
 	pub fn index_attr(&mut self, s: &str) {
 		let escaped = nixlike::serialize(s).expect("string");
 		self.out.push('.');
@@ -559,7 +561,9 @@
 pub enum Index {
 	Var(String),
 	String(String),
+	#[allow(dead_code)]
 	Apply(String),
+	#[allow(dead_code)]
 	Expr(NixExprBuilder),
 	ExprApply(NixExprBuilder),
 	Pipe(NixExprBuilder),
@@ -576,6 +580,7 @@
 	pub fn attr(v: impl AsRef<str>) -> Self {
 		Self::String(v.as_ref().to_owned())
 	}
+	#[allow(dead_code)]
 	pub fn apply(v: impl Serialize) -> Self {
 		let serialized = nixlike::serialize(v).expect("invalid value for apply");
 		Self::Apply(serialized.trim_end().to_owned())
@@ -749,6 +754,7 @@
 			.await
 			.with_context(|| context("as_json", self.0.full_path.as_deref(), &query))
 	}
+	#[allow(dead_code)]
 	pub async fn has_field(&self, name: &str) -> Result<bool> {
 		let id = self.0.value.expect("can't list root fields");
 		let key = nixlike::escape_string(name);
@@ -786,6 +792,7 @@
 			.await
 			.with_context(|| context("type_of", self.0.full_path.as_deref(), &query))
 	}
+	#[allow(dead_code)]
 	pub async fn import(&self) -> Result<Self> {
 		let import = Self::new(self.0.session.clone(), "import").await?;
 		Ok(nix_go!(self | import))
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -6,34 +6,40 @@
 use crate::host::{Config, ConfigHost};
 use crate::nix_go;
 use anyhow::{anyhow, Result};
-use clap::Parser;
+use clap::{Parser, ValueEnum};
 use itertools::Itertools as _;
 use tokio::{task::LocalSet, time::sleep};
 use tracing::{error, field, info, info_span, warn, Instrument};
 
-#[derive(Parser, Clone)]
-pub struct BuildSystems {
+#[derive(Parser)]
+pub struct Deploy {
 	/// Disable automatic rollback
 	#[clap(long)]
 	disable_rollback: bool,
-	#[clap(subcommand)]
-	subcommand: Subcommand,
+	action: DeployAction,
 }
 
-enum UploadAction {
+#[derive(ValueEnum, Clone, Copy)]
+enum DeployAction {
+	/// Upload derivation, but do not execute the update.
+	Upload,
+	/// Upload and execute the activation script, old version will be used after reboot.
 	Test,
+	/// Upload and set as current system profile, but do not execute activation script.
 	Boot,
+	/// Upload, set current profile, and execute activation script.
 	Switch,
 }
-impl UploadAction {
-	fn name(&self) -> &'static str {
+
+impl DeployAction {
+	pub(crate) fn name(&self) -> Option<&'static str> {
 		match self {
-			UploadAction::Test => "test",
-			UploadAction::Boot => "boot",
-			UploadAction::Switch => "switch",
+			DeployAction::Upload => None,
+			DeployAction::Test => Some("test"),
+			DeployAction::Boot => Some("boot"),
+			DeployAction::Switch => Some("switch"),
 		}
 	}
-
 	pub(crate) fn should_switch_profile(&self) -> bool {
 		matches!(self, Self::Switch | Self::Boot)
 	}
@@ -42,69 +48,15 @@
 	}
 	pub(crate) fn should_schedule_rollback_run(&self) -> bool {
 		matches!(self, Self::Switch | Self::Test)
-	}
-}
-
-enum PackageAction {
-	SdImage,
-	InstallationCd,
-}
-impl PackageAction {
-	fn build_attr(&self) -> String {
-		match self {
-			PackageAction::SdImage => "sdImage".to_owned(),
-			PackageAction::InstallationCd => "isoImage".to_owned(),
-		}
-	}
-}
-
-enum Action {
-	Upload { action: Option<UploadAction> },
-	Package(PackageAction),
-}
-impl Action {
-	fn build_attr(&self) -> String {
-		match self {
-			Action::Upload { .. } => "toplevel".to_owned(),
-			Action::Package(p) => p.build_attr(),
-		}
 	}
 }
 
-impl From<Subcommand> for Action {
-	fn from(s: Subcommand) -> Self {
-		match s {
-			Subcommand::Upload => Self::Upload { action: None },
-			Subcommand::Test => Self::Upload {
-				action: Some(UploadAction::Test),
-			},
-			Subcommand::Boot => Self::Upload {
-				action: Some(UploadAction::Boot),
-			},
-			Subcommand::Switch => Self::Upload {
-				action: Some(UploadAction::Switch),
-			},
-			Subcommand::SdImage => Self::Package(PackageAction::SdImage),
-			Subcommand::InstallationCd => Self::Package(PackageAction::InstallationCd),
-		}
-	}
-}
-
 #[derive(Parser, Clone)]
-enum Subcommand {
-	/// Upload, but do not switch
-	Upload,
-	/// Upload + switch to built system until reboot
-	Test,
-	/// Upload + switch to built system after reboot
-	Boot,
-	/// Upload + test + boot
-	Switch,
-
-	/// Build SD .img image
-	SdImage,
-	/// Build an installation cd ISO image
-	InstallationCd,
+pub struct BuildSystems {
+	/// Attribute to build. Systems are deployed from "toplevel" attr, well-known used attributes
+	/// are "sdImage"/"isoImage", and your configuration may include any other build attributes.
+	#[clap(long, default_value = "toplevel")]
+	build_attr: String,
 }
 
 struct Generation {
@@ -163,11 +115,11 @@
 	Ok(current)
 }
 
-async fn execute_upload(
-	build: &BuildSystems,
-	action: UploadAction,
+async fn deploy_task(
+	action: DeployAction,
 	host: &ConfigHost,
 	built: PathBuf,
+	disable_rollback: bool,
 ) -> Result<()> {
 	let mut failed = false;
 	// TODO: Lockfile, to prevent concurrent system switch?
@@ -175,7 +127,7 @@
 	// is scheduler on next boot (default behavior). On current boot - rollback activator will fail due to
 	// unit name conflict in systemd-run
 	// This code is tied to rollback.nix
-	if !build.disable_rollback {
+	if !disable_rollback {
 		let _span = info_span!("preparing").entered();
 		info!("preparing for rollback");
 		let generation = get_current_generation(host).await?;
@@ -235,13 +187,13 @@
 		switch_script.push("bin");
 		switch_script.push("switch-to-configuration");
 		let mut cmd = host.cmd(switch_script).in_current_span().await?;
-		cmd.arg(action.name());
+		cmd.arg(action.name().expect("upload.should_activate == false"));
 		if let Err(e) = cmd.sudo().run().in_current_span().await {
 			error!("failed to activate: {e}");
 			failed = true;
 		}
 	}
-	if !build.disable_rollback {
+	if !disable_rollback {
 		if failed {
 			info!("executing rollback");
 			if let Err(e) = host
@@ -280,97 +232,45 @@
 	Ok(())
 }
 
-impl BuildSystems {
-	async fn build_task(self, config: Config, host: String) -> Result<()> {
-		info!("building");
-		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[{ action.build_attr() }]
-		);
-		let outputs = drv.build().await.map_err(|e| {
-			if action.build_attr() == "sdImage" {
+async fn build_task(config: Config, host: String, build_attr: &str) -> Result<PathBuf> {
+	info!("building");
+	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 outputs = drv.build().await.map_err(|e| {
+			if build_attr == "sdImage" {
 				info!("sd-image build failed");
 				info!("Make sure you have imported modulesPath/installer/sd-card/sd-image-<arch>[-installer].nix (For installer, you may want to check config)");
 			}
 			e
 		})?;
-		let out_output = outputs
-			.get("out")
-			.ok_or_else(|| anyhow!("system build should produce \"out\" output"))?;
-
-		match action {
-			Action::Upload { action } => {
-				if !config.is_local(&host.name) {
-					info!("uploading system closure");
-					{
-						// TODO: Move to remote_derivation method.
-						// Alternatively, nix store make-content-addressed can be used,
-						// 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");
-						// Private key for host machine is registered in nix-sign.nix
-						sign.arg("store")
-							.arg("sign")
-							.comparg("--key-file", "/etc/nix/private-key")
-							.arg("-r")
-							.arg(out_output);
-						if let Err(e) = sign.sudo().run_nix().await {
-							warn!("Failed to sign store paths: {e}");
-						};
-					}
-					let mut tries = 0;
-					loop {
-						match host.remote_derivation(out_output).await {
-							Ok(remote) => {
-								assert!(&remote == out_output, "CA derivations aren't implemented");
-								break;
-							}
-							Err(e) if tries < 3 => {
-								tries += 1;
-								warn!("Copy failure ({}/3): {}", tries, e);
-								sleep(Duration::from_millis(5000)).await;
-							}
-							Err(e) => return Err(e),
-						}
-					}
-				}
-				if let Some(action) = action {
-					execute_upload(&self, action, &host, out_output.clone()).await?
-				}
-			}
-			Action::Package(PackageAction::SdImage) => {
-				let mut out = current_dir()?;
-				out.push(format!("sd-image-{}", host.name));
-
-				info!("linking sd image to {:?}", out);
-				symlink(out_output, out)?;
-			}
-			Action::Package(PackageAction::InstallationCd) => {
-				let mut out = current_dir()?;
-				out.push(format!("installation-cd-{}", host.name));
+	let out_output = outputs
+		.get("out")
+		.ok_or_else(|| anyhow!("system build should produce \"out\" output"))?;
 
-				info!("linking iso image to {:?}", out);
-				symlink(out_output, out)?;
-			}
-		};
-		Ok(())
-	}
+	Ok(out_output.clone())
+}
 
+impl BuildSystems {
 	pub async fn run(self, config: &Config) -> Result<()> {
 		let hosts = config.list_hosts().await?;
 		let set = LocalSet::new();
-		let this = &self;
+		let build_attr = self.build_attr.clone();
 		for host in hosts.into_iter() {
 			if config.should_skip(&host.name) {
 				continue;
 			}
 			let config = config.clone();
-			let this = this.clone();
-			let span = info_span!("deployment", host = field::display(&host.name));
+			let span = info_span!("build", host = field::display(&host.name));
 			let hostname = host.name;
+			let build_attr = build_attr.clone();
 			// FIXME: Since the introduction of better-nix-eval,
 			// due to single repl used for builds, hosts are waiting for each other to build,
 			// instead of building concurrently.
@@ -384,11 +284,94 @@
 			// multiple hosts.
 			set.spawn_local(
 				(async move {
-					match this.build_task(config, hostname).await {
-						Ok(_) => {}
+					let built = match build_task(config, hostname.clone(), &build_attr).await {
+						Ok(path) => path,
+						Err(e) => {
+							error!("failed to deploy host: {}", e);
+							return;
+						}
+					};
+					// TODO: Handle error
+					let mut out = current_dir().expect("cwd exists");
+					out.push(format!("built-{}", hostname));
+
+					info!("linking iso image to {:?}", out);
+					if let Err(e) = symlink(built, out) {
+						error!("failed to symlink: {e}")
+					}
+				})
+				.instrument(span),
+			);
+		}
+		set.await;
+		Ok(())
+	}
+}
+
+impl Deploy {
+	pub async fn run(self, config: &Config) -> Result<()> {
+		let hosts = config.list_hosts().await?;
+		let set = LocalSet::new();
+		for host in hosts.into_iter() {
+			if config.should_skip(&host.name) {
+				continue;
+			}
+			let config = config.clone();
+			let span = info_span!("deploy", host = field::display(&host.name));
+			let hostname = host.name.clone();
+			// FIXME: Fix repl concurrency (see build-systems)
+			set.spawn_local(
+				(async move {
+					let built = match build_task(config.clone(), hostname.clone(), "toplevel").await
+					{
+						Ok(path) => path,
 						Err(e) => {
-							error!("failed to deploy host: {}", e)
+							error!("failed to deploy host: {}", e);
+							return;
 						}
+					};
+					if !config.is_local(&hostname) {
+						info!("uploading system closure");
+						{
+							// TODO: Move to remote_derivation method.
+							// Alternatively, nix store make-content-addressed can be used,
+							// 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");
+							// Private key for host machine is registered in nix-sign.nix
+							sign.arg("store")
+								.arg("sign")
+								.comparg("--key-file", "/etc/nix/private-key")
+								.arg("-r")
+								.arg(&built);
+							if let Err(e) = sign.sudo().run_nix().await {
+								warn!("Failed to sign store paths: {e}");
+							};
+						}
+						let mut tries = 0;
+						loop {
+							match host.remote_derivation(&built).await {
+								Ok(remote) => {
+									assert!(remote == built, "CA derivations aren't implemented");
+									break;
+								}
+								Err(e) if tries < 3 => {
+									tries += 1;
+									warn!("copy failure ({}/3): {}", tries, e);
+									sleep(Duration::from_millis(5000)).await;
+								}
+								Err(e) => {
+									error!("upload failed: {e}");
+									return;
+								}
+							}
+						}
+					}
+					if let Err(e) =
+						deploy_task(self.action, &host, built, self.disable_rollback).await
+					{
+						error!("activation failed: {e}");
 					}
 				})
 				.instrument(span),
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use crate::{2	better_nix_eval::Field,3	fleetdata::{FleetSecret, FleetSharedSecret, SecretData},4	host::Config,5	nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use futures::StreamExt;11use itertools::Itertools;12use owo_colors::OwoColorize;13use serde::Deserialize;14use std::{15	collections::{BTreeSet, HashSet},16	io::{self, Cursor, Read},17	path::PathBuf,18};19use tabled::{Table, Tabled};20use tokio::fs::read_to_string;21use tracing::{error, info, info_span, warn, Instrument};2223#[derive(Parser)]24pub enum Secret {25	/// Force load host keys for all defined hosts26	ForceKeys,27	/// Add secret, data should be provided in stdin28	AddShared {29		/// Secret name30		name: String,31		/// Secret owners32		machines: Vec<String>,33		/// Override secret if already present34		#[clap(long)]35		force: bool,36		/// Secret public part37		#[clap(long)]38		public: Option<String>,39		/// Load public part from specified file40		#[clap(long)]41		public_file: Option<PathBuf>,4243		/// Create a notification on secret expiration44		#[clap(long)]45		expires_at: Option<DateTime<Utc>>,4647		/// Secret with this name already exists, override its value while keeping the same owners.48		#[clap(long)]49		re_add: bool,50	},51	/// Add secret, data should be provided in stdin52	Add {53		/// Secret name54		name: String,55		/// Secret owners56		machine: String,57		/// Override secret if already present58		#[clap(long)]59		force: bool,60		#[clap(long)]61		public: Option<String>,62		#[clap(long)]63		public_file: Option<PathBuf>,64	},65	/// Read secret from remote host, requires sudo on said host66	Read {67		name: String,68		machine: String,69		#[clap(long)]70		plaintext: bool,71	},72	UpdateShared {73		name: String,7475		#[clap(long)]76		machines: Option<Vec<String>>,7778		#[clap(long)]79		add_machines: Vec<String>,80		#[clap(long)]81		remove_machines: Vec<String>,8283		/// Which host should we use to decrypt84		#[clap(long)]85		prefer_identities: Vec<String>,86	},87	Regenerate {88		/// Which host should we use to decrypt, in case if reencryption is required, without89		/// regeneration90		#[clap(long)]91		prefer_identities: Vec<String>,92	},93	List {},94}9596#[tracing::instrument(skip(config, secret, field, prefer_identities))]97async fn update_owner_set(98	secret_name: &str,99	config: &Config,100	mut secret: FleetSharedSecret,101	field: Field,102	updated_set: &[String],103	prefer_identities: &[String],104) -> Result<FleetSharedSecret> {105	let original_set = secret.owners.clone();106107	let set = original_set.iter().collect::<BTreeSet<_>>();108	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();109110	if set == expected_set {111		info!("no need to update owner list, it is already correct");112		return Ok(secret);113	}114115	let should_regenerate = if set.difference(&expected_set).next().is_some() {116		// TODO: Remove this warning for revokable secrets.117		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");118		nix_go_json!(field.regenerateOnOwnerRemoved)119	} else if expected_set.difference(&set).next().is_some() {120		nix_go_json!(field.regenerateOnOwnerAdded)121	} else {122		false123	};124125	if should_regenerate {126		info!("secret is owner-dependent, will regenerate");127		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;128		Ok(generated)129	} else {130		let identity_holder = if !prefer_identities.is_empty() {131			prefer_identities132				.iter()133				.find(|i| original_set.iter().any(|s| s == *i))134		} else {135			secret.owners.first()136		};137		let Some(identity_holder) = identity_holder else {138			bail!("no available holder found");139		};140141		if let Some(data) = secret.secret.secret {142			let host = config.host(identity_holder).await?;143			let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;144			secret.secret.secret = Some(encrypted);145		}146147		secret.owners = updated_set.to_vec();148		Ok(secret)149	}150}151152#[derive(Deserialize)]153#[serde(rename_all = "camelCase")]154enum GeneratorKind {155	Impure,156}157158async fn generate_impure(159	config: &Config,160	_display_name: &str,161	secret: Field,162	default_generator: Field,163	owners: &[String],164) -> Result<FleetSecret> {165	let config_field = &config.config_unchecked_field;166	let generator = nix_go!(secret.generator);167168	let on: String = nix_go_json!(default_generator.impureOn);169	let call_package = nix_go!(170		config_field.hosts[{ on }]171			.nixosSystem172			.config173			.nixpkgs174			.resolvedPkgs175			.callPackage176	);177178	let host = config.host(&on).await?;179180	let generator = nix_go!(call_package(generator)(Obj {}));181	let generator = generator.build().await?;182	let generator = generator183		.get("out")184		.ok_or_else(|| anyhow!("missing generateImpure out"))?;185	let generator = host.remote_derivation(generator).await?;186187	let mut recipients = String::new();188	for owner in owners {189		let key = config.key(owner).await?;190		recipients.push_str(&format!("-r \"{key}\" "));191	}192	recipients.push_str("-e");193194	let out = host.mktemp_dir().await?;195196	let mut gen = host.cmd(generator).await?;197	gen.env("rageArgs", recipients).env("out", &out);198	gen.run().await.context("impure generator")?;199200	{201		let marker = host.read_file_text(format!("{out}/marker")).await?;202		ensure!(marker == "SUCCESS", "generation not succeeded");203	}204205	let public = host.read_file_text(format!("{out}/public")).await.ok();206	let secret = host.read_file_bin(format!("{out}/secret")).await.ok();207	if let Some(secret) = &secret {208		ensure!(209			age::Decryptor::new(Cursor::new(&secret)).is_ok(),210			"builder produced non-encrypted value as secret, this is highly insecure, and not allowed."211		);212	}213214	let created_at = host.read_file_value(format!("{out}/created_at")).await?;215	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();216217	Ok(FleetSecret {218		created_at,219		expires_at,220		public,221		secret: secret.map(SecretData),222	})223}224async fn generate(225	config: &Config,226	display_name: &str,227	secret: Field,228	owners: &[String],229) -> Result<FleetSecret> {230	let generator = nix_go!(secret.generator);231	// Can't properly check on nix module system level232	{233		let gen_ty = generator.type_of().await?;234		if gen_ty == "null" {235			bail!("secret has no generator defined, can't automatically generate it.");236		}237		if gen_ty != "lambda" {238			bail!("generator should be lambda, got {gen_ty}");239		}240	}241	let default_pkgs = &config.default_pkgs;242	let default_call_package = nix_go!(default_pkgs.callPackage);243	// Generators provide additional information in passthru, to access244	// passthru we should call generator, but information about where this generator is supposed to build245	// is located in passthru... Thus evaluating generator on host.246	//247	// Maybe it is also possible to do some magic with __functor?248	//249	// I don't want to make modules always responsible for additional secret data anyway,250	// so it should be in derivation, and not in the secret data itself.251	let default_generator = nix_go!(default_call_package(generator)(Obj {}));252253	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);254255	match kind {256		GeneratorKind::Impure => {257			generate_impure(config, display_name, secret, default_generator, owners).await258		}259	}260}261async fn generate_shared(262	config: &Config,263	display_name: &str,264	secret: Field,265	expected_owners: Vec<String>,266) -> Result<FleetSharedSecret> {267	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);268	Ok(FleetSharedSecret {269		secret: generate(config, display_name, secret, &expected_owners).await?,270		owners: expected_owners,271	})272}273274async fn parse_public(275	public: Option<String>,276	public_file: Option<PathBuf>,277) -> Result<Option<String>> {278	Ok(match (public, public_file) {279		(Some(v), None) => Some(v),280		(None, Some(v)) => Some(read_to_string(v).await?),281		(Some(_), Some(_)) => {282			bail!("only public or public_file should be set")283		}284		(None, None) => None,285	})286}287288fn parse_machines(289	initial: Vec<String>,290	machines: Option<Vec<String>>,291	mut add_machines: Vec<String>,292	mut remove_machines: Vec<String>,293) -> Result<Vec<String>> {294	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {295		bail!("no operation");296	}297298	let initial_machines = initial.clone();299	let mut target_machines = initial;300	info!("Currently encrypted for {initial_machines:?}");301302	// ensure!(machines.is_some() || !add_machines.is_empty() || )303	if let Some(machines) = machines {304		ensure!(305			add_machines.is_empty() && remove_machines.is_empty(),306			"can't combine --machines and --add-machines/--remove-machines"307		);308		let target = initial_machines.iter().collect::<HashSet<_>>();309		let source = machines.iter().collect::<HashSet<_>>();310		for removed in target.difference(&source) {311			remove_machines.push((*removed).clone());312		}313		for added in source.difference(&target) {314			add_machines.push((*added).clone());315		}316	}317318	for machine in &remove_machines {319		let mut removed = false;320		while let Some(pos) = target_machines.iter().position(|m| m == machine) {321			target_machines.swap_remove(pos);322			removed = true;323		}324		if !removed {325			warn!("secret is not enabled for {machine}");326		}327	}328	for machine in &add_machines {329		if target_machines.iter().any(|m| m == machine) {330			warn!("secret is already added to {machine}");331		} else {332			target_machines.push(machine.to_owned());333		}334	}335	if !remove_machines.is_empty() {336		// TODO: maybe force secret regeneration?337		// Not that useful without revokation.338		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");339	}340	Ok(target_machines)341}342impl Secret {343	pub async fn run(self, config: &Config) -> Result<()> {344		match self {345			Secret::ForceKeys => {346				for host in config.list_hosts().await? {347					if config.should_skip(&host.name) {348						continue;349					}350					config.key(&host.name).await?;351				}352			}353			Secret::AddShared {354				mut machines,355				name,356				force,357				public,358				public_file,359				expires_at,360				re_add,361			} => {362				let exists = config.has_shared(&name);363				if exists && !force && !re_add {364					bail!("secret already defined");365				}366				if re_add {367					// Fixme: use clap to limit this usage368					ensure!(!force, "--force and --readd are not compatible");369					ensure!(exists, "secret doesn't exists");370					ensure!(371						machines.is_empty(),372						"you can't use machines argument for --readd"373					);374					let shared = config.shared_secret(&name)?;375					machines = shared.owners;376				}377378				let recipients = config.recipients(machines.clone()).await?;379380				let secret = {381					let mut input = vec![];382					io::stdin().read_to_end(&mut input)?;383384					if input.is_empty() {385						None386					} else {387						Some(388							SecretData::encrypt(recipients, input)389								.ok_or_else(|| anyhow!("no recipients provided"))?,390						)391					}392				};393				let public = parse_public(public, public_file).await?;394				config.replace_shared(395					name,396					FleetSharedSecret {397						owners: machines,398						secret: FleetSecret {399							created_at: Utc::now(),400							expires_at,401							secret,402							public,403						},404					},405				);406			}407			Secret::Add {408				machine,409				name,410				force,411				public,412				public_file,413			} => {414				let recipient = config.recipient(&machine).await?;415416				let secret = {417					let mut input = vec![];418					io::stdin().read_to_end(&mut input)?;419					if input.is_empty() {420						bail!("no data provided")421					}422423					Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))424				};425426				if config.has_secret(&machine, &name) && !force {427					bail!("secret already defined");428				}429				let public = parse_public(public, public_file).await?;430431				config.insert_secret(432					&machine,433					name,434					FleetSecret {435						created_at: Utc::now(),436						expires_at: None,437						secret,438						public,439					},440				);441			}442			#[allow(clippy::await_holding_refcell_ref)]443			Secret::Read {444				name,445				machine,446				plaintext,447			} => {448				let secret = config.host_secret(&machine, &name)?;449				let Some(secret) = secret.secret else {450					bail!("no secret {name}");451				};452				let host = config.host(&machine).await?;453				let data = host.decrypt(secret).await?;454				if plaintext {455					let s = String::from_utf8(data).context("output is not utf8")?;456					print!("{s}");457				} else {458					println!("{}", z85::encode(&data));459				}460			}461			Secret::UpdateShared {462				name,463				machines,464				add_machines,465				remove_machines,466				prefer_identities,467			} => {468				let secret = config.shared_secret(&name)?;469				if secret.secret.secret.is_none() {470					bail!("no secret");471				}472473				let initial_machines = secret.owners.clone();474				let target_machines = parse_machines(475					initial_machines.clone(),476					machines,477					add_machines,478					remove_machines,479				)?;480481				if target_machines.is_empty() {482					info!("no machines left for secret, removing it");483					config.remove_shared(&name);484					return Ok(());485				}486487				let config_field = &config.config_unchecked_field;488				let field = nix_go!(config_field.sharedSecrets[{ name }]);489490				let updated = update_owner_set(491					&name,492					config,493					secret,494					field,495					&target_machines,496					&prefer_identities,497				)498				.await?;499				config.replace_shared(name, updated);500			}501			Secret::Regenerate { prefer_identities } => {502				info!("checking for secrets to regenerate");503				{504					let _span = info_span!("shared").entered();505					let expected_shared_set = config506						.list_configured_shared()507						.await?508						.into_iter()509						.collect::<HashSet<_>>();510					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();511					for missing in expected_shared_set.difference(&shared_set) {512						let config_field = &config.config_unchecked_field;513						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);514						let expected_owners: Option<Vec<String>> =515							nix_go_json!(secret.expectedOwners);516						let Some(expected_owners) = expected_owners else {517							// TODO: Might still need to regenerate518							continue;519						};520						info!("generating secret: {missing}");521						let shared = generate_shared(config, missing, secret, expected_owners)522							.in_current_span()523							.await?;524						config.replace_shared(missing.to_string(), shared)525					}526				}527				for host in config.list_hosts().await? {528					let _span = info_span!("host", host = host.name).entered();529					let expected_set = host530						.list_configured_secrets()531						.in_current_span()532						.await?533						.into_iter()534						.collect::<HashSet<_>>();535					let stored_set = config536						.list_secrets(&host.name)537						.into_iter()538						.collect::<HashSet<_>>();539					for missing in expected_set.difference(&stored_set) {540						info!("generating secret: {missing}");541						let secret = host.secret_field(missing).in_current_span().await?;542						let generated =543							match generate(config, missing, secret, &[host.name.clone()])544								.in_current_span()545								.await546							{547								Ok(v) => v,548								Err(e) => {549									error!("{e}");550									continue;551								}552							};553						config.insert_secret(&host.name, missing.to_string(), generated)554					}555				}556				let mut to_remove = Vec::new();557				for name in &config.list_shared() {558					info!("updating secret: {name}");559					let data = config.shared_secret(name)?;560					let config_field = &config.config_unchecked_field;561					let expected_owners: Vec<String> =562						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);563					if expected_owners.is_empty() {564						warn!("secret was removed from fleet config: {name}, removing from data");565						to_remove.push(name.to_string());566						continue;567					}568569					let secret = nix_go!(config_field.sharedSecrets[{ name }]);570					config.replace_shared(571						name.to_owned(),572						update_owner_set(573							&name,574							config,575							data,576							secret,577							&expected_owners,578							&prefer_identities,579						)580						.await?,581					);582				}583				for k in to_remove {584					config.remove_shared(&k);585				}586			}587			Secret::List {} => {588				let _span = info_span!("loading secrets").entered();589				let configured = config.list_configured_shared().await?;590				#[derive(Tabled)]591				struct SecretDisplay {592					#[tabled(rename = "Name")]593					name: String,594					#[tabled(rename = "Owners")]595					owners: String,596				}597				let mut table = vec![];598				for name in configured.iter().cloned() {599					let config = config.clone();600					let expected_owners = config.shared_secret_expected_owners(&name).await?;601					let data = config.shared_secret(&name)?;602					let owners = data603						.owners604						.iter()605						.map(|o| {606							if expected_owners.contains(o) {607								o.green().to_string()608							} else {609								o.red().to_string()610							}611						})612						.collect::<Vec<_>>();613					table.push(SecretDisplay {614						owners: owners.join(", "),615						name,616					})617				}618				info!("loaded\n{}", Table::new(table).to_string())619			}620		}621		Ok(())622	}623}
after · cmds/fleet/src/cmds/secrets/mod.rs
1use crate::{2	better_nix_eval::Field,3	fleetdata::{FleetSecret, FleetSharedSecret, SecretData},4	host::Config,5	nix_go, nix_go_json,6};7use anyhow::{anyhow, bail, ensure, Context, Result};8use chrono::{DateTime, Utc};9use clap::Parser;10use owo_colors::OwoColorize;11use serde::Deserialize;12use std::{13	collections::{BTreeSet, HashSet},14	io::{self, Cursor, Read},15	path::PathBuf,16};17use tabled::{Table, Tabled};18use tokio::fs::read_to_string;19use tracing::{error, info, info_span, warn, Instrument};2021#[derive(Parser)]22pub enum Secret {23	/// Force load host keys for all defined hosts24	ForceKeys,25	/// Add secret, data should be provided in stdin26	AddShared {27		/// Secret name28		name: String,29		/// Secret owners30		machines: Vec<String>,31		/// Override secret if already present32		#[clap(long)]33		force: bool,34		/// Secret public part35		#[clap(long)]36		public: Option<String>,37		/// Load public part from specified file38		#[clap(long)]39		public_file: Option<PathBuf>,4041		/// Create a notification on secret expiration42		#[clap(long)]43		expires_at: Option<DateTime<Utc>>,4445		/// Secret with this name already exists, override its value while keeping the same owners.46		#[clap(long)]47		re_add: bool,48	},49	/// Add secret, data should be provided in stdin50	Add {51		/// Secret name52		name: String,53		/// Secret owners54		machine: String,55		/// Override secret if already present56		#[clap(long)]57		force: bool,58		#[clap(long)]59		public: Option<String>,60		#[clap(long)]61		public_file: Option<PathBuf>,62	},63	/// Read secret from remote host, requires sudo on said host64	Read {65		name: String,66		machine: String,67		#[clap(long)]68		plaintext: bool,69	},70	UpdateShared {71		name: String,7273		#[clap(long)]74		machines: Option<Vec<String>>,7576		#[clap(long)]77		add_machines: Vec<String>,78		#[clap(long)]79		remove_machines: Vec<String>,8081		/// Which host should we use to decrypt82		#[clap(long)]83		prefer_identities: Vec<String>,84	},85	Regenerate {86		/// Which host should we use to decrypt, in case if reencryption is required, without87		/// regeneration88		#[clap(long)]89		prefer_identities: Vec<String>,90	},91	List {},92}9394#[tracing::instrument(skip(config, secret, field, prefer_identities))]95async fn update_owner_set(96	secret_name: &str,97	config: &Config,98	mut secret: FleetSharedSecret,99	field: Field,100	updated_set: &[String],101	prefer_identities: &[String],102) -> Result<FleetSharedSecret> {103	let original_set = secret.owners.clone();104105	let set = original_set.iter().collect::<BTreeSet<_>>();106	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();107108	if set == expected_set {109		info!("no need to update owner list, it is already correct");110		return Ok(secret);111	}112113	let should_regenerate = if set.difference(&expected_set).next().is_some() {114		// TODO: Remove this warning for revokable secrets.115		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");116		nix_go_json!(field.regenerateOnOwnerRemoved)117	} else if expected_set.difference(&set).next().is_some() {118		nix_go_json!(field.regenerateOnOwnerAdded)119	} else {120		false121	};122123	if should_regenerate {124		info!("secret is owner-dependent, will regenerate");125		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;126		Ok(generated)127	} else {128		let identity_holder = if !prefer_identities.is_empty() {129			prefer_identities130				.iter()131				.find(|i| original_set.iter().any(|s| s == *i))132		} else {133			secret.owners.first()134		};135		let Some(identity_holder) = identity_holder else {136			bail!("no available holder found");137		};138139		if let Some(data) = secret.secret.secret {140			let host = config.host(identity_holder).await?;141			let encrypted = host.reencrypt(data, updated_set.to_vec()).await?;142			secret.secret.secret = Some(encrypted);143		}144145		secret.owners = updated_set.to_vec();146		Ok(secret)147	}148}149150#[derive(Deserialize)]151#[serde(rename_all = "camelCase")]152enum GeneratorKind {153	Impure,154}155156async fn generate_impure(157	config: &Config,158	_display_name: &str,159	secret: Field,160	default_generator: Field,161	owners: &[String],162) -> Result<FleetSecret> {163	let config_field = &config.config_unchecked_field;164	let generator = nix_go!(secret.generator);165166	let on: String = nix_go_json!(default_generator.impureOn);167	let call_package = nix_go!(168		config_field.hosts[{ on }]169			.nixosSystem170			.config171			.nixpkgs172			.resolvedPkgs173			.callPackage174	);175176	let host = config.host(&on).await?;177178	let generator = nix_go!(call_package(generator)(Obj {}));179	let generator = generator.build().await?;180	let generator = generator181		.get("out")182		.ok_or_else(|| anyhow!("missing generateImpure out"))?;183	let generator = host.remote_derivation(generator).await?;184185	let mut recipients = String::new();186	for owner in owners {187		let key = config.key(owner).await?;188		recipients.push_str(&format!("-r \"{key}\" "));189	}190	recipients.push_str("-e");191192	let out = host.mktemp_dir().await?;193194	let mut gen = host.cmd(generator).await?;195	gen.env("rageArgs", recipients).env("out", &out);196	gen.run().await.context("impure generator")?;197198	{199		let marker = host.read_file_text(format!("{out}/marker")).await?;200		ensure!(marker == "SUCCESS", "generation not succeeded");201	}202203	let public = host.read_file_text(format!("{out}/public")).await.ok();204	let secret = host.read_file_bin(format!("{out}/secret")).await.ok();205	if let Some(secret) = &secret {206		ensure!(207			age::Decryptor::new(Cursor::new(&secret)).is_ok(),208			"builder produced non-encrypted value as secret, this is highly insecure, and not allowed."209		);210	}211212	let created_at = host.read_file_value(format!("{out}/created_at")).await?;213	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();214215	Ok(FleetSecret {216		created_at,217		expires_at,218		public,219		secret: secret.map(SecretData),220	})221}222async fn generate(223	config: &Config,224	display_name: &str,225	secret: Field,226	owners: &[String],227) -> Result<FleetSecret> {228	let generator = nix_go!(secret.generator);229	// Can't properly check on nix module system level230	{231		let gen_ty = generator.type_of().await?;232		if gen_ty == "null" {233			bail!("secret has no generator defined, can't automatically generate it.");234		}235		if gen_ty != "lambda" {236			bail!("generator should be lambda, got {gen_ty}");237		}238	}239	let default_pkgs = &config.default_pkgs;240	let default_call_package = nix_go!(default_pkgs.callPackage);241	// Generators provide additional information in passthru, to access242	// passthru we should call generator, but information about where this generator is supposed to build243	// is located in passthru... Thus evaluating generator on host.244	//245	// Maybe it is also possible to do some magic with __functor?246	//247	// I don't want to make modules always responsible for additional secret data anyway,248	// so it should be in derivation, and not in the secret data itself.249	let default_generator = nix_go!(default_call_package(generator)(Obj {}));250251	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);252253	match kind {254		GeneratorKind::Impure => {255			generate_impure(config, display_name, secret, default_generator, owners).await256		}257	}258}259async fn generate_shared(260	config: &Config,261	display_name: &str,262	secret: Field,263	expected_owners: Vec<String>,264) -> Result<FleetSharedSecret> {265	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);266	Ok(FleetSharedSecret {267		secret: generate(config, display_name, secret, &expected_owners).await?,268		owners: expected_owners,269	})270}271272async fn parse_public(273	public: Option<String>,274	public_file: Option<PathBuf>,275) -> Result<Option<String>> {276	Ok(match (public, public_file) {277		(Some(v), None) => Some(v),278		(None, Some(v)) => Some(read_to_string(v).await?),279		(Some(_), Some(_)) => {280			bail!("only public or public_file should be set")281		}282		(None, None) => None,283	})284}285286fn parse_machines(287	initial: Vec<String>,288	machines: Option<Vec<String>>,289	mut add_machines: Vec<String>,290	mut remove_machines: Vec<String>,291) -> Result<Vec<String>> {292	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {293		bail!("no operation");294	}295296	let initial_machines = initial.clone();297	let mut target_machines = initial;298	info!("Currently encrypted for {initial_machines:?}");299300	// ensure!(machines.is_some() || !add_machines.is_empty() || )301	if let Some(machines) = machines {302		ensure!(303			add_machines.is_empty() && remove_machines.is_empty(),304			"can't combine --machines and --add-machines/--remove-machines"305		);306		let target = initial_machines.iter().collect::<HashSet<_>>();307		let source = machines.iter().collect::<HashSet<_>>();308		for removed in target.difference(&source) {309			remove_machines.push((*removed).clone());310		}311		for added in source.difference(&target) {312			add_machines.push((*added).clone());313		}314	}315316	for machine in &remove_machines {317		let mut removed = false;318		while let Some(pos) = target_machines.iter().position(|m| m == machine) {319			target_machines.swap_remove(pos);320			removed = true;321		}322		if !removed {323			warn!("secret is not enabled for {machine}");324		}325	}326	for machine in &add_machines {327		if target_machines.iter().any(|m| m == machine) {328			warn!("secret is already added to {machine}");329		} else {330			target_machines.push(machine.to_owned());331		}332	}333	if !remove_machines.is_empty() {334		// TODO: maybe force secret regeneration?335		// Not that useful without revokation.336		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");337	}338	Ok(target_machines)339}340impl Secret {341	pub async fn run(self, config: &Config) -> Result<()> {342		match self {343			Secret::ForceKeys => {344				for host in config.list_hosts().await? {345					if config.should_skip(&host.name) {346						continue;347					}348					config.key(&host.name).await?;349				}350			}351			Secret::AddShared {352				mut machines,353				name,354				force,355				public,356				public_file,357				expires_at,358				re_add,359			} => {360				let exists = config.has_shared(&name);361				if exists && !force && !re_add {362					bail!("secret already defined");363				}364				if re_add {365					// Fixme: use clap to limit this usage366					ensure!(!force, "--force and --readd are not compatible");367					ensure!(exists, "secret doesn't exists");368					ensure!(369						machines.is_empty(),370						"you can't use machines argument for --readd"371					);372					let shared = config.shared_secret(&name)?;373					machines = shared.owners;374				}375376				let recipients = config.recipients(machines.clone()).await?;377378				let secret = {379					let mut input = vec![];380					io::stdin().read_to_end(&mut input)?;381382					if input.is_empty() {383						None384					} else {385						Some(386							SecretData::encrypt(recipients, input)387								.ok_or_else(|| anyhow!("no recipients provided"))?,388						)389					}390				};391				let public = parse_public(public, public_file).await?;392				config.replace_shared(393					name,394					FleetSharedSecret {395						owners: machines,396						secret: FleetSecret {397							created_at: Utc::now(),398							expires_at,399							secret,400							public,401						},402					},403				);404			}405			Secret::Add {406				machine,407				name,408				force,409				public,410				public_file,411			} => {412				let recipient = config.recipient(&machine).await?;413414				let secret = {415					let mut input = vec![];416					io::stdin().read_to_end(&mut input)?;417					if input.is_empty() {418						bail!("no data provided")419					}420421					Some(SecretData::encrypt(vec![recipient], input).expect("recipient provided"))422				};423424				if config.has_secret(&machine, &name) && !force {425					bail!("secret already defined");426				}427				let public = parse_public(public, public_file).await?;428429				config.insert_secret(430					&machine,431					name,432					FleetSecret {433						created_at: Utc::now(),434						expires_at: None,435						secret,436						public,437					},438				);439			}440			#[allow(clippy::await_holding_refcell_ref)]441			Secret::Read {442				name,443				machine,444				plaintext,445			} => {446				let secret = config.host_secret(&machine, &name)?;447				let Some(secret) = secret.secret else {448					bail!("no secret {name}");449				};450				let host = config.host(&machine).await?;451				let data = host.decrypt(secret).await?;452				if plaintext {453					let s = String::from_utf8(data).context("output is not utf8")?;454					print!("{s}");455				} else {456					println!("{}", z85::encode(&data));457				}458			}459			Secret::UpdateShared {460				name,461				machines,462				add_machines,463				remove_machines,464				prefer_identities,465			} => {466				let secret = config.shared_secret(&name)?;467				if secret.secret.secret.is_none() {468					bail!("no secret");469				}470471				let initial_machines = secret.owners.clone();472				let target_machines = parse_machines(473					initial_machines.clone(),474					machines,475					add_machines,476					remove_machines,477				)?;478479				if target_machines.is_empty() {480					info!("no machines left for secret, removing it");481					config.remove_shared(&name);482					return Ok(());483				}484485				let config_field = &config.config_unchecked_field;486				let field = nix_go!(config_field.sharedSecrets[{ name }]);487488				let updated = update_owner_set(489					&name,490					config,491					secret,492					field,493					&target_machines,494					&prefer_identities,495				)496				.await?;497				config.replace_shared(name, updated);498			}499			Secret::Regenerate { prefer_identities } => {500				info!("checking for secrets to regenerate");501				{502					let _span = info_span!("shared").entered();503					let expected_shared_set = config504						.list_configured_shared()505						.await?506						.into_iter()507						.collect::<HashSet<_>>();508					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();509					for missing in expected_shared_set.difference(&shared_set) {510						let config_field = &config.config_unchecked_field;511						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);512						let expected_owners: Option<Vec<String>> =513							nix_go_json!(secret.expectedOwners);514						let Some(expected_owners) = expected_owners else {515							// TODO: Might still need to regenerate516							continue;517						};518						info!("generating secret: {missing}");519						let shared = generate_shared(config, missing, secret, expected_owners)520							.in_current_span()521							.await?;522						config.replace_shared(missing.to_string(), shared)523					}524				}525				for host in config.list_hosts().await? {526					let _span = info_span!("host", host = host.name).entered();527					let expected_set = host528						.list_configured_secrets()529						.in_current_span()530						.await?531						.into_iter()532						.collect::<HashSet<_>>();533					let stored_set = config534						.list_secrets(&host.name)535						.into_iter()536						.collect::<HashSet<_>>();537					for missing in expected_set.difference(&stored_set) {538						info!("generating secret: {missing}");539						let secret = host.secret_field(missing).in_current_span().await?;540						let generated =541							match generate(config, missing, secret, &[host.name.clone()])542								.in_current_span()543								.await544							{545								Ok(v) => v,546								Err(e) => {547									error!("{e}");548									continue;549								}550							};551						config.insert_secret(&host.name, missing.to_string(), generated)552					}553				}554				let mut to_remove = Vec::new();555				for name in &config.list_shared() {556					info!("updating secret: {name}");557					let data = config.shared_secret(name)?;558					let config_field = &config.config_unchecked_field;559					let expected_owners: Vec<String> =560						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);561					if expected_owners.is_empty() {562						warn!("secret was removed from fleet config: {name}, removing from data");563						to_remove.push(name.to_string());564						continue;565					}566567					let secret = nix_go!(config_field.sharedSecrets[{ name }]);568					config.replace_shared(569						name.to_owned(),570						update_owner_set(571							name,572							config,573							data,574							secret,575							&expected_owners,576							&prefer_identities,577						)578						.await?,579					);580				}581				for k in to_remove {582					config.remove_shared(&k);583				}584			}585			Secret::List {} => {586				let _span = info_span!("loading secrets").entered();587				let configured = config.list_configured_shared().await?;588				#[derive(Tabled)]589				struct SecretDisplay {590					#[tabled(rename = "Name")]591					name: String,592					#[tabled(rename = "Owners")]593					owners: String,594				}595				let mut table = vec![];596				for name in configured.iter().cloned() {597					let config = config.clone();598					let expected_owners = config.shared_secret_expected_owners(&name).await?;599					let data = config.shared_secret(&name)?;600					let owners = data601						.owners602						.iter()603						.map(|o| {604							if expected_owners.contains(o) {605								o.green().to_string()606							} else {607								o.red().to_string()608							}609						})610						.collect::<Vec<_>>();611					table.push(SecretDisplay {612						owners: owners.join(", "),613						name,614					})615				}616				info!("loaded\n{}", Table::new(table).to_string())617			}618		}619		Ok(())620	}621}
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -14,7 +14,6 @@
 use openssh::SessionBuilder;
 use serde::de::DeserializeOwned;
 use tempfile::NamedTempFile;
-use tracing::instrument;
 
 use crate::{
 	better_nix_eval::{Field, NixSessionPool},
@@ -90,6 +89,7 @@
 		cmd.arg(path);
 		cmd.run_string().await
 	}
+	#[allow(dead_code)]
 	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {
 		let text = self.read_file_text(path).await?;
 		Ok(serde_json::from_str(&text)?)
modifiedcmds/fleet/src/main.rsdiffbeforeafterboth
--- a/cmds/fleet/src/main.rs
+++ b/cmds/fleet/src/main.rs
@@ -12,14 +12,17 @@
 mod fleetdata;
 
 use std::ffi::OsString;
-use std::io::{stderr, stdout, Write};
 use std::process::exit;
 use std::time::Duration;
 
 use anyhow::{bail, Result};
 use clap::Parser;
 
-use cmds::{build_systems::BuildSystems, info::Info, secrets::Secret};
+use cmds::{
+	build_systems::{BuildSystems, Deploy},
+	info::Info,
+	secrets::Secret,
+};
 use futures::future::LocalBoxFuture;
 use futures::stream::FuturesUnordered;
 use futures::TryStreamExt;
@@ -73,6 +76,8 @@
 enum Opts {
 	/// Prepare systems for deployments
 	BuildSystems(BuildSystems),
+
+	Deploy(Deploy),
 	/// Secret management
 	#[clap(subcommand)]
 	Secret(Secret),
@@ -94,6 +99,7 @@
 async fn run_command(config: &Config, command: Opts) -> Result<()> {
 	match command {
 		Opts::BuildSystems(c) => c.run(config).await?,
+		Opts::Deploy(d) => d.run(config).await?,
 		Opts::Secret(s) => s.run(config).await?,
 		Opts::Info(i) => i.run(config).await?,
 		Opts::Prefetch(p) => p.run(config).await?,
modifiedcrates/better-command/src/handler.rsdiffbeforeafterboth
--- a/crates/better-command/src/handler.rs
+++ b/crates/better-command/src/handler.rs
@@ -165,7 +165,7 @@
 								drv = pkg;
 							}
 						}
-						// info!(target: "nix","copying {} {} -> {}", drv, from, to);
+						info!(target: "nix","copying {} {} -> {}", drv, from, to);
 						let span = info_span!("copy", from, to, drv);
 						span.pb_start();
 						self.spans.insert(id, span);
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -38,11 +38,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1703974965,
-        "narHash": "sha256-dvZjLuAcLnv25bqStTL2ZICC5YSs8aynF5amRM+I6UM=",
+        "lastModified": 1704409229,
+        "narHash": "sha256-Vc41cRJ3trOnocovLe0zZE35pK5Lfuo/zHk0xx3CNDY=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "9f434bd436e2bb5615827469ed651e30c26daada",
+        "rev": "786f788914f2a6e94cedf361541894e972b8fd23",
         "type": "github"
       },
       "original": {
@@ -67,11 +67,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1703902408,
-        "narHash": "sha256-qXdWvu+tlgNjeoz8yQMRKSom6QyRROfgpmeOhwbujqw=",
+        "lastModified": 1704075545,
+        "narHash": "sha256-L3zgOuVKhPjKsVLc3yTm2YJ6+BATyZBury7wnhyc8QU=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "319f57cd2c34348c55970a4bf2b35afe82088681",
+        "rev": "a0df72e106322b67e9c6e591fe870380bd0da0d5",
         "type": "github"
       },
       "original": {
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -29,7 +29,7 @@
         llvmPkgs = pkgs.buildPackages.llvmPackages_11;
         rust =
           (pkgs.rustChannelOf {
-            date = "2023-12-29";
+            date = "2024-01-01";
             channel = "nightly";
           })
           .default