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::{
17    PodmanWorkspaceLocator, TargetConnection, TargetLocator, TargetRuntimeSettings,
18};
19use crate::targets;
20
21/// The single place path text crosses into an execution plan.
22fn path_text(path: &Path) -> String {
23    path.to_string_lossy().into_owned()
24}
25
26/// Why a stored locator and its target template cannot describe one target.
27///
28/// A session records its locator and its template independently, so a
29/// configuration edit can leave the pair disagreeing. Each variant names what
30/// disagreed rather than collapsing into one message.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TargetConversionError {
33    RecordedKindMismatch {
34        locator: &'static str,
35        recorded: String,
36    },
37    InvalidRecordedConnection,
38    /// The locator's target kind is not the template's target kind.
39    KindMismatch {
40        locator: &'static str,
41        template: &'static str,
42    },
43    /// Both sides are SSH targets of the same kind, but name different hosts.
44    SshHostMismatch {
45        locator: String,
46        template: String,
47    },
48    /// An EC2 locator was stored before its instance reported an address.
49    MissingAwsAddress,
50}
51
52impl std::fmt::Display for TargetConversionError {
53    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::RecordedKindMismatch { locator, recorded } => write!(
56                formatter,
57                "session target kind {locator} differs from recorded kind {recorded}"
58            ),
59            Self::InvalidRecordedConnection => {
60                formatter.write_str("recorded target connection has the wrong kind")
61            }
62            Self::KindMismatch { locator, template } => write!(
63                formatter,
64                "session locator/template mismatch: locator is {locator}, template is {template}"
65            ),
66            Self::SshHostMismatch { locator, template } => write!(
67                formatter,
68                "session locator/template SSH host mismatch: locator is {locator:?}, template is {template:?}"
69            ),
70            Self::MissingAwsAddress => formatter.write_str("AWS locator has no address"),
71        }
72    }
73}
74
75impl std::error::Error for TargetConversionError {}
76
77/// OpenSSH arguments that keep `ssh` non-interactive.
78///
79/// Mjolnir drives ssh from a TUI; a host-key or password prompt would steal
80/// the terminal and wedge provisioning. `BatchMode` fails fast instead of
81/// prompting, and `accept-new` trusts a first-seen host key (fresh EC2
82/// instances are always first-seen) while still rejecting changed keys.
83///
84/// OpenSSH uses the first value it sees for an option, so the user's
85/// `extra_args` come first and override these defaults: a machine with
86/// `-o StrictHostKeyChecking=yes` gets strict checking. Settings in
87/// `ssh_config` cannot override them, because command-line options always
88/// win over the config file.
89pub fn ssh_args_with_identity(args: &[String], identity: Option<&Path>) -> Vec<String> {
90    let mut result = args.to_vec();
91    result.extend([
92        "-o".into(),
93        "BatchMode=yes".into(),
94        "-o".into(),
95        "StrictHostKeyChecking=accept-new".into(),
96        "-o".into(),
97        "ConnectTimeout=15".into(),
98    ]);
99    if let Some(identity) = identity {
100        result.push("-i".into());
101        result.push(path_text(identity));
102    }
103    result
104}
105
106impl From<&SshConnection> for targets::SshTarget {
107    fn from(ssh: &SshConnection) -> Self {
108        let destination = match &ssh.user {
109            Some(user) => format!("{user}@{}", ssh.host),
110            None => ssh.host.clone(),
111        };
112        Self {
113            destination,
114            ssh_args: ssh_args_with_identity(&ssh.extra_args, ssh.identity_file.as_deref()),
115        }
116    }
117}
118
119impl TargetTemplate {
120    /// The host that downloads this configured target's image, with the
121    /// container settings that name the image. `None` for targets that run no
122    /// image.
123    ///
124    /// This mirrors [`targets::TargetTemplate::image_host`] for the stored
125    /// form of a target, which is what the daemon reads when it decides what
126    /// to download; the two template families never share a type.
127    pub fn image_host(&self) -> Option<(targets::ImageHost, &ContainerTemplate)> {
128        match self {
129            Self::LocalPodman { container } => Some((targets::ImageHost::LocalPodman, container)),
130            Self::LocalDocker { container } => Some((targets::ImageHost::LocalDocker, container)),
131            Self::AppleContainer { container } => {
132                Some((targets::ImageHost::AppleContainer, container))
133            }
134            Self::SshPodman { ssh, container } => Some((
135                targets::ImageHost::SshPodman(targets::SshTarget::from(ssh)),
136                container,
137            )),
138            Self::SshDocker { ssh, container } => Some((
139                targets::ImageHost::SshDocker(targets::SshTarget::from(ssh)),
140                container,
141            )),
142            Self::LocalBare | Self::AwsEc2 { .. } | Self::SshBare { .. } => None,
143        }
144    }
145}
146
147impl From<&PodmanWorkspaceStorage> for targets::PodmanWorkspaceStorage {
148    fn from(storage: &PodmanWorkspaceStorage) -> Self {
149        match storage {
150            PodmanWorkspaceStorage::PodmanVolume => Self::PodmanVolume,
151            PodmanWorkspaceStorage::HostHelper { root, helper } => Self::HostHelper {
152                root: path_text(root),
153                helper: helper.clone(),
154            },
155            PodmanWorkspaceStorage::ContainerLayer => Self::ContainerLayer,
156        }
157    }
158}
159
160impl From<&PodmanWorkspaceLocator> for targets::PodmanWorkspaceLocator {
161    fn from(storage: &PodmanWorkspaceLocator) -> Self {
162        match storage {
163            PodmanWorkspaceLocator::ContainerLayer => Self::ContainerLayer,
164            PodmanWorkspaceLocator::Volume { name } => Self::Volume { name: name.clone() },
165            PodmanWorkspaceLocator::HostPath {
166                path,
167                helper,
168                resource,
169            } => Self::HostPath {
170                path: path_text(path),
171                helper: helper.clone(),
172                resource: resource.clone(),
173            },
174        }
175    }
176}
177
178impl From<targets::PodmanWorkspaceLocator> for PodmanWorkspaceLocator {
179    fn from(storage: targets::PodmanWorkspaceLocator) -> Self {
180        match storage {
181            targets::PodmanWorkspaceLocator::ContainerLayer => Self::ContainerLayer,
182            targets::PodmanWorkspaceLocator::Volume { name } => Self::Volume { name },
183            targets::PodmanWorkspaceLocator::HostPath {
184                path,
185                helper,
186                resource,
187            } => Self::HostPath {
188                path: std::path::PathBuf::from(path),
189                helper,
190                resource,
191            },
192        }
193    }
194}
195
196/// One session's stored target: what the store holds, plus the configuration
197/// and the session identity the stored form deliberately does not repeat.
198///
199/// An SSH locator records only the host and an EC2 locator only the instance
200/// and its resolved address, while an execution plan needs the whole `ssh`
201/// invocation, the AWS profile and region, and the session's workspace path.
202#[derive(Debug, Clone, Copy)]
203pub struct StoredTarget<'a> {
204    pub locator: &'a TargetLocator,
205    pub template: &'a TargetTemplate,
206    pub session_id: &'a str,
207}
208
209/// A provisioned target paired with its durable access settings.
210pub struct RecordedTarget<'a> {
211    pub locator: &'a TargetLocator,
212    pub runtime: Option<&'a TargetRuntimeSettings>,
213    pub session_id: &'a str,
214}
215
216impl TryFrom<StoredTarget<'_>> for targets::TargetLocator {
217    type Error = TargetConversionError;
218    fn try_from(stored: StoredTarget<'_>) -> Result<Self, Self::Error> {
219        if locator_kind_name(stored.locator) != stored.template.kind_name() {
220            return Err(TargetConversionError::KindMismatch {
221                locator: locator_kind_name(stored.locator),
222                template: stored.template.kind_name(),
223            });
224        }
225        Self::try_from(RecordedTarget {
226            locator: stored.locator,
227            runtime: Some(&TargetRuntimeSettings::from(stored.template)),
228            session_id: stored.session_id,
229        })
230    }
231}
232
233impl TryFrom<RecordedTarget<'_>> for targets::TargetLocator {
234    type Error = TargetConversionError;
235    fn try_from(stored: RecordedTarget<'_>) -> Result<Self, Self::Error> {
236        let RecordedTarget {
237            locator,
238            runtime,
239            session_id,
240        } = stored;
241        if let Some(runtime) = runtime {
242            if locator_kind_name(locator) != runtime.kind {
243                return Err(TargetConversionError::RecordedKindMismatch {
244                    locator: locator_kind_name(locator),
245                    recorded: runtime.kind.clone(),
246                });
247            }
248            if !locator_needs_connection(locator) && runtime.connection != TargetConnection::Local {
249                return Err(TargetConversionError::InvalidRecordedConnection);
250            }
251        }
252        let ssh = |host: &str| -> Result<targets::SshTarget, TargetConversionError> {
253            let Some(TargetConnection::Ssh { ssh }) = runtime.map(|runtime| &runtime.connection)
254            else {
255                return Err(TargetConversionError::InvalidRecordedConnection);
256            };
257            if host != ssh.host {
258                return Err(TargetConversionError::SshHostMismatch {
259                    locator: host.into(),
260                    template: ssh.host.clone(),
261                });
262            }
263            Ok(ssh.into())
264        };
265        Ok(match locator {
266            TargetLocator::LocalBare { worker_root } => Self::LocalBare {
267                worker_root: path_text(worker_root),
268            },
269            TargetLocator::LocalPodman {
270                container_id,
271                workspace_storage,
272                borrowed_from,
273            } => Self::LocalPodman {
274                container_id: container_id.clone(),
275                workspace_storage: workspace_storage.into(),
276                borrowed_from: borrowed_from.clone(),
277            },
278            TargetLocator::LocalDocker {
279                container_id,
280                borrowed_from,
281            } => Self::LocalDocker {
282                container_id: container_id.clone(),
283                borrowed_from: borrowed_from.clone(),
284            },
285            TargetLocator::AppleContainer {
286                container_id,
287                borrowed_from,
288            } => Self::AppleContainer {
289                container_id: container_id.clone(),
290                borrowed_from: borrowed_from.clone(),
291            },
292            TargetLocator::SshBare {
293                host,
294                workspace,
295                worker_id,
296            } => Self::SshBare {
297                ssh: ssh(host)?,
298                workspace: path_text(workspace),
299                worker_id: worker_id.clone(),
300            },
301            TargetLocator::SshPodman {
302                host,
303                container_id,
304                workspace_storage,
305                borrowed_from,
306            } => Self::SshPodman {
307                ssh: ssh(host)?,
308                container_id: container_id.clone(),
309                workspace_storage: workspace_storage.into(),
310                borrowed_from: borrowed_from.clone(),
311            },
312            TargetLocator::SshDocker {
313                host,
314                container_id,
315                borrowed_from,
316            } => Self::SshDocker {
317                ssh: ssh(host)?,
318                container_id: container_id.clone(),
319                borrowed_from: borrowed_from.clone(),
320            },
321            TargetLocator::AwsEc2 {
322                instance_id,
323                address,
324            } => {
325                let Some(TargetConnection::Aws {
326                    profile,
327                    region,
328                    ssh_user,
329                    identity_file,
330                    ssh_args,
331                }) = runtime.map(|runtime| &runtime.connection)
332                else {
333                    return Err(TargetConversionError::InvalidRecordedConnection);
334                };
335                let address = address
336                    .as_deref()
337                    .ok_or(TargetConversionError::MissingAwsAddress)?;
338                Self::AwsEc2 {
339                    profile: profile.clone(),
340                    region: region.clone(),
341                    instance_id: instance_id.clone(),
342                    ssh: targets::SshTarget {
343                        destination: format!("{ssh_user}@{address}"),
344                        ssh_args: ssh_args_with_identity(ssh_args, identity_file.as_deref()),
345                    },
346                    workspace: targets::aws_workspace(session_id),
347                }
348            }
349        })
350    }
351}
352
353/// The stored locator's target kind, spelled as [`TargetTemplate::kind_name`]
354/// spells it so a mismatch names both sides the same way.
355const fn locator_kind_name(locator: &TargetLocator) -> &'static str {
356    match locator {
357        TargetLocator::LocalBare { .. } => "local-bare",
358        TargetLocator::LocalPodman { .. } => "local-podman",
359        TargetLocator::LocalDocker { .. } => "local-docker",
360        TargetLocator::AppleContainer { .. } => "apple-container",
361        TargetLocator::AwsEc2 { .. } => "aws-ec2",
362        TargetLocator::SshBare { .. } => "ssh-bare",
363        TargetLocator::SshPodman { .. } => "ssh-podman",
364        TargetLocator::SshDocker { .. } => "ssh-docker",
365    }
366}
367
368/// Local resource locators contain everything needed to reach the resource.
369pub fn locator_needs_connection(locator: &TargetLocator) -> bool {
370    matches!(
371        locator,
372        TargetLocator::SshBare { .. }
373            | TargetLocator::SshPodman { .. }
374            | TargetLocator::SshDocker { .. }
375            | TargetLocator::AwsEc2 { .. }
376    )
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn every_ssh_locator_checks_host_and_preserves_the_saved_connection() {
385        let connection = SshConnection {
386            host: "original.test".into(),
387            user: Some("builder".into()),
388            identity_file: Some("/keys/my key".into()),
389            extra_args: vec!["-p".into(), "2222".into()],
390        };
391        let locators = [
392            TargetLocator::SshBare {
393                host: connection.host.clone(),
394                workspace: "workspace".into(),
395                worker_id: None,
396            },
397            TargetLocator::SshPodman {
398                host: connection.host.clone(),
399                container_id: "podman-id".into(),
400                workspace_storage: Default::default(),
401                borrowed_from: Some("owner".into()),
402            },
403            TargetLocator::SshDocker {
404                host: connection.host.clone(),
405                container_id: "docker-id".into(),
406                borrowed_from: None,
407            },
408        ];
409        for locator in locators {
410            let mut runtime = TargetRuntimeSettings::from(&TargetTemplate::LocalBare);
411            runtime.kind = locator_kind_name(&locator).into();
412            runtime.connection = TargetConnection::Ssh {
413                ssh: connection.clone(),
414            };
415            let backend = targets::TargetLocator::try_from(RecordedTarget {
416                locator: &locator,
417                runtime: Some(&runtime),
418                session_id: "session",
419            })
420            .unwrap();
421            let ssh = match backend {
422                targets::TargetLocator::SshBare { ssh, .. }
423                | targets::TargetLocator::SshPodman { ssh, .. }
424                | targets::TargetLocator::SshDocker { ssh, .. } => ssh,
425                _ => unreachable!(),
426            };
427            assert_eq!(ssh.destination, "builder@original.test");
428            assert!(
429                ssh.ssh_args
430                    .windows(2)
431                    .any(|args| args == ["-i", "/keys/my key"])
432            );
433            assert!(ssh.ssh_args.windows(2).any(|args| args == ["-p", "2222"]));
434            let TargetConnection::Ssh { ssh } = &mut runtime.connection else {
435                unreachable!()
436            };
437            ssh.host = "replacement.test".into();
438            assert!(matches!(
439                targets::TargetLocator::try_from(RecordedTarget {
440                    locator: &locator,
441                    runtime: Some(&runtime),
442                    session_id: "session"
443                }),
444                Err(TargetConversionError::SshHostMismatch { .. })
445            ));
446            runtime.kind = "local-bare".into();
447            assert!(matches!(
448                targets::TargetLocator::try_from(RecordedTarget {
449                    locator: &locator,
450                    runtime: Some(&runtime),
451                    session_id: "session"
452                }),
453                Err(TargetConversionError::RecordedKindMismatch { .. })
454            ));
455        }
456    }
457
458    #[test]
459    fn recorded_ec2_access_preserves_region_profile_and_identity_without_launch_template() {
460        let template: TargetTemplate = serde_json::from_value(serde_json::json!({
461            "kind":"aws-ec2", "aws_profile":"production", "region":"eu-west-1",
462            "launch_template":"creation-only", "ssh_user":"ubuntu", "identity_file":"/keys/ec2",
463            "ssh_args":["-p","2222"]
464        }))
465        .unwrap();
466        let runtime = TargetRuntimeSettings::from(&template);
467        let encoded = serde_json::to_string(&runtime).unwrap();
468        assert!(!encoded.contains("creation-only"));
469        let runtime = serde_json::from_str(&encoded).unwrap();
470        let locator = TargetLocator::AwsEc2 {
471            instance_id: "i-original".into(),
472            address: Some("10.0.0.1".into()),
473        };
474        let backend = targets::TargetLocator::try_from(RecordedTarget {
475            locator: &locator,
476            runtime: Some(&runtime),
477            session_id: "session",
478        })
479        .unwrap();
480        let targets::TargetLocator::AwsEc2 {
481            profile,
482            region,
483            instance_id,
484            ssh,
485            ..
486        } = backend
487        else {
488            unreachable!()
489        };
490        assert_eq!(
491            (profile.as_str(), region.as_str(), instance_id.as_str()),
492            ("production", "eu-west-1", "i-original")
493        );
494        assert_eq!(ssh.destination, "ubuntu@10.0.0.1");
495        assert!(
496            ssh.ssh_args
497                .windows(2)
498                .any(|args| args == ["-i", "/keys/ec2"])
499        );
500        assert!(ssh.ssh_args.windows(2).any(|args| args == ["-p", "2222"]));
501    }
502
503    /// The value OpenSSH would use for `key`: the first `-o key=value` (or
504    /// `-okey=value`) on the command line wins, as `ssh -G` shows.
505    fn effective_ssh_option(args: &[String], key: &str) -> Option<String> {
506        let mut args = args.iter();
507        while let Some(arg) = args.next() {
508            let option = if arg == "-o" {
509                args.next().cloned()
510            } else {
511                arg.strip_prefix("-o").map(str::to_owned)
512            };
513            let Some(option) = option else { continue };
514            let (name, value) = option.split_once(['=', ' ']).unwrap_or((&option, ""));
515            if name.trim().eq_ignore_ascii_case(key) {
516                return Some(value.trim().to_owned());
517            }
518        }
519        None
520    }
521
522    #[test]
523    fn user_extra_args_can_require_strict_host_key_checking() {
524        let args = ssh_args_with_identity(&["-o".into(), "StrictHostKeyChecking=yes".into()], None);
525        assert_eq!(
526            effective_ssh_option(&args, "StrictHostKeyChecking").as_deref(),
527            Some("yes")
528        );
529        let args = ssh_args_with_identity(&["-oUserKnownHostsFile=/k".into()], None);
530        assert_eq!(
531            effective_ssh_option(&args, "UserKnownHostsFile").as_deref(),
532            Some("/k")
533        );
534    }
535
536    #[test]
537    fn mjolnir_ssh_defaults_apply_when_the_user_sets_nothing() {
538        let args = ssh_args_with_identity(&["-p".into(), "2222".into()], None);
539        assert_eq!(
540            effective_ssh_option(&args, "StrictHostKeyChecking").as_deref(),
541            Some("accept-new")
542        );
543        assert_eq!(
544            effective_ssh_option(&args, "BatchMode").as_deref(),
545            Some("yes")
546        );
547    }
548}