1use std::{
22 collections::HashMap,
23 path::{Path, PathBuf},
24 sync::{Arc, LazyLock, Mutex as SyncMutex, PoisonError, Weak, atomic::AtomicBool},
25 time::Duration,
26};
27
28use anyhow::{Context, Result, anyhow, bail};
29use scv_channels::hub::{Hub, Origin, Restart};
30use scv_client::Layout;
31use scv_protocol::{ComponentState, DaemonCommand, RestartInfo};
32use scv_tools::{background::BackgroundJobs, delegation::DelegationRegistry};
33use serde::{Deserialize, Serialize};
34use tokio::sync::Mutex;
35use tokio_util::sync::CancellationToken;
36
37use crate::components::Components;
38use crate::config::Instance;
39
40pub(crate) const CONFIG_LAYOUT: u32 = 1;
44
45const DEFAULT_MAX_WAIT: u64 = 10 * 60;
46const MAX_WAIT_LIMIT: u64 = 60 * 60;
47const VERIFY_SECONDS: u64 = 180;
50const ROLLBACK_SECONDS: u64 = 90;
52const CLEAR_CHECKS: u32 = 2;
55const DOWN_NOTICE_AFTER: Duration = Duration::from_secs(10 * 60);
57const MONITOR_INTERVAL: Duration = Duration::from_secs(30);
58const RESTART_CONTEXT_MAX_AGE: u64 = 60 * 60;
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct BuildInfo {
64 pub(crate) version: String,
65 pub(crate) config_layout: u32,
66}
67
68pub fn build_info() -> BuildInfo {
70 BuildInfo {
71 version: env!("CARGO_PKG_VERSION").into(),
72 config_layout: CONFIG_LAYOUT,
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub(crate) enum PlanState {
79 Waiting,
81 Restarting,
83 Verified,
85 RolledBack,
87 Failed,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub(crate) struct Requester {
95 pub(crate) handle: String,
96 pub(crate) session: String,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub(crate) struct Plan {
103 pub(crate) id: String,
104 pub(crate) state: PlanState,
105 pub(crate) from_version: String,
106 pub(crate) to_version: String,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub(crate) commit: Option<String>,
109 pub(crate) from_layout: u32,
110 pub(crate) to_layout: u32,
111 pub(crate) unit: String,
112 pub(crate) binary: PathBuf,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub(crate) previous: Option<PathBuf>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub(crate) requester: Option<Requester>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub(crate) origin: Option<Origin>,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub(crate) expected: Vec<String>,
126 pub(crate) requested_unix: u64,
127 pub(crate) deadline_unix: u64,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub(crate) restart_unix: Option<u64>,
130 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
132 pub(crate) waited_out: bool,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub(crate) detail: Option<String>,
136 #[serde(default = "default_verify_seconds")]
138 pub(crate) verify_seconds: u64,
139}
140
141fn default_verify_seconds() -> u64 {
142 VERIFY_SECONDS
143}
144
145impl Plan {
146 fn info(&self, waiting_for: Option<String>) -> RestartInfo {
147 RestartInfo {
148 to_version: self.to_version.clone(),
149 waiting_for,
150 requester: self.requester.as_ref().map(|r| r.handle.clone()),
151 origin: self.origin.as_ref().map(|origin| origin.component.clone()),
152 deadline_unix_seconds: self.deadline_unix,
153 }
154 }
155
156 fn label(&self) -> String {
157 match &self.commit {
158 Some(commit) => format!("v{} ({commit})", self.to_version),
159 None => format!("v{}", self.to_version),
160 }
161 }
162}
163
164pub(crate) fn load_plan(path: &Path) -> Result<Option<Plan>> {
165 match std::fs::read(path) {
166 Ok(bytes) => {
167 Ok(Some(serde_json::from_slice(&bytes).with_context(|| {
168 format!("parse restart plan {}", path.display())
169 })?))
170 }
171 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
172 Err(error) => Err(error).with_context(|| format!("read {}", path.display())),
173 }
174}
175
176pub(crate) fn save_plan(path: &Path, plan: &Plan) -> Result<()> {
177 write_private(path, &serde_json::to_vec_pretty(plan)?)
178}
179
180fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
181 let parent = path
182 .parent()
183 .ok_or_else(|| anyhow!("{} has no parent", path.display()))?;
184 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
185 scv_client::fs::replace_private(path, bytes)
186 .with_context(|| format!("write {}", path.display()))
187}
188
189fn unix_now() -> u64 {
190 std::time::SystemTime::now()
191 .duration_since(std::time::UNIX_EPOCH)
192 .map_or(0, |elapsed| elapsed.as_secs())
193}
194
195fn own_executable() -> Result<PathBuf> {
198 std::env::current_exe()
199 .map(strip_deleted)
200 .context("locate the daemon's executable")
201}
202
203fn strip_deleted(path: PathBuf) -> PathBuf {
204 match path
205 .to_str()
206 .and_then(|text| text.strip_suffix(" (deleted)"))
207 {
208 Some(stripped) => PathBuf::from(stripped),
209 None => path,
210 }
211}
212
213fn runs_as_unit(unit: &str) -> bool {
215 let suffix = format!("/{unit}");
216 std::fs::read_to_string("/proc/self/cgroup")
217 .is_ok_and(|text| text.lines().any(|line| line.ends_with(&suffix)))
218}
219
220async fn probe(binary: &Path) -> Result<BuildInfo> {
222 let output = tokio::time::timeout(
223 Duration::from_secs(10),
224 tokio::process::Command::new(binary)
225 .arg("build-info")
226 .stdin(std::process::Stdio::null())
227 .kill_on_drop(true)
228 .output(),
229 )
230 .await
231 .map_err(|_| anyhow!("it did not answer within 10 seconds"))??;
232 if !output.status.success() {
233 bail!("it exited with {}", output.status);
234 }
235 serde_json::from_slice(&output.stdout).context("it printed no build information")
236}
237
238struct SessionActivity {
243 busy: AtomicBool,
244 background: Option<Weak<BackgroundJobs>>,
245}
246
247static SESSIONS: LazyLock<SyncMutex<HashMap<String, Arc<SessionActivity>>>> =
248 LazyLock::new(Default::default);
249
250pub(crate) struct SessionTracker {
252 id: String,
253 activity: Arc<SessionActivity>,
254}
255
256impl SessionTracker {
257 pub(crate) fn new(id: &str, background: Option<&Arc<BackgroundJobs>>) -> Self {
258 let activity = Arc::new(SessionActivity {
259 busy: AtomicBool::new(false),
260 background: background.map(Arc::downgrade),
261 });
262 SESSIONS
263 .lock()
264 .unwrap_or_else(PoisonError::into_inner)
265 .insert(id.to_owned(), Arc::clone(&activity));
266 Self {
267 id: id.to_owned(),
268 activity,
269 }
270 }
271
272 pub(crate) fn set_busy(&self, busy: bool) {
274 self.activity
275 .busy
276 .store(busy, std::sync::atomic::Ordering::Release);
277 }
278}
279
280impl Drop for SessionTracker {
281 fn drop(&mut self) {
282 SESSIONS
283 .lock()
284 .unwrap_or_else(PoisonError::into_inner)
285 .remove(&self.id);
286 }
287}
288
289fn session_busy(id: &str) -> bool {
290 let activity = SESSIONS
291 .lock()
292 .unwrap_or_else(PoisonError::into_inner)
293 .get(id)
294 .cloned();
295 activity.is_some_and(|activity| {
296 activity.busy.load(std::sync::atomic::Ordering::Acquire)
297 || activity
298 .background
299 .as_ref()
300 .and_then(Weak::upgrade)
301 .is_some_and(|jobs| jobs.running() > 0)
302 })
303}
304
305#[derive(Debug, Clone, PartialEq, Eq)]
311struct Candidate {
312 component: String,
313 peer: Option<String>,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
317enum Pick {
318 Send {
319 component: String,
320 peer: String,
321 },
322 Wait,
324 Nothing,
325}
326
327fn pick(
331 candidates: &[Candidate],
332 states: &HashMap<String, ComponentState>,
333 owner: &dyn Fn(&str) -> Option<Option<String>>,
334 exclude: Option<&str>,
335 grace_over: bool,
336) -> Pick {
337 for candidate in candidates {
338 if exclude == Some(candidate.component.as_str()) {
339 continue;
340 }
341 let registered = owner(&candidate.component);
342 let peer = candidate
343 .peer
344 .clone()
345 .or_else(|| registered.clone().flatten());
346 match (states.get(&candidate.component), ®istered, peer) {
347 (Some(ComponentState::Connected), Some(_), Some(peer)) => {
348 return Pick::Send {
349 component: candidate.component.clone(),
350 peer,
351 };
352 }
353 (
354 Some(
355 ComponentState::Starting
356 | ComponentState::Connected
357 | ComponentState::Disconnected
358 | ComponentState::Backoff,
359 ),
360 _,
361 _,
362 ) if !grace_over => return Pick::Wait,
363 _ => {}
364 }
365 }
366 Pick::Nothing
367}
368
369fn channel_title(component: &str) -> &str {
371 match component.split(':').next() {
372 Some("wechat") => "WeChat",
373 Some("feishu") => "Feishu",
374 Some(other) => other,
375 None => component,
376 }
377}
378
379#[derive(Clone)]
381enum States {
382 Components(Weak<Mutex<Components>>),
383 #[cfg(test)]
384 Fixed(Arc<SyncMutex<HashMap<String, ComponentState>>>),
385}
386
387impl States {
388 async fn get(&self) -> HashMap<String, ComponentState> {
389 match self {
390 Self::Components(components) => match components.upgrade() {
391 Some(components) => components
392 .lock()
393 .await
394 .status()
395 .components
396 .into_iter()
397 .filter(|health| health.enabled)
398 .map(|health| (health.id, health.state))
399 .collect(),
400 None => HashMap::new(),
401 },
402 #[cfg(test)]
403 Self::Fixed(states) => states.lock().unwrap().clone(),
404 }
405 }
406}
407
408#[derive(Clone)]
410pub(crate) struct Notifier {
411 hub: Arc<Hub>,
412 states: States,
413 grace: Duration,
415 give_up: Duration,
417 poll: Duration,
418 instance: Instance,
420 #[cfg(test)]
422 list: Option<Vec<String>>,
423}
424
425impl Notifier {
426 pub(crate) fn new(
427 instance: Instance,
428 hub: Arc<Hub>,
429 components: Weak<Mutex<Components>>,
430 ) -> Self {
431 Self {
432 hub,
433 instance,
434 states: States::Components(components),
435 grace: Duration::from_secs(120),
436 give_up: Duration::from_secs(15 * 60),
437 poll: Duration::from_secs(2),
438 #[cfg(test)]
439 list: None,
440 }
441 }
442
443 #[cfg(test)]
446 pub(crate) fn fixed(
447 hub: &Arc<Hub>,
448 list: Vec<String>,
449 states: HashMap<String, ComponentState>,
450 ) -> Self {
451 Self {
452 hub: Arc::clone(hub),
453 states: States::Fixed(Arc::new(SyncMutex::new(states))),
454 grace: Duration::from_millis(200),
455 give_up: Duration::from_secs(5),
456 poll: Duration::from_millis(20),
457 instance: crate::test_support::test_instance("/unused"),
458 list: Some(list),
459 }
460 }
461
462 fn notify_list(&self) -> Vec<String> {
463 #[cfg(test)]
464 if let Some(list) = &self.list {
465 return list.clone();
466 }
467 self.instance.load_user().map_or_else(
468 |error| {
469 tracing::warn!(
470 "Notices use the owner's last chat; configuration failed: {error:#}"
471 );
472 Vec::new()
473 },
474 |config| config.notify.owner,
475 )
476 }
477
478 pub(crate) async fn owner_chat(&self) -> Option<Origin> {
482 let states = self.states.get().await;
483 let owner = |component: &str| self.hub.owner(component);
484 match pick(&self.candidates(), &states, &owner, None, true) {
485 Pick::Send { component, peer } => Some(Origin { component, peer }),
486 Pick::Wait | Pick::Nothing => None,
487 }
488 }
489
490 fn candidates(&self) -> Vec<Candidate> {
492 let list = self.notify_list();
493 if !list.is_empty() {
494 return list
495 .into_iter()
496 .map(|component| Candidate {
497 component,
498 peer: None,
499 })
500 .collect();
501 }
502 self.hub
503 .last_owner()
504 .map(|last| Candidate {
505 component: last.component,
506 peer: Some(last.peer),
507 })
508 .into_iter()
509 .collect()
510 }
511
512 pub(crate) async fn deliver(
516 &self,
517 origin: Option<&Origin>,
518 text: &str,
519 exclude: Option<&str>,
520 cancel: &CancellationToken,
521 ) -> Option<String> {
522 let started = tokio::time::Instant::now();
523 let fallback = self.candidates();
524 let mut phase = match origin {
527 Some(origin) => (
528 vec![Candidate {
529 component: origin.component.clone(),
530 peer: Some(origin.peer.clone()),
531 }],
532 None,
533 text.to_owned(),
534 ),
535 None => (fallback.clone(), exclude, text.to_owned()),
536 };
537 let mut phase_started = started;
538 loop {
539 let states = self.states.get().await;
540 let grace_over = phase_started.elapsed() >= self.grace;
541 if let Some(origin) = origin
542 && grace_over
543 && phase.1.is_none()
544 {
545 phase = (
546 fallback.clone(),
547 Some(origin.component.as_str()),
548 format!(
549 "(You asked on {}, which is not connected, so this comes here.) {text}",
550 channel_title(&origin.component)
551 ),
552 );
553 phase_started = tokio::time::Instant::now();
554 continue;
555 }
556 let (candidates, exclude, text) = &phase;
557 let owner = |component: &str| self.hub.owner(component);
558 match pick(candidates, &states, &owner, *exclude, grace_over) {
559 Pick::Send { component, peer } => {
560 match self.hub.notify(&component, &peer, text).await {
561 Ok(()) => return Some(component),
562 Err(error) => tracing::warn!("Notice to {component} not stored: {error}"),
563 }
564 }
565 Pick::Nothing if grace_over => {
566 tracing::warn!("No connected account can take this notice: {text}");
567 return None;
568 }
569 Pick::Wait | Pick::Nothing => {}
570 }
571 if started.elapsed() >= self.give_up {
572 tracing::warn!("Gave up delivering a notice: {text}");
573 return None;
574 }
575 tokio::select! {
576 () = cancel.cancelled() => return None,
577 () = tokio::time::sleep(self.poll) => {}
578 }
579 }
580 }
581}
582
583enum Launcher {
588 Systemd,
590 #[cfg(test)]
592 Record(Arc<SyncMutex<Vec<Plan>>>),
593}
594
595pub(crate) struct Restarter {
597 launcher: Launcher,
598 instance: Instance,
599 hub: Arc<Hub>,
600 registry: Arc<DelegationRegistry>,
601 notifier: Notifier,
602 components: Weak<Mutex<Components>>,
603 cancel: CancellationToken,
604 current: SyncMutex<Option<(Plan, Option<String>)>>,
606}
607
608impl Restarter {
609 pub(crate) fn new(
610 instance: Instance,
611 hub: Arc<Hub>,
612 registry: Arc<DelegationRegistry>,
613 components: &Arc<Mutex<Components>>,
614 cancel: CancellationToken,
615 ) -> Arc<Self> {
616 Arc::new(Self {
617 launcher: Launcher::Systemd,
618 notifier: Notifier::new(
619 instance.clone(),
620 Arc::clone(&hub),
621 Arc::downgrade(components),
622 ),
623 instance,
624 hub,
625 registry,
626 components: Arc::downgrade(components),
627 cancel,
628 current: SyncMutex::new(None),
629 })
630 }
631
632 pub(crate) fn notifier(&self) -> &Notifier {
633 &self.notifier
634 }
635
636 pub(crate) fn info(&self) -> Option<RestartInfo> {
638 self.current
639 .lock()
640 .unwrap_or_else(PoisonError::into_inner)
641 .as_ref()
642 .map(|(plan, waiting)| plan.info(waiting.clone()))
643 }
644
645 pub(crate) async fn request(
647 self: &Arc<Self>,
648 command: DaemonCommand,
649 ) -> std::result::Result<RestartInfo, String> {
650 let DaemonCommand::RestartWhenIdle {
651 version,
652 commit,
653 parent,
654 max_wait_seconds,
655 } = command
656 else {
657 return Err("not a restart request".into());
658 };
659 if let Some(info) = self.info() {
660 return if version.as_deref().is_none_or(|v| v == info.to_version) {
661 Ok(info)
662 } else {
663 Err(format!(
664 "a restart into v{} is already scheduled",
665 info.to_version
666 ))
667 };
668 }
669 let unit = self.instance.layout.service_name();
670 if !runs_as_unit(&unit) {
671 return Err(format!(
672 "this daemon does not run as {unit}, so it cannot restart itself; \
673 restart it yourself"
674 ));
675 }
676 let binary = own_executable().map_err(|error| format!("{error:#}"))?;
677 let installed = probe(&binary).await.map_err(|error| {
678 format!(
679 "the binary at {} does not run ({error:#}); not restarting",
680 binary.display()
681 )
682 })?;
683 if let Some(version) = &version
684 && version != &installed.version
685 {
686 return Err(format!(
687 "{} reports v{}, not v{version}; not restarting",
688 binary.display(),
689 installed.version
690 ));
691 }
692 let requester = parent.as_deref().and_then(|chain| self.requester(chain));
693 let now = unix_now();
694 let wait = max_wait_seconds
695 .unwrap_or(DEFAULT_MAX_WAIT)
696 .min(MAX_WAIT_LIMIT);
697 let plan = Plan {
698 id: uuid::Uuid::new_v4().simple().to_string()[..8].to_owned(),
699 state: PlanState::Waiting,
700 from_version: env!("CARGO_PKG_VERSION").into(),
701 to_version: installed.version,
702 commit: commit.filter(|commit| !commit.trim().is_empty()),
703 from_layout: CONFIG_LAYOUT,
704 to_layout: installed.config_layout,
705 unit,
706 previous: None,
707 binary,
708 requester,
709 origin: None,
710 expected: Vec::new(),
711 requested_unix: now,
712 deadline_unix: now + wait,
713 restart_unix: None,
714 waited_out: false,
715 detail: None,
716 verify_seconds: VERIFY_SECONDS,
717 };
718 self.arm(plan)
720 }
721
722 fn arm(self: &Arc<Self>, mut plan: Plan) -> std::result::Result<RestartInfo, String> {
724 plan.origin = plan
725 .requester
726 .as_ref()
727 .and_then(|requester| self.hub.origin(&requester.session));
728 save_plan(&self.instance.layout.update_plan(), &plan)
729 .map_err(|error| format!("{error:#}"))?;
730 let waiting = self.waiting_for(&plan);
731 let info = plan.info(waiting.clone());
732 *self.current.lock().unwrap_or_else(PoisonError::into_inner) =
733 Some((plan.clone(), waiting));
734 tracing::info!(
735 "Restart into v{} scheduled; waiting at most {} seconds",
736 plan.to_version,
737 plan.deadline_unix.saturating_sub(plan.requested_unix)
738 );
739 let restarter = Arc::clone(self);
740 tokio::spawn(async move { restarter.wait_and_restart(plan).await });
741 Ok(info)
742 }
743
744 fn requester(&self, chain: &str) -> Option<Requester> {
746 self.registry.own_run(chain).map(|run| Requester {
747 handle: run.handle,
748 session: run.session,
749 })
750 }
751
752 fn waiting_for(&self, plan: &Plan) -> Option<String> {
754 if let Some(requester) = &plan.requester {
755 let working = self
761 .registry
762 .list(true)
763 .into_iter()
764 .find(|entry| entry.record.handle == requester.handle && entry.working());
765 if let Some(entry) = working {
766 return Some(if entry.record.idle_since_unix.is_some() {
767 format!("{}'s background jobs", requester.handle)
768 } else {
769 format!("{} to finish", requester.handle)
770 });
771 }
772 if session_busy(&requester.session) || self.hub.session_work(&requester.session) > 0 {
773 return Some(format!("{}'s report", requester.handle));
774 }
775 }
776 if self.hub.owner_claims() > 0 {
777 return Some("an owner message to be answered".into());
778 }
779 None
780 }
781
782 async fn wait_and_restart(self: Arc<Self>, mut plan: Plan) {
783 let mut clear = 0;
784 loop {
785 tokio::select! {
786 () = self.cancel.cancelled() => return,
788 () = tokio::time::sleep(Duration::from_secs(1)) => {}
789 }
790 let waiting = self.waiting_for(&plan);
791 clear = if waiting.is_none() { clear + 1 } else { 0 };
792 if let Some((_, current)) = self
793 .current
794 .lock()
795 .unwrap_or_else(PoisonError::into_inner)
796 .as_mut()
797 {
798 current.clone_from(&waiting);
799 }
800 if clear >= CLEAR_CHECKS {
801 break;
802 }
803 if unix_now() >= plan.deadline_unix {
804 tracing::warn!(
805 "Restarting into v{} at its deadline while waiting for {}",
806 plan.to_version,
807 waiting.as_deref().unwrap_or("work")
808 );
809 plan.waited_out = true;
810 break;
811 }
812 }
813 if let Err(error) = self.hand_over(&mut plan).await {
814 tracing::error!("Restart into v{} did not start: {error:#}", plan.to_version);
815 plan.state = PlanState::Failed;
816 plan.detail = Some(format!("the restart did not start: {error:#}"));
817 let _ = save_plan(&self.instance.layout.update_plan(), &plan);
818 *self.current.lock().unwrap_or_else(PoisonError::into_inner) = None;
819 let text = announcement(&plan, env!("CARGO_PKG_VERSION"));
820 self.notifier
821 .deliver(plan.origin.as_ref(), &text, None, &self.cancel)
822 .await;
823 let _ = std::fs::remove_file(self.instance.layout.update_plan());
824 }
825 }
826
827 async fn hand_over(&self, plan: &mut Plan) -> Result<()> {
830 plan.state = PlanState::Restarting;
831 plan.restart_unix = Some(unix_now());
832 if let Some(components) = self.components.upgrade() {
833 plan.expected = components
834 .lock()
835 .await
836 .status()
837 .components
838 .into_iter()
839 .filter(|health| health.enabled && health.state == ComponentState::Connected)
840 .map(|health| health.id)
841 .collect();
842 }
843 match &self.launcher {
844 Launcher::Systemd => {}
845 #[cfg(test)]
846 Launcher::Record(plans) => {
847 save_plan(&self.instance.layout.update_plan(), plan)?;
848 plans.lock().unwrap().push(plan.clone());
849 return Ok(());
850 }
851 }
852 plan.previous = match keep_previous(&plan.binary) {
853 Ok(path) => Some(path),
854 Err(error) => {
855 tracing::warn!("No rollback copy of this release: {error:#}");
856 None
857 }
858 };
859 let path = self.instance.layout.update_plan();
860 save_plan(&path, plan)?;
861 let watchdog = plan.previous.clone().unwrap_or_else(|| plan.binary.clone());
863 let mut command = std::process::Command::new("systemd-run");
864 command.args([
865 "--user",
866 "--quiet",
867 "--collect",
868 &format!("--unit=scv-update-{}", plan.id),
869 ]);
870 let layout = &self.instance.layout;
872 let home = (!layout.is_default()).then(|| layout.home());
873 let config = self.instance.overrides.config_file.as_deref();
874 for (variable, value) in [("SCV_HOME", home), ("SCV_CONFIG", config)] {
875 if let Some(value) = value {
876 let mut setting = std::ffi::OsString::from(format!("--setenv={variable}="));
877 setting.push(value);
878 command.arg(setting);
879 }
880 }
881 command
882 .arg(watchdog)
883 .arg("restart-watchdog")
884 .arg("--plan")
885 .arg(&path)
886 .stdin(std::process::Stdio::null());
887 let status = tokio::task::spawn_blocking(move || command.status())
888 .await?
889 .context("run systemd-run")?;
890 if !status.success() {
891 bail!("systemd-run exited with {status}");
892 }
893 tracing::info!(
894 "Handed the restart into v{} to unit scv-update-{}",
895 plan.to_version,
896 plan.id
897 );
898 Ok(())
899 }
900}
901
902fn keep_previous(binary: &Path) -> Result<PathBuf> {
905 let previous = binary.with_file_name(format!(
906 "{}.prev",
907 binary
908 .file_name()
909 .and_then(|name| name.to_str())
910 .unwrap_or("scv")
911 ));
912 install_copy(Path::new("/proc/self/exe"), &previous)?;
913 Ok(previous)
914}
915
916fn install_copy(source: &Path, target: &Path) -> Result<()> {
918 let parent = target
919 .parent()
920 .ok_or_else(|| anyhow!("{} has no parent", target.display()))?;
921 let temporary = tempfile::Builder::new()
922 .prefix(".scv-install")
923 .tempfile_in(parent)?;
924 std::fs::copy(source, temporary.path())
925 .with_context(|| format!("copy {} to {}", source.display(), target.display()))?;
926 #[cfg(unix)]
927 {
928 use std::os::unix::fs::PermissionsExt;
929 std::fs::set_permissions(temporary.path(), std::fs::Permissions::from_mode(0o755))?;
930 }
931 temporary
932 .persist(target)
933 .map_err(|error| error.error)
934 .with_context(|| format!("install {}", target.display()))?;
935 Ok(())
936}
937
938pub async fn watchdog(layout: &Layout, plan_path: &Path) -> Result<()> {
944 let mut plan = load_plan(plan_path)?.context("no restart plan")?;
945 if plan.state != PlanState::Restarting {
946 bail!("the restart plan is {:?}, not restarting", plan.state);
947 }
948 let socket = layout.socket();
949 eprintln!("Restarting {} into v{}", plan.unit, plan.to_version);
950 systemctl_restart(&plan.unit);
951 let outcome = verify(
952 &socket,
953 &plan.to_version,
954 &plan.expected,
955 plan.verify_seconds,
956 )
957 .await;
958 match outcome {
959 Ok(()) => {
960 eprintln!("v{} is up with its channels", plan.to_version);
961 plan.state = PlanState::Verified;
962 }
963 Err(reason) => {
964 eprintln!("v{} failed: {reason}", plan.to_version);
965 match rollback_refusal(&plan) {
966 None => {
967 let previous = plan.previous.clone().expect("checked by rollback_refusal");
968 let detail = match install_copy(&previous, &plan.binary) {
969 Ok(()) => {
970 systemctl_restart(&plan.unit);
971 let seconds = plan.verify_seconds.min(ROLLBACK_SECONDS);
972 match verify(&socket, &plan.from_version, &[], seconds).await {
973 Ok(()) => reason,
974 Err(again) => format!(
975 "{reason}; after the rollback v{} did not come back either ({again})",
976 plan.from_version
977 ),
978 }
979 }
980 Err(error) => format!(
981 "{reason}; putting v{} back failed: {error:#}",
982 plan.from_version
983 ),
984 };
985 plan.state = PlanState::RolledBack;
986 plan.detail = Some(detail);
987 }
988 Some(refusal) => {
989 plan.state = PlanState::Failed;
990 plan.detail = Some(format!("{reason}; not rolled back: {refusal}"));
991 }
992 }
993 }
994 }
995 save_plan(plan_path, &plan)?;
996 Ok(())
997}
998
999fn systemctl_restart(unit: &str) {
1000 match std::process::Command::new("systemctl")
1001 .args(["--user", "restart", unit])
1002 .status()
1003 {
1004 Ok(status) if status.success() => {}
1005 Ok(status) => eprintln!("systemctl --user restart {unit} exited with {status}"),
1006 Err(error) => eprintln!("could not run systemctl: {error}"),
1007 }
1008}
1009
1010fn rollback_refusal(plan: &Plan) -> Option<String> {
1012 if plan.to_layout != plan.from_layout {
1013 return Some(format!(
1014 "v{} uses config layout {} and v{} uses {}, so the older binary cannot read the \
1015 current configuration",
1016 plan.to_version, plan.to_layout, plan.from_version, plan.from_layout
1017 ));
1018 }
1019 match &plan.previous {
1020 Some(previous) if previous.is_file() => None,
1021 _ => Some(format!("no copy of v{} was kept", plan.from_version)),
1022 }
1023}
1024
1025async fn verify(
1028 socket: &Path,
1029 version: &str,
1030 expected: &[String],
1031 seconds: u64,
1032) -> std::result::Result<(), String> {
1033 let deadline = tokio::time::Instant::now() + Duration::from_secs(seconds);
1034 let mut last = format!("v{version} did not start");
1035 loop {
1036 match scv_client::control(socket, DaemonCommand::Status).await {
1037 Ok(status) if status.version == version => {
1038 let missing: Vec<_> = expected
1039 .iter()
1040 .filter(|id| {
1041 !status.components.iter().any(|health| {
1042 &health.id == *id && health.state == ComponentState::Connected
1043 })
1044 })
1045 .map(String::as_str)
1046 .collect();
1047 if missing.is_empty() {
1048 return Ok(());
1049 }
1050 last = format!(
1051 "v{version} started, but {} did not reconnect",
1052 missing.join(" and ")
1053 );
1054 }
1055 Ok(status) => last = format!("SCV still reports v{}", status.version),
1056 Err(_) => {}
1057 }
1058 if tokio::time::Instant::now() >= deadline {
1059 return Err(last);
1060 }
1061 tokio::time::sleep(Duration::from_secs(2)).await;
1062 }
1063}
1064
1065pub(crate) struct Startup {
1070 plan: Option<Plan>,
1071 unclean: Option<(String, u64)>,
1074}
1075
1076#[derive(Serialize, Deserialize)]
1077struct Marker {
1078 pid: u32,
1079 version: String,
1080 started_unix: u64,
1081}
1082
1083pub(crate) fn startup(layout: &Layout, hub: &Hub) -> Startup {
1087 let plan = load_plan(&layout.update_plan()).unwrap_or_else(|error| {
1088 tracing::warn!("Ignoring an unreadable restart plan: {error:#}");
1089 let _ = std::fs::remove_file(layout.update_plan());
1090 None
1091 });
1092 let planned = plan.as_ref().filter(|plan| {
1093 plan.state != PlanState::Waiting
1094 && plan
1095 .restart_unix
1096 .is_some_and(|at| unix_now().saturating_sub(at) < RESTART_CONTEXT_MAX_AGE)
1097 });
1098 hub.set_restart(planned.map(|plan| Restart {
1099 to_version: plan.to_version.clone(),
1100 }));
1101 let marker = layout.daemon_marker();
1102 let unclean = std::fs::read(&marker)
1103 .ok()
1104 .and_then(|bytes| serde_json::from_slice::<Marker>(&bytes).ok())
1105 .filter(|previous| previous.pid != std::process::id())
1106 .map(|previous| (previous.version, previous.started_unix));
1107 let current = Marker {
1108 pid: std::process::id(),
1109 version: env!("CARGO_PKG_VERSION").into(),
1110 started_unix: unix_now(),
1111 };
1112 if let Err(error) = serde_json::to_vec(¤t)
1113 .map_err(anyhow::Error::from)
1114 .and_then(|bytes| write_private(&marker, &bytes))
1115 {
1116 tracing::warn!("Could not record the running daemon: {error:#}");
1117 }
1118 Startup { plan, unclean }
1119}
1120
1121pub(crate) fn clean_shutdown(layout: &Layout) {
1123 let _ = std::fs::remove_file(layout.daemon_marker());
1124}
1125
1126#[derive(Debug, PartialEq, Eq)]
1128enum Decision {
1129 Say(String),
1130 Wait,
1132 Drop,
1133}
1134
1135fn decide(plan: &Plan, own: &str, watchdog_overdue: bool) -> Decision {
1136 match plan.state {
1137 PlanState::Waiting => Decision::Say(if own == plan.to_version {
1138 format!(
1139 "SCV is now running {}. It stopped before the planned restart, so work that \
1140 was running then was stopped.",
1141 plan.label()
1142 )
1143 } else {
1144 format!(
1145 "SCV stopped before it could restart into v{}; it is running v{own}. Deploy \
1146 again to finish the update.",
1147 plan.to_version
1148 )
1149 }),
1150 PlanState::Restarting if !watchdog_overdue => Decision::Wait,
1151 PlanState::Restarting if own == plan.to_version => Decision::Say(format!(
1152 "SCV is now running {}; the update watchdog did not report back.",
1153 plan.label()
1154 )),
1155 PlanState::Restarting if own == plan.from_version => Decision::Say(format!(
1156 "The update to v{} did not take effect; SCV is still running v{own}.",
1157 plan.to_version
1158 )),
1159 PlanState::Restarting => Decision::Drop,
1160 PlanState::Verified | PlanState::RolledBack | PlanState::Failed => {
1161 Decision::Say(announcement(plan, own))
1162 }
1163 }
1164}
1165
1166fn announcement(plan: &Plan, own: &str) -> String {
1168 let detail = plan.detail.as_deref().unwrap_or("it did not come up");
1169 let mut text = match plan.state {
1170 PlanState::Verified => format!("SCV updated: now running {}.", plan.label()),
1171 PlanState::RolledBack => format!(
1172 "The update to v{} failed: {detail}. SCV rolled back to v{}.",
1173 plan.to_version, plan.from_version
1174 ),
1175 PlanState::Failed if own == plan.to_version => {
1176 format!("SCV is running {}, but {detail}.", plan.label())
1177 }
1178 _ => format!("The update to v{} failed: {detail}.", plan.to_version),
1179 };
1180 if plan.waited_out {
1181 let minutes = plan
1182 .deadline_unix
1183 .saturating_sub(plan.requested_unix)
1184 .div_ceil(60);
1185 text.push_str(&format!(
1186 " It waited {minutes} minutes for running work, then restarted anyway; work \
1187 still running then was stopped."
1188 ));
1189 }
1190 text
1191}
1192
1193pub(crate) async fn announce(
1195 layout: Layout,
1196 startup: Startup,
1197 notifier: Notifier,
1198 cancel: CancellationToken,
1199) {
1200 let own = env!("CARGO_PKG_VERSION");
1201 if let Some(mut plan) = startup.plan {
1202 let path = layout.update_plan();
1203 let overdue_at = plan.restart_unix.unwrap_or(plan.requested_unix)
1204 + plan.verify_seconds
1205 + ROLLBACK_SECONDS
1206 + 60;
1207 let text = loop {
1208 match decide(&plan, own, unix_now() >= overdue_at) {
1209 Decision::Say(text) => break Some(text),
1210 Decision::Drop => break None,
1211 Decision::Wait => {}
1212 }
1213 tokio::select! {
1214 () = cancel.cancelled() => return,
1215 () = tokio::time::sleep(Duration::from_secs(2)) => {}
1216 }
1217 match load_plan(&path) {
1218 Ok(Some(reloaded)) if reloaded.id == plan.id => plan = reloaded,
1219 _ => break None,
1220 }
1221 };
1222 if let Some(text) = text {
1223 tracing::info!("{text}");
1224 notifier
1225 .deliver(plan.origin.as_ref(), &text, None, &cancel)
1226 .await;
1227 }
1228 if !cancel.is_cancelled() {
1229 let _ = std::fs::remove_file(&path);
1230 }
1231 } else if let Some((version, started)) = startup.unclean {
1232 let text = format!(
1233 "SCV started again after an unexpected stop (a crash or a host restart); it had run \
1234 v{version} since {}. Work in progress then was stopped.",
1235 format_time(started)
1236 );
1237 tracing::warn!("{text}");
1238 notifier.deliver(None, &text, None, &cancel).await;
1239 }
1240}
1241
1242fn format_time(unix: u64) -> String {
1243 let age = unix_now().saturating_sub(unix);
1244 match age {
1245 0..=119 => "moments before".into(),
1246 120..=7199 => format!("{} minutes before", age / 60),
1247 7200..=172_799 => format!("{} hours before", age / 3600),
1248 _ => format!("{} days before", age / 86_400),
1249 }
1250}
1251
1252pub(crate) async fn monitor(notifier: Notifier, cancel: CancellationToken) {
1258 let mut down: HashMap<String, (tokio::time::Instant, bool)> = HashMap::new();
1259 loop {
1260 tokio::select! {
1261 () = cancel.cancelled() => return,
1262 () = tokio::time::sleep(MONITOR_INTERVAL) => {}
1263 }
1264 let states = notifier.states.get().await;
1265 down.retain(|id, _| {
1266 states
1267 .get(id)
1268 .is_some_and(|state| *state != ComponentState::Connected)
1269 });
1270 for (id, state) in &states {
1271 if *state == ComponentState::Connected {
1272 continue;
1273 }
1274 let (since, told) = down
1275 .entry(id.clone())
1276 .or_insert((tokio::time::Instant::now(), false));
1277 if *told || since.elapsed() < DOWN_NOTICE_AFTER {
1278 continue;
1279 }
1280 *told = true;
1281 let (channel, account) = id.split_once(':').unwrap_or((id, "default"));
1282 let text = format!(
1283 "SCV's {} account {account} has been disconnected for {} minutes; its sign-in \
1284 may have expired. On the host, check `scv channels status {channel}` and sign \
1285 in again with `scv channels login {channel}` if needed.",
1286 channel_title(id),
1287 since.elapsed().as_secs() / 60
1288 );
1289 tracing::warn!("{text}");
1290 let notifier = notifier.clone();
1291 let cancel = cancel.clone();
1292 let id = id.clone();
1293 tokio::spawn(async move { notifier.deliver(None, &text, Some(&id), &cancel).await });
1294 }
1295 }
1296}
1297
1298#[cfg(test)]
1299mod tests;