Skip to main content

mj_core/targets/
convert.rs

1//! Conversions from the stored target types to the execution-plan types.
2//!
3//! Two families of target types exist on purpose. [`crate::config`] and
4//! [`crate::state`] hold what is written to the configuration file and the
5//! session store: a kebab-case serde tag and `PathBuf` paths. This module's
6//! targets hold what an execution plan needs: a snake_case tag and `String`
7//! paths, because every path here ends up as an argv element or inside a
8//! POSIX-quoted remote command string.
9//!
10//! Path text becomes `String` here and nowhere else, so the rest of the
11//! workspace keeps handling paths as `Path`/`PathBuf`.
12
13use std::path::Path;
14
15use crate::config::{PodmanWorkspaceStorage, SshConnection, TargetTemplate};
16use crate::state::{PodmanWorkspaceLocator, TargetLocator};
17use crate::targets;
18
19/// The single place path text crosses into an execution plan.
20fn path_text(path: &Path) -> String {
21    path.to_string_lossy().into_owned()
22}
23
24/// Why a stored locator and its target template cannot describe one target.
25///
26/// A session records its locator and its template independently, so a
27/// configuration edit can leave the pair disagreeing. Each variant names what
28/// disagreed rather than collapsing into one message.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum TargetConversionError {
31    /// The locator's target kind is not the template's target kind.
32    KindMismatch {
33        locator: &'static str,
34        template: &'static str,
35    },
36    /// Both sides are SSH targets of the same kind, but name different hosts.
37    SshHostMismatch { locator: String, template: String },
38    /// An EC2 locator was stored before its instance reported an address.
39    MissingAwsAddress,
40}
41
42impl std::fmt::Display for TargetConversionError {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::KindMismatch { locator, template } => write!(
46                formatter,
47                "session locator/template mismatch: locator is {locator}, template is {template}"
48            ),
49            Self::SshHostMismatch { locator, template } => write!(
50                formatter,
51                "session locator/template SSH host mismatch: locator is {locator:?}, template is {template:?}"
52            ),
53            Self::MissingAwsAddress => formatter.write_str("AWS locator has no address"),
54        }
55    }
56}
57
58impl std::error::Error for TargetConversionError {}
59
60/// OpenSSH arguments that keep `ssh` non-interactive.
61///
62/// Mjolnir drives ssh from a TUI; a host-key or password prompt would steal
63/// the terminal and wedge provisioning. `BatchMode` fails fast instead of
64/// prompting, and `accept-new` trusts a first-seen host key (fresh EC2
65/// instances are always first-seen) while still rejecting changed keys.
66/// User-supplied arguments come last so they can override.
67pub fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
68    let mut result = vec![
69        "-o".into(),
70        "BatchMode=yes".into(),
71        "-o".into(),
72        "StrictHostKeyChecking=accept-new".into(),
73        "-o".into(),
74        "ConnectTimeout=15".into(),
75    ];
76    result.extend(args.iter().cloned());
77    if let Some(identity) = identity {
78        result.push("-i".into());
79        result.push(path_text(identity));
80    }
81    result
82}
83
84impl From<&SshConnection> for targets::SshTarget {
85    fn from(ssh: &SshConnection) -> Self {
86        let destination = match &ssh.user {
87            Some(user) => format!("{user}@{}", ssh.host),
88            None => ssh.host.clone(),
89        };
90        Self {
91            destination,
92            ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
93        }
94    }
95}
96
97impl From<&PodmanWorkspaceStorage> for targets::PodmanWorkspaceStorage {
98    fn from(storage: &PodmanWorkspaceStorage) -> Self {
99        match storage {
100            PodmanWorkspaceStorage::PodmanVolume => Self::PodmanVolume,
101            PodmanWorkspaceStorage::HostHelper { root, helper } => Self::HostHelper {
102                root: path_text(root),
103                helper: helper.clone(),
104            },
105            PodmanWorkspaceStorage::ContainerLayer => Self::ContainerLayer,
106        }
107    }
108}
109
110impl From<&PodmanWorkspaceLocator> for targets::PodmanWorkspaceLocator {
111    fn from(storage: &PodmanWorkspaceLocator) -> Self {
112        match storage {
113            PodmanWorkspaceLocator::ContainerLayer => Self::ContainerLayer,
114            PodmanWorkspaceLocator::Volume { name } => Self::Volume { name: name.clone() },
115            PodmanWorkspaceLocator::HostPath {
116                path,
117                helper,
118                resource,
119            } => Self::HostPath {
120                path: path_text(path),
121                helper: helper.clone(),
122                resource: resource.clone(),
123            },
124        }
125    }
126}
127
128impl From<targets::PodmanWorkspaceLocator> for PodmanWorkspaceLocator {
129    fn from(storage: targets::PodmanWorkspaceLocator) -> Self {
130        match storage {
131            targets::PodmanWorkspaceLocator::ContainerLayer => Self::ContainerLayer,
132            targets::PodmanWorkspaceLocator::Volume { name } => Self::Volume { name },
133            targets::PodmanWorkspaceLocator::HostPath {
134                path,
135                helper,
136                resource,
137            } => Self::HostPath {
138                path: std::path::PathBuf::from(path),
139                helper,
140                resource,
141            },
142        }
143    }
144}
145
146/// One session's stored target: what the store holds, plus the configuration
147/// and the session identity the stored form deliberately does not repeat.
148///
149/// An SSH locator records only the host and an EC2 locator only the instance
150/// and its resolved address, while an execution plan needs the whole `ssh`
151/// invocation, the AWS profile and region, and the session's workspace path.
152#[derive(Debug, Clone, Copy)]
153pub struct StoredTarget<'a> {
154    pub locator: &'a TargetLocator,
155    pub template: &'a TargetTemplate,
156    pub session_id: &'a str,
157}
158
159impl TryFrom<StoredTarget<'_>> for targets::TargetLocator {
160    type Error = TargetConversionError;
161
162    fn try_from(stored: StoredTarget<'_>) -> Result<Self, Self::Error> {
163        let StoredTarget {
164            locator,
165            template,
166            session_id,
167        } = stored;
168        let mismatch = || TargetConversionError::KindMismatch {
169            locator: locator_kind_name(locator),
170            template: template.kind_name(),
171        };
172        Ok(match locator {
173            TargetLocator::LocalBare { worker_root } => {
174                let TargetTemplate::LocalBare = template else {
175                    return Err(mismatch());
176                };
177                Self::LocalBare {
178                    worker_root: path_text(worker_root),
179                }
180            }
181            TargetLocator::LocalPodman {
182                container_id,
183                workspace_storage,
184                borrowed_from,
185            } => Self::LocalPodman {
186                borrowed_from: borrowed_from.clone(),
187                container_id: container_id.clone(),
188                workspace_storage: workspace_storage.into(),
189            },
190            TargetLocator::LocalDocker {
191                container_id,
192                borrowed_from,
193            } => Self::LocalDocker {
194                borrowed_from: borrowed_from.clone(),
195                container_id: container_id.clone(),
196            },
197            TargetLocator::AppleContainer {
198                container_id,
199                borrowed_from,
200            } => Self::AppleContainer {
201                borrowed_from: borrowed_from.clone(),
202                container_id: container_id.clone(),
203            },
204            TargetLocator::SshBare {
205                workspace,
206                worker_id,
207                ..
208            } => {
209                let TargetTemplate::SshBare { ssh, .. } = template else {
210                    return Err(mismatch());
211                };
212                Self::SshBare {
213                    ssh: ssh.into(),
214                    workspace: path_text(workspace),
215                    worker_id: worker_id.clone(),
216                }
217            }
218            TargetLocator::SshPodman {
219                container_id,
220                workspace_storage,
221                borrowed_from,
222                ..
223            } => {
224                let TargetTemplate::SshPodman { ssh, .. } = template else {
225                    return Err(mismatch());
226                };
227                Self::SshPodman {
228                    borrowed_from: borrowed_from.clone(),
229                    ssh: ssh.into(),
230                    container_id: container_id.clone(),
231                    workspace_storage: workspace_storage.into(),
232                }
233            }
234            TargetLocator::SshDocker {
235                host,
236                container_id,
237                borrowed_from,
238            } => {
239                let TargetTemplate::SshDocker { ssh, .. } = template else {
240                    return Err(mismatch());
241                };
242                if host != &ssh.host {
243                    return Err(TargetConversionError::SshHostMismatch {
244                        locator: host.clone(),
245                        template: ssh.host.clone(),
246                    });
247                }
248                Self::SshDocker {
249                    borrowed_from: borrowed_from.clone(),
250                    ssh: ssh.into(),
251                    container_id: container_id.clone(),
252                }
253            }
254            TargetLocator::AwsEc2 {
255                instance_id,
256                address,
257            } => {
258                let TargetTemplate::AwsEc2 {
259                    aws_profile,
260                    region,
261                    ssh_user,
262                    identity_file,
263                    ssh_args,
264                    ..
265                } = template
266                else {
267                    return Err(mismatch());
268                };
269                let address = address
270                    .as_deref()
271                    .ok_or(TargetConversionError::MissingAwsAddress)?;
272                Self::AwsEc2 {
273                    profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
274                    region: region.clone(),
275                    instance_id: instance_id.clone(),
276                    ssh: targets::SshTarget {
277                        destination: format!("{ssh_user}@{address}"),
278                        ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
279                    },
280                    workspace: targets::aws_workspace(session_id),
281                }
282            }
283        })
284    }
285}
286
287/// The stored locator's target kind, spelled as [`TargetTemplate::kind_name`]
288/// spells it so a mismatch names both sides the same way.
289const fn locator_kind_name(locator: &TargetLocator) -> &'static str {
290    match locator {
291        TargetLocator::LocalBare { .. } => "local-bare",
292        TargetLocator::LocalPodman { .. } => "local-podman",
293        TargetLocator::LocalDocker { .. } => "local-docker",
294        TargetLocator::AppleContainer { .. } => "apple-container",
295        TargetLocator::AwsEc2 { .. } => "aws-ec2",
296        TargetLocator::SshBare { .. } => "ssh-bare",
297        TargetLocator::SshPodman { .. } => "ssh-podman",
298        TargetLocator::SshDocker { .. } => "ssh-docker",
299    }
300}