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

difftreelog

feat ability to select specialisation to activate

Yaroslav Bolyukin2024-07-24parent: #d9fb30d.patch.diff
in: trunk

6 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -784,7 +784,7 @@
  "itertools",
  "nix-eval",
  "nixlike",
- "once_cell",
+ "nom",
  "openssh",
  "owo-colors",
  "peg",
modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -19,7 +19,6 @@
 serde_json.workspace = true
 tempfile.workspace = true
 time = { version = "0.3", features = ["serde"] }
-once_cell = "1.19"
 hostname = "0.4.0"
 age-core = "0.10"
 peg = "0.8"
@@ -45,6 +44,7 @@
 human-repr = { version = "1.1", optional = true }
 indicatif = { version = "0.17", optional = true }
 nix-eval.workspace = true
+nom = "7.1.3"
 
 [features]
 # Not quite stable
modifiedcmds/fleet/src/cmds/build_systems.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/build_systems.rs
+++ b/cmds/fleet/src/cmds/build_systems.rs
@@ -126,6 +126,7 @@
 	action: DeployAction,
 	host: &ConfigHost,
 	built: PathBuf,
+	specialisation: Option<String>,
 	disable_rollback: bool,
 ) -> Result<()> {
 	let mut failed = false;
@@ -190,9 +191,14 @@
 	if action.should_activate() && !failed {
 		let _span = info_span!("activating").entered();
 		info!("executing activation script");
-		let mut switch_script = built.clone();
-		switch_script.push("bin");
-		switch_script.push("switch-to-configuration");
+		let specialised = if let Some(specialisation) = specialisation {
+			let mut specialised = built.join("specialisation");
+			specialised.push(specialisation);
+			specialised
+		} else {
+			built.clone()
+		};
+		let switch_script = specialised.join("bin/switch-to-configuration");
 		let mut cmd = host.cmd(switch_script).in_current_span().await?;
 		cmd.arg(action.name().expect("upload.should_activate == false"));
 		if let Err(e) = cmd.sudo().run().in_current_span().await {
@@ -255,12 +261,11 @@
 			.system
 			.build[{ build_attr }]
 	);
-	let outputs = drv.build().await.map_err(|e| {
+	let outputs = drv.build().await.inspect_err(|_| {
 			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")
@@ -275,7 +280,7 @@
 		let set = LocalSet::new();
 		let build_attr = self.build_attr.clone();
 		for host in hosts.into_iter() {
-			if config.should_skip(&host.name) {
+			if config.should_skip(&host).await? {
 				continue;
 			}
 			let config = config.clone();
@@ -324,7 +329,7 @@
 		let hosts = config.list_hosts().await?;
 		let set = LocalSet::new();
 		for host in hosts.into_iter() {
-			if config.should_skip(&host.name) {
+			if config.should_skip(&host).await? {
 				continue;
 			}
 			let config = config.clone();
@@ -379,8 +384,19 @@
 							}
 						}
 					}
-					if let Err(e) =
-						deploy_task(self.action, &host, built, self.disable_rollback).await
+					if let Err(e) = deploy_task(
+						self.action,
+						&host,
+						built,
+						if let Ok(v) = config.action_attr(&host, "specialisation").await {
+							v
+						} else {
+							error!("unreachable? failed to get specialization");
+							return;
+						},
+						self.disable_rollback,
+					)
+					.await
 					{
 						error!("activation failed: {e}");
 					}
modifiedcmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth
before · cmds/fleet/src/cmds/secrets/mod.rs
1use std::{2	collections::{BTreeMap, BTreeSet, HashSet},3	ffi::OsString,4	io::{self, stdin, stdout, Read, Write},5	path::PathBuf,6};78use anyhow::{anyhow, bail, ensure, Context, Result};9use chrono::{DateTime, Utc};10use clap::Parser;11use crossterm::{terminal, tty::IsTty};12use fleet_shared::SecretData;13use itertools::Itertools;14use nix_eval::{nix_go, nix_go_json, Value};15use owo_colors::OwoColorize;16use serde::Deserialize;17use tabled::{Table, Tabled};18use tokio::{fs::read, process::Command};19use tracing::{error, info, info_span, warn, Instrument};2021use crate::{22	fleetdata::{encrypt_secret_data, FleetSecret, FleetSecretPart, FleetSharedSecret},23	host::Config,24};2526#[derive(Parser)]27pub enum Secret {28	/// Force load host keys for all defined hosts29	ForceKeys,30	/// Add secret, data should be provided in stdin31	AddShared {32		/// Secret name33		name: String,34		/// Secret owners35		#[clap(long, short)]36		machines: Vec<String>,37		/// Override secret if already present38		#[clap(long)]39		force: bool,40		/// Secret public part41		#[clap(long)]42		public: Option<String>,43		/// Load public part from specified file44		#[clap(long)]45		public_file: Option<PathBuf>,4647		/// Create a notification on secret expiration48		#[clap(long)]49		expires_at: Option<DateTime<Utc>>,5051		/// Secret with this name already exists, override its value while keeping the same owners.52		#[clap(long)]53		re_add: bool,5455		/// How to name public secret part56		#[clap(long, short = 'p', default_value = "public")]57		public_part: String,58		/// How to name private secret part59		#[clap(short = 's', long, default_value = "secret")]60		part: String,61	},62	/// Add secret, data should be provided in stdin63	Add {64		/// Secret name65		name: String,66		/// Secret owner67		#[clap(short = 'm', long)]68		machine: String,69		/// Replace secret if already present70		#[clap(long)]71		replace: bool,72		/// Add new parts to existing secret73		#[clap(long)]74		merge: bool,75		/// Secret public part76		#[clap(long)]77		public: Option<String>,78		/// Load public part from specified file79		#[clap(long)]80		public_file: Option<PathBuf>,8182		/// How to name public secret part83		#[clap(short = 'p', long, default_value = "public")]84		public_part: String,85		/// How to name private secret part86		#[clap(short = 's', long, default_value = "secret")]87		part: String,88	},89	/// Read secret from remote host, requires sudo on said host90	Read {91		name: String,92		#[clap(short = 'm', long)]93		machine: String,9495		/// Which private secret part to read96		#[clap(short = 'p', long, default_value = "secret")]97		part: String,98	},99	UpdateShared {100		name: String,101102		#[clap(short = 'm', long)]103		machine: Option<Vec<String>>,104105		#[clap(long)]106		add_machine: Vec<String>,107		#[clap(long)]108		remove_machine: Vec<String>,109110		/// Which host should we use to decrypt111		#[clap(long)]112		prefer_identities: Vec<String>,113	},114	Regenerate {115		/// Which host should we use to decrypt, in case if reencryption is required, without116		/// regeneration117		#[clap(long)]118		prefer_identities: Vec<String>,119	},120	List {},121	Edit {122		name: String,123		#[clap(short = 'm', long)]124		machine: String,125126		#[clap(long)]127		add: bool,128129		/// Which private secret part to read130		#[clap(short = 'p', long, default_value = "secret")]131		part: String,132	},133}134135#[tracing::instrument(skip(config, secret, field, prefer_identities))]136async fn update_owner_set(137	secret_name: &str,138	config: &Config,139	mut secret: FleetSharedSecret,140	field: Value,141	updated_set: &[String],142	prefer_identities: &[String],143) -> Result<FleetSharedSecret> {144	let original_set = secret.owners.clone();145146	let set = original_set.iter().collect::<BTreeSet<_>>();147	let expected_set = updated_set.iter().collect::<BTreeSet<_>>();148149	if set == expected_set {150		info!("no need to update owner list, it is already correct");151		return Ok(secret);152	}153154	let should_regenerate = if set.difference(&expected_set).next().is_some() {155		// TODO: Remove this warning for revokable secrets.156		warn!("host was removed from secret owners, but until this host rebuild, the secret will still be stored on it.");157		nix_go_json!(field.regenerateOnOwnerRemoved)158	} else if expected_set.difference(&set).next().is_some() {159		nix_go_json!(field.regenerateOnOwnerAdded)160	} else {161		false162	};163164	if should_regenerate {165		info!("secret is owner-dependent, will regenerate");166		let generated = generate_shared(config, secret_name, field, updated_set.to_vec()).await?;167		Ok(generated)168	} else {169		let identity_holder = if !prefer_identities.is_empty() {170			prefer_identities171				.iter()172				.find(|i| original_set.iter().any(|s| s == *i))173		} else {174			secret.owners.first()175		};176		let Some(identity_holder) = identity_holder else {177			bail!("no available holder found");178		};179180		for (part_name, part) in secret.secret.parts.iter_mut() {181			let _span = info_span!("part reencryption", part_name);182			if !part.raw.encrypted {183				continue;184			}185			let host = config.host(identity_holder).await?;186			let encrypted = host187				.reencrypt(part.raw.clone(), updated_set.to_vec())188				.await?;189			part.raw = encrypted;190		}191192		secret.owners = updated_set.to_vec();193		Ok(secret)194	}195}196197#[derive(Deserialize)]198#[serde(rename_all = "camelCase")]199enum GeneratorKind {200	Impure,201	Pure,202}203204async fn generate_pure(205	_config: &Config,206	_display_name: &str,207	_secret: Value,208	_default_generator: Value,209	_owners: &[String],210) -> Result<FleetSecret> {211	bail!("pure generators are broken for now")212}213async fn generate_impure(214	config: &Config,215	_display_name: &str,216	secret: Value,217	default_generator: Value,218	owners: &[String],219) -> Result<FleetSecret> {220	let generator = nix_go!(secret.generator);221	let on: Option<String> = nix_go_json!(default_generator.impureOn);222223	let host = if let Some(on) = &on {224		config.host(on).await?225	} else {226		config.local_host()227	};228	let on_pkgs = host.pkgs().await?;229	let call_package = nix_go!(on_pkgs.callPackage);230	let mk_secret_generators = nix_go!(on_pkgs.mkSecretGenerators);231232	let mut recipients = Vec::new();233	for owner in owners {234		let key = config.key(owner).await?;235		recipients.push(key);236	}237	let generators = nix_go!(mk_secret_generators(Obj {238		recipients: { recipients },239	}));240241	let generator = nix_go!(call_package(generator)(generators));242243	let generator = generator.build().await?;244	let generator = generator245		.get("out")246		.ok_or_else(|| anyhow!("missing generateImpure out"))?;247	let generator = host.remote_derivation(generator).await?;248249	let out_parent = host.mktemp_dir().await?;250	let out = format!("{out_parent}/out");251252	let mut gen = host.cmd(generator).await?;253	gen.env("out", &out);254	if on.is_none() {255		// This path is local, thus we can feed `OsString` directly to env var... But I don't think that's necessary to handle.256		let project_path: String = config257			.directory258			.clone()259			.into_os_string()260			.into_string()261			.map_err(|s| anyhow!("fleet project path is not utf-8: {s:?}"))?;262		gen.env("FLEET_PROJECT", project_path);263	}264	gen.run().await.context("impure generator")?;265266	{267		let marker = host.read_file_text(format!("{out}/marker")).await?;268		ensure!(marker == "SUCCESS", "generation not succeeded");269	}270271	let mut parts = BTreeMap::new();272	for part in host.read_dir(&out).await? {273		if part == "created_at" || part == "expired_at" || part == "marker" {274			continue;275		}276		let contents: SecretData = host277			.read_file_text(format!("{out}/{part}"))278			.await?279			.parse()280			.map_err(|e| anyhow!("failed to decode secret {out:?} part {part:?}: {e}"))?;281		parts.insert(part.to_owned(), FleetSecretPart { raw: contents });282	}283284	let created_at = host.read_file_value(format!("{out}/created_at")).await?;285	let expires_at = host.read_file_value(format!("{out}/expires_at")).await.ok();286287	Ok(FleetSecret {288		created_at,289		expires_at,290		parts,291	})292}293async fn generate(294	config: &Config,295	display_name: &str,296	secret: Value,297	owners: &[String],298) -> Result<FleetSecret> {299	let generator = nix_go!(secret.generator);300	// Can't properly check on nix module system level301	{302		let gen_ty = generator.type_of().await?;303		if gen_ty == "null" {304			bail!("secret has no generator defined, can't automatically generate it.");305		}306		if gen_ty != "lambda" {307			bail!("generator should be lambda, got {gen_ty}");308		}309	}310	let default_pkgs = &config.default_pkgs;311	let default_call_package = nix_go!(default_pkgs.callPackage);312	let default_mk_secret_generators = nix_go!(default_pkgs.mkSecretGenerators);313	// Generators provide additional information in passthru, to access314	// passthru we should call generator, but information about where this generator is supposed to build315	// is located in passthru... Thus evaluating generator on host.316	//317	// Maybe it is also possible to do some magic with __functor?318	//319	// I don't want to make modules always responsible for additional secret data anyway,320	// so it should be in derivation, and not in the secret data itself.321	let generators = nix_go!(default_mk_secret_generators(Obj {322		recipients: { <Vec<String>>::new() },323	}));324	let default_generator = nix_go!(default_call_package(generator)(generators));325326	let kind: GeneratorKind = nix_go_json!(default_generator.generatorKind);327328	match kind {329		GeneratorKind::Impure => {330			generate_impure(config, display_name, secret, default_generator, owners).await331		}332		GeneratorKind::Pure => {333			generate_pure(config, display_name, secret, default_generator, owners).await334		}335	}336}337async fn generate_shared(338	config: &Config,339	display_name: &str,340	secret: Value,341	expected_owners: Vec<String>,342) -> Result<FleetSharedSecret> {343	// let owners: Vec<String> = nix_go_json!(secret.expectedOwners);344	Ok(FleetSharedSecret {345		secret: generate(config, display_name, secret, &expected_owners).await?,346		owners: expected_owners,347	})348}349350async fn parse_public(351	public: Option<String>,352	public_file: Option<PathBuf>,353) -> Result<Option<SecretData>> {354	Ok(match (public, public_file) {355		(Some(v), None) => Some(SecretData {356			data: v.into(),357			encrypted: false,358		}),359		(None, Some(v)) => Some(SecretData {360			data: read(v).await?,361			encrypted: false,362		}),363		(Some(_), Some(_)) => {364			bail!("only public or public_file should be set")365		}366		(None, None) => None,367	})368}369370async fn parse_secret() -> Result<Option<Vec<u8>>> {371	let mut input = vec![];372	stdin().read_to_end(&mut input)?;373	if input.is_empty() {374		Ok(None)375	} else {376		Ok(Some(input))377	}378}379380fn parse_machines(381	initial: Vec<String>,382	machines: Option<Vec<String>>,383	mut add_machines: Vec<String>,384	mut remove_machines: Vec<String>,385) -> Result<Vec<String>> {386	if machines.is_none() && add_machines.is_empty() && remove_machines.is_empty() {387		bail!("no operation");388	}389390	let initial_machines = initial.clone();391	let mut target_machines = initial;392	info!("Currently encrypted for {initial_machines:?}");393394	// ensure!(machines.is_some() || !add_machines.is_empty() || )395	if let Some(machines) = machines {396		ensure!(397			add_machines.is_empty() && remove_machines.is_empty(),398			"can't combine --machines and --add-machines/--remove-machines"399		);400		let target = initial_machines.iter().collect::<HashSet<_>>();401		let source = machines.iter().collect::<HashSet<_>>();402		for removed in target.difference(&source) {403			remove_machines.push((*removed).clone());404		}405		for added in source.difference(&target) {406			add_machines.push((*added).clone());407		}408	}409410	for machine in &remove_machines {411		let mut removed = false;412		while let Some(pos) = target_machines.iter().position(|m| m == machine) {413			target_machines.swap_remove(pos);414			removed = true;415		}416		if !removed {417			warn!("secret is not enabled for {machine}");418		}419	}420	for machine in &add_machines {421		if target_machines.iter().any(|m| m == machine) {422			warn!("secret is already added to {machine}");423		} else {424			target_machines.push(machine.to_owned());425		}426	}427	if !remove_machines.is_empty() {428		// TODO: maybe force secret regeneration?429		// Not that useful without revokation.430		warn!("secret will not be regenerated for removed machines, and until host rebuild, they will still possess the ability to decode secret");431	}432	Ok(target_machines)433}434impl Secret {435	pub async fn run(self, config: &Config) -> Result<()> {436		match self {437			Secret::ForceKeys => {438				for host in config.list_hosts().await? {439					if config.should_skip(&host.name) {440						continue;441					}442					config.key(&host.name).await?;443				}444			}445			Secret::AddShared {446				mut machines,447				name,448				force,449				public,450				public_part: public_name,451				public_file,452				expires_at,453				re_add,454				part: part_name,455			} => {456				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).457458				let exists = config.has_shared(&name);459				if exists && !force && !re_add {460					bail!("secret already defined");461				}462				if re_add {463					// Fixme: use clap to limit this usage464					ensure!(!force, "--force and --readd are not compatible");465					ensure!(exists, "secret doesn't exists");466					ensure!(467						machines.is_empty(),468						"you can't use machines argument for --readd"469					);470					let shared = config.shared_secret(&name)?;471					machines = shared.owners;472				}473474				let recipients = config.recipients(machines.clone()).await?;475476				let mut parts = BTreeMap::new();477478				let mut input = vec![];479				io::stdin().read_to_end(&mut input)?;480481				if !input.is_empty() {482					let encrypted = encrypt_secret_data(recipients, input)483						.ok_or_else(|| anyhow!("no recipients provided"))?;484					parts.insert(part_name, FleetSecretPart { raw: encrypted });485				}486487				if let Some(public) = parse_public(public, public_file).await? {488					parts.insert(public_name, FleetSecretPart { raw: public });489				}490491				config.replace_shared(492					name,493					FleetSharedSecret {494						owners: machines,495						secret: FleetSecret {496							created_at: Utc::now(),497							expires_at,498							parts,499						},500					},501				);502			}503			Secret::Add {504				machine,505				name,506				replace,507				merge,508				public,509				public_part: public_name,510				public_file,511				part: part_name,512			} => {513				if config.has_secret(&machine, &name) && !replace && !merge {514					bail!("secret already defined.\nUse --replace to override, or --merge to add new parts to existing secret");515				}516517				let mut out = if merge && !replace {518					config519						.host_secret(&machine, &name)520						.context("failed to read existing secret for --merge")?521				} else {522					FleetSecret {523						created_at: Utc::now(),524						expires_at: None,525						parts: BTreeMap::new(),526					}527				};528529				if let Some(secret) = parse_secret().await? {530					let recipient = config.recipient(&machine).await?;531					let encrypted =532						encrypt_secret_data(vec![recipient], secret).expect("recipient provided");533					if out534						.parts535						.insert(part_name.clone(), FleetSecretPart { raw: encrypted })536						.is_some() && !replace537					{538						bail!("part {part_name:?} is already defined");539					}540				}541542				if let Some(public) = parse_public(public, public_file).await? {543					if out544						.parts545						.insert(public_name.clone(), FleetSecretPart { raw: public })546						.is_some() && !replace547					{548						bail!("part {public_name:?} is already defined");549					}550				};551552				config.insert_secret(&machine, name, out);553			}554			#[allow(clippy::await_holding_refcell_ref)]555			Secret::Read {556				name,557				machine,558				part: part_name,559			} => {560				let secret = config.host_secret(&machine, &name)?;561				let Some(secret) = secret.parts.get(&part_name) else {562					bail!("no part {part_name} in secret {name}");563				};564				let data = if secret.raw.encrypted {565					let host = config.host(&machine).await?;566					host.decrypt(secret.raw.clone()).await?567				} else {568					secret.raw.data.clone()569				};570571				stdout().write_all(&data)?;572			}573			Secret::UpdateShared {574				name,575				machine,576				add_machine,577				remove_machine,578				prefer_identities,579			} => {580				// TODO: Forbid updating secrets with set expectedOwners (= not user-managed).581582				let secret = config.shared_secret(&name)?;583				if secret.secret.parts.values().all(|v| !v.raw.encrypted) {584					bail!("no secret");585				}586587				let initial_machines = secret.owners.clone();588				let target_machines = parse_machines(589					initial_machines.clone(),590					machine,591					add_machine,592					remove_machine,593				)?;594595				if target_machines.is_empty() {596					info!("no machines left for secret, removing it");597					config.remove_shared(&name);598					return Ok(());599				}600601				let config_field = &config.config_unchecked_field;602				let field = nix_go!(config_field.sharedSecrets[{ name }]);603604				let updated = update_owner_set(605					&name,606					config,607					secret,608					field,609					&target_machines,610					&prefer_identities,611				)612				.await?;613				config.replace_shared(name, updated);614			}615			Secret::Regenerate { prefer_identities } => {616				info!("checking for secrets to regenerate");617				{618					let _span = info_span!("shared").entered();619					let expected_shared_set = config620						.list_configured_shared()621						.await?622						.into_iter()623						.collect::<HashSet<_>>();624					let shared_set = config.list_shared().into_iter().collect::<HashSet<_>>();625					for missing in expected_shared_set.difference(&shared_set) {626						let config_field = &config.config_unchecked_field;627						let secret = nix_go!(config_field.sharedSecrets[{ missing }]);628						let expected_owners: Option<Vec<String>> =629							nix_go_json!(secret.expectedOwners);630						let Some(expected_owners) = expected_owners else {631							// TODO: Might still need to regenerate632							continue;633						};634						info!("generating secret: {missing}");635						let shared = generate_shared(config, missing, secret, expected_owners)636							.in_current_span()637							.await?;638						config.replace_shared(missing.to_string(), shared)639					}640				}641				for host in config.list_hosts().await? {642					if config.should_skip(&host.name) {643						continue;644					}645646					let _span = info_span!("host", host = host.name).entered();647					let expected_set = host648						.list_configured_secrets()649						.in_current_span()650						.await?651						.into_iter()652						.collect::<HashSet<_>>();653					let stored_set = config654						.list_secrets(&host.name)655						.into_iter()656						.collect::<HashSet<_>>();657					for missing in expected_set.difference(&stored_set) {658						info!("generating secret: {missing}");659						let secret = host.secret_field(missing).in_current_span().await?;660						let generated =661							match generate(config, missing, secret, &[host.name.clone()])662								.in_current_span()663								.await664							{665								Ok(v) => v,666								Err(e) => {667									error!("{e:?}");668									continue;669								}670							};671						config.insert_secret(&host.name, missing.to_string(), generated)672					}673				}674				let mut to_remove = Vec::new();675				for name in &config.list_shared() {676					info!("updating secret: {name}");677					let data = config.shared_secret(name)?;678					let config_field = &config.config_unchecked_field;679					let expected_owners: Vec<String> =680						nix_go_json!(config_field.sharedSecrets[{ name }].expectedOwners);681					if expected_owners.is_empty() {682						warn!("secret was removed from fleet config: {name}, removing from data");683						to_remove.push(name.to_string());684						continue;685					}686687					let secret = nix_go!(config_field.sharedSecrets[{ name }]);688					config.replace_shared(689						name.to_owned(),690						update_owner_set(691							name,692							config,693							data,694							secret,695							&expected_owners,696							&prefer_identities,697						)698						.await?,699					);700				}701				for k in to_remove {702					config.remove_shared(&k);703				}704			}705			Secret::List {} => {706				let _span = info_span!("loading secrets").entered();707				let configured = config.list_configured_shared().await?;708				#[derive(Tabled)]709				struct SecretDisplay {710					#[tabled(rename = "Name")]711					name: String,712					#[tabled(rename = "Owners")]713					owners: String,714				}715				let mut table = vec![];716				for name in configured.iter().cloned() {717					let config = config.clone();718					let expected_owners = config.shared_secret_expected_owners(&name).await?;719					let data = config.shared_secret(&name)?;720					let owners = data721						.owners722						.iter()723						.map(|o| {724							if expected_owners.contains(o) {725								o.green().to_string()726							} else {727								o.red().to_string()728							}729						})730						.collect::<Vec<_>>();731					table.push(SecretDisplay {732						owners: owners.join(", "),733						name,734					})735				}736				info!("loaded\n{}", Table::new(table).to_string())737			}738			Secret::Edit {739				name,740				machine,741				part,742				add,743			} => {744				let secret = config.host_secret(&machine, &name)?;745				if let Some(data) = secret.parts.get(&part) {746					let host = config.host(&machine).await?;747					let secret = host.decrypt(data.raw.clone()).await?;748					String::from_utf8(secret).context("secret is not utf8")?749				} else if add {750					String::new()751				} else {752					bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");753				};754			}755		}756		Ok(())757	}758}759760async fn edit_temp_file(761	builder: tempfile::Builder<'_, '_>,762	r: Vec<u8>,763	header: &str,764	comment: &str,765) -> Result<(Vec<u8>, Option<String>), anyhow::Error> {766	if !stdin().is_tty() {767		// TODO: Also try to open /dev/tty directly?768		bail!("stdin is not tty, can't open editor");769	}770771	use std::fmt::Write;772	let mut file = builder.tempfile()?;773774	let mut full_header = String::new();775	let mut had = false;776	for line in header.trim_end().lines() {777		had = true;778		writeln!(&mut full_header, "{comment}{line}")?;779	}780	if had {781		writeln!(&mut full_header, "{}", comment.trim_end())?;782	}783	writeln!(784		&mut full_header,785		"{comment}Do not touch this header! It will be removed automatically"786	)?;787788	file.write_all(full_header.as_bytes())?;789	file.write_all(&r)?;790791	let abs_path = file.into_temp_path();792	let editor = std::env::var_os("VISUAL")793		.or_else(|| std::env::var_os("EDITOR"))794		.unwrap_or_else(|| "vi".into());795	let editor_args = shlex::bytes::split(editor.as_encoded_bytes())796		.ok_or_else(|| anyhow!("EDITOR env var has wrong syntax"))?;797	let editor_args = editor_args798		.into_iter()799		.map(|v| {800			// Only ASCII subsequences are replaced801			unsafe { OsString::from_encoded_bytes_unchecked(v) }802		})803		.collect_vec();804	let Some((editor, args)) = editor_args.split_first() else {805		bail!("EDITOR env var has no command");806	};807	let mut command = Command::new(editor);808	command.args(args);809810	let path_arg = abs_path.canonicalize()?;811812	// TODO: Save full state, using tcget/_getmode/_setmode813	let was_raw = terminal::is_raw_mode_enabled()?;814	terminal::enable_raw_mode()?;815816	let status = command.arg(path_arg).status().await;817818	if !was_raw {819		terminal::disable_raw_mode()?;820	}821822	let success = match status {823		Ok(s) => s.success(),824		Err(e) if e.kind() == io::ErrorKind::NotFound => {825			bail!("editor not found")826		}827		Err(e) => bail!("editor spawn error: {e}"),828	};829830	let mut file = std::fs::read(&abs_path).context("read editor output")?;831	let Some(v) = file.strip_prefix(full_header.as_bytes()) else {832		todo!();833	};834	todo!();835836	// Ok((success, abs_path))837}
modifiedcmds/fleet/src/host.rsdiffbeforeafterboth
--- a/cmds/fleet/src/host.rs
+++ b/cmds/fleet/src/host.rs
@@ -1,4 +1,6 @@
 use std::{
+	cell::OnceCell,
+	collections::BTreeMap,
 	env::current_dir,
 	ffi::{OsStr, OsString},
 	fmt::Display,
@@ -10,9 +12,16 @@
 };
 
 use anyhow::{anyhow, bail, ensure, Context, Result};
-use clap::{ArgGroup, Parser};
+use clap::Parser;
 use fleet_shared::SecretData;
 use nix_eval::{nix_go, nix_go_json, NixSessionPool, Value};
+use nom::{
+	bytes::complete::take_while1,
+	character::complete::char,
+	combinator::{map, opt},
+	multi::separated_list1,
+	sequence::{preceded, separated_pair},
+};
 use openssh::SessionBuilder;
 use serde::de::DeserializeOwned;
 use tempfile::NamedTempFile;
@@ -53,10 +62,26 @@
 	pub name: String,
 	pub local: bool,
 	pub session: OnceLock<Arc<openssh::Session>>,
+	groups: OnceCell<Vec<String>>,
 
 	pub nixos_config: Option<Value>,
 }
 impl ConfigHost {
+	pub async fn tags(&self) -> Result<Vec<String>> {
+		if let Some(v) = self.groups.get() {
+			return Ok(v.clone());
+		}
+		// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,
+		// assuming getting tags always returns the same value.
+		let Some(nixos_config) = &self.nixos_config else {
+			return Ok(vec![]);
+		};
+		let tags: Vec<String> = nix_go_json!(nixos_config.tags);
+
+		let _ = self.groups.set(tags.clone());
+
+		Ok(tags)
+	}
 	async fn open_session(&self) -> Result<Arc<openssh::Session>> {
 		assert!(!self.local, "do not open ssh connection to local session");
 		// FIXME: TOCTOU
@@ -217,15 +242,71 @@
 }
 
 impl Config {
-	pub fn should_skip(&self, host: &str) -> bool {
-		if !self.opts.skip.is_empty() {
-			self.opts.skip.iter().any(|h| h as &str == host)
-		} else if !self.opts.only.is_empty() {
-			!self.opts.only.iter().any(|h| h as &str == host)
-		} else {
-			false
+	pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {
+		if !self.opts.skip.is_empty() && self.opts.skip.iter().any(|h| h as &str == host.name) {
+			return Ok(true);
+		}
+		if self.opts.only.is_empty() {
+			return Ok(false);
+		}
+		let mut have_group_matches = false;
+		for item in self.opts.only.iter() {
+			match item {
+				HostItem::Host { name, .. } if *name == host.name => {
+					return Ok(false);
+				}
+				HostItem::Tag { .. } => {
+					have_group_matches = true;
+				}
+				_ => {}
+			}
 		}
+		if have_group_matches {
+			let host_tags = host.tags().await?;
+			for item in self.opts.only.iter() {
+				match item {
+					HostItem::Tag { name, .. } if host_tags.contains(name) => {
+						return Ok(false);
+					}
+					_ => {}
+				}
+			}
+		}
+		Ok(true)
 	}
+	pub async fn action_attr(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {
+		if self.opts.only.is_empty() {
+			return Ok(None);
+		}
+		let mut have_group_matches = false;
+		for item in self.opts.only.iter() {
+			match item {
+				HostItem::Host { name, attrs }
+					if *name == host.name && attrs.contains_key(attr) =>
+				{
+					return Ok(attrs.get(attr).cloned());
+				}
+				HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {
+					have_group_matches = true;
+				}
+				_ => {}
+			}
+		}
+		if have_group_matches {
+			let host_tags = host.tags().await?;
+			for item in self.opts.only.iter() {
+				match item {
+					HostItem::Tag { name, attrs }
+						if host_tags.contains(name) && attrs.contains_key(attr) =>
+					{
+						return Ok(attrs.get(attr).cloned());
+					}
+					_ => {}
+				}
+			}
+		}
+		Ok(None)
+	}
 	pub fn is_local(&self, host: &str) -> bool {
 		self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)
 	}
@@ -237,6 +318,11 @@
 			local: true,
 			session: OnceLock::new(),
 			nixos_config: None,
+			groups: {
+				let cell = OnceCell::new();
+				let _ = cell.set(vec![]);
+				cell
+			},
 		}
 	}
 
@@ -249,6 +335,7 @@
 			local: self.is_local(name),
 			session: OnceLock::new(),
 			nixos_config: Some(nixos_config),
+			groups: OnceCell::new(),
 		})
 	}
 	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {
@@ -356,15 +443,59 @@
 	}
 }
 
+#[derive(Clone)]
+enum HostItem {
+	Host {
+		name: String,
+		attrs: BTreeMap<String, String>,
+	},
+	Tag {
+		name: String,
+		attrs: BTreeMap<String, String>,
+	},
+}
+fn host_item_parser(input: &str) -> Result<HostItem, String> {
+	fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {
+		err.to_string()
+	}
+
+	let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;
+	let (input, name) = map(
+		take_while1(|v| v != ',' && v != '?' && v != '@'),
+		str::to_owned,
+	)(input)
+	.map_err(err_to_string)?;
+
+	let kw_item = separated_pair(
+		map(take_while1(|v| v != '&' && v != '='), str::to_owned),
+		char('='),
+		map(take_while1(|v| v != '&'), str::to_owned),
+	);
+	let kw = map(separated_list1(char('&'), kw_item), |vec| {
+		vec.into_iter().collect::<BTreeMap<_, _>>()
+	});
+	let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);
+
+	let (input, attrs) = opt_kw(input).map_err(err_to_string)?;
+
+	if !input.is_empty() {
+		return Err(format!("unexpected trailing input: {input:?}"));
+	}
+	Ok(if is_tag {
+		HostItem::Tag { name, attrs }
+	} else {
+		HostItem::Host { name, attrs }
+	})
+}
+
 #[derive(Parser, Clone)]
-#[clap(group = ArgGroup::new("target_hosts"))]
 pub struct FleetOpts {
 	/// All hosts except those would be skipped
-	#[clap(long, number_of_values = 1, group = "target_hosts")]
-	only: Vec<String>,
+	#[clap(long, number_of_values = 1, value_parser = host_item_parser)]
+	only: Vec<HostItem>,
 
 	/// Hosts to skip
-	#[clap(long, number_of_values = 1, group = "target_hosts")]
+	#[clap(long, number_of_values = 1)]
 	skip: Vec<String>,
 
 	/// Host, which should be threaten as current machine
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -7,11 +7,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1720226507,
-        "narHash": "sha256-yHVvNsgrpyNTXZBEokL8uyB2J6gB1wEx0KOJzoeZi1A=",
+        "lastModified": 1721699339,
+        "narHash": "sha256-UqtSwU13vpzzM6w8tGghEbA7ObM3NCDzSpz19QQo9XE=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "0aed560c5c0a61c9385bddff471a13036203e11c",
+        "rev": "0081e9c447f3b70822c142908f08ceeb436982b8",
         "type": "github"
       },
       "original": {
@@ -40,11 +40,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1720525988,
-        "narHash": "sha256-6Vvrwl2rKrRt5gAYTFlM/pihCwHw8SY2o81TBm7KhIQ=",
+        "lastModified": 1721814637,
+        "narHash": "sha256-L3QkCvxeByJfW45wLkdZ9pL5h9PezOwwfx7G2sRfjiU=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "a630e7a8476e51b116f1ca7444dbad20701823d7",
+        "rev": "e0c444a0b8413a31df199052f5714d409dc4c1d0",
         "type": "github"
       },
       "original": {
@@ -68,11 +68,11 @@
     },
     "nixpkgs-stable-for-tests": {
       "locked": {
-        "lastModified": 1720386169,
-        "narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=",
+        "lastModified": 1721548954,
+        "narHash": "sha256-7cCC8+Tdq1+3OPyc3+gVo9dzUNkNIQfwSDJ2HSi2u3o=",
         "owner": "nixos",
         "repo": "nixpkgs",
-        "rev": "194846768975b7ad2c4988bdb82572c00222c0d7",
+        "rev": "63d37ccd2d178d54e7fb691d7ec76000740ea24a",
         "type": "github"
       },
       "original": {
@@ -98,11 +98,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1720491570,
-        "narHash": "sha256-PHS2BcQ9kxBpu9GKlDg3uAlrX/ahQOoAiVmwGl6BjD4=",
+        "lastModified": 1721810656,
+        "narHash": "sha256-33UCMmgPL+sz06+iupNkl99hcBABP56ENcxSoKqr0TY=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "b970af40fdc4bd80fd764796c5f97c15e2b564eb",
+        "rev": "a6afdaab4a47d6ecf647a74968e92a51c4a18e5a",
         "type": "github"
       },
       "original": {