1use std::path::Path;
14
15use crate::config::{ContainerTemplate, PodmanWorkspaceStorage, SshConnection, TargetTemplate};
16use crate::state::{
17 PodmanWorkspaceLocator, TargetConnection, TargetLocator, TargetRuntimeSettings,
18};
19use crate::targets;
20
21fn path_text(path: &Path) -> String {
23 path.to_string_lossy().into_owned()
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum TargetConversionError {
33 RecordedKindMismatch {
34 locator: &'static str,
35 recorded: String,
36 },
37 InvalidRecordedConnection,
38 KindMismatch {
40 locator: &'static str,
41 template: &'static str,
42 },
43 SshHostMismatch {
45 locator: String,
46 template: String,
47 },
48 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
77pub 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 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#[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
209pub 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
353const 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
368pub 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 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}