1use std::os::unix::fs::symlink;2use std::path::PathBuf;3use std::{env::current_dir, time::Duration};45use crate::command::MyCommand;6use crate::host::{Config, ConfigHost};7use crate::nix_go;8use anyhow::{anyhow, Result};9use clap::Parser;10use itertools::Itertools as _;11use tokio::{task::LocalSet, time::sleep};12use tracing::{error, field, info, info_span, warn, Instrument};1314#[derive(Parser, Clone)]15pub struct BuildSystems {16 17 #[clap(long)]18 disable_rollback: bool,19 #[clap(subcommand)]20 subcommand: Subcommand,21}2223enum UploadAction {24 Test,25 Boot,26 Switch,27}28impl UploadAction {29 fn name(&self) -> &'static str {30 match self {31 UploadAction::Test => "test",32 UploadAction::Boot => "boot",33 UploadAction::Switch => "switch",34 }35 }3637 pub(crate) fn should_switch_profile(&self) -> bool {38 matches!(self, Self::Switch | Self::Boot)39 }40 pub(crate) fn should_activate(&self) -> bool {41 matches!(self, Self::Switch | Self::Test)42 }43 pub(crate) fn should_schedule_rollback_run(&self) -> bool {44 matches!(self, Self::Switch | Self::Test)45 }46}4748enum PackageAction {49 SdImage,50 InstallationCd,51}52impl PackageAction {53 fn build_attr(&self) -> String {54 match self {55 PackageAction::SdImage => "sdImage".to_owned(),56 PackageAction::InstallationCd => "isoImage".to_owned(),57 }58 }59}6061enum Action {62 Upload { action: Option<UploadAction> },63 Package(PackageAction),64}65impl Action {66 fn build_attr(&self) -> String {67 match self {68 Action::Upload { .. } => "toplevel".to_owned(),69 Action::Package(p) => p.build_attr(),70 }71 }72}7374impl From<Subcommand> for Action {75 fn from(s: Subcommand) -> Self {76 match s {77 Subcommand::Upload => Self::Upload { action: None },78 Subcommand::Test => Self::Upload {79 action: Some(UploadAction::Test),80 },81 Subcommand::Boot => Self::Upload {82 action: Some(UploadAction::Boot),83 },84 Subcommand::Switch => Self::Upload {85 action: Some(UploadAction::Switch),86 },87 Subcommand::SdImage => Self::Package(PackageAction::SdImage),88 Subcommand::InstallationCd => Self::Package(PackageAction::InstallationCd),89 }90 }91}9293#[derive(Parser, Clone)]94enum Subcommand {95 96 Upload,97 98 Test,99 100 Boot,101 102 Switch,103104 105 SdImage,106 107 InstallationCd,108}109110struct Generation {111 id: u32,112 current: bool,113 datetime: String,114}115async fn get_current_generation(host: &ConfigHost) -> Result<Generation> {116 let mut cmd = host.cmd("nix-env").await?;117 cmd.comparg("--profile", "/nix/var/nix/profiles/system")118 .arg("--list-generations");119 120 let data = cmd.sudo().run_string().await?;121 let generations = data122 .split('\n')123 .map(|e| e.trim())124 .filter(|&l| !l.is_empty())125 .filter_map(|g| {126 let gen: Option<Generation> = try {127 let mut parts = g.split_whitespace();128 let id = parts.next()?;129 let id: u32 = id.parse().ok()?;130 let date = parts.next()?;131 let time = parts.next()?;132 let current = if let Some(current) = parts.next() {133 if current == "(current)" {134 Some(true)135 } else {136 None137 }138 } else {139 Some(false)140 };141 let current = current?;142 if parts.next().is_some() {143 warn!("unexpected text after generation: {g}");144 }145 Generation {146 id,147 current,148 datetime: format!("{date} {time}"),149 }150 };151 if gen.is_none() {152 warn!("bad generation: {g}")153 }154 gen155 })156 .collect::<Vec<_>>();157 let current = generations158 .into_iter()159 .filter(|g| g.current)160 .at_most_one()161 .map_err(|_e| anyhow!("bad list-generations output"))?162 .ok_or_else(|| anyhow!("failed to find generation"))?;163 Ok(current)164}165166async fn execute_upload(167 build: &BuildSystems,168 action: UploadAction,169 host: &ConfigHost,170 built: PathBuf,171) -> Result<()> {172 let mut failed = false;173 174 175 176 177 178 if !build.disable_rollback {179 let _span = info_span!("preparing").entered();180 info!("preparing for rollback");181 let generation = get_current_generation(host).await?;182 info!(183 "rollback target would be {} {}",184 generation.id, generation.datetime185 );186 {187 let mut cmd = host.cmd("sh").await?;188 cmd.arg("-c").arg(format!("mark=$(mktemp -p /etc -t fleet_rollback_marker.XXXXX) && echo -n {} > $mark && mv --no-clobber $mark /etc/fleet_rollback_marker", generation.id));189 if let Err(e) = cmd.sudo().run().await {190 error!("failed to set rollback marker: {e}");191 failed = true;192 }193 }194 195 196 197 198 199200 201 202 203 204 if action.should_schedule_rollback_run() {205 let mut cmd = host.cmd("systemd-run").await?;206 cmd.comparg("--on-active", "3min")207 .comparg("--unit", "rollback-watchdog-run")208 .arg("systemctl")209 .arg("start")210 .arg("rollback-watchdog.service");211 if let Err(e) = cmd.sudo().run().await {212 error!("failed to schedule rollback run: {e}");213 failed = true;214 }215 }216 }217218 if action.should_switch_profile() && !failed {219 info!("switching generation");220 let mut cmd = host.cmd("nix-env").await?;221 cmd.comparg("--profile", "/nix/var/nix/profiles/system")222 .comparg("--set", &built);223 if let Err(e) = cmd.sudo().run().await {224 error!("failed to switch generation: {e}");225 failed = true;226 }227 }228229 230231 if action.should_activate() && !failed {232 let _span = info_span!("activating").entered();233 info!("executing activation script");234 let mut switch_script = built.clone();235 switch_script.push("bin");236 switch_script.push("switch-to-configuration");237 let mut cmd = host.cmd(switch_script).in_current_span().await?;238 cmd.arg(action.name());239 if let Err(e) = cmd.sudo().run().in_current_span().await {240 error!("failed to activate: {e}");241 failed = true;242 }243 }244 if !build.disable_rollback {245 if failed {246 info!("executing rollback");247 if let Err(e) = host248 .systemctl_start("rollback-watchdog.service")249 .instrument(info_span!("rollback"))250 .await251 {252 error!("failed to trigger rollback: {e}")253 }254 } else {255 info!("trying to mark upgrade as successful");256 if let Err(e) = host257 .rm_file("/etc/fleet_rollback_marker", true)258 .in_current_span()259 .await260 {261 error!("failed to remove rollback marker. This is bad, as the system will be rolled back by watchdog: {e}")262 }263 }264 info!("disarming watchdog, just in case");265 if let Err(_e) = host.systemctl_stop("rollback-watchdog.timer").await {266 267 }268 if action.should_schedule_rollback_run() {269 if let Err(e) = host.systemctl_stop("rollback-watchdog-run.timer").await {270 error!("failed to disarm rollback run: {e}");271 }272 }273 } else if let Err(_e) = host274 .rm_file("/etc/fleet_rollback_marker", true)275 .in_current_span()276 .await277 {278 279 }280 Ok(())281}282283impl BuildSystems {284 async fn build_task(self, config: Config, host: String) -> Result<()> {285 info!("building");286 let host = config.host(&host).await?;287 let action = Action::from(self.subcommand.clone());288 let fleet_config = &config.config_field;289 let drv = nix_go!(290 fleet_config.hosts[{ &host.name }].nixosSystem.config.system.build[{ action.build_attr() }]291 );292 let outputs = drv.build().await.map_err(|e| {293 if action.build_attr() == "sdImage" {294 info!("sd-image build failed");295 info!("Make sure you have imported modulesPath/installer/sd-card/sd-image-<arch>[-installer].nix (For installer, you may want to check config)");296 }297 e298 })?;299 let out_output = outputs300 .get("out")301 .ok_or_else(|| anyhow!("system build should produce \"out\" output"))?;302303 match action {304 Action::Upload { action } => {305 if !config.is_local(&host.name) {306 info!("uploading system closure");307 {308 309 310 311 312 313 let mut sign = MyCommand::new("nix");314 315 sign.arg("store")316 .arg("sign")317 .comparg("--key-file", "/etc/nix/private-key")318 .arg("-r")319 .arg(out_output);320 if let Err(e) = sign.sudo().run_nix().await {321 warn!("Failed to sign store paths: {e}");322 };323 }324 let mut tries = 0;325 loop {326 match host.remote_derivation(out_output).await {327 Ok(remote) => {328 assert!(&remote == out_output, "CA derivations aren't implemented");329 break;330 }331 Err(e) if tries < 3 => {332 tries += 1;333 warn!("Copy failure ({}/3): {}", tries, e);334 sleep(Duration::from_millis(5000)).await;335 }336 Err(e) => return Err(e),337 }338 }339 }340 if let Some(action) = action {341 execute_upload(&self, action, &host, out_output.clone()).await?342 }343 }344 Action::Package(PackageAction::SdImage) => {345 let mut out = current_dir()?;346 out.push(format!("sd-image-{}", host.name));347348 info!("linking sd image to {:?}", out);349 symlink(out_output, out)?;350 }351 Action::Package(PackageAction::InstallationCd) => {352 let mut out = current_dir()?;353 out.push(format!("installation-cd-{}", host.name));354355 info!("linking iso image to {:?}", out);356 symlink(out_output, out)?;357 }358 };359 Ok(())360 }361362 pub async fn run(self, config: &Config) -> Result<()> {363 let hosts = config.list_hosts().await?;364 let set = LocalSet::new();365 let this = &self;366 for host in hosts.into_iter() {367 if config.should_skip(&host.name) {368 continue;369 }370 let config = config.clone();371 let this = this.clone();372 let span = info_span!("deployment", host = field::display(&host.name));373 let hostname = host.name;374 375 376 377 378 379 380 381 382 383 384 385 set.spawn_local(386 (async move {387 match this.build_task(config, hostname).await {388 Ok(_) => {}389 Err(e) => {390 error!("failed to deploy host: {}", e)391 }392 }393 })394 .instrument(span),395 );396 }397 set.await;398 Ok(())399 }400}
1use std::os::unix::fs::symlink;2use std::path::PathBuf;3use std::{env::current_dir, time::Duration};45use crate::command::MyCommand;6use crate::host::{Config, ConfigHost};7use crate::nix_go;8use anyhow::{anyhow, Result};9use clap::{Parser, ValueEnum};10use itertools::Itertools as _;11use tokio::{task::LocalSet, time::sleep};12use tracing::{error, field, info, info_span, warn, Instrument};1314#[derive(Parser)]15pub struct Deploy {16 17 #[clap(long)]18 disable_rollback: bool,19 action: DeployAction,20}2122#[derive(ValueEnum, Clone, Copy)]23enum DeployAction {24 25 Upload,26 27 Test,28 29 Boot,30 31 Switch,32}3334impl DeployAction {35 pub(crate) fn name(&self) -> Option<&'static str> {36 match self {37 DeployAction::Upload => None,38 DeployAction::Test => Some("test"),39 DeployAction::Boot => Some("boot"),40 DeployAction::Switch => Some("switch"),41 }42 }43 pub(crate) fn should_switch_profile(&self) -> bool {44 matches!(self, Self::Switch | Self::Boot)45 }46 pub(crate) fn should_activate(&self) -> bool {47 matches!(self, Self::Switch | Self::Test)48 }49 pub(crate) fn should_schedule_rollback_run(&self) -> bool {50 matches!(self, Self::Switch | Self::Test)51 }52}5354#[derive(Parser, Clone)]55pub struct BuildSystems {56 57 58 #[clap(long, default_value = "toplevel")]59 build_attr: String,60}6162struct Generation {63 id: u32,64 current: bool,65 datetime: String,66}67async fn get_current_generation(host: &ConfigHost) -> Result<Generation> {68 let mut cmd = host.cmd("nix-env").await?;69 cmd.comparg("--profile", "/nix/var/nix/profiles/system")70 .arg("--list-generations");71 72 let data = cmd.sudo().run_string().await?;73 let generations = data74 .split('\n')75 .map(|e| e.trim())76 .filter(|&l| !l.is_empty())77 .filter_map(|g| {78 let gen: Option<Generation> = try {79 let mut parts = g.split_whitespace();80 let id = parts.next()?;81 let id: u32 = id.parse().ok()?;82 let date = parts.next()?;83 let time = parts.next()?;84 let current = if let Some(current) = parts.next() {85 if current == "(current)" {86 Some(true)87 } else {88 None89 }90 } else {91 Some(false)92 };93 let current = current?;94 if parts.next().is_some() {95 warn!("unexpected text after generation: {g}");96 }97 Generation {98 id,99 current,100 datetime: format!("{date} {time}"),101 }102 };103 if gen.is_none() {104 warn!("bad generation: {g}")105 }106 gen107 })108 .collect::<Vec<_>>();109 let current = generations110 .into_iter()111 .filter(|g| g.current)112 .at_most_one()113 .map_err(|_e| anyhow!("bad list-generations output"))?114 .ok_or_else(|| anyhow!("failed to find generation"))?;115 Ok(current)116}117118async fn deploy_task(119 action: DeployAction,120 host: &ConfigHost,121 built: PathBuf,122 disable_rollback: bool,123) -> Result<()> {124 let mut failed = false;125 126 127 128 129 130 if !disable_rollback {131 let _span = info_span!("preparing").entered();132 info!("preparing for rollback");133 let generation = get_current_generation(host).await?;134 info!(135 "rollback target would be {} {}",136 generation.id, generation.datetime137 );138 {139 let mut cmd = host.cmd("sh").await?;140 cmd.arg("-c").arg(format!("mark=$(mktemp -p /etc -t fleet_rollback_marker.XXXXX) && echo -n {} > $mark && mv --no-clobber $mark /etc/fleet_rollback_marker", generation.id));141 if let Err(e) = cmd.sudo().run().await {142 error!("failed to set rollback marker: {e}");143 failed = true;144 }145 }146 147 148 149 150 151152 153 154 155 156 if action.should_schedule_rollback_run() {157 let mut cmd = host.cmd("systemd-run").await?;158 cmd.comparg("--on-active", "3min")159 .comparg("--unit", "rollback-watchdog-run")160 .arg("systemctl")161 .arg("start")162 .arg("rollback-watchdog.service");163 if let Err(e) = cmd.sudo().run().await {164 error!("failed to schedule rollback run: {e}");165 failed = true;166 }167 }168 }169170 if action.should_switch_profile() && !failed {171 info!("switching generation");172 let mut cmd = host.cmd("nix-env").await?;173 cmd.comparg("--profile", "/nix/var/nix/profiles/system")174 .comparg("--set", &built);175 if let Err(e) = cmd.sudo().run().await {176 error!("failed to switch generation: {e}");177 failed = true;178 }179 }180181 182183 if action.should_activate() && !failed {184 let _span = info_span!("activating").entered();185 info!("executing activation script");186 let mut switch_script = built.clone();187 switch_script.push("bin");188 switch_script.push("switch-to-configuration");189 let mut cmd = host.cmd(switch_script).in_current_span().await?;190 cmd.arg(action.name().expect("upload.should_activate == false"));191 if let Err(e) = cmd.sudo().run().in_current_span().await {192 error!("failed to activate: {e}");193 failed = true;194 }195 }196 if !disable_rollback {197 if failed {198 info!("executing rollback");199 if let Err(e) = host200 .systemctl_start("rollback-watchdog.service")201 .instrument(info_span!("rollback"))202 .await203 {204 error!("failed to trigger rollback: {e}")205 }206 } else {207 info!("trying to mark upgrade as successful");208 if let Err(e) = host209 .rm_file("/etc/fleet_rollback_marker", true)210 .in_current_span()211 .await212 {213 error!("failed to remove rollback marker. This is bad, as the system will be rolled back by watchdog: {e}")214 }215 }216 info!("disarming watchdog, just in case");217 if let Err(_e) = host.systemctl_stop("rollback-watchdog.timer").await {218 219 }220 if action.should_schedule_rollback_run() {221 if let Err(e) = host.systemctl_stop("rollback-watchdog-run.timer").await {222 error!("failed to disarm rollback run: {e}");223 }224 }225 } else if let Err(_e) = host226 .rm_file("/etc/fleet_rollback_marker", true)227 .in_current_span()228 .await229 {230 231 }232 Ok(())233}234235async fn build_task(config: Config, host: String, build_attr: &str) -> Result<PathBuf> {236 info!("building");237 let host = config.host(&host).await?;238 239 let fleet_config = &config.config_field;240 let drv = nix_go!(241 fleet_config.hosts[{ &host.name }]242 .nixosSystem243 .config244 .system245 .build[{ build_attr }]246 );247 let outputs = drv.build().await.map_err(|e| {248 if build_attr == "sdImage" {249 info!("sd-image build failed");250 info!("Make sure you have imported modulesPath/installer/sd-card/sd-image-<arch>[-installer].nix (For installer, you may want to check config)");251 }252 e253 })?;254 let out_output = outputs255 .get("out")256 .ok_or_else(|| anyhow!("system build should produce \"out\" output"))?;257258 Ok(out_output.clone())259}260261impl BuildSystems {262 pub async fn run(self, config: &Config) -> Result<()> {263 let hosts = config.list_hosts().await?;264 let set = LocalSet::new();265 let build_attr = self.build_attr.clone();266 for host in hosts.into_iter() {267 if config.should_skip(&host.name) {268 continue;269 }270 let config = config.clone();271 let span = info_span!("build", host = field::display(&host.name));272 let hostname = host.name;273 let build_attr = build_attr.clone();274 275 276 277 278 279 280 281 282 283 284 285 set.spawn_local(286 (async move {287 let built = match build_task(config, hostname.clone(), &build_attr).await {288 Ok(path) => path,289 Err(e) => {290 error!("failed to deploy host: {}", e);291 return;292 }293 };294 295 let mut out = current_dir().expect("cwd exists");296 out.push(format!("built-{}", hostname));297298 info!("linking iso image to {:?}", out);299 if let Err(e) = symlink(built, out) {300 error!("failed to symlink: {e}")301 }302 })303 .instrument(span),304 );305 }306 set.await;307 Ok(())308 }309}310311impl Deploy {312 pub async fn run(self, config: &Config) -> Result<()> {313 let hosts = config.list_hosts().await?;314 let set = LocalSet::new();315 for host in hosts.into_iter() {316 if config.should_skip(&host.name) {317 continue;318 }319 let config = config.clone();320 let span = info_span!("deploy", host = field::display(&host.name));321 let hostname = host.name.clone();322 323 set.spawn_local(324 (async move {325 let built = match build_task(config.clone(), hostname.clone(), "toplevel").await326 {327 Ok(path) => path,328 Err(e) => {329 error!("failed to deploy host: {}", e);330 return;331 }332 };333 if !config.is_local(&hostname) {334 info!("uploading system closure");335 {336 337 338 339 340 341 let mut sign = MyCommand::new("nix");342 343 sign.arg("store")344 .arg("sign")345 .comparg("--key-file", "/etc/nix/private-key")346 .arg("-r")347 .arg(&built);348 if let Err(e) = sign.sudo().run_nix().await {349 warn!("Failed to sign store paths: {e}");350 };351 }352 let mut tries = 0;353 loop {354 match host.remote_derivation(&built).await {355 Ok(remote) => {356 assert!(remote == built, "CA derivations aren't implemented");357 break;358 }359 Err(e) if tries < 3 => {360 tries += 1;361 warn!("copy failure ({}/3): {}", tries, e);362 sleep(Duration::from_millis(5000)).await;363 }364 Err(e) => {365 error!("upload failed: {e}");366 return;367 }368 }369 }370 }371 if let Err(e) =372 deploy_task(self.action, &host, built, self.disable_rollback).await373 {374 error!("activation failed: {e}");375 }376 })377 .instrument(span),378 );379 }380 set.await;381 Ok(())382 }383}