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::{ContainerTemplate, 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 TargetTemplate {
98    /// The host that downloads this configured target's image, with the
99    /// container settings that name the image. `None` for targets that run no
100    /// image.
101    ///
102    /// This mirrors [`targets::TargetTemplate::image_host`] for the stored
103    /// form of a target, which is what the daemon reads when it decides what
104    /// to download; the two template families never share a type.
105    pub fn image_host(&self) -> Option<(targets::ImageHost, &ContainerTemplate)> {
106        match self {
107            Self::LocalPodman { container } => Some((targets::ImageHost::LocalPodman, container)),
108            Self::LocalDocker { container } => Some((targets::ImageHost::LocalDocker, container)),
109            Self::AppleContainer { container } => {
110                Some((targets::ImageHost::AppleContainer, container))
111            }
112            Self::SshPodman { ssh, container } => Some((
113                targets::ImageHost::SshPodman(targets::SshTarget::from(ssh)),
114                container,
115            )),
116            Self::SshDocker { ssh, container } => Some((
117                targets::ImageHost::SshDocker(targets::SshTarget::from(ssh)),
118                container,
119            )),
120            Self::LocalBare | Self::AwsEc2 { .. } | Self::SshBare { .. } => None,
121        }
122    }
123}
124
125impl From<&PodmanWorkspaceStorage> for targets::PodmanWorkspaceStorage {
126    fn from(storage: &PodmanWorkspaceStorage) -> Self {
127        match storage {
128            PodmanWorkspaceStorage::PodmanVolume => Self::PodmanVolume,
129            PodmanWorkspaceStorage::HostHelper { root, helper } => Self::HostHelper {
130                root: path_text(root),
131                helper: helper.clone(),
132            },
133            PodmanWorkspaceStorage::ContainerLayer => Self::ContainerLayer,
134        }
135    }
136}
137
138impl From<&PodmanWorkspaceLocator> for targets::PodmanWorkspaceLocator {
139    fn from(storage: &PodmanWorkspaceLocator) -> Self {
140        match storage {
141            PodmanWorkspaceLocator::ContainerLayer => Self::ContainerLayer,
142            PodmanWorkspaceLocator::Volume { name } => Self::Volume { name: name.clone() },
143            PodmanWorkspaceLocator::HostPath {
144                path,
145                helper,
146                resource,
147            } => Self::HostPath {
148                path: path_text(path),
149                helper: helper.clone(),
150                resource: resource.clone(),
151            },
152        }
153    }
154}
155
156impl From<targets::PodmanWorkspaceLocator> for PodmanWorkspaceLocator {
157    fn from(storage: targets::PodmanWorkspaceLocator) -> Self {
158        match storage {
159            targets::PodmanWorkspaceLocator::ContainerLayer => Self::ContainerLayer,
160            targets::PodmanWorkspaceLocator::Volume { name } => Self::Volume { name },
161            targets::PodmanWorkspaceLocator::HostPath {
162                path,
163                helper,
164                resource,
165            } => Self::HostPath {
166                path: std::path::PathBuf::from(path),
167                helper,
168                resource,
169            },
170        }
171    }
172}
173
174/// One session's stored target: what the store holds, plus the configuration
175/// and the session identity the stored form deliberately does not repeat.
176///
177/// An SSH locator records only the host and an EC2 locator only the instance
178/// and its resolved address, while an execution plan needs the whole `ssh`
179/// invocation, the AWS profile and region, and the session's workspace path.
180#[derive(Debug, Clone, Copy)]
181pub struct StoredTarget<'a> {
182    pub locator: &'a TargetLocator,
183    pub template: &'a TargetTemplate,
184    pub session_id: &'a str,
185}
186
187impl TryFrom<StoredTarget<'_>> for targets::TargetLocator {
188    type Error = TargetConversionError;
189
190    fn try_from(stored: StoredTarget<'_>) -> Result<Self, Self::Error> {
191        let StoredTarget {
192            locator,
193            template,
194            session_id,
195        } = stored;
196        let mismatch = || TargetConversionError::KindMismatch {
197            locator: locator_kind_name(locator),
198            template: template.kind_name(),
199        };
200        Ok(match locator {
201            TargetLocator::LocalBare { worker_root } => {
202                let TargetTemplate::LocalBare = template else {
203                    return Err(mismatch());
204                };
205                Self::LocalBare {
206                    worker_root: path_text(worker_root),
207                }
208            }
209            TargetLocator::LocalPodman {
210                container_id,
211                workspace_storage,
212                borrowed_from,
213            } => Self::LocalPodman {
214                borrowed_from: borrowed_from.clone(),
215                container_id: container_id.clone(),
216                workspace_storage: workspace_storage.into(),
217            },
218            TargetLocator::LocalDocker {
219                container_id,
220                borrowed_from,
221            } => Self::LocalDocker {
222                borrowed_from: borrowed_from.clone(),
223                container_id: container_id.clone(),
224            },
225            TargetLocator::AppleContainer {
226                container_id,
227                borrowed_from,
228            } => Self::AppleContainer {
229                borrowed_from: borrowed_from.clone(),
230                container_id: container_id.clone(),
231            },
232            TargetLocator::SshBare {
233                workspace,
234                worker_id,
235                ..
236            } => {
237                let TargetTemplate::SshBare { ssh, .. } = template else {
238                    return Err(mismatch());
239                };
240                Self::SshBare {
241                    ssh: ssh.into(),
242                    workspace: path_text(workspace),
243                    worker_id: worker_id.clone(),
244                }
245            }
246            TargetLocator::SshPodman {
247                container_id,
248                workspace_storage,
249                borrowed_from,
250                ..
251            } => {
252                let TargetTemplate::SshPodman { ssh, .. } = template else {
253                    return Err(mismatch());
254                };
255                Self::SshPodman {
256                    borrowed_from: borrowed_from.clone(),
257                    ssh: ssh.into(),
258                    container_id: container_id.clone(),
259                    workspace_storage: workspace_storage.into(),
260                }
261            }
262            TargetLocator::SshDocker {
263                host,
264                container_id,
265                borrowed_from,
266            } => {
267                let TargetTemplate::SshDocker { ssh, .. } = template else {
268                    return Err(mismatch());
269                };
270                if host != &ssh.host {
271                    return Err(TargetConversionError::SshHostMismatch {
272                        locator: host.clone(),
273                        template: ssh.host.clone(),
274                    });
275                }
276                Self::SshDocker {
277                    borrowed_from: borrowed_from.clone(),
278                    ssh: ssh.into(),
279                    container_id: container_id.clone(),
280                }
281            }
282            TargetLocator::AwsEc2 {
283                instance_id,
284                address,
285            } => {
286                let TargetTemplate::AwsEc2 {
287                    aws_profile,
288                    region,
289                    ssh_user,
290                    identity_file,
291                    ssh_args,
292                    ..
293                } = template
294                else {
295                    return Err(mismatch());
296                };
297                let address = address
298                    .as_deref()
299                    .ok_or(TargetConversionError::MissingAwsAddress)?;
300                Self::AwsEc2 {
301                    profile: aws_profile.clone().unwrap_or_else(|| "default".into()),
302                    region: region.clone(),
303                    instance_id: instance_id.clone(),
304                    ssh: targets::SshTarget {
305                        destination: format!("{ssh_user}@{address}"),
306                        ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
307                    },
308                    workspace: targets::aws_workspace(session_id),
309                }
310            }
311        })
312    }
313}
314
315/// The stored locator's target kind, spelled as [`TargetTemplate::kind_name`]
316/// spells it so a mismatch names both sides the same way.
317const fn locator_kind_name(locator: &TargetLocator) -> &'static str {
318    match locator {
319        TargetLocator::LocalBare { .. } => "local-bare",
320        TargetLocator::LocalPodman { .. } => "local-podman",
321        TargetLocator::LocalDocker { .. } => "local-docker",
322        TargetLocator::AppleContainer { .. } => "apple-container",
323        TargetLocator::AwsEc2 { .. } => "aws-ec2",
324        TargetLocator::SshBare { .. } => "ssh-bare",
325        TargetLocator::SshPodman { .. } => "ssh-podman",
326        TargetLocator::SshDocker { .. } => "ssh-docker",
327    }
328}