difftreelog
feat ability to select specialisation to activate
in: trunk
6 files changed
Cargo.lockdiffbeforeafterboth--- a/Cargo.lock
+++ b/Cargo.lock
@@ -784,7 +784,7 @@
"itertools",
"nix-eval",
"nixlike",
- "once_cell",
+ "nom",
"openssh",
"owo-colors",
"peg",
cmds/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
cmds/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}");
}
cmds/fleet/src/cmds/secrets/mod.rsdiffbeforeafterboth--- a/cmds/fleet/src/cmds/secrets/mod.rs
+++ b/cmds/fleet/src/cmds/secrets/mod.rs
@@ -436,7 +436,7 @@
match self {
Secret::ForceKeys => {
for host in config.list_hosts().await? {
- if config.should_skip(&host.name) {
+ if config.should_skip(&host).await? {
continue;
}
config.key(&host.name).await?;
@@ -639,7 +639,7 @@
}
}
for host in config.list_hosts().await? {
- if config.should_skip(&host.name) {
+ if config.should_skip(&host).await? {
continue;
}
cmds/fleet/src/host.rsdiffbeforeafterboth1use std::{2 env::current_dir,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::{anyhow, bail, ensure, Context, Result};13use clap::{ArgGroup, Parser};14use fleet_shared::SecretData;15use nix_eval::{nix_go, nix_go_json, NixSessionPool, Value};16use openssh::SessionBuilder;17use serde::de::DeserializeOwned;18use tempfile::NamedTempFile;1920use crate::{21 command::MyCommand,22 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},23};2425pub struct FleetConfigInternals {26 pub local_system: String,27 pub directory: PathBuf,28 pub opts: FleetOpts,29 pub data: Mutex<FleetData>,30 pub nix_args: Vec<OsString>,31 /// fleet_config.config32 pub config_field: Value,33 /// fleet_config.unchecked.config34 pub config_unchecked_field: Value,3536 /// import nixpkgs {system = local};37 pub default_pkgs: Value,38}3940#[derive(Clone)]41pub struct Config(Arc<FleetConfigInternals>);4243impl Deref for Config {44 type Target = FleetConfigInternals;4546 fn deref(&self) -> &Self::Target {47 &self.048 }49}5051pub struct ConfigHost {52 config: Config,53 pub name: String,54 pub local: bool,55 pub session: OnceLock<Arc<openssh::Session>>,5657 pub nixos_config: Option<Value>,58}59impl ConfigHost {60 async fn open_session(&self) -> Result<Arc<openssh::Session>> {61 assert!(!self.local, "do not open ssh connection to local session");62 // FIXME: TOCTOU63 if let Some(session) = &self.session.get() {64 return Ok((*session).clone());65 };66 let session = SessionBuilder::default();6768 let session = session69 .connect(&self.name)70 .await71 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;72 let session = Arc::new(session);73 self.session.set(session.clone()).expect("TOCTOU happened");74 Ok(session)75 }76 pub async fn mktemp_dir(&self) -> Result<String> {77 let mut cmd = self.cmd("mktemp").await?;78 cmd.arg("-d");79 let path = cmd.run_string().await?;80 Ok(path.trim_end().to_owned())81 }82 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {83 let mut cmd = self.cmd("cat").await?;84 cmd.arg(path);85 cmd.run_bytes().await86 }87 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {88 let mut cmd = self.cmd("cat").await?;89 cmd.arg(path);90 cmd.run_string().await91 }92 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {93 let mut cmd = self.cmd("ls").await?;94 cmd.arg(path);95 let out = cmd.run_string().await?;96 let mut lines = out.split('\n');97 if let Some(last) = lines.next_back() {98 ensure!(last.is_empty(), "output of ls should end with newline");99 }100 Ok(lines.map(ToOwned::to_owned).collect())101 }102 #[allow(dead_code)]103 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {104 let text = self.read_file_text(path).await?;105 Ok(serde_json::from_str(&text)?)106 }107 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>108 where109 <D as FromStr>::Err: Display,110 {111 let text = self.read_file_text(path).await?;112 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))113 }114 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {115 if self.local {116 Ok(MyCommand::new(cmd))117 } else {118 let session = self.open_session().await?;119 Ok(MyCommand::new_on(cmd, session))120 }121 }122123 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {124 ensure!(data.encrypted, "secret is not encrypted");125 let mut cmd = self.cmd("fleet-install-secrets").await?;126 cmd.arg("decrypt").eqarg("--secret", data.to_string());127 let encoded = cmd128 .sudo()129 .run_string()130 .await131 .context("failed to call remote host for decrypt")?;132 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;133 ensure!(!data.encrypted, "secret came out encrypted");134 Ok(data.data)135 }136 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {137 ensure!(data.encrypted, "secret is not encrypted");138 let mut cmd = self.cmd("fleet-install-secrets").await?;139 cmd.arg("reencrypt").eqarg("--secret", data.to_string());140 for target in targets {141 let key = self.config.key(&target).await?;142 cmd.eqarg("--targets", key);143 }144 let encoded = cmd145 .sudo()146 .run_string()147 .await148 .context("failed to call remote host for decrypt")?;149 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;150 ensure!(data.encrypted, "secret came out not encrypted");151 Ok(data)152 }153 /// Returns path for futureproofing, as path might change i.e on conversion to CA154 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {155 if self.local {156 // Path is located locally, thus already trusted.157 return Ok(path.to_owned());158 }159 let mut nix = MyCommand::new("nix");160 nix.arg("copy")161 .arg("--substitute-on-destination")162 .comparg("--to", format!("ssh-ng://{}", self.name))163 .arg(path);164 nix.run_nix().await.context("nix copy")?;165 Ok(path.to_owned())166 }167 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {168 let mut cmd = self.cmd("systemctl").await?;169 cmd.arg("stop").arg(name);170 cmd.sudo().run().await171 }172 pub async fn systemctl_start(&self, name: &str) -> Result<()> {173 let mut cmd = self.cmd("systemctl").await?;174 cmd.arg("start").arg(name);175 cmd.sudo().run().await176 }177178 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {179 let mut cmd = self.cmd("rm").await?;180 cmd.arg("-f").arg(path);181 if sudo {182 cmd = cmd.sudo()183 }184 cmd.run().await185 }186187 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {188 let Some(nixos) = &self.nixos_config else {189 return Ok(vec![]);190 };191 let secrets = nix_go!(nixos.secrets);192 let mut out = Vec::new();193 for name in secrets.list_fields().await? {194 let secret = nix_go!(secrets[{ name }]);195 let is_shared: bool = nix_go_json!(secret.shared);196 if is_shared {197 continue;198 }199 out.push(name);200 }201 Ok(out)202 }203 pub async fn secret_field(&self, name: &str) -> Result<Value> {204 let Some(nixos) = &self.nixos_config else {205 bail!("host is virtual and has no secrets");206 };207 Ok(nix_go!(nixos.secrets[{ name }]))208 }209210 /// Packages for this host, resolved with nixpkgs overlays211 pub async fn pkgs(&self) -> Result<Value> {212 let Some(nixos) = &self.nixos_config else {213 return Ok(self.config.default_pkgs.clone());214 };215 Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))216 }217}218219impl Config {220 pub fn should_skip(&self, host: &str) -> bool {221 if !self.opts.skip.is_empty() {222 self.opts.skip.iter().any(|h| h as &str == host)223 } else if !self.opts.only.is_empty() {224 !self.opts.only.iter().any(|h| h as &str == host)225 } else {226 false227 }228 }229 pub fn is_local(&self, host: &str) -> bool {230 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)231 }232233 pub fn local_host(&self) -> ConfigHost {234 ConfigHost {235 config: self.clone(),236 name: "<virtual localhost>".to_owned(),237 local: true,238 session: OnceLock::new(),239 nixos_config: None,240 }241 }242243 pub async fn host(&self, name: &str) -> Result<ConfigHost> {244 let config = &self.config_unchecked_field;245 let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);246 Ok(ConfigHost {247 config: self.clone(),248 name: name.to_owned(),249 local: self.is_local(name),250 session: OnceLock::new(),251 nixos_config: Some(nixos_config),252 })253 }254 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {255 let config = &self.config_unchecked_field;256 let names = nix_go!(config.hosts).list_fields().await?;257 let mut out = vec![];258 for name in names {259 out.push(self.host(&name).await?);260 }261 Ok(out)262 }263 pub async fn system_config(&self, host: &str) -> Result<Value> {264 let fleet_field = &self.config_unchecked_field;265 Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))266 }267268 pub(super) fn data(&self) -> MutexGuard<FleetData> {269 self.data.lock().unwrap()270 }271 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {272 self.data.lock().unwrap()273 }274 /// Shared secrets configured in fleet.nix or in flake275 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {276 let config_field = &self.config_unchecked_field;277 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)278 }279 /// Shared secrets configured in fleet.nix280 pub fn list_shared(&self) -> Vec<String> {281 let data = self.data();282 data.shared_secrets.keys().cloned().collect()283 }284 pub fn has_shared(&self, name: &str) -> bool {285 let data = self.data();286 data.shared_secrets.contains_key(name)287 }288 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {289 let mut data = self.data_mut();290 data.shared_secrets.insert(name.to_owned(), shared);291 }292 pub fn remove_shared(&self, secret: &str) {293 let mut data = self.data_mut();294 data.shared_secrets.remove(secret);295 }296297 pub fn list_secrets(&self, host: &str) -> Vec<String> {298 let data = self.data();299 let Some(secrets) = data.host_secrets.get(host) else {300 return Vec::new();301 };302 secrets.keys().cloned().collect()303 }304305 pub fn has_secret(&self, host: &str, secret: &str) -> bool {306 let data = self.data();307 let Some(host_secrets) = data.host_secrets.get(host) else {308 return false;309 };310 host_secrets.contains_key(secret)311 }312 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {313 let mut data = self.data_mut();314 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();315 host_secrets.insert(secret, value);316 }317318 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {319 let data = self.data();320 let Some(host_secrets) = data.host_secrets.get(host) else {321 bail!("no secrets for machine {host}");322 };323 let Some(secret) = host_secrets.get(secret) else {324 bail!("machine {host} has no secret {secret}");325 };326 Ok(secret.clone())327 }328 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {329 let data = self.data();330 let Some(secret) = data.shared_secrets.get(secret) else {331 bail!("no shared secret {secret}");332 };333 Ok(secret.clone())334 }335 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {336 let config_field = &self.config_unchecked_field;337 Ok(nix_go_json!(338 config_field.sharedSecrets[{ secret }].expectedOwners339 ))340 }341342 pub fn save(&self) -> Result<()> {343 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.")?;344 let data = nixlike::serialize(&self.data() as &FleetData)?;345 tempfile.write_all(346 format!(347 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",348 data349 )350 .as_bytes(),351 )?;352 let mut fleet_data_path = self.directory.clone();353 fleet_data_path.push("fleet.nix");354 tempfile.persist(fleet_data_path)?;355 Ok(())356 }357}358359#[derive(Parser, Clone)]360#[clap(group = ArgGroup::new("target_hosts"))]361pub struct FleetOpts {362 /// All hosts except those would be skipped363 #[clap(long, number_of_values = 1, group = "target_hosts")]364 only: Vec<String>,365366 /// Hosts to skip367 #[clap(long, number_of_values = 1, group = "target_hosts")]368 skip: Vec<String>,369370 /// Host, which should be threaten as current machine371 #[clap(long)]372 pub localhost: Option<String>,373374 /// Override detected system for host, to perform builds via375 /// binfmt-declared qemu instead of trying to crosscompile376 #[clap(long, default_value = "detect")]377 pub local_system: String,378}379380impl FleetOpts {381 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {382 if self.localhost.is_none() {383 self.localhost384 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());385 }386 let directory = current_dir()?;387388 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;389 let root_field = pool.get().await?;390391 let builtins_field = Value::binding(root_field.clone(), "builtins").await?;392 if self.local_system == "detect" {393 self.local_system = nix_go_json!(builtins_field.currentSystem);394 }395 let local_system = self.local_system.clone();396397 let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;398 let fleet_field = nix_go!(fleet_root.default);399400 let config_field = nix_go!(fleet_field.config);401 let config_unchecked_field = nix_go!(fleet_field.unchecked.config);402403 let import = nix_go!(builtins_field.import);404 let overlays = nix_go!(config_unchecked_field.overlays);405 let nixpkgs = nix_go!(fleet_field.nixpkgs | import);406407 let default_pkgs = nix_go!(nixpkgs(Obj {408 overlays,409 system: { self.local_system.clone() },410 }));411412 let mut fleet_data_path = directory.clone();413 fleet_data_path.push("fleet.nix");414 let bytes = std::fs::read_to_string(fleet_data_path)?;415 let data = nixlike::parse_str(&bytes)?;416417 Ok(Config(Arc::new(FleetConfigInternals {418 opts: self,419 directory,420 data,421 local_system,422 nix_args,423 config_field,424 config_unchecked_field,425 default_pkgs,426 })))427 }428}1use std::{2 cell::OnceCell,3 collections::BTreeMap,4 env::current_dir,5 ffi::{OsStr, OsString},6 fmt::Display,7 io::Write,8 ops::Deref,9 path::PathBuf,10 str::FromStr,11 sync::{Arc, Mutex, MutexGuard, OnceLock},12};1314use anyhow::{anyhow, bail, ensure, Context, Result};15use clap::Parser;16use fleet_shared::SecretData;17use nix_eval::{nix_go, nix_go_json, NixSessionPool, Value};18use nom::{19 bytes::complete::take_while1,20 character::complete::char,21 combinator::{map, opt},22 multi::separated_list1,23 sequence::{preceded, separated_pair},24};25use openssh::SessionBuilder;26use serde::de::DeserializeOwned;27use tempfile::NamedTempFile;2829use crate::{30 command::MyCommand,31 fleetdata::{FleetData, FleetSecret, FleetSharedSecret},32};3334pub struct FleetConfigInternals {35 pub local_system: String,36 pub directory: PathBuf,37 pub opts: FleetOpts,38 pub data: Mutex<FleetData>,39 pub nix_args: Vec<OsString>,40 /// fleet_config.config41 pub config_field: Value,42 /// fleet_config.unchecked.config43 pub config_unchecked_field: Value,4445 /// import nixpkgs {system = local};46 pub default_pkgs: Value,47}4849#[derive(Clone)]50pub struct Config(Arc<FleetConfigInternals>);5152impl Deref for Config {53 type Target = FleetConfigInternals;5455 fn deref(&self) -> &Self::Target {56 &self.057 }58}5960pub struct ConfigHost {61 config: Config,62 pub name: String,63 pub local: bool,64 pub session: OnceLock<Arc<openssh::Session>>,65 groups: OnceCell<Vec<String>>,6667 pub nixos_config: Option<Value>,68}69impl ConfigHost {70 pub async fn tags(&self) -> Result<Vec<String>> {71 if let Some(v) = self.groups.get() {72 return Ok(v.clone());73 }74 // TOCTOU is possible here in case if config is changed, but this case is not handled anywhere anyway,75 // assuming getting tags always returns the same value.76 let Some(nixos_config) = &self.nixos_config else {77 return Ok(vec![]);78 };79 let tags: Vec<String> = nix_go_json!(nixos_config.tags);8081 let _ = self.groups.set(tags.clone());8283 Ok(tags)84 }85 async fn open_session(&self) -> Result<Arc<openssh::Session>> {86 assert!(!self.local, "do not open ssh connection to local session");87 // FIXME: TOCTOU88 if let Some(session) = &self.session.get() {89 return Ok((*session).clone());90 };91 let session = SessionBuilder::default();9293 let session = session94 .connect(&self.name)95 .await96 .map_err(|e| anyhow!("ssh error while connecting to {}: {e}", self.name))?;97 let session = Arc::new(session);98 self.session.set(session.clone()).expect("TOCTOU happened");99 Ok(session)100 }101 pub async fn mktemp_dir(&self) -> Result<String> {102 let mut cmd = self.cmd("mktemp").await?;103 cmd.arg("-d");104 let path = cmd.run_string().await?;105 Ok(path.trim_end().to_owned())106 }107 pub async fn read_file_bin(&self, path: impl AsRef<OsStr>) -> Result<Vec<u8>> {108 let mut cmd = self.cmd("cat").await?;109 cmd.arg(path);110 cmd.run_bytes().await111 }112 pub async fn read_file_text(&self, path: impl AsRef<OsStr>) -> Result<String> {113 let mut cmd = self.cmd("cat").await?;114 cmd.arg(path);115 cmd.run_string().await116 }117 pub async fn read_dir(&self, path: impl AsRef<OsStr>) -> Result<Vec<String>> {118 let mut cmd = self.cmd("ls").await?;119 cmd.arg(path);120 let out = cmd.run_string().await?;121 let mut lines = out.split('\n');122 if let Some(last) = lines.next_back() {123 ensure!(last.is_empty(), "output of ls should end with newline");124 }125 Ok(lines.map(ToOwned::to_owned).collect())126 }127 #[allow(dead_code)]128 pub async fn read_file_json<D: DeserializeOwned>(&self, path: impl AsRef<OsStr>) -> Result<D> {129 let text = self.read_file_text(path).await?;130 Ok(serde_json::from_str(&text)?)131 }132 pub async fn read_file_value<D: FromStr>(&self, path: impl AsRef<OsStr>) -> Result<D>133 where134 <D as FromStr>::Err: Display,135 {136 let text = self.read_file_text(path).await?;137 D::from_str(&text).map_err(|e| anyhow!("failed to parse value: {e}"))138 }139 pub async fn cmd(&self, cmd: impl AsRef<OsStr>) -> Result<MyCommand> {140 if self.local {141 Ok(MyCommand::new(cmd))142 } else {143 let session = self.open_session().await?;144 Ok(MyCommand::new_on(cmd, session))145 }146 }147148 pub async fn decrypt(&self, data: SecretData) -> Result<Vec<u8>> {149 ensure!(data.encrypted, "secret is not encrypted");150 let mut cmd = self.cmd("fleet-install-secrets").await?;151 cmd.arg("decrypt").eqarg("--secret", data.to_string());152 let encoded = cmd153 .sudo()154 .run_string()155 .await156 .context("failed to call remote host for decrypt")?;157 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;158 ensure!(!data.encrypted, "secret came out encrypted");159 Ok(data.data)160 }161 pub async fn reencrypt(&self, data: SecretData, targets: Vec<String>) -> Result<SecretData> {162 ensure!(data.encrypted, "secret is not encrypted");163 let mut cmd = self.cmd("fleet-install-secrets").await?;164 cmd.arg("reencrypt").eqarg("--secret", data.to_string());165 for target in targets {166 let key = self.config.key(&target).await?;167 cmd.eqarg("--targets", key);168 }169 let encoded = cmd170 .sudo()171 .run_string()172 .await173 .context("failed to call remote host for decrypt")?;174 let data: SecretData = encoded.parse().map_err(|e| anyhow!("{e}"))?;175 ensure!(data.encrypted, "secret came out not encrypted");176 Ok(data)177 }178 /// Returns path for futureproofing, as path might change i.e on conversion to CA179 pub async fn remote_derivation(&self, path: &PathBuf) -> Result<PathBuf> {180 if self.local {181 // Path is located locally, thus already trusted.182 return Ok(path.to_owned());183 }184 let mut nix = MyCommand::new("nix");185 nix.arg("copy")186 .arg("--substitute-on-destination")187 .comparg("--to", format!("ssh-ng://{}", self.name))188 .arg(path);189 nix.run_nix().await.context("nix copy")?;190 Ok(path.to_owned())191 }192 pub async fn systemctl_stop(&self, name: &str) -> Result<()> {193 let mut cmd = self.cmd("systemctl").await?;194 cmd.arg("stop").arg(name);195 cmd.sudo().run().await196 }197 pub async fn systemctl_start(&self, name: &str) -> Result<()> {198 let mut cmd = self.cmd("systemctl").await?;199 cmd.arg("start").arg(name);200 cmd.sudo().run().await201 }202203 pub async fn rm_file(&self, path: impl AsRef<OsStr>, sudo: bool) -> Result<()> {204 let mut cmd = self.cmd("rm").await?;205 cmd.arg("-f").arg(path);206 if sudo {207 cmd = cmd.sudo()208 }209 cmd.run().await210 }211212 pub async fn list_configured_secrets(&self) -> Result<Vec<String>> {213 let Some(nixos) = &self.nixos_config else {214 return Ok(vec![]);215 };216 let secrets = nix_go!(nixos.secrets);217 let mut out = Vec::new();218 for name in secrets.list_fields().await? {219 let secret = nix_go!(secrets[{ name }]);220 let is_shared: bool = nix_go_json!(secret.shared);221 if is_shared {222 continue;223 }224 out.push(name);225 }226 Ok(out)227 }228 pub async fn secret_field(&self, name: &str) -> Result<Value> {229 let Some(nixos) = &self.nixos_config else {230 bail!("host is virtual and has no secrets");231 };232 Ok(nix_go!(nixos.secrets[{ name }]))233 }234235 /// Packages for this host, resolved with nixpkgs overlays236 pub async fn pkgs(&self) -> Result<Value> {237 let Some(nixos) = &self.nixos_config else {238 return Ok(self.config.default_pkgs.clone());239 };240 Ok(nix_go!(nixos.nixpkgs.resolvedPkgs))241 }242}243244impl Config {245 pub async fn should_skip(&self, host: &ConfigHost) -> Result<bool> {246 if !self.opts.skip.is_empty() && self.opts.skip.iter().any(|h| h as &str == host.name) {247 return Ok(true);248 }249 if self.opts.only.is_empty() {250 return Ok(false);251 }252 let mut have_group_matches = false;253 for item in self.opts.only.iter() {254 match item {255 HostItem::Host { name, .. } if *name == host.name => {256 return Ok(false);257 }258 HostItem::Tag { .. } => {259 have_group_matches = true;260 }261 _ => {}262 }263 }264 if have_group_matches {265 let host_tags = host.tags().await?;266 for item in self.opts.only.iter() {267 match item {268 HostItem::Tag { name, .. } if host_tags.contains(name) => {269 return Ok(false);270 }271 _ => {}272 }273 }274 }275 Ok(true)276 }277 pub async fn action_attr(&self, host: &ConfigHost, attr: &str) -> Result<Option<String>> {278 if self.opts.only.is_empty() {279 return Ok(None);280 }281 let mut have_group_matches = false;282 for item in self.opts.only.iter() {283 match item {284 HostItem::Host { name, attrs }285 if *name == host.name && attrs.contains_key(attr) =>286 {287 return Ok(attrs.get(attr).cloned());288 }289 HostItem::Tag { attrs, .. } if attrs.contains_key(attr) => {290 have_group_matches = true;291 }292 _ => {}293 }294 }295 if have_group_matches {296 let host_tags = host.tags().await?;297 for item in self.opts.only.iter() {298 match item {299 HostItem::Tag { name, attrs }300 if host_tags.contains(name) && attrs.contains_key(attr) =>301 {302 return Ok(attrs.get(attr).cloned());303 }304 _ => {}305 }306 }307 }308 Ok(None)309 }310 pub fn is_local(&self, host: &str) -> bool {311 self.opts.localhost.as_ref().map(|s| s as &str) == Some(host)312 }313314 pub fn local_host(&self) -> ConfigHost {315 ConfigHost {316 config: self.clone(),317 name: "<virtual localhost>".to_owned(),318 local: true,319 session: OnceLock::new(),320 nixos_config: None,321 groups: {322 let cell = OnceCell::new();323 let _ = cell.set(vec![]);324 cell325 },326 }327 }328329 pub async fn host(&self, name: &str) -> Result<ConfigHost> {330 let config = &self.config_unchecked_field;331 let nixos_config = nix_go!(config.hosts[{ name }].nixosSystem.config);332 Ok(ConfigHost {333 config: self.clone(),334 name: name.to_owned(),335 local: self.is_local(name),336 session: OnceLock::new(),337 nixos_config: Some(nixos_config),338 groups: OnceCell::new(),339 })340 }341 pub async fn list_hosts(&self) -> Result<Vec<ConfigHost>> {342 let config = &self.config_unchecked_field;343 let names = nix_go!(config.hosts).list_fields().await?;344 let mut out = vec![];345 for name in names {346 out.push(self.host(&name).await?);347 }348 Ok(out)349 }350 pub async fn system_config(&self, host: &str) -> Result<Value> {351 let fleet_field = &self.config_unchecked_field;352 Ok(nix_go!(fleet_field.hosts[{ host }].nixosSystem.config))353 }354355 pub(super) fn data(&self) -> MutexGuard<FleetData> {356 self.data.lock().unwrap()357 }358 pub(super) fn data_mut(&self) -> MutexGuard<FleetData> {359 self.data.lock().unwrap()360 }361 /// Shared secrets configured in fleet.nix or in flake362 pub async fn list_configured_shared(&self) -> Result<Vec<String>> {363 let config_field = &self.config_unchecked_field;364 Ok(nix_go!(config_field.sharedSecrets).list_fields().await?)365 }366 /// Shared secrets configured in fleet.nix367 pub fn list_shared(&self) -> Vec<String> {368 let data = self.data();369 data.shared_secrets.keys().cloned().collect()370 }371 pub fn has_shared(&self, name: &str) -> bool {372 let data = self.data();373 data.shared_secrets.contains_key(name)374 }375 pub fn replace_shared(&self, name: String, shared: FleetSharedSecret) {376 let mut data = self.data_mut();377 data.shared_secrets.insert(name.to_owned(), shared);378 }379 pub fn remove_shared(&self, secret: &str) {380 let mut data = self.data_mut();381 data.shared_secrets.remove(secret);382 }383384 pub fn list_secrets(&self, host: &str) -> Vec<String> {385 let data = self.data();386 let Some(secrets) = data.host_secrets.get(host) else {387 return Vec::new();388 };389 secrets.keys().cloned().collect()390 }391392 pub fn has_secret(&self, host: &str, secret: &str) -> bool {393 let data = self.data();394 let Some(host_secrets) = data.host_secrets.get(host) else {395 return false;396 };397 host_secrets.contains_key(secret)398 }399 pub fn insert_secret(&self, host: &str, secret: String, value: FleetSecret) {400 let mut data = self.data_mut();401 let host_secrets = data.host_secrets.entry(host.to_owned()).or_default();402 host_secrets.insert(secret, value);403 }404405 pub fn host_secret(&self, host: &str, secret: &str) -> Result<FleetSecret> {406 let data = self.data();407 let Some(host_secrets) = data.host_secrets.get(host) else {408 bail!("no secrets for machine {host}");409 };410 let Some(secret) = host_secrets.get(secret) else {411 bail!("machine {host} has no secret {secret}");412 };413 Ok(secret.clone())414 }415 pub fn shared_secret(&self, secret: &str) -> Result<FleetSharedSecret> {416 let data = self.data();417 let Some(secret) = data.shared_secrets.get(secret) else {418 bail!("no shared secret {secret}");419 };420 Ok(secret.clone())421 }422 pub async fn shared_secret_expected_owners(&self, secret: &str) -> Result<Vec<String>> {423 let config_field = &self.config_unchecked_field;424 Ok(nix_go_json!(425 config_field.sharedSecrets[{ secret }].expectedOwners426 ))427 }428429 pub fn save(&self) -> Result<()> {430 let mut tempfile = NamedTempFile::new_in(self.directory.clone()).context("failed to create updated version of fleet.nix in the same directory as original.\nDo you have write access to it? Access only to the fleet.nix won't be enough, the directory is used for atomic overwrite operation.\nIt is not recommended to use fleet by root anyway, move fleet project to your home directory.")?;431 let data = nixlike::serialize(&self.data() as &FleetData)?;432 tempfile.write_all(433 format!(434 "# This file contains fleet state and shouldn't be edited by hand\n\n{}\n\n# vim: ts=2 et nowrap\n",435 data436 )437 .as_bytes(),438 )?;439 let mut fleet_data_path = self.directory.clone();440 fleet_data_path.push("fleet.nix");441 tempfile.persist(fleet_data_path)?;442 Ok(())443 }444}445446#[derive(Clone)]447enum HostItem {448 Host {449 name: String,450 attrs: BTreeMap<String, String>,451 },452 Tag {453 name: String,454 attrs: BTreeMap<String, String>,455 },456}457fn host_item_parser(input: &str) -> Result<HostItem, String> {458 fn err_to_string(err: nom::Err<nom::error::Error<&str>>) -> String {459 err.to_string()460 }461462 let (input, is_tag) = map(opt(char('@')), |c| c.is_some())(input).map_err(err_to_string)?;463 let (input, name) = map(464 take_while1(|v| v != ',' && v != '?' && v != '@'),465 str::to_owned,466 )(input)467 .map_err(err_to_string)?;468469 let kw_item = separated_pair(470 map(take_while1(|v| v != '&' && v != '='), str::to_owned),471 char('='),472 map(take_while1(|v| v != '&'), str::to_owned),473 );474 let kw = map(separated_list1(char('&'), kw_item), |vec| {475 vec.into_iter().collect::<BTreeMap<_, _>>()476 });477 let mut opt_kw = map(opt(preceded(char('?'), kw)), Option::unwrap_or_default);478479 let (input, attrs) = opt_kw(input).map_err(err_to_string)?;480481 if !input.is_empty() {482 return Err(format!("unexpected trailing input: {input:?}"));483 }484 Ok(if is_tag {485 HostItem::Tag { name, attrs }486 } else {487 HostItem::Host { name, attrs }488 })489}490491#[derive(Parser, Clone)]492pub struct FleetOpts {493 /// All hosts except those would be skipped494 #[clap(long, number_of_values = 1, value_parser = host_item_parser)]495 only: Vec<HostItem>,496497 /// Hosts to skip498 #[clap(long, number_of_values = 1)]499 skip: Vec<String>,500501 /// Host, which should be threaten as current machine502 #[clap(long)]503 pub localhost: Option<String>,504505 /// Override detected system for host, to perform builds via506 /// binfmt-declared qemu instead of trying to crosscompile507 #[clap(long, default_value = "detect")]508 pub local_system: String,509}510511impl FleetOpts {512 pub async fn build(mut self, nix_args: Vec<OsString>) -> Result<Config> {513 if self.localhost.is_none() {514 self.localhost515 .replace(hostname::get().unwrap().to_str().unwrap().to_owned());516 }517 let directory = current_dir()?;518519 let pool = NixSessionPool::new(directory.as_os_str().to_owned(), nix_args.clone()).await?;520 let root_field = pool.get().await?;521522 let builtins_field = Value::binding(root_field.clone(), "builtins").await?;523 if self.local_system == "detect" {524 self.local_system = nix_go_json!(builtins_field.currentSystem);525 }526 let local_system = self.local_system.clone();527528 let fleet_root = Value::binding(root_field, "fleetConfigurations").await?;529 let fleet_field = nix_go!(fleet_root.default);530531 let config_field = nix_go!(fleet_field.config);532 let config_unchecked_field = nix_go!(fleet_field.unchecked.config);533534 let import = nix_go!(builtins_field.import);535 let overlays = nix_go!(config_unchecked_field.overlays);536 let nixpkgs = nix_go!(fleet_field.nixpkgs | import);537538 let default_pkgs = nix_go!(nixpkgs(Obj {539 overlays,540 system: { self.local_system.clone() },541 }));542543 let mut fleet_data_path = directory.clone();544 fleet_data_path.push("fleet.nix");545 let bytes = std::fs::read_to_string(fleet_data_path)?;546 let data = nixlike::parse_str(&bytes)?;547548 Ok(Config(Arc::new(FleetConfigInternals {549 opts: self,550 directory,551 data,552 local_system,553 nix_args,554 config_field,555 config_unchecked_field,556 default_pkgs,557 })))558 }559}flake.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": {