difftreelog
feat fleet secret read command reimplementation
in: trunk
2 files changed
cmds/fleet/src/cmds/secrets.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/secrets.rs
+++ b/cmds/fleet/src/cmds/secrets.rs
@@ -1,16 +1,10 @@
-use std::{
- collections::{BTreeSet, HashSet},
- io::{Read, stdin},
- path::PathBuf,
-};
+use std::io::{Write as _, stdout};
-use anyhow::{Context as _, Result, anyhow, bail, ensure};
+use anyhow::{Context as _, Result, anyhow, bail};
use clap::Parser;
use fleet_base::{fleetdata::SecretOwner, host::Config, opts::FleetOpts};
-use fleet_shared::SecretData;
-use itertools::{ExactlyOneError, Itertools as _};
-use tokio::fs::read;
-use tracing::{info, warn};
+use itertools::Itertools as _;
+use tracing::warn;
#[derive(Parser)]
pub enum Secret {
@@ -27,13 +21,9 @@
machine: Option<String>,
/// Which private secret part to read
- #[clap(short = 'p', long, default_value = "secret")]
+ /// If not specified - only one existing part is read
+ #[clap(short = 'p', long)]
part: Option<String>,
-
- /// Which host should we use to decrypt, in case if reencryption is required, without
- /// regeneration
- #[clap(long)]
- prefer_identities: Vec<String>,
},
/// Prune (remove, mark for regeneration) secrets
Prune {
@@ -54,18 +44,6 @@
machine: Vec<String>,
},
List {},
- Edit {
- name: String,
- #[clap(short = 'm', long)]
- machine: String,
-
- #[clap(long)]
- add: bool,
-
- /// Which private secret part to read
- #[clap(short = 'p', long, default_value = "secret")]
- part: String,
- },
}
impl Secret {
@@ -83,76 +61,65 @@
name,
machine,
part: part_name,
- mut prefer_identities,
} => {
- let secret = config.data.secrets.read().expect("not poisoned");
-
- let Some(dist) = secret.get("name") else {
- bail!("secret doesn't exists");
- };
-
- let dist = if let Some(machine) = &machine {
- dist.get(&SecretOwner::host(machine))
- .ok_or_else(|| anyhow!("machine {machine} has no secret generated"))?
- } else {
- dist.distributions()
- .exactly_one()
- .map_err(|e| anyhow!("{e}"))
- .context(
- "with no machine specified, there should be exactly one distribution",
- )?
- };
-
- let part_name = part_name.unwrap_or_else(|| "secret".to_string());
- let Some(part) = dist.secret.parts.get(&part_name) else {
- bail!("secret part {part_name:?} is not defined");
- };
+ let (owners, secret_data) = {
+ let secret = config.data.secrets.read().expect("not poisoned");
- // dist.get(SecretOwner(name));
+ let Some(dist) = secret.get(&name) else {
+ bail!("secret doesn't exists");
+ };
- todo!();
- /*
- let Some(secret) = config.shared_secret(&name) else {
- bail!("secret doesn't exists");
- };
+ let dist = if let Some(machine) = &machine {
+ dist.get(&SecretOwner::host(machine))
+ .ok_or_else(|| anyhow!("machine {machine} has no secret generated"))?
+ } else {
+ dist.distributions()
+ .exactly_one()
+ .map_err(|e| anyhow!("{e}"))
+ .context(
+ "with no machine specified, there should be exactly one distribution",
+ )?
+ };
- let dist = if secret.len() == 1 {
- &secret[0]
- } else if let Some(machine) = machine {
- let dist = secret.get(&machine);
- let Some(dist) = dist else {
- bail!("machine {machine} has no distribution of secret {name}");
+ let part = if let Some(part_name) = &part_name {
+ dist.secret.parts.get(part_name).ok_or_else(|| {
+ anyhow!("secret {name} does not have part named {part_name}")
+ })?
+ } else {
+ dist.secret
+ .parts
+ .iter()
+ .exactly_one()
+ .map_err(|e| anyhow!("{e}"))
+ .context("with no part specified, there should be exactly one part")?
+ .1
};
- prefer_identities.push(machine);
- dist
- } else {
- bail!(
- "secret {name} has shares, but no --machine specified for specifing which do you need"
- )
+ let owners = dist.owners().cloned().collect::<Vec<_>>();
+ let secret_data = part.raw.clone();
+ (owners, secret_data)
};
- let Some(part) = dist.secret.parts.get(&part_name) else {
- bail!("no part {part_name} in secret {name}");
- };
- let data = if part.raw.encrypted {
- let identity_holder = if !prefer_identities.is_empty() {
- prefer_identities
- .iter()
- .find(|i| dist.owners.iter().any(|s| s == *i))
- } else {
- dist.owners.first()
+ for host in config
+ .preferred_hosts(|h| owners.iter().any(|o| o.as_host() == Some(h)))
+ .context("failed to list hosts")?
+ {
+ let host = match host {
+ Ok(h) => h,
+ Err(e) => {
+ warn!("failed to use host: {e}");
+ continue;
+ }
};
- let Some(identity_holder) = identity_holder else {
- bail!("no available holder found");
+ match host.decrypt(secret_data.clone()).await {
+ Ok(data) => {
+ let mut w = stdout();
+ w.write_all(&data)?;
+ return Ok(());
+ }
+ Err(e) => warn!("failed to decrypt on {}: {e}", host.name),
};
- let host = config.host(identity_holder)?;
- host.decrypt(part.raw.clone()).await?
- } else {
- part.raw.data.clone()
- };
- stdout().write_all(&data)?;
- */
- todo!()
+ }
+ bail!("failed to find suitable decrypting host");
}
Secret::List {} => {
/*
@@ -190,26 +157,6 @@
}
// info!("loaded\n{}", Table::new(table).to_string())
*/
- todo!()
- }
- Secret::Edit {
- name,
- machine,
- part,
- add,
- } => {
- /*let secret = config
- .host_secret(&machine, &name)
- .context("secret not found")?;
- if let Some(data) = secret.secret.parts.get(&part) {
- let host = config.host(&machine)?;
- let secret = host.decrypt(data.raw.clone()).await?;
- String::from_utf8(secret).context("secret is not utf8")?
- } else if add {
- String::new()
- } else {
- bail!("part {part} not found in secret {name}. Did you mean to `--add` it?");
- };*/
todo!()
}
Secret::Prune { name, machine } => todo!(),
crates/fleet-base/src/host.rsdiffbeforeafterboth1use std::{2 collections::{BTreeMap, BTreeSet},3 ffi::{OsStr, OsString},4 fmt::Display,5 io::Write,6 ops::Deref,7 path::PathBuf,8 str::FromStr,9 sync::{Arc, Mutex, MutexGuard, OnceLock},10};1112use anyhow::{Context, Result, anyhow, bail, ensure};13use chrono::{DateTime, Utc};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::{26 FleetData, FleetSecretData, FleetSecretDistribution, FleetSecretPart, SecretOwner,27 },28};2930pub struct FleetConfigInternals {31 pub prefer_identities: BTreeSet<SecretOwner>,32 pub now: DateTime<Utc>,3334 /// Fleet project directory, containing fleet.nix file.35 pub directory: PathBuf,36 /// builtins.currentSystem37 pub local_system: String,38 pub data: Arc<FleetData>,39 pub nix_args: Vec<OsString>,40 /// fleet_config.config41 pub config_field: Value,42 /// flake.output43 pub flake_outputs: Value,44 // TODO: Remove with connectivity refactor45 pub localhost: String,4647 /// import nixpkgs {system = local};48 pub default_pkgs: Value,49 /// inputs.nixpkgs50 pub nixpkgs: Value,51}5253// TODO: Make field not pub54#[derive(Clone)]55pub struct Config(pub Arc<FleetConfigInternals>);5657impl Deref for Config {58 type Target = FleetConfigInternals;5960 fn deref(&self) -> &Self::Target {61 &self.062 }63}6465#[derive(Clone, Copy, Debug)]66pub enum EscalationStrategy {67 Sudo,68 Run0,69 Su,70}7172#[derive(Clone, PartialEq, Copy, Debug)]73pub enum DeployKind {74 /// NixOS => NixOS managed by fleet75 UpgradeToFleet,76 /// NixOS managed by fleet => NixOS managed by fleet77 Fleet,78 /// Remote host has /mnt, /mnt/boot mounted,79 /// generated config is added to fleet configuration.80 NixosInstall,81 /// Remote host has some system and nix installed in multi-user mode (/nix is owned by root),82 /// generated config is added to fleet configuration,83 /// and /etc/NIXOS_LUSTRATE exists, fleet will perform the rest.84 NixosLustrate,85}8687impl FromStr for DeployKind {88 type Err = anyhow::Error;89 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {90 match s {91 "upgrade-to-fleet" => Ok(Self::UpgradeToFleet),92 "fleet" => Ok(Self::Fleet),93 "nixos-install" => Ok(Self::NixosInstall),94 "nixos-lustrate" => Ok(Self::NixosLustrate),95 v => bail!(96 "unknown deploy_kind: {v}; expected on of \"upgrade-to-fleet\", \"fleet\", \"nixos-install\", \"nixos-lustrate\""97 ),98 }99 }100}101pub struct ConfigHost {102 config: Config,103 pub name: String,104 groups: OnceLock<Vec<String>>,105106 // TODO: Both of those values are taken from host opts, there should be a cleaner way to specify it107 deploy_kind: OnceLock<DeployKind>,108 session_destination: OnceLock<String>,109 legacy_ssh_store: OnceLock<bool>,110111 pub host_config: Option<Value>,112 pub nixos_config: OnceLock<Value>,113 pub nixos_unchecked_config: OnceLock<Value>,114 pub pkgs_override: Option<Value>,115116 // TODO: Move command helpers away with connectivity refactor117 pub local: bool,118 pub session: OnceLock<Arc<openssh::Session>>,119}120121#[derive(Debug, Clone, Copy)]122pub enum GenerationStorage {123 Deployer,124 Machine,125 Pusher,126}127impl GenerationStorage {128 fn prefix(&self) -> &'static str {129 match self {130 GenerationStorage::Deployer => "deployer.",131 GenerationStorage::Machine => "",132 GenerationStorage::Pusher => "pusher.",133 }134 }135}136137#[derive(Tabled, Debug)]138pub struct Generation {139 #[tabled(rename = "ID", format("{}", self.rollback_id()))]140 pub id: u32,141 #[tabled(rename = "Current")]142 pub current: bool,143 #[tabled(rename = "Created at")]144 pub datetime: UtcDateTime,145 #[tabled(format = "{:?}")]146 pub store_path: PathBuf,147 #[tabled(skip)]148 pub location: GenerationStorage,149}150impl Generation {151 pub fn rollback_id(&self) -> String {152 format!("{}{}", self.location.prefix(), self.id)153 }154}155156fn parse_generation_line(g: &str) -> Option<Generation> {157 let mut parts = g.split_whitespace();158 let id = parts.next()?;159 let id: u32 = id.parse().ok()?;160 let date = parts.next()?;161 let time = parts.next()?;162 let current = if let Some(current) = parts.next() {163 if current == "(current)" {164 Some(true)165 } else {166 None167 }168 } else {169 Some(false)170 };171 let current = current?;172 if parts.next().is_some() {173 warn!("unexpected text after generation: {g}");174 }175176 let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]")177 .expect("valid format");178 let datetime = UtcDateTime::parse(&format!("{date} {time}"), &format).ok()?;179180 Some(Generation {181 id,182 current,183 datetime,184 store_path: PathBuf::new(),185 location: GenerationStorage::Machine,186 })187}188// TODO: Move command helpers away with connectivity refactor189impl ConfigHost {190 pub async fn list_generations(&self, profile: &str) -> Result<Vec<Generation>> {191 let mut cmd = self.cmd("nix-env").await?;192 cmd.comparg("--profile", format!("/nix/var/nix/profiles/{profile}"))193 .arg("--list-generations")194 .env("TZ", "UTC");195 // Sudo is required because --list-generations tries to acquire profile lock196 let data = cmd.sudo().run_string().await?;197 let mut generations = data198 .split('\n')199 .map(|e| e.trim())200 .filter(|&l| !l.is_empty())201 .filter_map(|g| {202 let generation = parse_generation_line(g);203 if generation.is_none() {204 warn!("bad generation: {g}");205 };206 generation207 })208 .collect::<Vec<_>>();209 for ele in generations.iter_mut() {210 let mut cmd = self.cmd("readlink").await?;211 cmd.arg("--")212 .arg(format!("/nix/var/nix/profiles/{profile}-{}-link", ele.id));213 let path = cmd.run_string().await?;214 ele.store_path = PathBuf::from(path.trim_end_matches("\n"));215 }216217 Ok(generations)218 }219220 pub fn set_session_destination(&self, dest: String) {221 self.session_destination222 .set(dest)223 .expect("session destination is already set")224 }225 pub fn set_deploy_kind(&self, kind: DeployKind) {226 self.deploy_kind227 .set(kind)228 .expect("deploy kind is already set");229 }230 pub fn set_legacy_ssh_store(&self, legacy: bool) {231 self.legacy_ssh_store232 .set(legacy)233 .expect("legacy ssh store is already set")234 }235 pub async fn deploy_kind(&self) -> Result<DeployKind> {236 if let Some(kind) = self.deploy_kind.get() {237 return Ok(*kind);238 }239 let is_fleet_managed = match self.file_exists("/etc/FLEET_HOST").await {240 Ok(v) => v,241 Err(e) => {242 bail!("failed to query remote system kind: {e}");243 }244 };245 if !is_fleet_managed {246 bail!(247 "{}",248 indoc::indoc! {"249 host is not marked as managed by fleet250 if you're not trying to lustrate/install system from scratch,251 you should either252 1. manually create /etc/FLEET_HOST file on the target host,253 2. use ?deploy_kind=fleet host argument if you're upgrading from older version of fleet254 3. use ?deploy_kind=upgrade_to_fleet if you're upgrading from plain nixos to fleet-managed nixos255 "}256 );257 }258 // TOCTOU is possible259 let _ = self.deploy_kind.set(DeployKind::Fleet);260 Ok(*self.deploy_kind.get().expect("deploy kind is just set"))261 }262 pub async fn escalation_strategy(&self) -> Result<EscalationStrategy> {263 // Prefer sudo, as run0 has some gotchas with polkit264 // and too many repeating prompts.265 if (self.find_in_path("sudo").await).is_ok() {266 return Ok(EscalationStrategy::Sudo);267 }268 if (self.find_in_path("run0").await).is_ok() {269 return Ok(EscalationStrategy::Run0);270 }271 Ok(EscalationStrategy::Su)272 }273 async fn open_session(&self) -> Result<Arc<openssh::Session>> {274 assert!(!self.local, "do not open ssh connection to local session");275 // FIXME: TOCTOU276 if let Some(session) = &self.session.get() {277 return Ok((*session).clone());278 };279 let mut session = SessionBuilder::default();280 session.control_persist(ControlPersist::ClosedAfterInitialConnection);281282 let dest = self.session_destination.get().unwrap_or(&self.name);283 let session = session284 .connect(&dest)285 .await286 .map_err(|e| anyhow!("ssh error while connecting to {}: {e:#?}", self.name))?;287 let session = Arc::new(session);288 self.session.set(session.clone()).expect("TOCTOU happened");289 Ok(session)290 }291 pub async fn mktemp_dir(&self) -> Result<String> {292 let mut cmd = self.cmd("mktemp").await?;293 cmd.arg("-d");294 let path = cmd.run_string().await?;295 Ok(path.trim_end().to_owned())296 }297 pub async fn file_exists(&self, path: impl AsRef<OsStr>) -> Result<bool> {298 let mut cmd = self.cmd("sh").await?;299 cmd.arg("-c")300 .arg("test -e \"$1\" && echo true || echo false")301 .arg("_")302 .arg(path);303 cmd.run_value().await304 }305 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {306 let mut cmd = self.cmd("cat").await?;307 cmd.arg(path);308 cmd.run_bytes().await309 }310 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {311 let mut cmd = self.cmd("cat").await?;312 cmd.arg(path);313 cmd.run_string().await314 }315 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {316 let mut cmd = self.cmd("ls").await?;317 cmd.arg(path);318 let out = cmd.run_string().await?;319 let mut lines = out.split('\n');320 if let Some(last) = lines.next_back() {321 ensure!(last.is_empty(), "output of ls should end with newline");322 }323 Ok(lines.map(ToOwned::to_owned).collect())324 }325 #[allow(dead_code)]326 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {327 let text = self.read_file_text(path).await?;328 Ok(serde_json::from_str(&text)?)329 }330 pub async fn read_env(&self, env: &str) -> Result<String> {331 let mut cmd = self.cmd("printenv").await?;332 cmd.arg(env);333 cmd.run_string().await334 }335 pub async fn find_in_path(&self, command: &str) -> Result<String> {336 // // `which` is not a part of coreutils, and it might not exist on machine.337 // let path = self.read_env("PATH").await?;338 // // Assuming delimiter is :, we don't work with windows host, this check will be much339 // // more sophisticated in remowt backend (and quicker, since actual PATH search will be done on remote machine)340 // for ele in path.split(':') {341 // let test_path = format!("{ele}/{cmd}");342 // test -x etc343 // }344 // let mut cmd = self.cmd("printenv").await?;345 // cmd.arg(env);346 // Ok(cmd.run_string().await?)347 // Assuming this is an environment issue if which doesn't exist, will be fixed with remowt.348 let mut cmd = self349 .cmd_escalation(350 // Not used351 EscalationStrategy::Su,352 "which",353 )354 .await?;355 cmd.arg(command);356 cmd.run_string().await357 }358 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>359 where360 <D as FromStr>::Err: Display,361 {362 let text = self.read_file_text(path).await?;363 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))364 }365 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {366 self.cmd_escalation(self.escalation_strategy().await?, cmd)367 .await368 }369 pub async fn cmd_escalation(370 &self,371 escalation: EscalationStrategy,372 cmd: impl AsRef<OsStr>,373 ) -> Result<MyCommand> {374 if self.local {375 Ok(MyCommand::new(escalation, cmd))376 } else {377 let session = self.open_session().await?;378 Ok(MyCommand::new_on(escalation, cmd, session))379 }380 }381 pub async fn nix_cmd(&self) -> Result<MyCommand> {382 let mut nix = self.cmd("nix").await?;383 nix.args([384 "--extra-experimental-features",385 "nix-command",386 "--extra-experimental-features",387 "flakes",388 ]);389 Ok(nix)390 }391392 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {393 ensure!(data.encrypted, "secret is not encrypted");394 let mut cmd = self.cmd("fleet-install-secrets").await?;395 cmd.arg("decrypt").eqarg("--secret", data.to_string());396 let encoded = cmd397 .sudo()398 .run_string()399 .await400 .context("failed to call remote host for decrypt")?;401 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;402 ensure!(!data.encrypted, "secret came out encrypted");403 Ok(data.data)404 }405 pub async fn reencrypt_distribution(406 &self,407 data: &FleetSecretDistribution,408 targets: BTreeSet<SecretOwner>,409 now: DateTime<Utc>,410 ) -> Result<FleetSecretDistribution> {411 let mut parts = BTreeMap::new();412 for (part_name, part) in &data.secret.parts {413 parts.insert(414 part_name.clone(),415 if part.raw.encrypted {416 FleetSecretPart {417 raw: self.reencrypt(part.raw.clone(), targets.clone()).await?,418 }419 } else {420 part.clone()421 },422 );423 }424 let secret = FleetSecretData {425 created_at: data.secret.created_at,426 expires_at: data.secret.expires_at,427 generation_data: data.secret.generation_data.clone(),428 parts,429 };430 Ok(FleetSecretDistribution::new(targets, secret, now))431 }432 pub async fn reencrypt(433 &self,434 data: SecretData,435 targets: BTreeSet<SecretOwner>,436 ) -> Result<SecretData> {437 ensure!(data.encrypted, "secret is not encrypted");438 let mut cmd = self.cmd("fleet-install-secrets").await?;439 cmd.arg("reencrypt").eqarg("--secret", data.to_string());440 for target in targets {441 let key = self.config.key(&target).await?;442 cmd.eqarg("--targets", key);443 }444 let encoded = cmd445 .sudo()446 .run_string()447 .await448 .context("failed to call remote host for decrypt")?;449 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;450 ensure!(data.encrypted, "secret came out not encrypted");451 Ok(data)452 }453 /// Returns path for futureproofing, as path might change i.e on conversion to CA454 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {455 if self.local {456 // Path is located locally, thus already trusted.457 return Ok(path.to_owned());458 }459 let mut sign = MyCommand::new(460 // TODO: Look at the current escalation strategy.461 // ... or switch to run0 right after polkit update462 EscalationStrategy::Sudo,463 "nix",464 );465 sign.arg("store")466 .arg("sign")467 .comparg("--key-file", "/etc/nix/private-key")468 .arg("-r")469 .arg(&path);470 if let Err(e) = sign.sudo().run_nix().await {471 warn!("failed to sign store paths: {e}");472 }473 let mut nix = MyCommand::new(474 // Not used475 EscalationStrategy::Su,476 "nix",477 );478 nix.arg("copy").arg("--substitute-on-destination");479480 let proto = if self.legacy_ssh_store.get().cloned().unwrap_or(false) {481 "ssh"482 } else {483 "ssh-ng"484 };485486 match self.deploy_kind().await? {487 DeployKind::Fleet | DeployKind::UpgradeToFleet | DeployKind::NixosLustrate => {488 nix.comparg("--to", format!("{proto}://{}", self.name));489 }490 DeployKind::NixosInstall => {491 nix492 // Signature checking makes no sense with remote-store store argument set, as we're not even interacting with remote nix daemon493 .arg("--no-check-sigs")494 .comparg(495 "--to",496 format!("{proto}://root@{}?remote-store=/mnt", self.name),497 );498 }499 }500 nix.arg(path);501 nix.run_nix().await.context("nix copy")?;502 Ok(path.to_owned())503 }504 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {505 let mut cmd = self.cmd("systemctl").await?;506 cmd.arg("stop").arg(name);507 cmd.sudo().run().await508 }509 pub async fn systemctl_start(&self, name: &str) -> Result<()> {510 let mut cmd = self.cmd("systemctl").await?;511 cmd.arg("start").arg(name);512 cmd.sudo().run().await513 }514515 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {516 let mut cmd = self.cmd("rm").await?;517 cmd.arg("-f").arg(path);518 if sudo {519 cmd = cmd.sudo()520 }521 cmd.run().await522 }523}524525struct HostSecretDefinition(Value);526527impl ConfigHost {528 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,529 // assuming getting tags always returns the same value.530 pub fn tags(&self) -> Result<Vec<String>> {531 if let Some(v) = self.groups.get() {532 return Ok(v.clone());533 }534 let Some(host_config) = &self.host_config else {535 return Ok(vec![]);536 };537 let tags: Vec<String> = nix_go_json!(host_config.tags);538539 let _ = self.groups.set(tags.clone());540541 Ok(tags)542 }543 pub fn nixos_config(&self) -> Result<Value> {544 if let Some(v) = self.nixos_config.get() {545 return Ok(v.clone());546 }547 let Some(host_config) = &self.host_config else {548 bail!("local host has no nixos_config");549 };550 let nixos_config = nix_go!(host_config.nixos.config);551 assert_warn("nixos config evaluation", &nixos_config)?;552553 let _ = self.nixos_config.set(nixos_config.clone());554555 Ok(nixos_config)556 }557 pub fn nixos_unchecked_config(&self) -> Result<Value> {558 if let Some(v) = self.nixos_unchecked_config.get() {559 return Ok(v.clone());560 }561 let Some(host_config) = &self.host_config else {562 bail!("local host has no nixos_config");563 };564 let nixos_config = nix_go!(host_config.nixos_unchecked.config);565566 let _ = self.nixos_unchecked_config.set(nixos_config.clone());567568 Ok(nixos_config)569 }570571 pub fn list_defined_secrets(&self) -> Result<Vec<String>> {572 let nixos = self.nixos_unchecked_config()?;573 let secrets = nix_go!(nixos.secrets);574 secrets.list_fields()575 }576577 /// Packages for this host, resolved with nixpkgs overlays578 pub fn pkgs(&self) -> Result<Value> {579 if let Some(value) = &self.pkgs_override {580 return Ok(value.clone());581 }582 let Some(host_config) = &self.host_config else {583 bail!("local host has no host_config");584 };585 // TODO: Should nixos.options be cached?586 Ok(nix_go!(host_config.nixos.options._module.args.value.pkgs))587 }588}589590#[derive(Clone)]591pub struct SharedSecretDefinition(Value);592impl SharedSecretDefinition {593 pub fn expected_owners(&self) -> Result<BTreeSet<SecretOwner>> {594 let secret = &self.0;595 Ok(nix_go_json!(secret.expectedOwners))596 }597 pub fn allow_different(&self) -> Result<bool> {598 let secret = &self.0;599 Ok(nix_go_json!(secret.allowDifferent))600 }601 pub fn regenerate_on_owner_added(&self) -> Result<bool> {602 let secret = &self.0;603 Ok(nix_go_json!(secret.regenerateOnOwnerAdded))604 }605 pub fn regenerate_on_owner_removed(&self) -> Result<bool> {606 let secret = &self.0;607 Ok(nix_go_json!(secret.regenerateOnOwnerRemoved))608 }609 pub fn generator(&self) -> Result<Value> {610 let secret = &self.0;611 Ok(nix_go!(secret.generator))612 }613}614615impl Config {616 pub fn tagged_hostnames(&self, tag: &str) -> Result<Vec<String>> {617 let config = &self.config_field;618 let tagged: Vec<String> = nix_go_json!(config.taggedWith[{ tag }]);619 Ok(tagged)620 }621 pub fn expand_owner_set(&self, owners: Vec<String>) -> Result<BTreeSet<String>> {622 let mut out = BTreeSet::new();623 for owner in owners {624 if let Some(tag) = owner.strip_prefix('@') {625 let hosts = self.tagged_hostnames(tag)?;626 out.extend(hosts);627 } else {628 out.insert(owner);629 }630 }631 Ok(out)632 }633 pub fn local_host(&self) -> ConfigHost {634 ConfigHost {635 config: self.clone(),636 name: "<virtual localhost>".to_owned(),637 host_config: None,638 nixos_config: OnceLock::new(),639 nixos_unchecked_config: OnceLock::new(),640 groups: {641 let cell = OnceLock::new();642 let _ = cell.set(vec![]);643 cell644 },645 pkgs_override: Some(self.default_pkgs.clone()),646647 local: true,648 session: OnceLock::new(),649 deploy_kind: OnceLock::new(),650 session_destination: OnceLock::new(),651 legacy_ssh_store: OnceLock::new(),652 }653 }654655 pub fn host(&self, name: &str) -> Result<ConfigHost> {656 let config = &self.config_field;657 let host_config = nix_go!(config.hosts[{ name }]);658659 Ok(ConfigHost {660 config: self.clone(),661 name: name.to_owned(),662 host_config: Some(host_config),663 nixos_config: OnceLock::new(),664 nixos_unchecked_config: OnceLock::new(),665 groups: OnceLock::new(),666 pkgs_override: None,667668 // TODO: Remove with connectivit refactor669 local: self.localhost == name,670 session: OnceLock::new(),671 deploy_kind: OnceLock::new(),672 session_destination: OnceLock::new(),673 legacy_ssh_store: OnceLock::new(),674 })675 }676 pub fn list_hosts(&self) -> Result<Vec<ConfigHost>> {677 let config = &self.config_field;678 let names = nix_go!(config.hosts).list_fields()?;679 let mut out = vec![];680 for name in names {681 out.push(self.host(&name)?);682 }683 Ok(out)684 }685 // TODO: Replace usages with .host().nixos_config686 pub fn system_config(&self, host: &str) -> Result<Value> {687 let fleet_field = &self.config_field;688 Ok(nix_go!(fleet_field.hosts[{ host }].nixos.config))689 }690691 pub fn secret_definition(&self, secret: &str) -> Result<Option<SharedSecretDefinition>> {692 let config = &self.config_field;693 let shared_secrets = nix_go!(config.secrets);694 if !shared_secrets.has_field(secret)? {695 return Ok(None);696 }697 Ok(Some(SharedSecretDefinition(nix_go!(698 shared_secrets[secret]699 ))))700 }701702 pub fn save(&self) -> Result<()> {703 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.")?;704 let data = nixlike::serialize(&*self.data)?;705 tempfile.write_all(706 format!(707 "# This file contains fleet state and shouldn't be edited by hand\n\n{data}\n\n# vim: ts=2 et nowrap\n"708 )709 .as_bytes(),710 )?;711 let mut fleet_data_path = self.directory.clone();712 fleet_data_path.push("fleet.nix");713 tempfile.persist(fleet_data_path)?;714 Ok(())715 }716}