Skip to main content

pg_ephemeral/
session.rs

1//! Named pg-ephemeral sessions — running containers tagged with the
2//! [`crate::label::SESSION_KEY`] label.
3
4use crate::label;
5
6/// User-facing identifier for a named container, paired with the
7/// runtime-level OCI container name it derives at construction time.
8///
9/// Naming a container is independent of its lifecycle — see
10/// [`crate::Definition::session_name`]. The OCI name is prefixed with
11/// `pg-ephemeral-session-` so a session named `foo` cannot collide with an
12/// unrelated `foo` container the user happens to be running.
13///
14/// The user-facing name is validated against [`ociman::ContainerName`]'s
15/// rules at parse time, and the derived OCI name is constructed and
16/// validated alongside it — so downstream callers can use either value
17/// without re-parsing or unwrapping.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct Name {
20    name: String,
21    container_name: ociman::ContainerName,
22}
23
24impl Name {
25    /// Prefix applied to the user-facing name to derive the OCI container
26    /// name.
27    pub const OCI_PREFIX: &'static str = "pg-ephemeral-session-";
28
29    /// The user-facing name, e.g. `foo`.
30    #[must_use]
31    pub fn as_str(&self) -> &str {
32        &self.name
33    }
34
35    /// The runtime-level OCI container name, e.g. `pg-ephemeral-session-foo`.
36    #[must_use]
37    pub fn container_name(&self) -> &ociman::ContainerName {
38        &self.container_name
39    }
40}
41
42impl std::str::FromStr for Name {
43    type Err = ociman::ContainerNameError;
44
45    fn from_str(value: &str) -> Result<Self, Self::Err> {
46        // Validate the user-facing shape using ContainerName's rules — we
47        // discard the parsed value and keep the raw String, but the parse
48        // is the validation gate.
49        let _: ociman::ContainerName = value.parse()?;
50        let container_name: ociman::ContainerName =
51            format!("{}{value}", Self::OCI_PREFIX).parse()?;
52        Ok(Self {
53            name: value.to_owned(),
54            container_name,
55        })
56    }
57}
58
59impl std::fmt::Display for Name {
60    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        formatter.write_str(&self.name)
62    }
63}
64
65/// A running pg-ephemeral container marked as a named session.
66///
67/// Pairs the OCI [`ociman::Container`] handle with the decoded
68/// [`Name`] taken from the container's `pg-ephemeral.session` label.
69#[derive(Debug)]
70pub struct Session {
71    container: ociman::Container,
72    name: Name,
73}
74
75impl Session {
76    /// The user-facing session name.
77    #[must_use]
78    pub fn name(&self) -> &Name {
79        &self.name
80    }
81
82    /// The underlying container handle.
83    #[must_use]
84    pub fn container(&self) -> &ociman::Container {
85        &self.container
86    }
87
88    /// Consume this session, returning its ociman container handle.
89    #[must_use]
90    pub fn into_ociman_container(self) -> ociman::Container {
91        self.container
92    }
93
94    /// Read and decode pg-ephemeral [`crate::label::Metadata`] from this
95    /// session's container labels. Used by staleness detection and any
96    /// other surface that needs to compare a session's recorded
97    /// configuration to current state.
98    pub async fn metadata(&self) -> Result<crate::label::Metadata, MetadataError> {
99        let labels = self.container.labels().await?;
100        Ok(crate::label::read_container(&labels)?)
101    }
102
103    /// Stop and remove the underlying container, consuming this `Session`.
104    ///
105    /// Force-removes via the runtime, which stops the container first if
106    /// it's still running. After this call returns successfully the
107    /// session no longer exists on the backend.
108    pub async fn stop(mut self) -> Result<(), StopError> {
109        self.container
110            .remove_force()
111            .await
112            .map_err(StopError::Remove)
113    }
114
115    /// List every running pg-ephemeral session on the given backend.
116    ///
117    /// Containers are discovered by filtering on the
118    /// [`crate::label::SESSION_KEY`] label. For each match, the container's
119    /// labels are inspected to decode the session name — so this performs
120    /// one `ps --filter` plus one `inspect` per match.
121    pub async fn list(backend: &ociman::Backend) -> Result<Vec<Self>, ListError> {
122        Self::list_filtered(backend, None).await
123    }
124
125    /// Look up a session by its user-facing name.
126    ///
127    /// Returns `Ok(None)` when no session with this name is running.
128    /// Multiple matches indicate an invariant violation (the OCI name
129    /// derivation enforces uniqueness on `start`) and surface as
130    /// [`FindError::MultipleMatches`].
131    pub async fn find(backend: &ociman::Backend, name: &Name) -> Result<Option<Self>, FindError> {
132        // `Name` is already validated as a `ContainerName`, whose character
133        // set is a strict subset of what a label value allows — so this
134        // conversion cannot fail in practice.
135        let value: ociman::label::Value = name.as_str().to_string().try_into().unwrap();
136        let mut sessions = Self::list_filtered(backend, Some(&value))
137            .await
138            .map_err(FindError::List)?;
139        match sessions.len() {
140            0 => Ok(None),
141            1 => Ok(Some(sessions.pop().unwrap())),
142            count => Err(FindError::MultipleMatches { count }),
143        }
144    }
145
146    async fn list_filtered(
147        backend: &ociman::Backend,
148        value: Option<&ociman::label::Value>,
149    ) -> Result<Vec<Self>, ListError> {
150        let key = label::SESSION_KEY;
151        let filter = match value {
152            None => ociman::label::Filter::key_only(&key),
153            Some(value) => ociman::label::Filter::exact(&key, value),
154        };
155        let entries = backend
156            .container_list_with_name([filter])
157            .await
158            .map_err(ListError::ListWithName)?;
159
160        entries
161            .into_iter()
162            .map(|(container, container_name)| {
163                let raw = container_name
164                    .as_str()
165                    .strip_prefix(Name::OCI_PREFIX)
166                    .ok_or(ListError::MissingOciPrefix {
167                        container_name: container_name.clone(),
168                    })?;
169                let name: Name = raw.parse().map_err(ListError::InvalidSessionName)?;
170                Ok(Self { container, name })
171            })
172            .collect()
173    }
174}
175
176#[derive(Debug, thiserror::Error)]
177pub enum StopError {
178    #[error("failed to remove session container")]
179    Remove(#[source] cmd_proc::CommandError),
180}
181
182#[derive(Debug, thiserror::Error)]
183pub enum FindError {
184    #[error(transparent)]
185    List(#[from] ListError),
186    /// More than one container carried the same session label value.
187    /// `Definition::session_name` derives a deterministic OCI container
188    /// name and the runtime atomically rejects duplicates on `start`, so
189    /// this should be impossible — it indicates a tampered or
190    /// hand-crafted container that bypassed the start path.
191    #[error("multiple containers ({count}) carry the same session label value")]
192    MultipleMatches { count: usize },
193}
194
195#[derive(Debug, thiserror::Error)]
196pub enum MetadataError {
197    #[error("failed to read session container labels")]
198    ReadLabels(#[from] ociman::label::ContainerError),
199    #[error("failed to decode pg-ephemeral metadata from session labels")]
200    Decode(#[from] crate::label::ReadError),
201}
202
203/// Seed-chain status of a running session relative to the current
204/// instance config.
205#[derive(Clone, Debug, PartialEq)]
206pub enum SeedStatus {
207    /// Chain shape and every seed hash match — re-booting today would
208    /// land on the same cache layer the session is running.
209    Sync,
210    /// Anything else: base image differs, chain shape differs (seeds
211    /// added/removed/renamed/reordered), or at least one seed hash
212    /// differs at a matching-name position.
213    Diverged,
214}
215
216impl std::fmt::Display for SeedStatus {
217    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        match self {
219            Self::Sync => formatter.write_str("sync"),
220            Self::Diverged => formatter.write_str("diverged"),
221        }
222    }
223}
224
225/// Diff a session's stored seed chain (decoded from labels) against the
226/// current instance config's chain. Binary outcome — see [`SeedStatus`].
227///
228/// Pure: no IO. Callers feed it the decoded [`crate::label::Metadata`]
229/// (from [`Session::metadata`]) plus the current
230/// [`crate::seed::LoadedSeeds`] (from `definition.load_seeds(...)`).
231#[must_use]
232pub fn compute_seed_status(
233    stored_image: &ociman::image::Reference,
234    stored_seeds: &[crate::label::SeedEntry],
235    current_image: &crate::image::Image,
236    current_seeds: &crate::seed::LoadedSeeds<'_>,
237) -> SeedStatus {
238    let current_image_reference = ociman::image::Reference::from(current_image);
239    if stored_image != &current_image_reference {
240        return SeedStatus::Diverged;
241    }
242    let current: Vec<_> = current_seeds.iter_seeds().collect();
243    if stored_seeds.len() != current.len() {
244        return SeedStatus::Diverged;
245    }
246    for (stored_entry, current_seed) in stored_seeds.iter().zip(current.iter()) {
247        if &stored_entry.name != current_seed.name() {
248            return SeedStatus::Diverged;
249        }
250        if stored_entry.hash.as_ref() != current_seed.cache_status().hash() {
251            return SeedStatus::Diverged;
252        }
253    }
254    SeedStatus::Sync
255}
256
257#[derive(Debug, thiserror::Error)]
258pub enum ListError {
259    #[error("failed to list session containers")]
260    ListWithName(#[source] ociman::backend::ContainerListWithNameError),
261    /// A container carried [`label::SESSION_KEY`] but its runtime name did not
262    /// start with [`Name::OCI_PREFIX`]. Sessions can only be created through
263    /// our codepath (which sets the name via [`Name::container_name`]), so
264    /// this indicates a hand-crafted or tampered container.
265    #[error(
266        "container {container_name} matched session label filter but name does not start with {:?}",
267        Name::OCI_PREFIX
268    )]
269    MissingOciPrefix {
270        container_name: ociman::ContainerName,
271    },
272    #[error("session container name suffix is not a valid session name")]
273    InvalidSessionName(#[source] ociman::ContainerNameError),
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn name_derives_prefixed_oci_name() {
282        let session: Name = "foo".parse().unwrap();
283        assert_eq!(session.as_str(), "foo");
284        assert_eq!(session.to_string(), "foo");
285        assert_eq!(
286            session.container_name().as_str(),
287            "pg-ephemeral-session-foo"
288        );
289    }
290
291    #[test]
292    fn name_rejects_invalid_user_facing() {
293        // Empty input fails ContainerName validation.
294        assert!("".parse::<Name>().is_err());
295        // Leading dash is invalid for ContainerName.
296        assert!("-foo".parse::<Name>().is_err());
297    }
298}