Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 47 additions & 31 deletions tools/selur-compose/crates/selur-compose-driver/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
//! ```

use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::Duration;

use async_trait::async_trait;
Expand Down Expand Up @@ -70,69 +70,69 @@ pub enum MockCall {
pub struct MockDriver {
calls: Mutex<Vec<MockCall>>,
// Canned responses — Option::None means "return a default success".
run_response: Mutex<Option<Result<ContainerId>>>,
build_response: Mutex<Option<Result<ImageId>>>,
pull_response: Mutex<Option<Result<ImageId>>>,
inspect_map: Mutex<HashMap<String, Result<ContainerState>>>,
healthcheck_map: Mutex<HashMap<String, Result<HealthState>>>,
ps_response: Mutex<Option<Result<Vec<ContainerSummary>>>>,
run_response: Mutex<Option<Result<ContainerId>>>,
build_response: Mutex<Option<Result<ImageId>>>,
pull_response: Mutex<Option<Result<ImageId>>>,
inspect_map: Mutex<HashMap<String, Result<ContainerState>>>,
healthcheck_map: Mutex<HashMap<String, Result<HealthState>>>,
ps_response: Mutex<Option<Result<Vec<ContainerSummary>>>>,
}

impl MockDriver {
/// Create a new `MockDriver` with all-success defaults.
pub fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
run_response: Mutex::new(None),
build_response: Mutex::new(None),
pull_response: Mutex::new(None),
inspect_map: Mutex::new(HashMap::new()),
healthcheck_map: Mutex::new(HashMap::new()),
ps_response: Mutex::new(None),
calls: Mutex::new(Vec::new()),
run_response: Mutex::new(None),
build_response: Mutex::new(None),
pull_response: Mutex::new(None),
inspect_map: Mutex::new(HashMap::new()),
healthcheck_map: Mutex::new(HashMap::new()),
ps_response: Mutex::new(None),
}
}

// ---- call recording ----

/// Return a snapshot of all recorded calls, in order.
pub fn calls(&self) -> Vec<MockCall> {
self.calls.lock().unwrap().clone()
lock(&self.calls).clone()
}

fn record(&self, call: MockCall) {
self.calls.lock().unwrap().push(call);
lock(&self.calls).push(call);
}

// ---- canned-response setters ----

/// Configure the canned response for `Driver::run`.
pub fn set_run_response(&self, r: Result<ContainerId>) {
*self.run_response.lock().unwrap() = Some(r);
*lock(&self.run_response) = Some(r);
}

/// Configure the canned response for `Driver::build`.
pub fn set_build_response(&self, r: Result<ImageId>) {
*self.build_response.lock().unwrap() = Some(r);
*lock(&self.build_response) = Some(r);
}

/// Configure the canned response for `Driver::pull`.
pub fn set_pull_response(&self, r: Result<ImageId>) {
*self.pull_response.lock().unwrap() = Some(r);
*lock(&self.pull_response) = Some(r);
}

/// Configure the canned `inspect` response for a specific container.
pub fn set_inspect_response(&self, id: ContainerId, r: Result<ContainerState>) {
self.inspect_map.lock().unwrap().insert(id.0, r);
lock(&self.inspect_map).insert(id.0, r);
}

/// Configure the canned `healthcheck_run` response for a specific container.
pub fn set_healthcheck_response(&self, id: ContainerId, r: Result<HealthState>) {
self.healthcheck_map.lock().unwrap().insert(id.0, r);
lock(&self.healthcheck_map).insert(id.0, r);
}

/// Configure the canned response for `Driver::ps`.
pub fn set_ps_response(&self, r: Result<Vec<ContainerSummary>>) {
*self.ps_response.lock().unwrap() = Some(r);
*lock(&self.ps_response) = Some(r);
}

// ---- helpers ----
Expand Down Expand Up @@ -161,7 +161,7 @@ impl Default for MockDriver {
impl Driver for MockDriver {
async fn build(&self, spec: &BuildSpec) -> Result<ImageId> {
self.record(MockCall::Build(spec.clone()));
let guard = self.build_response.lock().unwrap();
let guard = lock(&self.build_response);
match guard.as_ref() {
Some(Ok(id)) => Ok(id.clone()),
Some(Err(e)) => Err(mock_error(e)),
Expand All @@ -171,7 +171,7 @@ impl Driver for MockDriver {

async fn pull(&self, image: &str) -> Result<ImageId> {
self.record(MockCall::Pull(image.to_string()));
let guard = self.pull_response.lock().unwrap();
let guard = lock(&self.pull_response);
match guard.as_ref() {
Some(Ok(id)) => Ok(id.clone()),
Some(Err(e)) => Err(mock_error(e)),
Expand All @@ -191,7 +191,7 @@ impl Driver for MockDriver {

async fn run(&self, spec: &RunSpec) -> Result<ContainerId> {
self.record(MockCall::Run(spec.clone()));
let guard = self.run_response.lock().unwrap();
let guard = lock(&self.run_response);
match guard.as_ref() {
Some(Ok(id)) => Ok(id.clone()),
Some(Err(e)) => Err(mock_error(e)),
Expand All @@ -201,7 +201,7 @@ impl Driver for MockDriver {

async fn inspect(&self, id: &ContainerId) -> Result<ContainerState> {
self.record(MockCall::Inspect(id.clone()));
let map = self.inspect_map.lock().unwrap();
let map = lock(&self.inspect_map);
match map.get(&id.0) {
Some(Ok(state)) => Ok(state.clone()),
Some(Err(e)) => Err(mock_error(e)),
Expand All @@ -211,7 +211,7 @@ impl Driver for MockDriver {

async fn healthcheck_run(&self, id: &ContainerId) -> Result<HealthState> {
self.record(MockCall::HealthcheckRun(id.clone()));
let map = self.healthcheck_map.lock().unwrap();
let map = lock(&self.healthcheck_map);
match map.get(&id.0) {
Some(Ok(hs)) => Ok(hs.clone()),
Some(Err(e)) => Err(mock_error(e)),
Expand All @@ -223,24 +223,33 @@ impl Driver for MockDriver {
}

async fn stop(&self, id: &ContainerId, grace: Duration) -> Result<()> {
self.record(MockCall::Stop { id: id.clone(), grace });
self.record(MockCall::Stop {
id: id.clone(),
grace,
});
Ok(())
}

async fn rm(&self, id: &ContainerId, force: bool) -> Result<()> {
self.record(MockCall::Rm { id: id.clone(), force });
self.record(MockCall::Rm {
id: id.clone(),
force,
});
Ok(())
}

async fn logs(&self, id: &ContainerId, follow: bool) -> Result<LogStream> {
self.record(MockCall::Logs { id: id.clone(), follow });
self.record(MockCall::Logs {
id: id.clone(),
follow,
});
// Return an empty cursor.
Ok(Box::new(tokio::io::empty()))
}

async fn ps(&self, project: &str) -> Result<Vec<ContainerSummary>> {
self.record(MockCall::Ps(project.to_string()));
let guard = self.ps_response.lock().unwrap();
let guard = lock(&self.ps_response);
match guard.as_ref() {
Some(Ok(list)) => Ok(list.clone()),
Some(Err(e)) => Err(mock_error(e)),
Expand All @@ -249,6 +258,13 @@ impl Driver for MockDriver {
}
}

/// Acquire a mock-state lock even if an earlier test thread panicked while
/// holding it. Poisoning is advisory here: the contained test fixture remains
/// the most useful state for diagnostics and replay.
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}

/// Clone a `DriverError` for canned-response replay.
///
/// `DriverError` is not `Clone` (it may contain `std::io::Error` which isn't
Expand Down
40 changes: 23 additions & 17 deletions tools/selur-compose/crates/selur-compose-interp/src/env.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: MPL-2.0
# Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
// SPDX-License-Identifier: MPL-2.0
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//! `EnvMap` — ordered environment variable store and `.env` file loader.
//!
//! ## Lookup order
Expand All @@ -20,10 +20,7 @@
//! built in order of *decreasing* priority, so the first match in a linear
//! scan is always the highest-priority value.

use std::{
collections::HashMap,
path::Path,
};
use std::{collections::HashMap, path::Path};

use crate::error::InterpError;

Expand Down Expand Up @@ -96,7 +93,10 @@ impl EnvMap {
}
}

EnvMap { entries: new_entries, index: new_index }
EnvMap {
entries: new_entries,
index: new_index,
}
}

/// Load one or more `.env`-format files and merge them **below** the
Expand Down Expand Up @@ -138,7 +138,10 @@ impl EnvMap {
}
}

Ok(EnvMap { entries: new_entries, index: new_index })
Ok(EnvMap {
entries: new_entries,
index: new_index,
})
}

/// Number of entries in the map.
Expand Down Expand Up @@ -172,15 +175,17 @@ impl EnvMap {
pub fn load_env_file(path: &Path) -> Result<Vec<(String, String)>, InterpError> {
let content = {
use std::io::Read;
let mut file = std::fs::File::open(path).map_err(|e| InterpError::EnvFile {
let file = std::fs::File::open(path).map_err(|e| InterpError::EnvFile {
path: path.display().to_string(),
reason: e.to_string(),
})?;
let mut buf = String::new();
file.take(1024 * 1024).read_to_string(&mut buf).map_err(|e| InterpError::EnvFile {
path: path.display().to_string(),
reason: e.to_string(),
})?;
file.take(1024 * 1024)
.read_to_string(&mut buf)
.map_err(|e| InterpError::EnvFile {
path: path.display().to_string(),
reason: e.to_string(),
})?;
buf
};
parse_env_str(&content, path)
Expand Down Expand Up @@ -230,9 +235,7 @@ pub fn parse_env_str(content: &str, path: &Path) -> Result<Vec<(String, String)>
fn strip_quotes(s: &str) -> &str {
let s = s.trim_end();
if s.len() >= 2 {
if (s.starts_with('"') && s.ends_with('"'))
|| (s.starts_with('\'') && s.ends_with('\''))
{
if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
return &s[1..s.len() - 1];
}
}
Expand All @@ -255,7 +258,10 @@ mod tests {
#[test]
fn basic_key_value() {
let pairs = parse_env_str("FOO=bar\nBAZ=qux\n", &dummy_path()).unwrap();
assert_eq!(pairs, vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]);
assert_eq!(
pairs,
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into())]
);
}

#[test]
Expand Down
42 changes: 24 additions & 18 deletions tools/selur-compose/crates/selur-compose/src/load.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: MPL-2.0
# Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
// SPDX-License-Identifier: MPL-2.0
// Owner: Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//! Shared compose-file loader.
//!
//! Every subcommand uses [`load`] to locate the compose file, discover `.env`
Expand Down Expand Up @@ -31,7 +31,7 @@ use selur_compose_schema::{parse_str, Compose};
#[allow(dead_code)] // compose_path will be used by driver-dependent subcommands in Phase 4
pub struct Loaded {
pub compose: Compose,
pub plan: Plan,
pub plan: Plan,
/// Absolute path to the compose file that was loaded.
pub compose_path: PathBuf,
}
Expand All @@ -46,11 +46,11 @@ pub struct Loaded {
/// * `project_name` — optional override from `-p/--project-name`.
/// * `services` — restrict to these services (empty = all).
pub fn load(
file: Option<&Path>,
env_files: &[PathBuf],
profiles: &[String],
file: Option<&Path>,
env_files: &[PathBuf],
profiles: &[String],
project_name: Option<&str>,
services: &[String],
services: &[String],
) -> Result<Loaded> {
// -----------------------------------------------------------------------
// 1. Locate the compose file
Expand All @@ -63,7 +63,8 @@ pub fn load(
let mut file = std::fs::File::open(&compose_path)
.with_context(|| format!("failed to open {}", compose_path.display()))?;
let mut buf = String::new();
file.take(5 * 1024 * 1024).read_to_string(&mut buf)
file.take(5 * 1024 * 1024)
.read_to_string(&mut buf)
.with_context(|| format!("failed to read {}", compose_path.display()))?;
buf
};
Expand All @@ -79,27 +80,34 @@ pub fn load(
// -----------------------------------------------------------------------
// Start from process environment, then layer the compose-dir .env, then
// any --env-file overrides.
let env = build_env(&compose_path, env_files)
.context("failed to load environment files")?;
let env = build_env(&compose_path, env_files).context("failed to load environment files")?;

// -----------------------------------------------------------------------
// 4. Interpolate
// -----------------------------------------------------------------------
let compose = interpolate(compose, &env)
.with_context(|| format!("variable interpolation failed for {}", compose_path.display()))?;
let compose = interpolate(compose, &env).with_context(|| {
format!(
"variable interpolation failed for {}",
compose_path.display()
)
})?;

// -----------------------------------------------------------------------
// 5. Plan
// -----------------------------------------------------------------------
let opts = PlanOptions {
profiles: profiles.to_vec(),
profiles: profiles.to_vec(),
project_name: project_name.map(str::to_string),
services: services.to_vec(),
services: services.to_vec(),
};
let plan = plan(&compose, &opts)
.with_context(|| format!("planning failed for {}", compose_path.display()))?;

Ok(Loaded { compose, plan, compose_path })
Ok(Loaded {
compose,
plan,
compose_path,
})
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -128,9 +136,7 @@ fn build_env(compose_path: &Path, extra_env_files: &[PathBuf]) -> Result<EnvMap>
let mut env = EnvMap::from_process();

// Look for a .env in the compose file's directory.
let compose_dir = compose_path
.parent()
.unwrap_or_else(|| Path::new("."));
let compose_dir = compose_path.parent().unwrap_or_else(|| Path::new("."));
let dot_env = compose_dir.join(".env");
if dot_env.exists() {
env = env
Expand Down
Loading