Skip to main content

aws_ssm_bridge/
pool.rs

1//! Managing many concurrent sessions.
2//!
3//! A pool is worth having when you fan out across a fleet: it caps how many
4//! sessions you hold open, indexes them by ID and target, and terminates the lot
5//! on shutdown so nothing is left running on the AWS side.
6//!
7//! Sessions that end on their own — a dead network, an agent restart, an idle
8//! timeout — are dropped from the pool the next time it is inspected. There is
9//! no background reaper: [`Session::is_closed`] is authoritative, so filtering on
10//! access is both cheaper and impossible to get out of sync.
11//!
12//! ```no_run
13//! use aws_ssm_bridge::{PoolConfig, SessionPool};
14//!
15//! # async fn example() -> aws_ssm_bridge::Result<()> {
16//! let pool = SessionPool::new(PoolConfig { max_sessions: 25, ..Default::default() }).await?;
17//!
18//! let session = pool.start("i-0123456789abcdef0").await?;
19//! session.wait_ready().await?;
20//! session.send(&b"uptime\r"[..]).await?;
21//!
22//! println!("{} sessions live", pool.stats().live);
23//! pool.shutdown().await;
24//! # Ok(()) }
25//! ```
26
27use std::collections::HashMap;
28use std::sync::{Arc, Mutex};
29
30use crate::errors::{Error, Result};
31use crate::session::{Session, SessionConfig, SessionManager};
32
33/// Limits and defaults for a [`SessionPool`].
34#[derive(Debug, Clone)]
35pub struct PoolConfig {
36    /// Maximum live sessions; `0` means unlimited.
37    pub max_sessions: usize,
38    /// Allow more than one session to the same target.
39    pub allow_duplicate_targets: bool,
40    /// Template applied to sessions started through [`SessionPool::start`].
41    pub session_defaults: SessionConfig,
42}
43
44impl Default for PoolConfig {
45    fn default() -> Self {
46        Self {
47            max_sessions: 100,
48            allow_duplicate_targets: true,
49            session_defaults: SessionConfig::default(),
50        }
51    }
52}
53
54/// A point-in-time view of a pool.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct PoolStats {
57    /// Sessions currently open.
58    pub live: usize,
59    /// Sessions started over the pool's lifetime.
60    pub started: u64,
61    /// Sessions that have ended, however they ended.
62    pub ended: u64,
63}
64
65/// A bounded collection of concurrent sessions.
66pub struct SessionPool {
67    manager: SessionManager,
68    config: PoolConfig,
69    sessions: Mutex<HashMap<String, Arc<Session>>>,
70    started: Mutex<u64>,
71}
72
73impl SessionPool {
74    /// Build a pool with its own [`SessionManager`].
75    pub async fn new(config: PoolConfig) -> Result<Self> {
76        Ok(Self::with_manager(config, SessionManager::new().await?))
77    }
78
79    /// Build a pool around an existing manager.
80    pub fn with_manager(config: PoolConfig, manager: SessionManager) -> Self {
81        Self {
82            manager,
83            config,
84            sessions: Mutex::new(HashMap::new()),
85            started: Mutex::new(0),
86        }
87    }
88
89    /// Start a shell session against `target` using the pool's defaults.
90    pub async fn start(&self, target: impl Into<String>) -> Result<Arc<Session>> {
91        let config = SessionConfig {
92            target: target.into(),
93            ..self.config.session_defaults.clone()
94        };
95        self.start_with(config).await
96    }
97
98    /// Start a session with an explicit configuration.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`Error::Config`] if the pool is full, or if a session to this
103    /// target already exists and duplicates are disallowed.
104    pub async fn start_with(&self, config: SessionConfig) -> Result<Arc<Session>> {
105        let target = config.target.clone();
106        self.check_admission(&target)?;
107
108        let session = Arc::new(self.manager.start_session(config).await?);
109
110        // Re-check under the lock: another task may have filled the last slot
111        // while this one was waiting on StartSession. Terminating the session we
112        // just opened is the only way to honour the limit without leaking it.
113        let rejection = {
114            let mut sessions = self.lock();
115            reap(&mut sessions);
116            match admit(&self.config, &sessions, &target) {
117                Ok(()) => {
118                    sessions.insert(session.id().to_owned(), Arc::clone(&session));
119                    None
120                }
121                Err(e) => Some(e),
122            }
123        };
124
125        if let Some(e) = rejection {
126            let _ = session.terminate().await;
127            return Err(e);
128        }
129        *lock(&self.started) += 1;
130
131        Ok(session)
132    }
133
134    /// Add an externally created session to the pool.
135    ///
136    /// Useful when a session was built with [`SessionBuilder`] but should share
137    /// the pool's lifecycle management.
138    ///
139    /// [`SessionBuilder`]: crate::SessionBuilder
140    pub fn insert(&self, session: Arc<Session>) -> Result<Arc<Session>> {
141        let target = session.config().target.clone();
142        let mut sessions = self.lock();
143        reap(&mut sessions);
144        admit(&self.config, &sessions, &target)?;
145        sessions.insert(session.id().to_owned(), Arc::clone(&session));
146        drop(sessions);
147        *lock(&self.started) += 1;
148        Ok(session)
149    }
150
151    /// Look up a live session by ID.
152    pub fn get(&self, session_id: &str) -> Option<Arc<Session>> {
153        let mut sessions = self.lock();
154        reap(&mut sessions);
155        sessions.get(session_id).cloned()
156    }
157
158    /// Every live session against `target`.
159    pub fn for_target(&self, target: &str) -> Vec<Arc<Session>> {
160        let mut sessions = self.lock();
161        reap(&mut sessions);
162        sessions
163            .values()
164            .filter(|s| s.config().target == target)
165            .cloned()
166            .collect()
167    }
168
169    /// Every live session.
170    pub fn sessions(&self) -> Vec<Arc<Session>> {
171        let mut sessions = self.lock();
172        reap(&mut sessions);
173        sessions.values().cloned().collect()
174    }
175
176    /// IDs of every live session.
177    pub fn session_ids(&self) -> Vec<String> {
178        let mut sessions = self.lock();
179        reap(&mut sessions);
180        sessions.keys().cloned().collect()
181    }
182
183    /// Current counts.
184    pub fn stats(&self) -> PoolStats {
185        let mut sessions = self.lock();
186        reap(&mut sessions);
187        let live = sessions.len();
188        let started = *lock(&self.started);
189        PoolStats {
190            live,
191            started,
192            ended: started.saturating_sub(live as u64),
193        }
194    }
195
196    /// Terminate one session and drop it from the pool.
197    ///
198    /// Unknown IDs are not an error — the session may already have ended and
199    /// been reaped.
200    pub async fn terminate(&self, session_id: &str) -> Result<()> {
201        let session = self.lock().remove(session_id);
202        match session {
203            Some(session) => session.terminate().await,
204            None => Ok(()),
205        }
206    }
207
208    /// Terminate every session against `target`.
209    pub async fn terminate_target(&self, target: &str) -> Result<()> {
210        let doomed: Vec<Arc<Session>> = {
211            let mut sessions = self.lock();
212            let ids: Vec<String> = sessions
213                .iter()
214                .filter(|(_, s)| s.config().target == target)
215                .map(|(id, _)| id.clone())
216                .collect();
217            ids.iter().filter_map(|id| sessions.remove(id)).collect()
218        };
219        terminate_all(doomed).await;
220        Ok(())
221    }
222
223    /// Terminate everything.
224    ///
225    /// Sessions are terminated concurrently, so a fleet-wide shutdown costs one
226    /// round trip rather than one per session. Failures are logged, not
227    /// returned: there is nothing useful a caller can do about a session that
228    /// refuses to die, and AWS reclaims it on its own timeout.
229    pub async fn shutdown(&self) {
230        let doomed: Vec<Arc<Session>> = self.lock().drain().map(|(_, s)| s).collect();
231        tracing::info!(count = doomed.len(), "shutting down the session pool");
232        terminate_all(doomed).await;
233    }
234
235    fn check_admission(&self, target: &str) -> Result<()> {
236        let mut sessions = self.lock();
237        reap(&mut sessions);
238        admit(&self.config, &sessions, target)
239    }
240
241    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Arc<Session>>> {
242        self.sessions.lock().unwrap_or_else(|e| e.into_inner())
243    }
244}
245
246impl std::fmt::Debug for SessionPool {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        f.debug_struct("SessionPool")
249            .field("stats", &self.stats())
250            .field("max_sessions", &self.config.max_sessions)
251            .finish()
252    }
253}
254
255/// Drop sessions that have already ended.
256fn reap(sessions: &mut HashMap<String, Arc<Session>>) {
257    sessions.retain(|_, session| !session.is_closed());
258}
259
260fn admit(
261    config: &PoolConfig,
262    sessions: &HashMap<String, Arc<Session>>,
263    target: &str,
264) -> Result<()> {
265    if config.max_sessions > 0 && sessions.len() >= config.max_sessions {
266        return Err(Error::Config(format!(
267            "session pool is full ({} of {} in use)",
268            sessions.len(),
269            config.max_sessions
270        )));
271    }
272    if !config.allow_duplicate_targets && sessions.values().any(|s| s.config().target == target) {
273        return Err(Error::Config(format!(
274            "a session to {target} is already open and PoolConfig::allow_duplicate_targets is false"
275        )));
276    }
277    Ok(())
278}
279
280async fn terminate_all(sessions: Vec<Arc<Session>>) {
281    let outcomes = futures_util::future::join_all(
282        sessions
283            .iter()
284            .map(|session| async move { (session.id(), session.terminate().await) }),
285    )
286    .await;
287
288    for (id, result) in outcomes {
289        if let Err(e) = result {
290            tracing::warn!(session_id = %id, error = %e, "failed to terminate a pooled session");
291        }
292    }
293}
294
295fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
296    mutex.lock().unwrap_or_else(|e| e.into_inner())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn config(max_sessions: usize, allow_duplicates: bool) -> PoolConfig {
304        PoolConfig {
305            max_sessions,
306            allow_duplicate_targets: allow_duplicates,
307            session_defaults: SessionConfig::default(),
308        }
309    }
310
311    #[test]
312    fn defaults_are_permissive_but_bounded() {
313        let config = PoolConfig::default();
314        assert_eq!(config.max_sessions, 100);
315        assert!(config.allow_duplicate_targets);
316    }
317
318    #[test]
319    fn an_empty_pool_admits_under_every_policy() {
320        let sessions = HashMap::new();
321        assert!(
322            admit(&config(0, true), &sessions, "i-a").is_ok(),
323            "0 means unlimited"
324        );
325        assert!(admit(&config(1, true), &sessions, "i-a").is_ok());
326        assert!(admit(&config(1, false), &sessions, "i-a").is_ok());
327    }
328
329    #[test]
330    fn stats_start_at_zero() {
331        assert_eq!(
332            PoolStats::default(),
333            PoolStats {
334                live: 0,
335                started: 0,
336                ended: 0
337            }
338        );
339    }
340
341    #[test]
342    fn reaping_an_empty_map_is_a_no_op() {
343        let mut sessions: HashMap<String, Arc<Session>> = HashMap::new();
344        reap(&mut sessions);
345        assert!(sessions.is_empty());
346    }
347}