git.delta.rocks / jrsonnet / refs/commits / 8fa5c73b5fe4

difftreelog

feat use fleet-tf

usksyzorYaroslav Bolyukin2025-10-01parent: #1b17cca.patch.diff
in: trunk

10 files changed

modifiedcmds/fleet/Cargo.tomldiffbeforeafterboth
--- a/cmds/fleet/Cargo.toml
+++ b/cmds/fleet/Cargo.toml
@@ -45,10 +45,10 @@
 human-repr = { version = "1.1", optional = true }
 indicatif = { version = "0.18", optional = true }
 nom = "8.0.0"
+opentelemetry = "0.30.0"
+opentelemetry_sdk = "0.30.0"
 tracing-indicatif = { version = "0.3", optional = true }
 tracing-opentelemetry = "0.31.0"
-opentelemetry = "0.30.0"
-opentelemetry_sdk = "0.30.0"
 
 [features]
 default = []
modifiedcmds/fleet/src/cmds/tf.rsdiffbeforeafterboth
--- a/cmds/fleet/src/cmds/tf.rs
+++ b/cmds/fleet/src/cmds/tf.rs
@@ -38,7 +38,7 @@
 		{
 			debug!("generating terraform configs");
 			let system = &config.local_system;
-			let config = &config.config_field;
+			let config = &config.flake_outputs;
 			let data = nix_go!(config.tf({ system }));
 			let data: PathBuf = spawn_blocking(move || data.build("out"))
 				.await
modifiedcrates/fleet-base/src/host.rsdiffbeforeafterboth
before · crates/fleet-base/src/host.rs
1use std::{2	cell::OnceCell,3	collections::BTreeSet,4	ffi::{OsStr, OsString},5	fmt::Display,6	io::Write,7	ops::Deref,8	path::PathBuf,9	str::FromStr,10	sync::{Arc, Mutex, MutexGuard, OnceLock},11};1213use anyhow::{Context, Result, anyhow, bail, ensure};14use fleet_shared::SecretData;15use nix_eval::{Value, nix_go, nix_go_json, util::assert_warn};16use openssh::{ControlPersist, SessionBuilder};17use serde::de::DeserializeOwned;18use tabled::Tabled;19use tempfile::NamedTempFile;20use time::{UtcDateTime, format_description};21use tracing::warn;2223use crate::{24	command::MyCommand,25	fleetdata::{FleetData, FleetSecret, FleetSharedSecret},26};2728pub struct FleetConfigInternals {29	/// Fleet project directory, containing fleet.nix file.30	pub directory: PathBuf,31	/// builtins.currentSystem32	pub local_system: String,33	pub data: Mutex<FleetData>,34	pub nix_args: Vec<OsString>,35	/// fleet_config.config36	pub config_field: Value,37	// TODO: Remove with connectivity refactor38	pub localhost: String,3940	/// import nixpkgs {system = local};41	pub default_pkgs: Value,42	/// inputs.nixpkgs43	pub nixpkgs: Value,44}4546// TODO: Make field not pub47#[derive(Clone)]48pub struct Config(pub Arc<FleetConfigInternals>);4950impl Deref for Config {51	type Target = FleetConfigInternals;5253	fn deref(&self) -> &Self::Target {54		&self.055	}56}5758#[derive(Clone, Copy, Debug)]59pub enum EscalationStrategy {60	Sudo,61	Run0,62	Su,63}6465#[derive(Clone, PartialEq, Copy, Debug)]66pub enum DeployKind {67	/// NixOS => NixOS managed by fleet68	UpgradeToFleet,69	/// NixOS managed by fleet => NixOS managed by fleet70	Fleet,71	/// Remote host has /mnt, /mnt/boot mounted,72	/// generated config is added to fleet configuration.73	NixosInstall,74	/// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),75	/// generated config is added to fleet configuration,76	/// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.77	NixosLustrate,78}7980impl FromStr for DeployKind {81	type Err = anyhow::Error;82	fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {83		match s {84			"upgrade-to-fleet" => Ok(Self::UpgradeToFleet),85			"fleet" => Ok(Self::Fleet),86			"nixos-install" => Ok(Self::NixosInstall),87			"nixos-lustrate" => Ok(Self::NixosLustrate),88			v => bail!(89				"unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""90			),91		}92	}93}94pub struct ConfigHost {95	config: Config,96	pub name: String,97	groups: OnceCell<Vec<String>>,9899	// TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it100	deploy_kind: OnceCell<DeployKind>,101	session_destination: OnceCell<String>,102	legacy_ssh_store: OnceCell<bool>,103104	pub host_config: Option<Value>,105	pub nixos_config: OnceCell<Value>,106	pub nixos_unchecked_config: OnceCell<Value>,107	pub pkgs_override: Option<Value>,108109	// TODO: Move command helpers away with connectivity refactor110	pub local: bool,111	pub session: OnceLock<Arc<openssh::Session>>,112}113114#[derive(Debug, Clone, Copy)]115pub enum GenerationStorage {116	Deployer,117	Machine,118	Pusher,119}120impl GenerationStorage {121	fn prefix(&self) -> &'static str {122		match self {123			GenerationStorage::Deployer => "deployer.",124			GenerationStorage::Machine => "",125			GenerationStorage::Pusher => "pusher.",126		}127	}128}129130#[derive(Tabled, Debug)]131pub struct Generation {132	#[tabled(rename = "ID", format("{}", self.rollback_id()))]133	pub id: u32,134	#[tabled(rename = "Current")]135	pub current: bool,136	#[tabled(rename = "Created at")]137	pub datetime: UtcDateTime,138	#[tabled(format = "{:?}")]139	pub store_path: PathBuf,140	#[tabled(skip)]141	pub location: GenerationStorage,142}143impl Generation {144	pub fn rollback_id(&self) -> String {145		format!("{}{}", self.location.prefix(), self.id)146	}147}148149fn parse_generation_line(g: &str) -> Option<Generation> {150	let mut parts = g.split_whitespace();151	let id = parts.next()?;152	let id: u32 = id.parse().ok()?;153	let date = parts.next()?;154	let time = parts.next()?;155	let current = if let Some(current) = parts.next() {156		if current == "(current)" {157			Some(true)158		} else {159			None160		}161	} else {162		Some(false)163	};164	let current = current?;165	if parts.next().is_some() {166		warn!("unexpected text after generation: {g}");167	}168169	let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")170		.expect("valid format");171	let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;172173	Some(Generation {174		id,175		current,176		datetime,177		store_path: PathBuf::new(),178		location: GenerationStorage::Machine,179	})180}181// TODO: Move command helpers away with connectivity refactor182impl ConfigHost {183	pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {184		let mut cmd = self.cmd("nix-env").await?;185		cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))186			.arg("--list-generations")187			.env("TZ", "UTC");188		// Sudo is required because --list-generations tries to acquire profile lock189		let data = cmd.sudo().run_string().await?;190		let mut generations = data191			.split('\n')192			.map(|e| e.trim())193			.filter(|&l| !l.is_empty())194			.filter_map(|g| {195				let generation = parse_generation_line(g);196				if generation.is_none() {197					warn!("bad generation: {g}");198				};199				generation200			})201			.collect::<Vec<_>>();202		for ele in generations.iter_mut() {203			let mut cmd = self.cmd("readlink").await?;204			cmd.arg("--")205				.arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));206			let path = cmd.run_string().await?;207			ele.store_path = PathBuf::from(path.trim_end_matches("\n"));208		}209210		Ok(generations)211	}212213	pub fn set_session_destination(&self, dest: String) {214		self.session_destination215			.set(dest)216			.expect("session destination is already set")217	}218	pub fn set_deploy_kind(&self, kind: DeployKind) {219		self.deploy_kind220			.set(kind)221			.expect("deploy kind is already set");222	}223	pub fn set_legacy_ssh_store(&self, legacy: bool) {224		self.legacy_ssh_store225			.set(legacy)226			.expect("legacy ssh store is already set")227	}228	pub async fn deploy_kind(&self) -> Result<DeployKind> {229		if let Some(kind) = self.deploy_kind.get() {230			return Ok(*kind);231		}232		let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {233			Ok(v) => v,234			Err(e) => {235				bail!("failed to query remote system kind: {}", e);236			}237		};238		if !is_fleet_managed {239			bail!(240				"{}",241				indoc::indoc! {"242				host is not marked as managed by fleet243				if you're not trying to lustrate/install system from scratch,244				you should either245					1. manually create /etc/FLEET_HOST file on the target host,246					2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet247					3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos248			"}249			);250		}251		// TOCTOU is possible252		let _ = self.deploy_kind.set(DeployKind::Fleet);253		Ok(*self.deploy_kind.get().expect("deploy kind is just set"))254	}255	pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {256		// Prefer sudo, as run0 has some gotchas with polkit257		// and too many repeating prompts.258		if (self.find_in_path("sudo").await).is_ok() {259			return Ok(EscalationStrategy::Sudo);260		}261		if (self.find_in_path("run0").await).is_ok() {262			return Ok(EscalationStrategy::Run0);263		}264		Ok(EscalationStrategy::Su)265	}266	async fn open_session(&self) -> Result<Arc<openssh::Session>> {267		assert!(!self.local, "do not open ssh connection to local session");268		// FIXME: TOCTOU269		if let Some(session) = &self.session.get() {270			return Ok((*session).clone());271		};272		let mut session = SessionBuilder::default();273		session.control_persist(ControlPersist::ClosedAfterInitialConnection);274275		let dest = self.session_destination.get().unwrap_or(&self.name);276		let session = session277			.connect(&dest)278			.await279			.map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;280		let session = Arc::new(session);281		self.session.set(session.clone()).expect("TOCTOU happened");282		Ok(session)283	}284	pub async fn mktemp_dir(&self) -> Result<String> {285		let mut cmd = self.cmd("mktemp").await?;286		cmd.arg("-d");287		let path = cmd.run_string().await?;288		Ok(path.trim_end().to_owned())289	}290	pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {291		let mut cmd = self.cmd("sh").await?;292		cmd.arg("-c")293			.arg("test -e \"$1\" && echo true || echo false")294			.arg("_")295			.arg(path);296		cmd.run_value().await297	}298	pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {299		let mut cmd = self.cmd("cat").await?;300		cmd.arg(path);301		cmd.run_bytes().await302	}303	pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {304		let mut cmd = self.cmd("cat").await?;305		cmd.arg(path);306		cmd.run_string().await307	}308	pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {309		let mut cmd = self.cmd("ls").await?;310		cmd.arg(path);311		let out = cmd.run_string().await?;312		let mut lines = out.split('\n');313		if let Some(last) = lines.next_back() {314			ensure!(last.is_empty(), "output of ls should end with newline");315		}316		Ok(lines.map(ToOwned::to_owned).collect())317	}318	#[allow(dead_code)]319	pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {320		let text = self.read_file_text(path).await?;321		Ok(serde_json::from_str(&text)?)322	}323	pub async fn read_env(&self, env: &str) -> Result<String> {324		let mut cmd = self.cmd("printenv").await?;325		cmd.arg(env);326		cmd.run_string().await327	}328	pub async fn find_in_path(&self, command: &str) -> Result<String> {329		// // `which` is not a part of coreutils, and it might not exist on machine.330		// let path = self.read_env("PATH").await?;331		// // Assuming delimiter is :, we don't work with windows host, this check will be much332		// // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)333		// for ele in path.split(':') {334		// 	let test_path = format!("{ele}/{cmd}");335		// 	test -x etc336		// }337		// let mut cmd = self.cmd("printenv").await?;338		// cmd.arg(env);339		// Ok(cmd.run_string().await?)340		// Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.341		let mut cmd = self342			.cmd_escalation(343				// Not used344				EscalationStrategy::Su,345				"which",346			)347			.await?;348		cmd.arg(command);349		cmd.run_string().await350	}351	pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>352	where353		<D as FromStr>::Err: Display,354	{355		let text = self.read_file_text(path).await?;356		D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))357	}358	pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {359		self.cmd_escalation(self.escalation_strategy().await?, cmd)360			.await361	}362	pub async fn cmd_escalation(363		&self,364		escalation: EscalationStrategy,365		cmd: impl AsRef<OsStr>,366	) -> Result<MyCommand> {367		if self.local {368			Ok(MyCommand::new(escalation, cmd))369		} else {370			let session = self.open_session().await?;371			Ok(MyCommand::new_on(escalation, cmd, session))372		}373	}374	pub async fn nix_cmd(&self) -> Result<MyCommand> {375		let mut nix = self.cmd("nix").await?;376		nix.args([377			"--extra-experimental-features",378			"nix-command",379			"--extra-experimental-features",380			"flakes",381		]);382		Ok(nix)383	}384385	pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {386		ensure!(data.encrypted, "secret is not encrypted");387		let mut cmd = self.cmd("fleet-install-secrets").await?;388		cmd.arg("decrypt").eqarg("--secret", data.to_string());389		let encoded = cmd390			.sudo()391			.run_string()392			.await393			.context("failed to call remote host for decrypt")?;394		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;395		ensure!(!data.encrypted, "secret came out encrypted");396		Ok(data.data)397	}398	pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {399		ensure!(data.encrypted, "secret is not encrypted");400		let mut cmd = self.cmd("fleet-install-secrets").await?;401		cmd.arg("reencrypt").eqarg("--secret", data.to_string());402		for target in targets {403			let key = self.config.key(&target).await?;404			cmd.eqarg("--targets", key);405		}406		let encoded = cmd407			.sudo()408			.run_string()409			.await410			.context("failed to call remote host for decrypt")?;411		let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;412		ensure!(data.encrypted, "secret came out not encrypted");413		Ok(data)414	}415	/// Returns path for futureproofing, as path might change i.e on conversion to CA416	pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {417		if self.local {418			// Path is located locally, thus already trusted.419			return Ok(path.to_owned());420		}421		let mut nix = MyCommand::new(422			// Not used423			EscalationStrategy::Su,424			"nix",425		);426		nix.arg("copy").arg("--substitute-on-destination");427428		let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {429			"ssh"430		} else {431			"ssh-ng"432		};433434		match self.deploy_kind().await? {435			DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {436				nix.comparg("--to", format!("{proto}://{}", self.name));437			}438			DeployKind::NixosInstall => {439				nix440					// Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon441					.arg("--no-check-sigs")442					.comparg(443						"--to",444						format!("{proto}://root@{}?remote-store=/mnt", self.name),445					);446			}447		}448		nix.arg(path);449		nix.run_nix().await.context("nix copy")?;450		Ok(path.to_owned())451	}452	pub async fn systemctl_stop(&self, name: &str) -> Result<()> {453		let mut cmd = self.cmd("systemctl").await?;454		cmd.arg("stop").arg(name);455		cmd.sudo().run().await456	}457	pub async fn systemctl_start(&self, name: &str) -> Result<()> {458		let mut cmd = self.cmd("systemctl").await?;459		cmd.arg("start").arg(name);460		cmd.sudo().run().await461	}462463	pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {464		let mut cmd = self.cmd("rm").await?;465		cmd.arg("-f").arg(path);466		if sudo {467			cmd = cmd.sudo()468		}469		cmd.run().await470	}471}472impl ConfigHost {473	// TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,474	// assuming getting tags always returns the same value.475	pub async fn tags(&self) -> Result<Vec<String>> {476		if let Some(v) = self.groups.get() {477			return Ok(v.clone());478		}479		let Some(host_config) = &self.host_config else {480			return Ok(vec![]);481		};482		let tags: Vec<String> = nix_go_json!(host_config.tags);483484		let _ = self.groups.set(tags.clone());485486		Ok(tags)487	}488	pub async fn nixos_config(&self) -> Result<Value> {489		if let Some(v) = self.nixos_config.get() {490			return Ok(v.clone());491		}492		let Some(host_config) = &self.host_config else {493			bail!("local host has no nixos_config");494		};495		let nixos_config = nix_go!(host_config.nixos.config);496		assert_warn("nixos config evaluation", &nixos_config).await?;497498		let _ = self.nixos_config.set(nixos_config.clone());499500		Ok(nixos_config)501	}502	pub async fn nixos_unchecked_config(&self) -> Result<Value> {503		if let Some(v) = self.nixos_unchecked_config.get() {504			return Ok(v.clone());505		}506		let Some(host_config) = &self.host_config else {507			bail!("local host has no nixos_config");508		};509		let nixos_config = nix_go!(host_config.nixos_unchecked.config);510511		let _ = self.nixos_unchecked_config.set(nixos_config.clone());512513		Ok(nixos_config)514	}515516	pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {517		let nixos = self.nixos_unchecked_config().await?;518		let secrets = nix_go!(nixos.secrets);519		let mut out = Vec::new();520		for name in secrets.list_fields()? {521			let secret = secrets.get_field(&name)?;522			let is_shared: bool = nix_go_json!(secret.shared);523			if is_shared {524				continue;525			}526			out.push(name);527		}528		Ok(out)529	}530	pub async fn secret_field(&self, name: &str) -> Result<Value> {531		let nixos = self.nixos_unchecked_config().await?;532		Ok(nix_go!(nixos.secrets[{ name }]))533	}534535	/// Packages for this host, resolved with nixpkgs overlays536	pub async fn pkgs(&self) -> Result<Value> {537		if let Some(value) = &self.pkgs_override {538			return Ok(value.clone());539		}540		let Some(host_config) = &self.host_config else {541			bail!("local host has no host_config");542		};543		// TODO: Should nixos.options be cached?544		Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))545	}546}547548impl Config {549	pub async fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {550		let config = &self.config_field;551		let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);552		Ok(tagged)553	}554	pub async fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {555		let mut out = BTreeSet::new();556		for owner in owners {557			if let Some(tag) = owner.strip_prefix('@') {558				let hosts = self.tagged_hostnames(tag).await?;559				out.extend(hosts);560			} else {561				out.insert(owner);562			}563		}564		Ok(out)565	}566	pub fn local_host(&self) -> ConfigHost {567		ConfigHost {568			config: self.clone(),569			name: "<virtual localhost>".to_owned(),570			host_config: None,571			nixos_config: OnceCell::new(),572			nixos_unchecked_config: OnceCell::new(),573			groups: {574				let cell = OnceCell::new();575				let _ = cell.set(vec![]);576				cell577			},578			pkgs_override: Some(self.default_pkgs.clone()),579580			local: true,581			session: OnceLock::new(),582			deploy_kind: OnceCell::new(),583			session_destination: OnceCell::new(),584			legacy_ssh_store: OnceCell::new(),585		}586	}587588	pub async fn host(&self, name: &str) -> Result<ConfigHost> {589		let config = &self.config_field;590		let host_config = nix_go!(config.hosts[{ name }]);591592		Ok(ConfigHost {593			config: self.clone(),594			name: name.to_owned(),595			host_config: Some(host_config),596			nixos_config: OnceCell::new(),597			nixos_unchecked_config: OnceCell::new(),598			groups: OnceCell::new(),599			pkgs_override: None,600601			// TODO: Remove with connectivit refactor602			local: self.localhost == name,603			session: OnceLock::new(),604			deploy_kind: OnceCell::new(),605			session_destination: OnceCell::new(),606			legacy_ssh_store: OnceCell::new(),607		})608	}609	pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {610		let config = &self.config_field;611		let names = nix_go!(config.hosts).list_fields()?;612		let mut out = vec![];613		for name in names {614			out.push(self.host(&name).await?);615		}616		Ok(out)617	}618	// TODO: Replace usages with .host().nixos_config619	pub async fn system_config(&self, host: &str) -> Result<Value> {620		let fleet_field = &self.config_field;621		Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))622	}623624	/// Shared secrets configured in fleet.nix or in flake625	pub async fn list_configured_shared(&self) -> Result<Vec<String>> {626		let config_field = &self.config_field;627		nix_go!(config_field.sharedSecrets).list_fields()628	}629	/// Shared secrets configured in fleet.nix630	pub fn list_shared(&self) -> Vec<String> {631		let data = self.data();632		data.shared_secrets.keys().cloned().collect()633	}634	pub fn has_shared(&self, name: &str) -> bool {635		let data = self.data();636		data.shared_secrets.contains_key(name)637	}638	pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {639		let mut data = self.data_mut();640		data.shared_secrets.insert(name.to_owned(), shared);641	}642	pub fn remove_shared(&self, secret: &str) {643		let mut data = self.data_mut();644		data.shared_secrets.remove(secret);645	}646647	pub fn list_secrets(&self, host: &str) -> Vec<String> {648		let data = self.data();649		let Some(secrets) = data.host_secrets.get(host) else {650			return Vec::new();651		};652		secrets.keys().cloned().collect()653	}654655	pub fn has_secret(&self, host: &str, secret: &str) -> bool {656		let data = self.data();657		let Some(host_secrets) = data.host_secrets.get(host) else {658			return false;659		};660		host_secrets.contains_key(secret)661	}662	pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {663		let mut data = self.data_mut();664		let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();665		host_secrets.insert(secret, value);666	}667668	pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {669		let data = self.data();670		let Some(host_secrets) = data.host_secrets.get(host) else {671			bail!("no secrets for machine {host}");672		};673		let Some(secret) = host_secrets.get(secret) else {674			bail!("machine {host} has no secret {secret}");675		};676		Ok(secret.clone())677	}678	pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {679		let data = self.data();680		let Some(secret) = data.shared_secrets.get(secret) else {681			bail!("no shared secret {secret}");682		};683		Ok(secret.clone())684	}685	pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {686		let config_field = &self.config_field;687		Ok(nix_go_json!(688			config_field.sharedSecrets[{ secret }].expectedOwners689		))690	}691692	// TODO: Should this be something modifiable from other processes?693	// E.g terraform provider might want to update FleetData (e.g secrets),694	// and current implementation assumes only one process holds current fleet.nix695	// Given that it is no longer needs to be a file for nix evaluation,696	// maybe it can be a .nix file for persistence, but accessible only697	// thru some shared state controller? Might it be stored in terraform698	// state provider?699	pub fn data(&'_ self) -> MutexGuard<'_, FleetData> {700		self.data.lock().unwrap()701	}702	pub fn data_mut(&'_ self) -> MutexGuard<'_, FleetData> {703		self.data.lock().unwrap()704	}705	pub fn save(&self) -> Result<()> {706		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.")?;707		let data = nixlike::serialize(&self.data() as &FleetData)?;708		tempfile.write_all(709			format!(710				"# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"711			)712			.as_bytes(),713		)?;714		let mut fleet_data_path = self.directory.clone();715		fleet_data_path.push("fleet.nix");716		tempfile.persist(fleet_data_path)?;717		Ok(())718	}719}
modifiedcrates/fleet-base/src/opts.rsdiffbeforeafterboth
--- a/crates/fleet-base/src/opts.rs
+++ b/crates/fleet-base/src/opts.rs
@@ -267,6 +267,7 @@
 		Ok(Config(Arc::new(FleetConfigInternals {
 			directory,
 			data,
+			flake_outputs: flake,
 			local_system: self.local_system.clone(),
 			nix_args,
 			config_field,
modifiedcrates/fleet-shared/src/encoding.rsdiffbeforeafterboth
--- a/crates/fleet-shared/src/encoding.rs
+++ b/crates/fleet-shared/src/encoding.rs
@@ -1,5 +1,7 @@
 use std::{
-	collections::BTreeMap, fmt::{self, Display}, str::FromStr
+	collections::BTreeMap,
+	fmt::{self, Display},
+	str::FromStr,
 };
 
 use base64::engine::{Engine, general_purpose::STANDARD_NO_PAD};
modifiedcrates/nix-eval/src/logging.ccdiffbeforeafterboth
--- a/crates/nix-eval/src/logging.cc
+++ b/crates/nix-eval/src/logging.cc
@@ -9,12 +9,14 @@
 
   bool isVerbose() override { return true; }
   void log(Verbosity lvl, std::string_view s) override {
-    rust::Slice<const unsigned char> str(reinterpret_cast<const unsigned char*>(s.data()), s.size());
+    rust::Slice<const unsigned char> str(
+        reinterpret_cast<const unsigned char *>(s.data()), s.size());
     emit_log(lvl, str);
   }
   void logEI(const ErrorInfo &ei) override {
     auto s = ei.msg.str();
-    rust::Slice<const unsigned char> str(reinterpret_cast<const unsigned char*>(s.data()), s.size());
+    rust::Slice<const unsigned char> str(
+        reinterpret_cast<const unsigned char *>(s.data()), s.size());
     emit_log(ei.level, str);
   }
 
@@ -27,7 +29,8 @@
         b->add_int_field(f.i);
       } else if (f.type == Logger::Field::tString) {
         auto s = &f.s;
-        rust::Slice<const unsigned char> str(reinterpret_cast<const unsigned char*>(s->data()), s->size());
+        rust::Slice<const unsigned char> str(
+            reinterpret_cast<const unsigned char *>(s->data()), s->size());
         b->add_string_field(str);
       } else {
         unreachable();
@@ -45,7 +48,8 @@
         b->add_int_field(f.i);
       } else if (f.type == Logger::Field::tString) {
         auto s = &f.s;
-        rust::Slice<const unsigned char> str(reinterpret_cast<const unsigned char*>(s->data()), s->size());
+        rust::Slice<const unsigned char> str(
+            reinterpret_cast<const unsigned char *>(s->data()), s->size());
         b->add_string_field(str);
       } else {
         unreachable();
modifiedflake.lockdiffbeforeafterboth
--- a/flake.lock
+++ b/flake.lock
@@ -71,6 +71,31 @@
         "url": "https://flakehub.com/f/hercules-ci/flake-parts/0.1"
       }
     },
+    "fleet-tf": {
+      "inputs": {
+        "flake-parts": [
+          "flake-parts"
+        ],
+        "nixpkgs": [
+          "nixpkgs"
+        ],
+        "shelly": [
+          "shelly"
+        ]
+      },
+      "locked": {
+        "lastModified": 1759080490,
+        "owner": "CertainLach",
+        "repo": "fleet-tf",
+        "rev": "878bd8c23933d628bf750378bbe527b841901c3d",
+        "type": "github"
+      },
+      "original": {
+        "owner": "CertainLach",
+        "repo": "fleet-tf",
+        "type": "github"
+      }
+    },
     "git-hooks-nix": {
       "inputs": {
         "flake-compat": "flake-compat",
@@ -183,6 +208,7 @@
       "inputs": {
         "crane": "crane",
         "flake-parts": "flake-parts",
+        "fleet-tf": "fleet-tf",
         "nix": "nix",
         "nixpkgs": "nixpkgs_2",
         "rust-overlay": "rust-overlay",
modifiedflake.nixdiffbeforeafterboth
--- a/flake.nix
+++ b/flake.nix
@@ -13,6 +13,12 @@
     };
     crane.url = "github:ipetkov/crane";
     shelly.url = "github:CertainLach/shelly";
+    fleet-tf = {
+      url = "github:CertainLach/fleet-tf";
+      inputs.nixpkgs.follows = "nixpkgs";
+      inputs.shelly.follows = "shelly";
+      inputs.flake-parts.follows = "flake-parts";
+    };
     treefmt-nix = {
       url = "github:numtide/treefmt-nix";
       inputs.nixpkgs.follows = "nixpkgs";
@@ -42,7 +48,7 @@
           };
           flakeModule = flakeModules.default;
 
-          fleetModules.tf = ./modules/extras/tf.nix;
+          flakeModules.fleet-tf = ./modules/extras/tf.nix;
 
           # Used to test nix-eval bindings
           testData = {
@@ -114,13 +120,16 @@
           {
             _module.args.pkgs = import inputs.nixpkgs {
               inherit system;
-              overlays = [ (inputs.rust-overlay.overlays.default) (final: prev: {
-                boehmgc = prev.boehmgc.overrideAttrs (prevAttrs: {
-                  configureFlags = prevAttrs.configureFlags ++ [
-                    "--enable-gc-assertions"
-                  ];
-                });
-              }) ];
+              overlays = [
+                (inputs.rust-overlay.overlays.default)
+                (final: prev: {
+                  boehmgc = prev.boehmgc.overrideAttrs (prevAttrs: {
+                    configureFlags = prevAttrs.configureFlags ++ [
+                      "--enable-gc-assertions"
+                    ];
+                  });
+                })
+              ];
             };
             # Reference fleet package should be built with nightly rust, specified in rust-toolchain.toml.
             packages = lib.mkIf deployerSystem (
addedmodules/extras/tf-bootstrap.nixdiffbeforeafterboth
--- /dev/null
+++ b/modules/extras/tf-bootstrap.nix
@@ -0,0 +1,37 @@
+{
+  lib,
+  inputs',
+  pkgs,
+  config,
+  ...
+}:
+let
+  inherit (lib.options) mkOption mkPackageOption;
+  inherit (lib.types) listOf package functionTo;
+in
+{
+  options = {
+    tf.package = mkPackageOption pkgs "terraform" {
+      extraDescription = "Terraform package to use";
+    };
+    tf.providers = mkOption {
+      description = "List of used terraform providers";
+      type = functionTo (listOf package);
+      default = _: [ ];
+    };
+    tf.finalPackage = mkOption {
+      description = "Terraform package with all providers";
+      type = package;
+    };
+  };
+  config = {
+    tf.finalPackage = inputs'.fleet-tf.packages.terraform-locked.override {
+      inherit (config.tf) providers;
+      terraform = config.tf.package;
+    };
+    shelly.shells.default = {
+      packages = [ config.tf.finalPackage ];
+    };
+    packages.terraform = config.tf.finalPackage;
+  };
+}
modifiedmodules/extras/tf.nixdiffbeforeafterboth
--- a/modules/extras/tf.nix
+++ b/modules/extras/tf.nix
@@ -11,6 +11,7 @@
   inherit (fleetLib.options) mkDataOption;
 in
 {
+
   options = {
     tf = mkOption {
       type = deferredModule;
@@ -18,7 +19,7 @@
         module: system:
         inputs.terranix.lib.terranixConfiguration {
           inherit system;
-          pkgs = config.nixpkgs.buildUsing.legacyPackages.${system};
+          pkgs = inputs.nixpkgs.legacyPackages.${system};
           modules = [
             module
           ];
@@ -35,6 +36,8 @@
   };
 
   config = {
+    flake.tf = config.tf;
+
     tf.output.fleet = {
       value = {
         managed = true;
@@ -43,6 +46,8 @@
       # will be somehow processed by fleet tf.
       sensitive = true;
     };
-    hosts = config.data.extra.terraformHosts;
+    fleetConfigurations.default.hosts = config.data.extra.terraformHosts;
+
+    perSystem.imports = [ ./tf-bootstrap.nix ];
   };
 }