//! 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<PathBuf>,
/// SSH user, when it differs from the target's configured account.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh_user: Option<String>,
/// SSH port.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh_port: Option<u16>,
/// 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<String>,
}
/// 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<HostKeyPolicy> 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<Placement> {
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<RemotePlacementConfig>,
/// 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<RemotePlacementConfig>,
}
impl PlacementConfig {
pub fn from_path(path: &Path) -> Result<Self> {
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<Self> {
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))
}
}