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