//! Packaged placement configuration. //! //! A daemon that runs a session's workbench somewhere other than its own //! process must say so in configuration, not in a test-local placement //! source. This module is that declaration: the operator names the target, //! the workspace, the namespace, the relay/interpreter/runner paths used to //! reach it, and which sessions it applies to. //! //! Every field here is DECLARED configuration. Identity (account, platform, //! interpreter, cwd) is still what the connect boundary actually returns and //! verifies; nothing in this module substitutes for that check, and a broken //! declaration is a typed refusal rather than a fallback to local execution. use anyhow::{ensure, Context, Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use crate::{ remote::{ relay::RemoteBackend, ssh::{SshConfig, StrictHostKeyChecking}, }, session::Placement, target::{NamespaceId, RemoteTarget}, }; /// One configured remote workbench. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RemotePlacementConfig { /// The configured target. Its workspace path is required and never /// defaults to a home directory. pub target: RemoteTarget, /// Namespace this workbench is opened under. Namespace generations are /// still minted per launch; this is the configured name. pub namespace: NamespaceId, /// Target-side directory of the active behavior release. pub behavior_bundle: String, /// Transport executable launched on the target. pub relay_bin: PathBuf, /// Target interpreter. pub python: PathBuf, /// Target-side runner entry point. pub runner: PathBuf, /// Host-side directory holding the behavior bundle to ship. #[serde(default, skip_serializing_if = "Option::is_none")] pub bundle_dir: Option, /// SSH user, when it differs from the target's configured account. #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh_user: Option, /// SSH port. #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh_port: Option, /// Host-key policy. `accept_new` is the default, matching SshConfig. #[serde(default)] pub strict_host_key_checking: HostKeyPolicy, /// Sessions this entry applies to. Empty means every session this /// service opens, which is what a "remote primary" service declares. #[serde(default)] pub sessions: Vec, } /// SSH host-key policy as configuration. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum HostKeyPolicy { Yes, #[default] AcceptNew, No, } impl From for StrictHostKeyChecking { fn from(policy: HostKeyPolicy) -> Self { match policy { HostKeyPolicy::Yes => StrictHostKeyChecking::Yes, HostKeyPolicy::AcceptNew => StrictHostKeyChecking::AcceptNew, HostKeyPolicy::No => StrictHostKeyChecking::No, } } } impl RemotePlacementConfig { /// The transport this configured target is reached over. #[must_use] pub fn ssh_config(&self) -> SshConfig { let mut ssh = SshConfig::new(self.target.ssh_alias.clone()) .with_strict_host_key_checking(self.strict_host_key_checking.into()); if let Some(user) = self.ssh_user.as_ref().or(self.target.account.as_ref()) { ssh = ssh.with_user(user.clone()); } if let Some(port) = self.ssh_port { ssh = ssh.with_port(port); } ssh } /// Build the runtime placement. A declared workspace is required here and /// re-checked by `SessionPlacement::from_json`; a missing one is refused /// before any transport is opened. pub fn placement(&self) -> Result { ensure!( !self.target.workspace.path.trim().is_empty(), "remote target {} declares no workspace", self.target.id ); ensure!( !self.behavior_bundle.trim().is_empty(), "remote target {} declares no behavior bundle", self.target.id ); let mut backend = RemoteBackend::new( self.ssh_config(), self.target.clone(), self.relay_bin.clone(), self.python.clone(), self.runner.clone(), ); if let Some(bundle_dir) = &self.bundle_dir { backend = backend.with_bundle_dir(bundle_dir.clone()); } Ok(Placement::remote( self.target.clone(), std::sync::Arc::new(backend), self.namespace.clone(), self.behavior_bundle.clone(), )) } /// Does this entry own `session`? #[must_use] pub fn owns(&self, session: &str) -> bool { self.sessions.is_empty() || self.sessions.iter().any(|name| name == session) } } /// The whole service placement declaration. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PlacementConfig { /// Workbench for every session this service opens, when declared. #[serde(default, skip_serializing_if = "Option::is_none")] pub primary: Option, /// Named sessions that run on a remote target while the primary session /// stays local. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub auxiliary: Vec, } impl PlacementConfig { pub fn from_path(path: &Path) -> Result { let contents = std::fs::read_to_string(path) .with_context(|| format!("failed to read placement config {}", path.display()))?; let config: Self = serde_json::from_str(&contents) .with_context(|| format!("failed to parse placement config {}", path.display()))?; config.validate()?; Ok(config) } pub fn from_json(contents: &str) -> Result { let config: Self = serde_json::from_str(contents).context("failed to parse placement config")?; config.validate()?; Ok(config) } /// A declaration is refused, not repaired: an auxiliary entry with no /// session selector would silently claim every session, and an entry with /// an unusable workspace must fail at load rather than at first use. pub fn validate(&self) -> Result<()> { for entry in self.auxiliary.iter() { ensure!( !entry.sessions.is_empty(), "auxiliary target {} declares no sessions and would claim every session", entry.target.id ); } for entry in self.primary.iter().chain(self.auxiliary.iter()) { ensure!( !entry.target.workspace.path.trim().is_empty(), "target {} declares no workspace", entry.target.id ); } Ok(()) } /// The entry that owns `session`, if any. Primary wins over auxiliary. #[must_use] pub fn entry_for(&self, session: &str) -> Option<&RemotePlacementConfig> { self.primary .iter() .chain(self.auxiliary.iter()) .find(|entry| entry.owns(session)) } }