-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add configuration management for SAMMY execution backends
- Loading branch information
Showing
3 changed files
with
353 additions
and
42 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
#!/usr/env/bin python | ||
"""Local backend implementation for SAMMY execution.""" | ||
import subprocess | ||
import textwrap | ||
from datetime import datetime | ||
from pathlib import Path | ||
from uuid import uuid4 | ||
import logging | ||
from typing import List | ||
|
||
from pleiades.sammy.interface import ( | ||
SammyRunner, | ||
SammyFiles, | ||
SammyExecutionResult, | ||
EnvironmentPreparationError, | ||
SammyExecutionError, | ||
OutputCollectionError, | ||
) | ||
from pleiades.sammy.config import LocalSammyConfig | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
# Known SAMMY output file patterns | ||
SAMMY_OUTPUT_FILES = { | ||
'SAMMY.LPT', # Log file | ||
'SAMMIE.ODF', # Output data file | ||
'SAMNDF.PAR', # Updated parameter file | ||
'SAMRESOLVED.PAR' # Additional parameter file | ||
} | ||
|
||
class LocalSammyRunner(SammyRunner): | ||
"""Implementation of SAMMY runner for local installation.""" | ||
|
||
def __init__(self, config: LocalSammyConfig): | ||
super().__init__(config) | ||
self.config: LocalSammyConfig = config | ||
self._moved_files: List[Path] = [] | ||
|
||
def prepare_environment(self, files: SammyFiles) -> None: | ||
"""Prepare environment for local SAMMY execution.""" | ||
try: | ||
logger.debug("Validating input files") | ||
files.validate() | ||
|
||
# No need to validate directories as this is done in config validation | ||
logger.debug("Environment preparation complete") | ||
|
||
except Exception as e: | ||
raise EnvironmentPreparationError( | ||
f"Environment preparation failed: {str(e)}" | ||
) | ||
|
||
def execute_sammy(self, files: SammyFiles) -> SammyExecutionResult: | ||
"""Execute SAMMY using local installation.""" | ||
execution_id = str(uuid4()) | ||
start_time = datetime.now() | ||
|
||
logger.info(f"Starting SAMMY execution {execution_id}") | ||
logger.debug(f"Working directory: {self.config.working_dir}") | ||
|
||
sammy_command = textwrap.dedent(f"""\ | ||
{self.config.sammy_executable} <<EOF | ||
{files.input_file} | ||
{files.parameter_file} | ||
{files.data_file} | ||
EOF""") | ||
|
||
try: | ||
process = subprocess.run( | ||
sammy_command, | ||
shell=True, | ||
executable=str(self.config.shell_path), | ||
env=self.config.env_vars, | ||
cwd=str(self.config.working_dir), | ||
capture_output=True, | ||
text=True | ||
) | ||
|
||
end_time = datetime.now() | ||
console_output = process.stdout + process.stderr | ||
success = " Normal finish to SAMMY" in console_output | ||
|
||
if not success: | ||
logger.error(f"SAMMY execution failed for {execution_id}") | ||
error_message = ( | ||
f"SAMMY execution failed with return code {process.returncode}. " | ||
"Check console output for details." | ||
) | ||
else: | ||
logger.info(f"SAMMY execution completed successfully for {execution_id}") | ||
error_message = None | ||
|
||
return SammyExecutionResult( | ||
success=success, | ||
execution_id=execution_id, | ||
start_time=start_time, | ||
end_time=end_time, | ||
console_output=console_output, | ||
error_message=error_message | ||
) | ||
|
||
except Exception as e: | ||
logger.exception(f"SAMMY execution failed for {execution_id}") | ||
raise SammyExecutionError(f"SAMMY execution failed: {str(e)}") | ||
|
||
def collect_outputs(self, result: SammyExecutionResult) -> None: | ||
"""Collect and validate output files after execution.""" | ||
collection_start = datetime.now() | ||
logger.info(f"Collecting outputs for execution {result.execution_id}") | ||
|
||
try: | ||
self._moved_files = [] # Reset moved files list | ||
found_outputs = set() | ||
|
||
# First check for known output files | ||
for known_file in SAMMY_OUTPUT_FILES: | ||
output_file = self.config.working_dir / known_file | ||
if output_file.is_file(): | ||
found_outputs.add(output_file) | ||
logger.debug(f"Found known output file: {known_file}") | ||
|
||
# Then look for any additional SAM* files | ||
for output_file in self.config.working_dir.glob("SAM*"): | ||
if output_file.is_file() and output_file not in found_outputs: | ||
found_outputs.add(output_file) | ||
logger.debug(f"Found additional output file: {output_file.name}") | ||
|
||
if not found_outputs: | ||
logger.warning("No SAMMY output files found") | ||
if result.success: | ||
logger.error("SAMMY reported success but produced no output files") | ||
return | ||
|
||
# Move all found outputs | ||
for output_file in found_outputs: | ||
dest = self.config.output_dir / output_file.name | ||
try: | ||
if dest.exists(): | ||
logger.debug(f"Removing existing output file: {dest}") | ||
dest.unlink() | ||
|
||
output_file.rename(dest) | ||
self._moved_files.append(dest) | ||
logger.debug(f"Moved {output_file} to {dest}") | ||
|
||
except OSError as e: | ||
self._rollback_moves() | ||
raise OutputCollectionError( | ||
f"Failed to move output file {output_file}: {str(e)}" | ||
) | ||
|
||
logger.info( | ||
f"Successfully collected {len(self._moved_files)} output files in " | ||
f"{(datetime.now() - collection_start).total_seconds():.2f} seconds" | ||
) | ||
|
||
except Exception as e: | ||
self._rollback_moves() | ||
raise OutputCollectionError(f"Output collection failed: {str(e)}") | ||
|
||
def _rollback_moves(self) -> None: | ||
"""Rollback any moved files in case of error.""" | ||
for moved_file in self._moved_files: | ||
try: | ||
original = self.config.working_dir / moved_file.name | ||
moved_file.rename(original) | ||
except Exception as e: | ||
logger.error(f"Failed to rollback move for {moved_file}: {str(e)}") | ||
|
||
def cleanup(self) -> None: | ||
"""Clean up after execution.""" | ||
logger.debug("Performing cleanup for local backend") | ||
self._moved_files = [] | ||
|
||
def validate_config(self) -> bool: | ||
"""Validate the configuration.""" | ||
return self.config.validate() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
#!/usr/env/bin python | ||
""" | ||
Configuration management for SAMMY execution backends. | ||
This module provides concrete configuration classes for each SAMMY backend type, | ||
inheriting from the base configuration defined in the interface module. | ||
""" | ||
|
||
from dataclasses import dataclass, field | ||
from pathlib import Path | ||
from typing import Dict, Optional | ||
from urllib.parse import urlparse | ||
import shutil | ||
|
||
from pleiades.sammy.interface import BaseSammyConfig, ConfigurationError | ||
|
||
@dataclass | ||
class LocalSammyConfig(BaseSammyConfig): | ||
"""Configuration for local SAMMY installation.""" | ||
sammy_executable: Path | ||
shell_path: Path = Path("/bin/bash") | ||
env_vars: Dict[str, str] = field(default_factory=dict) | ||
|
||
def validate(self) -> bool: | ||
"""Validate local SAMMY configuration.""" | ||
# Validate base configuration first | ||
super().validate() | ||
|
||
# Validate SAMMY executable exists and is executable | ||
sammy_path = shutil.which(str(self.sammy_executable)) | ||
if not sammy_path: | ||
raise ConfigurationError(f"SAMMY executable not found: {self.sammy_executable}") | ||
self.sammy_executable = Path(sammy_path) | ||
|
||
# Validate shell exists | ||
if not self.shell_path.exists(): | ||
raise ConfigurationError(f"Shell not found: {self.shell_path}") | ||
|
||
return True | ||
|
||
@dataclass | ||
class DockerSammyConfig(BaseSammyConfig): | ||
"""Configuration for Docker-based SAMMY execution.""" | ||
image_name: str | ||
container_working_dir: Path = Path("/sammy") | ||
volume_mappings: Dict[Path, Path] = field(default_factory=dict) | ||
network: Optional[str] = None | ||
|
||
def validate(self) -> bool: | ||
"""Validate Docker SAMMY configuration.""" | ||
# Validate base configuration first | ||
super().validate() | ||
|
||
# Validate image name format | ||
if not self.image_name: | ||
raise ConfigurationError("Docker image name cannot be empty") | ||
|
||
# Validate container working directory is absolute | ||
if not self.container_working_dir.is_absolute(): | ||
raise ConfigurationError("Container working directory must be absolute path") | ||
|
||
# Validate volume mappings exist on host | ||
for host_path in self.volume_mappings: | ||
if not host_path.exists(): | ||
raise ConfigurationError(f"Host path does not exist: {host_path}") | ||
|
||
return True | ||
|
||
@dataclass | ||
class NovaSammyConfig(BaseSammyConfig): | ||
"""Configuration for NOVA web service SAMMY execution.""" | ||
url: str | ||
api_key: str | ||
tool_id: str = "neutrons_imaging_sammy" | ||
timeout: int = 3600 # Default 1 hour timeout | ||
verify_ssl: bool = True | ||
|
||
def validate(self) -> bool: | ||
"""Validate NOVA SAMMY configuration.""" | ||
# First validate base configuration | ||
super().validate() | ||
|
||
# Validate URL format | ||
try: | ||
parsed_url = urlparse(self.url) | ||
if not all([parsed_url.scheme, parsed_url.netloc]): | ||
raise ConfigurationError(f"Invalid URL format: {self.url}") | ||
except Exception as e: | ||
raise ConfigurationError(f"URL validation failed: {str(e)}") | ||
|
||
# Validate API key format | ||
if not self.api_key or len(self.api_key) < 32: | ||
raise ConfigurationError("Invalid API key format") | ||
|
||
# Validate timeout | ||
if self.timeout <= 0: | ||
raise ConfigurationError(f"Invalid timeout value: {self.timeout}") | ||
|
||
return True |
Oops, something went wrong.