Skip to main content

artifactize_core/remote/
session.rs

1use std::{
2    collections::{BTreeSet, HashMap},
3    io::Write,
4    path::Path,
5    sync::{
6        Arc, Mutex,
7        atomic::{AtomicBool, Ordering},
8    },
9    time::{Duration, Instant},
10};
11
12use tokio::sync::OnceCell;
13
14use super::Record;
15use crate::{
16    auth::remote::{self, Failure, Principal, Remote, Share},
17    store::{Execution, Receipts, Request},
18};
19
20/// A key is looked up again at most once per second, however often verify polls,
21/// so concurrent waiters cannot flood the optional remote store with duplicate lookups.
22pub(crate) const REFRESH: Duration = Duration::from_secs(1);
23
24/// One process's use of the remote review store.
25///
26/// Outages (network errors, timeouts, 5xx) fail open: one warning, then the process
27/// continues without the remote. Every other failure is an error.
28pub struct Session {
29    remote: Remote,
30    principal: OnceCell<Option<Principal>>,
31    offline: AtomicBool,
32    looked_up: Mutex<HashMap<crate::types::ReuseKey, Instant>>,
33}
34
35fn warn(message: &str) {
36    let _ = writeln!(std::io::stderr().lock(), "{message}");
37}
38
39impl Session {
40    /// Resolve the configuration offline; `None` when no remote is configured.
41    pub fn open(state: Option<&Path>, repo: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
42        Ok(remote::remote(state, repo)?.map(|remote| {
43            Arc::new(Self {
44                remote,
45                principal: OnceCell::new(),
46                offline: AtomicBool::new(false),
47                looked_up: Mutex::default(),
48            })
49        }))
50    }
51
52    fn failed<T: Default>(&self, failure: Failure) -> Result<T, String> {
53        if !failure.unavailable {
54            return Err(failure.message);
55        }
56        if !self.offline.swap(true, Ordering::SeqCst) {
57            warn(&format!(
58                "{}; continuing without the remote review store.",
59                failure.message.trim_end_matches('.')
60            ));
61        }
62        Ok(T::default())
63    }
64
65    /// The token's principal and scopes, asked once; `None` while the remote is unavailable.
66    async fn principal(&self) -> Result<Option<&Principal>, String> {
67        if self.offline.load(Ordering::SeqCst) {
68            return Ok(None);
69        }
70        let principal = self
71            .principal
72            .get_or_try_init(|| async {
73                match self.remote.whoami().await {
74                    Ok(principal) => Ok(Some(principal)),
75                    Err(failure) => self.failed(failure),
76                }
77            })
78            .await?;
79        Ok(principal.as_ref())
80    }
81
82    /// Read-only: the latest remote record of each key as a self-contained execution, not yet
83    /// stored.
84    pub async fn lookup(&self, keys: &[crate::types::ReuseKey]) -> Result<Vec<Execution>, String> {
85        if keys.is_empty() {
86            return Ok(Vec::new());
87        }
88        let Some(principal) = self.principal().await? else {
89            return Ok(Vec::new());
90        };
91        if !has_scope(principal, &crate::auth::remote::Scope::Read) {
92            return Ok(Vec::new());
93        }
94        let entries = match self.remote.lookup(keys).await {
95            Ok(entries) => entries,
96            Err(failure) => return self.failed(failure),
97        };
98        let requested: BTreeSet<_> = keys.iter().map(crate::types::ReuseKey::as_str).collect();
99        let mut executions = Vec::new();
100        for entry in entries {
101            // A record that does not validate is skipped; reviewing locally is always correct.
102            let mirrored = entry.and_then(|record| {
103                if !requested.contains(record.key.as_str()) {
104                    return Err("the store returned an unrequested key".into());
105                }
106                record.mirror(self.remote.url.as_str())
107            });
108            match mirrored {
109                Ok(execution) => executions.push(execution),
110                Err(error) => warn(&format!(
111                    "Ignoring an invalid remote review record: {error}"
112                )),
113            }
114        }
115        Ok(executions)
116    }
117
118    /// Look up the store's latest record of each key and mirror it into the local history when
119    /// it completed after the key's latest local record, so the normal reuse path takes the
120    /// newer of the two. Each key is looked up at most once per second.
121    pub async fn refresh(
122        &self,
123        receipts: &Receipts,
124        keys: Vec<crate::types::ReuseKey>,
125    ) -> Result<(), String> {
126        let now = Instant::now();
127        let keys: Vec<_> = {
128            let mut looked_up = self.looked_up.lock().expect("lookup times");
129            keys.into_iter()
130                .filter(|key| {
131                    let due = looked_up
132                        .get(key)
133                        .is_none_or(|at| now.duration_since(*at) >= REFRESH);
134                    if due {
135                        looked_up.insert(key.clone(), now);
136                    }
137                    due
138                })
139                .collect()
140        };
141        if keys.is_empty() || self.offline.load(Ordering::SeqCst) {
142            return Ok(());
143        }
144        for execution in self.lookup(&keys).await? {
145            receipts.mirror_execution(&execution).await?;
146        }
147        Ok(())
148    }
149
150    /// Publish a request's result when its own settle just appended it to the local history:
151    /// a GREEN/RED with a key, forced or not, never a mirror or an error.
152    pub async fn publish_request(
153        &self,
154        receipts: &Receipts,
155        request: &Request,
156    ) -> Result<(), String> {
157        let (Some(_), Some(execution_id)) = (&request.key, &request.execution_id) else {
158            return Ok(());
159        };
160        match receipts.published_execution(execution_id).await? {
161            Some(execution) => self.publish(&execution).await,
162            None => Ok(()),
163        }
164    }
165
166    /// Publish a result whose local settle published its cache entry. Nothing is sent while
167    /// the remote is unavailable or without the publish scope; Human results need `human`.
168    async fn publish(&self, execution: &Execution) -> Result<(), String> {
169        let Some(principal) = self.principal().await? else {
170            return Ok(());
171        };
172        if !has_scope(principal, &crate::auth::remote::Scope::Publish) {
173            return Ok(());
174        }
175        let record = match record(execution, principal, self.remote.share) {
176            Ok(record) => record,
177            Err(reason) => {
178                warn(&format!("This result stays local: {reason}."));
179                return Ok(());
180            }
181        };
182        match self.remote.publish(&record).await {
183            Ok(_) => Ok(()),
184            Err(failure) => self.failed(failure),
185        }
186    }
187}
188
189pub(super) fn has_scope(principal: &Principal, scope: &crate::auth::remote::Scope) -> bool {
190    principal.scopes.contains(scope)
191}
192
193/// The record a publishing token may send for a local result, or why it stays local.
194pub(super) fn record(
195    execution: &Execution,
196    principal: &Principal,
197    share: Share,
198) -> Result<Record, String> {
199    if execution.profile.kind() == crate::config::ProfileKind::Human
200        && !has_scope(principal, &crate::auth::remote::Scope::Human)
201    {
202        return Err(format!(
203            "remote token {} lacks the human scope",
204            principal.principal
205        ));
206    }
207    Record::new(execution, share == Share::Full)
208}