diff --git a/.gitignore b/.gitignore index f0241d628..755a17978 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,7 @@ terraform.rc # Application specific config/config.json +config/cyclecloud-credentials.json # Runtime-generated script copies (orb init) /scripts/ data/ diff --git a/dev-tools/quality/quality_check.py b/dev-tools/quality/quality_check.py index 602eae748..0f7699aca 100755 --- a/dev-tools/quality/quality_check.py +++ b/dev-tools/quality/quality_check.py @@ -265,6 +265,22 @@ def can_autofix(self) -> bool: return True +class UnjustifiedGetAttrViolation(Violation): + """getattr call without a justifying ``# getattr`` comment.""" + + def __init__(self, file_path: str, line_num: int, content: str): + super().__init__( + file_path, + line_num, + content, + "getattr should only be used when absolutely needed; " + "justify with a comment starting with '# getattr' on the same " + "or a preceding line if it is needed, remove getattr if not. Strongly prefer" + "removing, use web search to find the real API for external SDK calls. Do not replace" + "with an equivalent pattern that avoids getattr but is still the same anti-pattern.", + ) + + # --- Checker Classes --- @@ -555,6 +571,120 @@ def check_content(self, file_path: str, content: str) -> list[Violation]: return violations +class GetAttrChecker(FileChecker): + """Enforce justified ``getattr`` use in Azure and GCP provider code. + + Every ``getattr(...)`` call in ``src/orb/providers/azure/`` and + ``src/orb/providers/gcp/`` must have a comment starting with ``# getattr`` + on the same line, on a preceding line within the same scope, or in the + enclosing function/method docstring. This keeps defensive SDK access + intentional and documented at the provider boundary. + """ + + _PROVIDER_PREFIXES = ( + os.path.join("src", "orb", "providers", "azure"), + os.path.join("src", "orb", "providers", "gcp"), + ) + _GETATTR_CALL = re.compile(r"\bgetattr\s*\(") + _JUSTIFICATION_COMMENT = re.compile(r"#\s*getattr\b") + _JUSTIFICATION_DOCSTRING = re.compile(r"\bgetattr\b") + _SCOPE_BOUNDARY = re.compile(r"^\s*(def |class )") + _MAX_LOOKBACK = 30 + + def check_content(self, file_path: str, content: str) -> list[Violation]: + if not file_path.endswith(".py"): + return [] + normalised = os.path.normpath(file_path) + if not any(prefix in normalised for prefix in self._PROVIDER_PREFIXES): + return [] + + violations: list[Violation] = [] + lines = content.splitlines() + + # Pre-compute the docstring that covers each function/method body so + # we can check it cheaply per getattr line. + scope_docstrings = self._build_scope_docstring_map(lines) + + for idx, line in enumerate(lines): + if not self._GETATTR_CALL.search(line): + continue + if self._is_justified(lines, idx, scope_docstrings): + continue + violations.append( + UnjustifiedGetAttrViolation(file_path, idx + 1, line.strip()) + ) + return violations + + def _is_justified( + self, + lines: list[str], + idx: int, + scope_docstrings: dict[int, str], + ) -> bool: + """Return True if the getattr at *idx* has a visible justification.""" + line = lines[idx] + + # 1. Same-line comment. + if self._JUSTIFICATION_COMMENT.search(line): + return True + + # 2. Scan backward within the same scope for a ``# getattr`` comment. + for back in range(1, self._MAX_LOOKBACK + 1): + prev_idx = idx - back + if prev_idx < 0: + break + prev = lines[prev_idx] + if self._JUSTIFICATION_COMMENT.search(prev): + return True + if self._SCOPE_BOUNDARY.search(prev): + break + + # 3. Enclosing function/method docstring mentions ``getattr``. + for scope_start, docstring in scope_docstrings.items(): + if scope_start < idx and self._JUSTIFICATION_DOCSTRING.search(docstring): + # Check that *idx* is inside this scope (no newer scope in + # between). + next_scope = min( + (s for s in scope_docstrings if s > scope_start), + default=len(lines), + ) + if idx < next_scope: + return True + + return False + + @staticmethod + def _build_scope_docstring_map(lines: list[str]) -> dict[int, str]: + """Map ``def``/``class`` line indices to their docstrings (if any).""" + scope_boundary = re.compile(r"^\s*(def |class )") + result: dict[int, str] = {} + for idx, line in enumerate(lines): + if not scope_boundary.search(line): + continue + # Look for a docstring on the next non-blank line. + doc_start = idx + 1 + while doc_start < len(lines) and not lines[doc_start].strip(): + doc_start += 1 + if doc_start >= len(lines): + continue + first = lines[doc_start].strip() + if not (first.startswith('"""') or first.startswith("'''")): + continue + quote = first[:3] + if first.count(quote) >= 2: + # Single-line docstring. + result[idx] = first + else: + # Multi-line docstring — collect until closing quotes. + parts = [first] + for j in range(doc_start + 1, len(lines)): + parts.append(lines[j]) + if quote in lines[j]: + break + result[idx] = "\n".join(parts) + return result + + class QualityChecker: """Main quality checker that runs all checks.""" @@ -565,6 +695,7 @@ def __init__(self): DocstringChecker(), ImportChecker(), CommentChecker(), + GetAttrChecker(), ] self.gitignore_spec = self._load_gitignore() @@ -781,6 +912,8 @@ def main(): category = "Unused imports" elif "Commented-out code" in v.message: category = "Commented-out code" + elif "getattr should only" in v.message: + category = "Unjustified getattr" else: category = "Other issues" diff --git a/docs/root/architecture/clean_architecture.md b/docs/root/architecture/clean_architecture.md index 78659533f..1ee802b3c 100644 --- a/docs/root/architecture/clean_architecture.md +++ b/docs/root/architecture/clean_architecture.md @@ -18,13 +18,13 @@ Clean Architecture organizes code into layers with clear dependency rules: The domain layer contains the core business logic and has no external dependencies. #### Location -- `src/domain/` +- `src/orb/domain/` #### Components **Entities (Aggregates)** ```python -# src/domain/template/aggregate.py +# src/orb/domain/template/template_aggregate.py class Template(BaseModel): """Template configuration representing VM template.""" template_id: str @@ -38,7 +38,7 @@ class Template(BaseModel): **Value Objects** ```python -# src/domain/machine/machine_status.py +# src/orb/domain/machine/value_objects.py class MachineStatus(Enum): """Machine status value object.""" PENDING = "pending" @@ -48,7 +48,7 @@ class MachineStatus(Enum): **Domain Services** ```python -# src/domain/template/ami_resolver.py +# src/orb/providers/aws/domain/services/ami_resolver.py class AMIResolver: """Domain service for AMI resolution logic.""" @@ -58,7 +58,7 @@ class AMIResolver: **Repository Interfaces** ```python -# src/domain/template/repository.py +# src/orb/domain/template/repository.py class TemplateRepository(ABC): """Abstract repository interface.""" @@ -78,13 +78,13 @@ class TemplateRepository(ABC): The application layer orchestrates domain objects and implements use cases. #### Location -- `src/application/` +- `src/orb/application/` #### Components **Application Services** ```python -# src/application/service.py +# src/orb/application/services/provisioning_orchestration_service.py @injectable class ApplicationService: """Main application orchestrator.""" @@ -92,13 +92,13 @@ class ApplicationService: def __init__(self, command_bus: CommandBus, query_bus: QueryBus, - provider_context: ProviderContext): + provider_selection_port: ProviderSelectionPort): # Dependencies injected, not created ``` **Command Handlers (CQRS)** ```python -# src/application/commands/template_handlers.py +# src/orb/application/commands/template_handlers.py class GetTemplatesHandler: """Handle template retrieval commands.""" @@ -108,7 +108,7 @@ class GetTemplatesHandler: **Query Handlers (CQRS)** ```python -# src/application/queries/handlers.py +# src/orb/application/queries/template_query_handlers.py class TemplateQueryHandler: """Handle template queries.""" @@ -118,7 +118,7 @@ class TemplateQueryHandler: **Data Transfer Objects** ```python -# src/application/dto/commands.py +# src/orb/application/dto/commands.py class CreateRequestCommand: """Command for creating requests.""" template_id: str @@ -136,14 +136,14 @@ class CreateRequestCommand: The infrastructure layer implements external concerns and technical details. #### Location -- `src/infrastructure/` -- `src/providers/` +- `src/orb/infrastructure/` +- `src/orb/providers/` #### Components **Repository Implementations** ```python -# src/infrastructure/storage/repositories/template_repository.py +# src/orb/infrastructure/storage/repositories/template_repository.py class TemplateRepositoryImpl(TemplateRepository): """Template repository implementation.""" @@ -153,18 +153,18 @@ class TemplateRepositoryImpl(TemplateRepository): **External Service Adapters** ```python -# src/providers/aws/managers/aws_instance_manager.py +# src/orb/providers/aws/infrastructure/aws_client.py @injectable -class AWSInstanceManager: - """AWS-specific instance management.""" +class AWSClient: + """Provider-specific resource management.""" - def __init__(self, aws_client: AWSClient, logger: LoggingPort): + def __init__(self, config: ConfigurationPort, logger: LoggingPort): # Infrastructure dependencies ``` **Dependency Injection Container** ```python -# src/infrastructure/di/container.py +# src/orb/infrastructure/di/container.py class DIContainer: """Dependency injection container.""" @@ -174,7 +174,7 @@ class DIContainer: **Configuration Management** ```python -# src/infrastructure/config/manager.py +# src/orb/config/managers/configuration_manager.py class ConfigurationManager: """Configuration management implementation.""" ``` @@ -190,15 +190,15 @@ class ConfigurationManager: The interface layer provides external access points to the system. #### Location -- `src/interface/` -- `src/api/` -- `src/cli/` +- `src/orb/interface/` +- `src/orb/api/` +- `src/orb/cli/` #### Components **CLI Interface** ```python -# src/cli/main.py +# src/orb/cli/main.py def main(): """CLI entry point.""" # Parse arguments @@ -208,7 +208,7 @@ def main(): **REST API Interface** ```python -# src/api/routers/templates.py +# src/orb/api/routers/templates.py @router.get("/templates") async def get_templates(): """REST API endpoint.""" @@ -219,7 +219,7 @@ async def get_templates(): **Interface Command Handlers** ```python -# src/interface/template_command_handlers.py +# src/orb/interface/template_command_handlers.py class TemplateCommandHandler: """Handle CLI template commands.""" diff --git a/docs/root/developer_guide/dependency_injection.md b/docs/root/developer_guide/dependency_injection.md index 0b2a9fa72..ba987c836 100644 --- a/docs/root/developer_guide/dependency_injection.md +++ b/docs/root/developer_guide/dependency_injection.md @@ -11,7 +11,7 @@ Before working with the DI system, you should understand: This guide provides practical implementation guidance for using the dependency injection system in the Open Resource Broker. For comprehensive technical details, see the [Architecture Reference](../architecture/dependency_injection.md). -The plugin uses a comprehensive dependency injection system that follows Clean Architecture principles, with DI abstractions moved to the domain layer while maintaining backward compatibility. +The project uses a comprehensive dependency injection system that follows Clean Architecture principles, with DI abstractions moved to the domain layer. ## Next Steps @@ -28,7 +28,7 @@ The DI system follows correct dependency direction: ```python # [[]] Clean Architecture compliant -from src.domain.base.dependency_injection import injectable +from orb.domain.base.dependency_injection import injectable @injectable class ApplicationService: @@ -41,7 +41,7 @@ class ApplicationService: ## Domain DI Layer -**Location:** `src/domain/base/dependency_injection.py` +**Location:** `src/orb/domain/base/dependency_injection.py` ### Available Decorators @@ -69,7 +69,7 @@ The domain DI layer provides these decorators: ## Infrastructure DI Container -**Location:** `src/infrastructure/di/container.py` +**Location:** `src/orb/infrastructure/di/container.py` The `DIContainer` class implements domain contracts: @@ -100,7 +100,7 @@ The `DIContainer` class implements domain contracts: ### Basic Dependency Injection ```python -from src.domain.base.dependency_injection import injectable +from orb.domain.base.dependency_injection import injectable @injectable class ApplicationService: @@ -109,7 +109,7 @@ class ApplicationService: self.config = config # Container automatically resolves dependencies -from src.infrastructure.di.container import get_container +from orb.infrastructure.di.container import get_container container = get_container() app_service = container.get(ApplicationService) ``` @@ -117,7 +117,7 @@ app_service = container.get(ApplicationService) ### Singleton Pattern ```python -from src.domain.base.dependency_injection import injectable, singleton +from orb.domain.base.dependency_injection import injectable, singleton @singleton @injectable @@ -142,21 +142,21 @@ The system includes actual command and query handlers: #### Command Handlers (Actual Examples) ```python -# From src/application/commands/request_handlers.py +# From src/orb/application/commands/request_handlers.py @injectable class CreateMachineRequestHandler: def __init__(self, repository, logger): self.repository = repository self.logger = logger -# From src/application/commands/template_handlers.py +# From src/orb/application/commands/template_handlers.py @injectable class CreateTemplateHandler: def handle(self, command): # Handle template creation pass -# From src/application/commands/system_handlers.py +# From src/orb/application/commands/system_handlers.py @injectable class MigrateProviderConfigHandler: def handle(self, command): @@ -167,21 +167,21 @@ class MigrateProviderConfigHandler: #### Query Handlers (Actual Examples) ```python -# From src/application/queries/handlers.py +# From src/orb/application/queries/request_query_handlers.py @injectable class GetRequestHandler: def handle(self, query): # Handle request retrieval pass -# From src/application/queries/system_handlers.py +# From src/orb/application/queries/system_handlers.py @injectable class GetProviderConfigHandler: def handle(self, query): # Handle provider config retrieval pass -# From src/application/queries/specialized_handlers.py +# From src/orb/application/queries/specialized_handlers.py @injectable class GetActiveMachineCountHandler: def handle(self, query): @@ -194,7 +194,7 @@ class GetActiveMachineCountHandler: The system defines these actual commands and queries: ```python -# From src/application/dto/commands.py +# From src/orb/application/dto/commands.py class CreateRequestCommand(Command, BaseModel): template_id: str count: int @@ -205,7 +205,7 @@ class UpdateRequestStatusCommand(Command, BaseModel): status: str # ... other fields -# From src/application/dto/queries.py +# From src/orb/application/dto/queries.py class GetRequestQuery(Query, BaseModel): request_id: str # ... other fields @@ -218,7 +218,7 @@ class ListActiveRequestsQuery(Query, BaseModel): ### Container Operations ```python -from src.infrastructure.di.container import get_container +from orb.infrastructure.di.container import get_container container = get_container() @@ -248,22 +248,21 @@ The DI system integrates with registry patterns for strategy-based component sel The scheduler port demonstrates registry integration for configuration-driven strategy selection: ```python -# Automatic registration using registry pattern -def create_scheduler_port(container): - from src.infrastructure.registry.scheduler_registry import get_scheduler_registry - from src.config.manager import get_config_manager - - config_manager = get_config_manager() - scheduler_config = config_manager.get_scheduler_config() - scheduler_type = scheduler_config.get('strategy', 'hostfactory') - - registry = get_scheduler_registry() - return registry.get_active_strategy(scheduler_type, scheduler_config) +# Registration pattern used by the bootstrap layer +from orb.application.services.scheduler_registry_service import SchedulerRegistryService +from orb.infrastructure.scheduler.registry import get_scheduler_registry + +container.register_singleton( + SchedulerRegistryService, + lambda c: SchedulerRegistryService( + registry=get_scheduler_registry(), + logger=c.get(LoggingPort), + ), +) # Usage - transparent to consumers -from src.domain.base.ports import SchedulerPort - -scheduler = container.get(SchedulerPort) # Automatically resolves via registry +scheduler_registry_service = container.get(SchedulerRegistryService) +available_schedulers = scheduler_registry_service.get_available_schedulers() ``` ### Benefits of Registry Integration @@ -279,22 +278,22 @@ scheduler = container.get(SchedulerPort) # Automatically resolves via registry Based on actual codebase analysis: ### Application Layer -- `ApplicationService` - Main application orchestrator [[]] Injectable -- Command handlers in `src/application/commands/`: +- CQRS handlers such as `CreateMachineRequestHandler`, `CreateTemplateHandler`, and `GetRequestHandler` +- Command handlers in `src/orb/application/commands/`: - `CreateMachineRequestHandler` - `CreateTemplateHandler` - `MigrateProviderConfigHandler` - And many more... -- Query handlers in `src/application/queries/`: +- Query handlers in `src/orb/application/queries/`: - `GetRequestHandler` - `GetProviderConfigHandler` - `GetActiveMachineCountHandler` - And many more... ### Provider Layer -- `AWSInstanceManager` - AWS instance management [[]] Injectable -- `AWSOperations` - AWS operations wrapper [[]] Injectable -- Various AWS adapters and handlers throughout `src/providers/aws/` +- `AWSOperations` - AWS operations wrapper +- Provider infrastructure such as `AWSClient` and handler factories +- Various AWS adapters and handlers throughout `src/orb/providers/aws/` ### Infrastructure Layer - Various infrastructure services and adapters @@ -306,8 +305,8 @@ Some services are registered manually in the DI container instead of using the ` ```python # Example: TemplateConfigurationManager registration -# Location: src/infrastructure/di/port_registrations.py -def _register_template_configuration_services(container: DIContainer) -> None: +# Location: src/orb/bootstrap/infrastructure_services.py +def register_infrastructure_services(container: DIContainer) -> None: """Register template configuration services.""" # Factory-based singleton registration with complex initialization @@ -326,23 +325,23 @@ def _register_template_configuration_services(container: DIContainer) -> None: ## Migration Guide ### For Existing Code -All existing code continues to work with backward compatibility: +Use the canonical `orb` imports: ```python -# Existing imports still work -from src.application.service import ApplicationService -from src.infrastructure.di.container import get_container +# Canonical imports +from orb.application.commands.template_handlers import CreateTemplateHandler +from orb.infrastructure.di.container import get_container -# Classes that were injectable remain injectable +# Injectable handlers resolve through the container container = get_container() -service = container.get(ApplicationService) +handler = container.get(CreateTemplateHandler) ``` ### For New Code -Use domain DI imports for new classes: +Use canonical `orb` imports for new classes: ```python -from src.domain.base.dependency_injection import injectable +from orb.domain.base.dependency_injection import injectable @injectable class NewService: @@ -353,7 +352,7 @@ class NewService: ## Testing with DI ```python -from src.infrastructure.di.container import DIContainer +from orb.infrastructure.di.container import DIContainer def test_service(): # Create test container diff --git a/docs/root/patterns/ports_and_adapters.md b/docs/root/patterns/ports_and_adapters.md index b9c0d523f..fb3846677 100644 --- a/docs/root/patterns/ports_and_adapters.md +++ b/docs/root/patterns/ports_and_adapters.md @@ -20,7 +20,7 @@ Ports are abstract interfaces defined in the domain layer that specify contracts #### Logging Port ```python -# src/domain/base/ports/logging_port.py +# src/orb/domain/base/ports/logging_port.py from abc import ABC, abstractmethod class LoggingPort(ABC): @@ -50,7 +50,7 @@ class LoggingPort(ABC): #### Configuration Port ```python -# src/domain/base/ports/configuration_port.py +# src/orb/domain/base/ports/configuration_port.py from abc import ABC, abstractmethod from typing import Any, Dict, Optional @@ -81,7 +81,7 @@ class ConfigurationPort(ABC): #### Container Port ```python -# src/domain/base/ports/container_port.py +# src/orb/domain/base/ports/container_port.py from abc import ABC, abstractmethod from typing import Type, TypeVar, Any @@ -111,7 +111,7 @@ class ContainerPort(ABC): #### Template Repository Port ```python -# src/domain/template/repository.py +# src/orb/domain/template/repository.py from abc import ABC, abstractmethod from typing import List, Optional, Dict, Any from .aggregate import Template @@ -146,11 +146,11 @@ class TemplateRepository(ABC): #### Provider Strategy Port ```python -# src/domain/base/provider_interfaces.py +# src/orb/providers/base/strategy/provider_strategy.py from abc import ABC, abstractmethod from typing import List, Dict, Any -from src.domain.request.aggregate import Request -from src.domain.machine.aggregate import Machine +from orb.domain.request.aggregate import Request +from orb.domain.machine.aggregate import Machine class ProviderStrategy(ABC): """Abstract interface for cloud provider strategies.""" @@ -185,9 +185,9 @@ Adapters are concrete implementations of ports that handle specific technologies #### Logging Adapter ```python -# src/infrastructure/adapters/logging_adapter.py -from src.domain.base.ports import LoggingPort -from src.infrastructure.logging.logger import get_logger +# src/orb/infrastructure/adapters/logging_adapter.py +from orb.domain.base.ports import LoggingPort +from orb.infrastructure.logging.logger import get_logger import logging class LoggingAdapter(LoggingPort): @@ -216,9 +216,9 @@ class LoggingAdapter(LoggingPort): #### Configuration Adapter ```python -# src/infrastructure/adapters/configuration_adapter.py -from src.domain.base.ports import ConfigurationPort -from src.config.manager import ConfigurationManager +# src/orb/infrastructure/adapters/configuration_adapter.py +from orb.domain.base.ports import ConfigurationPort +from orb.config.managers.configuration_manager import ConfigurationManager from typing import Any, Dict class ConfigurationAdapter(ConfigurationPort): @@ -247,9 +247,9 @@ class ConfigurationAdapter(ConfigurationPort): #### Container Adapter ```python -# src/infrastructure/adapters/container_adapter.py -from src.domain.base.ports import ContainerPort -from src.infrastructure.di.container import DIContainer +# src/orb/infrastructure/adapters/container_adapter.py +from orb.domain.base.ports import ContainerPort +from orb.infrastructure.di.container import DIContainer from typing import Type, TypeVar T = TypeVar('T') @@ -278,10 +278,10 @@ class ContainerAdapter(ContainerPort): #### AWS Template Adapter ```python -# src/providers/aws/infrastructure/adapters/template_adapter.py -from src.domain.base.dependency_injection import injectable -from src.domain.base.ports import LoggingPort, ConfigurationPort -from src.providers.aws.infrastructure.aws_client import AWSClient +# src/orb/providers/aws/infrastructure/adapters/template_adapter.py +from orb.domain.base.dependency_injection import injectable +from orb.domain.base.ports import LoggingPort, ConfigurationPort +from orb.providers.aws.infrastructure.aws_client import AWSClient from typing import Dict, Any, List @injectable @@ -328,12 +328,12 @@ class AWSTemplateAdapter: #### AWS Provider Strategy Adapter ```python -# src/providers/aws/strategy/aws_provider_strategy.py -from src.domain.base.dependency_injection import injectable -from src.domain.base.ports import LoggingPort -from src.domain.base.provider_interfaces import ProviderStrategy -from src.providers.aws.configuration.config import AWSProviderConfig -from src.providers.aws.infrastructure.aws_client import AWSClient +# src/orb/providers/aws/strategy/aws_provider_strategy.py +from orb.domain.base.dependency_injection import injectable +from orb.domain.base.ports import LoggingPort +from orb.providers.base.strategy.provider_strategy import ProviderStrategy +from orb.providers.aws.configuration.config import AWSProviderConfig +from orb.providers.aws.infrastructure.aws_client import AWSClient from typing import List, Dict, Any @injectable @@ -385,10 +385,10 @@ class AWSProviderStrategy(ProviderStrategy): #### DynamoDB Template Repository ```python -# src/infrastructure/storage/dynamodb/template_repository.py -from src.domain.template.repository import TemplateRepository -from src.domain.template.aggregate import Template -from src.domain.base.ports import LoggingPort +# src/orb/infrastructure/storage/dynamodb/template_repository.py +from orb.domain.template.repository import TemplateRepository +from orb.domain.template.template_aggregate import Template +from orb.domain.base.ports import LoggingPort from typing import List, Optional, Dict, Any import boto3 diff --git a/docs/root/patterns/strategy_pattern.md b/docs/root/patterns/strategy_pattern.md index a93d20e72..baad5d6e0 100644 --- a/docs/root/patterns/strategy_pattern.md +++ b/docs/root/patterns/strategy_pattern.md @@ -18,12 +18,12 @@ The Strategy pattern allows the plugin to: The core provider strategy interface defines the contract for all provider implementations: ```python -# src/providers/base/strategy/provider_strategy.py +# src/orb/providers/base/strategy/provider_strategy.py from abc import ABC, abstractmethod from typing import List, Dict, Any, Optional -from src.domain.request.aggregate import Request -from src.domain.machine.aggregate import Machine -from src.domain.template.aggregate import Template +from orb.domain.request.aggregate import Request +from orb.domain.machine.aggregate import Machine +from orb.domain.template.template_aggregate import Template class ProviderStrategy(ABC): """Abstract base class for provider strategies.""" @@ -69,13 +69,13 @@ class ProviderStrategy(ABC): The AWS provider strategy implements the provider interface for Amazon Web Services: ```python -# src/providers/aws/strategy/aws_provider_strategy.py -from src.domain.base.dependency_injection import injectable -from src.domain.base.ports import LoggingPort -from src.providers.base.strategy.provider_strategy import ProviderStrategy -from src.providers.aws.configuration.config import AWSProviderConfig -from src.providers.aws.infrastructure.aws_client import AWSClient -from src.providers.aws.infrastructure.aws_handler_factory import AWSHandlerFactory +# src/orb/providers/aws/strategy/aws_provider_strategy.py +from orb.domain.base.dependency_injection import injectable +from orb.domain.base.ports import LoggingPort +from orb.providers.base.strategy.provider_strategy import ProviderStrategy +from orb.providers.aws.configuration.config import AWSProviderConfig +from orb.providers.aws.infrastructure.aws_client import AWSClient +from orb.providers.aws.infrastructure.aws_handler_factory import AWSHandlerFactory @injectable class AWSProviderStrategy(ProviderStrategy): @@ -204,10 +204,10 @@ class AWSProviderStrategy(ProviderStrategy): The provider context manages strategy selection and execution: ```python -# src/providers/base/strategy/provider_context.py +# src/orb/providers/base/strategy/provider_context.py from typing import Dict, List, Optional, Any -from src.domain.base.ports import LoggingPort -from src.providers.base.strategy.provider_strategy import ProviderStrategy +from orb.domain.base.ports import LoggingPort +from orb.providers.base.strategy.provider_strategy import ProviderStrategy class ProviderContext: """Context for managing provider strategies.""" diff --git a/pyproject.toml b/pyproject.toml index acdad1468..de767c7cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -276,6 +276,7 @@ dev = [ "pip-tools>=7.3.0,<8.0.0", "twine>=4.0.0", "git-changelog>=2.6.0,<3.0.0", + "pytest>=9.0.2", ] [dependency-groups] @@ -627,6 +628,9 @@ line-ending = "auto" known-first-party = ["orb"] combine-as-imports = true +[tool.pycodestyle] +max-line-length = 100 + [tool.pyright] pythonVersion = "3.12" pythonPlatform = "Linux" diff --git a/src/orb/api/routers/providers.py b/src/orb/api/routers/providers.py index ef6f841f0..f9f388f36 100644 --- a/src/orb/api/routers/providers.py +++ b/src/orb/api/routers/providers.py @@ -82,17 +82,16 @@ def _get_schema_for_provider_type(provider_type: str) -> list[dict[str, Any]]: Raises ``KeyError`` when the provider type is not registered. """ from orb.providers.registry.provider_registry import get_provider_registry - from orb.providers.registry.types import ProviderRegistration registry = get_provider_registry() - reg = registry._get_type_registration(provider_type) # raises ValueError if missing - if not isinstance(reg, ProviderRegistration) or reg.strategy_class is None: + strategy_class = registry.get_strategy_class(provider_type) + if strategy_class is None: return [] try: # Call the classmethod directly — no instance needed. # get_ui_column_schema only constructs UIColumnDescriptor objects; no I/O. - schema = reg.strategy_class.get_ui_column_schema() + schema = strategy_class.get_ui_column_schema() return [col.to_dict() for col in schema] except Exception as exc: logger.warning( diff --git a/src/orb/application/commands/request_creation_handlers.py b/src/orb/application/commands/request_creation_handlers.py index 941d5dd58..52aecb143 100644 --- a/src/orb/application/commands/request_creation_handlers.py +++ b/src/orb/application/commands/request_creation_handlers.py @@ -16,6 +16,7 @@ from orb.application.services.provisioning_orchestration_service import ( ProvisioningOrchestrationService, ) +from orb.application.services.request_follow_up_context import with_request_follow_up_context from orb.domain.base import UnitOfWorkFactory from orb.domain.base.configuration_service import DomainConfigurationService from orb.domain.base.exceptions import ApplicationError, EntityNotFoundError @@ -30,6 +31,9 @@ from orb.domain.request.request_identifiers import RequestId from orb.domain.request.request_types import RequestStatus from orb.domain.request.value_objects import RequestType +from orb.domain.template.factory import TemplateFactoryPort +from orb.domain.template.template_aggregate import Template +from orb.infrastructure.template.dtos import TemplateDTO @command_handler(CreateRequestCommand) # type: ignore[arg-type] @@ -120,7 +124,7 @@ async def execute_command(self, command: CreateRequestCommand) -> None: self.logger.info("Machine request created successfully: %s", request.request_id) - async def _load_template(self, template_id: str) -> Any: + async def _load_template(self, template_id: str) -> Template: """Load template using CQRS QueryBus.""" if not self._query_bus: raise ApplicationError("QueryBus is required for template lookup") @@ -133,6 +137,14 @@ async def _load_template(self, template_id: str) -> Any: if not template: raise EntityNotFoundError("Template", template_id) + # The query bus may return a TemplateDTO (production path) or a domain + # Template (test paths, or callers that pre-construct one). Only convert + # in the DTO case — calling .to_template_config() on a domain Template + # would AttributeError. + if isinstance(template, TemplateDTO): + template_factory = self._container.get(TemplateFactoryPort) + template = template_factory.create_template(template.to_template_config()) + self.logger.debug("Template found: %s (id=%s)", type(template), template.template_id) return template @@ -479,6 +491,7 @@ async def _execute_deprovisioning_for_request( # Update request status based on result if provisioning_result.get("success", False): + self._persist_return_follow_up_context(request, provisioning_result) self._update_machines_to_pending(machine_ids) if skipped_ids: from orb.application.dto.commands import UpdateRequestStatusCommand @@ -531,6 +544,21 @@ def _update_machines_to_pending(self, machine_ids: list[str]) -> None: ) uow.machines.save(updated_machine) + def _persist_return_follow_up_context( + self, request: Any, provisioning_result: dict[str, Any] + ) -> None: + """Persist durable provider follow-up metadata needed after this process exits.""" + provider_data = provisioning_result.get("provider_data") + if not isinstance(provider_data, dict) or not provider_data: + return + + with self.uow_factory.create_unit_of_work() as uow: + current_request = uow.requests.get_by_id(request.request_id) or request + updated_request = with_request_follow_up_context(current_request, provider_data) + events = uow.requests.save(updated_request) + for event in events or []: + self.event_publisher.publish(event) # type: ignore[union-attr] + async def _update_request_status( self, request: Request, status: RequestStatus, message: str ) -> None: @@ -603,17 +631,3 @@ async def _update_request_to_terminating(self, request: Any) -> None: RequestStatus.IN_PROGRESS, "Termination accepted: waiting for instances to reach terminated state", ) - - async def _update_request_to_completed(self, request: Any) -> None: - """Update return request status to completed and persist.""" - try: - await self._update_request_status( - request, - RequestStatus.COMPLETED, - "Return request completed: termination initiated", - ) - except Exception as update_error: - # Ensure the request reaches a terminal status so callers don't poll forever - await self._update_request_to_failed( - request, [f"Failed to mark completed: {update_error}"] - ) diff --git a/src/orb/application/commands/request_sync_handlers.py b/src/orb/application/commands/request_sync_handlers.py index b255e7f40..bcee4131e 100644 --- a/src/orb/application/commands/request_sync_handlers.py +++ b/src/orb/application/commands/request_sync_handlers.py @@ -8,6 +8,9 @@ PopulateMachineIdsCommand, SyncRequestCommand, ) +from orb.application.services.request_follow_up_context import ( + merge_request_metadata_with_follow_up_context, +) from orb.domain.base import UnitOfWorkFactory from orb.domain.base.exceptions import EntityNotFoundError from orb.domain.base.ports import ( @@ -75,6 +78,7 @@ async def _discover_machine_ids(self, request) -> list[str]: "resource_ids": request.resource_ids, "provider_api": request.provider_api, "template_id": request.template_id, + "request_metadata": self._build_request_metadata(request), }, ) @@ -114,6 +118,11 @@ async def _discover_machine_ids(self, request) -> list[str]: ) return [] + @staticmethod + def _build_request_metadata(request) -> dict: + """Merge durable request metadata with persisted provider follow-up context.""" + return merge_request_metadata_with_follow_up_context(request) + @command_handler(SyncRequestCommand) # type: ignore[arg-type] class SyncRequestHandler(BaseCommandHandler[SyncRequestCommand, None]): # type: ignore[type-var] diff --git a/src/orb/application/commands/template_handlers.py b/src/orb/application/commands/template_handlers.py index d37b3f321..a710863e9 100644 --- a/src/orb/application/commands/template_handlers.py +++ b/src/orb/application/commands/template_handlers.py @@ -1,5 +1,7 @@ """Template command handlers for CQRS pattern.""" +from typing import Any + from orb.application.base.handlers import BaseCommandHandler from orb.application.decorators import command_handler from orb.application.template.commands import ( @@ -86,8 +88,14 @@ async def execute_command(self, command: CreateTemplateCommand) -> None: ) # Build DTO from command fields — configuration provides defaults, named fields win + valid_fields = set(TemplateDTO.model_fields.keys()) - {"provider_config"} + provider_specific_config = { + k: v + for k, v in command.configuration.items() + if k not in valid_fields and k != "metadata" and v is not None + } dto_fields = { - **command.configuration, + **{k: v for k, v in command.configuration.items() if k in valid_fields}, "template_id": command.template_id, "name": command.name or command.template_id, "description": command.description, @@ -98,6 +106,8 @@ async def execute_command(self, command: CreateTemplateCommand) -> None: # instance_type → machine_types (backward compat) if command.instance_type is not None and "machine_types" not in dto_fields: dto_fields["machine_types"] = {command.instance_type: 1} + if provider_specific_config: + dto_fields["provider_config"] = provider_specific_config # Remove None values so TemplateDTO defaults apply dto_fields = {k: v for k, v in dto_fields.items() if v is not None} dto = TemplateDTO(**dto_fields) @@ -147,17 +157,22 @@ async def execute_command(self, command: UpdateTemplateCommand) -> None: if not existing: raise EntityNotFoundError("Template", command.template_id) - # Apply updates onto the existing DTO - from typing import Any - update_fields: dict[str, Any] = {} # Apply configuration as field overrides first + provider_specific_updates: dict[str, Any] = {} if command.configuration: - valid_fields = TemplateDTO.model_fields.keys() + valid_fields = set(TemplateDTO.model_fields.keys()) - {"provider_config"} update_fields.update( {k: v for k, v in command.configuration.items() if k in valid_fields} ) + provider_specific_updates.update( + { + k: v + for k, v in command.configuration.items() + if k not in valid_fields and k != "metadata" and v is not None + } + ) # Named fields override configuration for field, value in { @@ -171,14 +186,25 @@ async def execute_command(self, command: UpdateTemplateCommand) -> None: # instance_type → machine_types (backward compat) if command.instance_type is not None and "machine_types" not in update_fields: update_fields["machine_types"] = {command.instance_type: 1} + if provider_specific_updates: + existing_provider_config = ( + existing.provider_config.model_dump(exclude_none=True) + if existing.provider_config is not None + else {} + ) + update_fields["provider_config"] = { + **existing_provider_config, + **provider_specific_updates, + } if update_fields: - updated = existing.model_copy(update=update_fields) + current_fields = {field: getattr(existing, field) for field in type(existing).model_fields} + updated = TemplateDTO(**{**current_fields, **update_fields}) else: updated = existing # Validate the fully-merged DTO (not the raw partial patch) - validation_errors = self._template_port.validate_template_config(updated.model_dump()) + validation_errors = self._template_port.validate_template_config(updated.to_template_config()) if validation_errors: self.logger.warning( "Template update validation failed for %s: %s", diff --git a/src/orb/application/queries/request_query_handlers.py b/src/orb/application/queries/request_query_handlers.py index 264ac0064..459517594 100644 --- a/src/orb/application/queries/request_query_handlers.py +++ b/src/orb/application/queries/request_query_handlers.py @@ -54,12 +54,32 @@ def __init__( self._dto_factory = RequestDTOFactory() async def execute_query(self, query: GetRequestQuery) -> RequestDTO: - """Execute get request query.""" + """Execute get request query. + + Two modes controlled by ``query.lightweight``: + + * **lightweight=False** (default): sync live provider state into the + DB, then return the result. This is the only mode that detects + async provider-side transitions (e.g. Azure VMSS creation completing, + instances terminating). Orchestrator polling loops must use this. + + * **lightweight=True**: return stored DB state immediately with no + provider call. Useful for cheap reads where freshness is not + critical (e.g. UI polling after a recent full sync). + + Cache semantics: + * Full-sync queries populate the cache but never read from it — + callers asking for a sync want fresh data and would be confused + by a stale cache hit. + * Lightweight queries read from the cache (fast path) and skip the + write since the lightweight DTO is intentionally a partial view. + """ self.logger.info("Getting request details for: %s", query.request_id) try: if ( - not query.skip_cache + query.lightweight + and not query.skip_cache and self._cache_service and self._cache_service.is_caching_enabled() ): diff --git a/src/orb/application/queries/template_query_handlers.py b/src/orb/application/queries/template_query_handlers.py index d1dec6cb4..f0bda261e 100644 --- a/src/orb/application/queries/template_query_handlers.py +++ b/src/orb/application/queries/template_query_handlers.py @@ -51,7 +51,7 @@ async def execute_query(self, query: GetTemplateQuery) -> Template: # type: ign if not template_dto: raise EntityNotFoundError("Template", query.template_id) - template_data = template_dto.model_dump() + template_data = template_dto.to_template_config() template_data.setdefault("template_id", template_dto.template_id) template_data.setdefault("name", template_dto.name or template_dto.template_id) template_data.setdefault("provider_api", template_dto.provider_api) @@ -135,7 +135,7 @@ async def execute_query(self, query: ListTemplatesQuery) -> Paginated[TemplateDT template_dtos = [t for t in template_dtos if getattr(t, "is_active", True)] if query.filter_expressions: - template_dicts = [dto.model_dump() for dto in template_dtos] + template_dicts = [dto.to_dict() for dto in template_dtos] filtered_dicts = self._generic_filter_service.apply_filters( template_dicts, query.filter_expressions ) @@ -248,7 +248,7 @@ async def execute_query(self, query: ValidateTemplateQuery) -> dict[str, Any]: raise EntityNotFoundError("Template", template_id) - template_config = template_dto.model_dump(exclude_none=True) + template_config = template_dto.to_template_config() template_config["template_id"] = template_dto.template_id except EntityNotFoundError: diff --git a/src/orb/application/services/deprovisioning_orchestrator.py b/src/orb/application/services/deprovisioning_orchestrator.py index ee9fc768d..47290a99b 100644 --- a/src/orb/application/services/deprovisioning_orchestrator.py +++ b/src/orb/application/services/deprovisioning_orchestrator.py @@ -10,6 +10,9 @@ from typing import Any from orb.application.ports.query_bus_port import QueryBusPort +from orb.application.services.request_follow_up_context import ( + merge_request_metadata_with_follow_up_context, +) from orb.domain.base import UnitOfWorkFactory from orb.domain.base.ports import ContainerPort, LoggingPort, ProviderSelectionPort @@ -75,6 +78,7 @@ async def execute_deprovisioning( success_count = 0 error_count = 0 errors = [] + provider_data_items: list[dict[str, Any]] = [] for i, result in enumerate(results): if isinstance(result, Exception): @@ -83,6 +87,9 @@ async def execute_deprovisioning( self.logger.error("Task %s failed: %s", tasks[i].get_name(), result) elif isinstance(result, dict) and result.get("success", False): success_count += 1 + provider_data = result.get("provider_data") + if isinstance(provider_data, dict) and provider_data: + provider_data_items.append(provider_data) else: error_count += 1 if isinstance(result, dict): @@ -94,11 +101,24 @@ async def execute_deprovisioning( "Deprovisioning completed: %d successful, %d failed", success_count, error_count ) + aggregated_provider_data: dict[str, Any] = {} + termination_requests = [ + item for item in provider_data_items if item.get("termination_requests") + ] + if termination_requests: + aggregated_provider_data["termination_requests"] = [ + request + for item in termination_requests + for request in item.get("termination_requests", []) + if isinstance(request, dict) + ] + return { "success": error_count == 0, "successful_operations": success_count, "failed_operations": error_count, "errors": errors, + "provider_data": aggregated_provider_data, } except Exception as e: @@ -174,6 +194,7 @@ async def _process_resource_group( "resource_id": resource_id, "resource_mapping": {iid: (resource_id, 1) for iid in instance_ids}, "request_id": origin_request_id, + "request_metadata": self._build_request_metadata(request), "request": request, }, context={ @@ -197,7 +218,11 @@ async def _process_resource_group( len(instance_ids), resource_id, ) - return {"success": True, "terminated_instances": len(instance_ids)} + return { + "success": True, + "terminated_instances": len(instance_ids), + "provider_data": result.metadata.get("provider_data", {}), + } else: self.logger.error( "Termination failed for resource %s: %s", resource_id, result.error_message @@ -207,3 +232,8 @@ async def _process_resource_group( except Exception as e: self.logger.error("Failed to process resource group %s: %s", resource_id, e) return {"success": False, "error_message": str(e)} + + @staticmethod + def _build_request_metadata(request: Any) -> dict[str, Any]: + """Merge durable request metadata with persisted provider follow-up context.""" + return merge_request_metadata_with_follow_up_context(request) diff --git a/src/orb/application/services/machine_sync_service.py b/src/orb/application/services/machine_sync_service.py index 44dfd395a..93d5fc5ee 100644 --- a/src/orb/application/services/machine_sync_service.py +++ b/src/orb/application/services/machine_sync_service.py @@ -7,6 +7,9 @@ from orb.application.services.provider_registry_service import ProviderRegistryService from orb.application.ports.command_bus_port import CommandBusPort +from orb.application.services.request_follow_up_context import ( + merge_request_metadata_with_follow_up_context, +) from orb.domain.base import UnitOfWorkFactory from orb.domain.base.ports.configuration_port import ConfigurationPort from orb.domain.base.ports.logging_port import LoggingPort @@ -69,11 +72,16 @@ async def fetch_provider_machines( # machines being returned — not resource-level discovery which returns all # ASG/fleet instances including unrelated ones. if request.request_type.value == "return" and request.machine_ids: + resource_id = self._resolve_return_resource_id(request, db_machines) operation_type = ProviderOperationType.GET_INSTANCE_STATUS parameters = { "instance_ids": request.machine_ids, + "provider_api": request.provider_api, "template_id": request.template_id, + "request_metadata": self._build_request_metadata(request), } + if resource_id: + parameters["resource_id"] = resource_id # Use resource-level discovery for acquire requests (handles scaling/replacement) elif request.resource_ids: operation_type = ProviderOperationType.DESCRIBE_RESOURCE_INSTANCES @@ -81,6 +89,7 @@ async def fetch_provider_machines( "resource_ids": request.resource_ids, "provider_api": request.provider_api, "template_id": request.template_id, + "request_metadata": self._build_request_metadata(request), "requested_count": request.requested_count, } # Fallback to instance-level discovery for requests without resource tracking @@ -90,6 +99,7 @@ async def fetch_provider_machines( parameters = { "instance_ids": instance_ids, "template_id": request.template_id, + "request_metadata": self._build_request_metadata(request), } else: return [], {} @@ -129,7 +139,10 @@ async def fetch_provider_machines( processed_data = { **instance_data, "request_id": str(request.request_id), - "resource_id": request.resource_ids[0] if request.resource_ids else "", + "resource_id": self._resolve_instance_resource_id( + instance_data, + request.resource_ids, + ), } terminal_states = {"shutting-down", "terminated", "stopping", "stopped"} instance_status = processed_data.get("status", "") @@ -147,14 +160,16 @@ async def fetch_provider_machines( except Exception as e: self.logger.warning(f"Failed to create machine from instance data: {e}") - # For return requests: instances missing from AWS response have been - # terminated and purged (~1hr window). Treat them as terminated. + # For return requests: instances missing from provider status results + # have effectively disappeared from the provider view. Treat them as + # terminated so request-status reconciliation can finish. if request.request_type.value == "return": queried_ids = set(parameters.get("instance_ids", [])) missing_ids = queried_ids - returned_ids for missing_id in missing_ids: self.logger.info( - f"Instance {missing_id} not found in AWS — treating as terminated" + "Instance %s not found in provider status response — treating as terminated", + missing_id, ) existing = next( (m for m in db_machines if m.machine_id.value == missing_id), None @@ -171,6 +186,72 @@ async def fetch_provider_machines( self.logger.error(f"Failed to fetch provider machines: {e}") return db_machines, {} + @staticmethod + def _build_request_metadata(request: Request) -> dict: + """Merge durable request metadata with persisted provider follow-up context.""" + return merge_request_metadata_with_follow_up_context(request) + + @staticmethod + def _resolve_instance_resource_id( + instance_data: dict, + request_resource_ids: list[str], + ) -> str: + """Prefer instance-owned resource identity over request-level fallback.""" + provider_data = instance_data.get("provider_data") or {} + candidate_resource_ids = ( + instance_data.get("resource_id"), + provider_data.get("resource_id") if isinstance(provider_data, dict) else None, + request_resource_ids[0] if request_resource_ids else None, + ) + for resource_id in candidate_resource_ids: + if resource_id not in (None, ""): + return str(resource_id) + return "" + + def _resolve_return_resource_id( + self, + request: Request, + db_machines: list[Machine], + ) -> str: + """Resolve the single resource ID for a return status request.""" + resource_ids: set[str] = set() + requested_ids = {str(machine_id) for machine_id in request.machine_ids} + + for machine in db_machines: + resource_id = machine.resource_id + machine_id = machine.machine_id.value + if resource_id in (None, "") or machine_id in (None, ""): + continue + if str(machine_id) in requested_ids: + resource_ids.add(str(resource_id)) + + request_metadata = self._build_request_metadata(request) + termination_requests = request_metadata.get("termination_requests") + if not isinstance(termination_requests, list): + return next(iter(resource_ids)) if len(resource_ids) == 1 else "" + + for termination_request in termination_requests: + if not isinstance(termination_request, dict): + continue + pending_cleanup = termination_request.get("pending_resource_cleanup") + if not isinstance(pending_cleanup, dict): + continue + + resource_id = pending_cleanup.get("resource_id") + if resource_id in (None, ""): + continue + + pending_machine_ids = pending_cleanup.get("machine_ids", []) + if not isinstance(pending_machine_ids, list): + continue + + for machine_id in pending_machine_ids: + machine_id_str = str(machine_id) + if machine_id_str and machine_id_str in requested_ids: + resource_ids.add(str(resource_id)) + + return next(iter(resource_ids)) if len(resource_ids) == 1 else "" + def _create_machine_from_processed_data( self, processed_data: dict, request: Request ) -> Machine: @@ -312,10 +393,8 @@ async def sync_machines_with_provider( # Stamp a terminal "Terminated" if provider didn't # supply something more specific (EC2's StateReason). # - Otherwise pass through whatever provider returned. - from orb.domain.machine.machine_status import MachineStatus as _MS - _new_reason = provider_machine.status_reason - if provider_machine.status == _MS.TERMINATED: + if provider_machine.status == MachineStatus.TERMINATED: if not _new_reason or "in progress" in (_new_reason or "").lower(): _new_reason = "Terminated" machine_data["status_reason"] = _new_reason diff --git a/src/orb/application/services/orchestration/acquire_machines.py b/src/orb/application/services/orchestration/acquire_machines.py index 5088e4230..7f255d115 100644 --- a/src/orb/application/services/orchestration/acquire_machines.py +++ b/src/orb/application/services/orchestration/acquire_machines.py @@ -72,7 +72,17 @@ async def _poll_until_terminal( consecutive_errors = 0 while elapsed < timeout_seconds: try: - query = GetRequestQuery(request_id=request_id, lightweight=True) + # Polling must use the full syncing path (lightweight=False). + # + # lightweight=True returns stored DB state without querying the + # provider. For providers with async provisioning (Azure VMSS, + # ARM deployments), the creation handler sets IN_PROGRESS and + # the only way to detect completion is to sync live provider + # state on each poll. AWS happened to work with lightweight + # polling because its handlers set terminal status synchronously, + # but that was accidental — the sync path is the correct + # general-purpose contract. + query = GetRequestQuery(request_id=request_id, lightweight=False, verbose=True) result = await self._query_bus.execute(query) consecutive_errors = 0 status_val = getattr(result, "status", None) diff --git a/src/orb/application/services/orchestration/return_machines.py b/src/orb/application/services/orchestration/return_machines.py index 3aa0cdc0f..2cfdc3404 100644 --- a/src/orb/application/services/orchestration/return_machines.py +++ b/src/orb/application/services/orchestration/return_machines.py @@ -125,7 +125,11 @@ async def _poll_until_terminal(self, request_id: str, timeout_seconds: int) -> s consecutive_errors = 0 while elapsed < timeout_seconds: try: - query = GetRequestQuery(request_id=request_id, lightweight=True) + # Polling must use the full syncing path (lightweight=False). + # See acquire_machines.py for the full rationale — providers + # with async termination (Azure) set IN_PROGRESS and rely on + # the sync to detect when instances are actually gone. + query = GetRequestQuery(request_id=request_id, lightweight=False, verbose=True) result = await self._query_bus.execute(query) consecutive_errors = 0 status_val = getattr(result, "status", None) diff --git a/src/orb/application/services/provider_validation_service.py b/src/orb/application/services/provider_validation_service.py index bb9245a12..464a48f13 100644 --- a/src/orb/application/services/provider_validation_service.py +++ b/src/orb/application/services/provider_validation_service.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from orb.domain.base.exceptions import ApplicationError @@ -10,7 +10,6 @@ from orb.domain.template.template_aggregate import Template from orb.domain.base.ports import ContainerPort, LoggingPort, ProviderSelectionPort -from orb.domain.base.ports.provider_validation_port import ProviderValidationPort from orb.domain.base.results import ProviderSelectionResult @@ -22,12 +21,10 @@ def __init__( container: ContainerPort, logger: LoggingPort, provider_selection_port: ProviderSelectionPort, - validator: Optional[ProviderValidationPort] = None, ) -> None: self._container = container self.logger = logger self._provider_selection_port = provider_selection_port - self._validator = validator async def select_and_validate_provider(self, template: Template) -> ProviderSelectionResult: """Select provider and validate template compatibility.""" @@ -38,13 +35,6 @@ async def select_and_validate_provider(self, template: Template) -> ProviderSele selection_result.selection_reason, ) - if self._validator is not None: - template_dict = template if isinstance(template, dict) else vars(template) - result = self._validator.validate_template_configuration(template_dict) - if not result.get("valid", True): - errors = result.get("errors", []) - raise ApplicationError("; ".join(errors)) - validation_result = self._provider_selection_port.validate_template_requirements( template, selection_result.provider_name, diff --git a/src/orb/application/services/provisioning_orchestration_service.py b/src/orb/application/services/provisioning_orchestration_service.py index a51a0523e..3efbc7c02 100644 --- a/src/orb/application/services/provisioning_orchestration_service.py +++ b/src/orb/application/services/provisioning_orchestration_service.py @@ -186,8 +186,17 @@ async def execute_provisioning( request = request.update_metadata({"provisioning_attempt": attempt_number}) try: + remaining_timeout_seconds = max( + timeout_seconds + - (datetime.now(timezone.utc) - started_at).total_seconds(), + 0.001, + ) last_result = await self._dispatch_single_attempt( - template, request, selection_result, attempt_count, dispatch_timeout_seconds + template, + request, + selection_result, + attempt_count, + min(dispatch_timeout_seconds, remaining_timeout_seconds), ) except Exception as e: if not isinstance(e, CircuitBreakerOpenError): @@ -542,9 +551,12 @@ async def _dispatch_single_attempt( # provider_data sub-key for handlers that have not yet been # migrated to the flat shape (e.g. legacy code paths). metadata_dict: dict[str, Any] = dict(result.metadata or {}) - legacy_pd = result.data.get("provider_data") or {} - if isinstance(legacy_pd, dict): - for k, v in legacy_pd.items(): + provider_data = metadata_dict.pop("provider_data", None) + legacy_provider_data = result.data.get("provider_data") or {} + for provider_payload in (provider_data, legacy_provider_data): + if not isinstance(provider_payload, dict): + continue + for k, v in provider_payload.items(): metadata_dict.setdefault(k, v) # ``requires_async_polling`` — True means the caller must @@ -562,7 +574,8 @@ async def _dispatch_single_attempt( # "still pending" (provider just hasn't reported yet) from # "stuck pending" (provider returned a capacity error). # Provider handlers set the flag; consumers read it. - merged_provider_data["capacity_constrained"] = has_capacity_error + if "capacity_constrained" in metadata_dict: + merged_provider_data["capacity_constrained"] = has_capacity_error # requires_async_polling=False means the provider has finished # provisioning and the result is final — emit Completed. @@ -592,12 +605,16 @@ async def _dispatch_single_attempt( # is_final derived from outcome in __post_init__ ) else: + provider_data = dict(result.metadata or {}) + nested_provider_data = provider_data.pop("provider_data", None) + if isinstance(nested_provider_data, dict): + provider_data = {**nested_provider_data, **provider_data} return ProvisioningResult( success=False, resource_ids=[], machine_ids=[], instances=[], - provider_data=result.metadata or {}, + provider_data=provider_data, error_message=result.error_message, outcome=Failed( error=result.error_message or "Provider returned failure", @@ -609,7 +626,7 @@ async def _dispatch_single_attempt( raise # do not swallow — let it propagate to execute_provisioning except TimeoutError: - timeout_msg = f"Dispatch timed out after {dispatch_timeout_seconds:.0f}s" + timeout_msg = "Provisioning operation timed out; provider submission status is unknown" self._logger.warning( "Dispatch timed out after %.1fs for provider %s (request %s)", dispatch_timeout_seconds, @@ -621,7 +638,11 @@ async def _dispatch_single_attempt( resource_ids=[], machine_ids=[], instances=[], - provider_data={}, + provider_data={ + "operation_status": "timeout", + "submission_status": "unknown", + "timed_out": True, + }, error_message=timeout_msg, outcome=Failed(error=timeout_msg, recoverable=True), ) diff --git a/src/orb/application/services/request_follow_up_context.py b/src/orb/application/services/request_follow_up_context.py index 1a6a9bad5..94cf0ee77 100644 --- a/src/orb/application/services/request_follow_up_context.py +++ b/src/orb/application/services/request_follow_up_context.py @@ -1,17 +1,55 @@ -"""Re-export shim — FollowUpContext types live in the domain layer. +"""Helpers for durable provider follow-up context stored on requests.""" -Import from ``orb.domain.base.follow_up_context`` directly for new code. -This module exists only for backward compatibility. -""" +from __future__ import annotations + +from typing import Any from orb.domain.base.follow_up_context import ( DeploymentPollingFollowUpContext, FollowUpContext, TerminationFollowUpContext, ) +from orb.domain.request.aggregate import Request + +FOLLOW_UP_CONTEXT_KEY = "follow_up_context" __all__ = [ "DeploymentPollingFollowUpContext", + "FOLLOW_UP_CONTEXT_KEY", "FollowUpContext", "TerminationFollowUpContext", + "get_request_follow_up_context", + "merge_request_metadata_with_follow_up_context", + "with_request_follow_up_context", ] + + +def get_request_follow_up_context(request: Request) -> dict[str, Any]: + """Return durable provider follow-up context for a request.""" + follow_up_context = request.provider_data.get(FOLLOW_UP_CONTEXT_KEY) + if not isinstance(follow_up_context, dict): + return {} + + return dict(follow_up_context) + + +def merge_request_metadata_with_follow_up_context(request: Request) -> dict[str, Any]: + """Merge request metadata with durable provider follow-up context.""" + request_metadata = dict(request.metadata) + request_metadata = {**get_request_follow_up_context(request), **request_metadata} + return request_metadata + + +def with_request_follow_up_context(request: Request, updates: dict[str, Any]) -> Request: + """Return a request with merged durable provider follow-up context.""" + if not updates: + return request + + request_provider_data = dict(request.provider_data) + follow_up_context = get_request_follow_up_context(request) + for key, value in updates.items(): + if value not in (None, ""): + follow_up_context[key] = value + + request_provider_data[FOLLOW_UP_CONTEXT_KEY] = follow_up_context + return request.set_provider_data(request_provider_data) diff --git a/src/orb/application/services/request_query_service.py b/src/orb/application/services/request_query_service.py index a97a4c937..619633225 100644 --- a/src/orb/application/services/request_query_service.py +++ b/src/orb/application/services/request_query_service.py @@ -36,7 +36,12 @@ async def get_machines_for_request(self, request: Request) -> list[Machine]: try: with self.uow_factory.create_unit_of_work() as uow: if request.request_type == RequestType.RETURN: - machines = uow.machines.find_by_return_request_id(str(request.request_id.value)) + if request.machine_ids: + machines = uow.machines.find_by_ids(request.machine_ids) + else: + machines = uow.machines.find_by_return_request_id( + str(request.request_id.value) + ) else: machines = uow.machines.find_by_request_id(str(request.request_id.value)) self.logger.debug( diff --git a/src/orb/application/services/request_status_management_service.py b/src/orb/application/services/request_status_management_service.py index c8c0c4448..9ca747805 100644 --- a/src/orb/application/services/request_status_management_service.py +++ b/src/orb/application/services/request_status_management_service.py @@ -7,6 +7,7 @@ from orb.domain.machine.aggregate import Machine from .provisioning_orchestration_service import ProvisioningResult +from .request_follow_up_context import with_request_follow_up_context class RequestStatusManagementService: @@ -49,9 +50,10 @@ async def update_request_from_provisioning( request = request.add_machine_ids(instance_ids) self._logger.info("Populated %d machine IDs immediately", len(instance_ids)) - # Store provider-specific data + # Persist provider follow-up data through the shared durable context helper + # so later sync / status operations can rehydrate request-scoped metadata. if provider_data: - request = request.set_provider_data({**request.provider_data, **provider_data}) + request = with_request_follow_up_context(request, provider_data) # Handle provider errors for partial success provider_errors = ( diff --git a/src/orb/application/services/request_status_service.py b/src/orb/application/services/request_status_service.py index ff9c1a9a5..3c03aa228 100644 --- a/src/orb/application/services/request_status_service.py +++ b/src/orb/application/services/request_status_service.py @@ -20,6 +20,7 @@ import dataclasses from typing import Optional, Tuple +from orb.application.services.request_follow_up_context import get_request_follow_up_context from orb.domain.base import UnitOfWorkFactory from orb.domain.base.exceptions import ProviderContractError from orb.domain.base.ports.logging_port import LoggingPort @@ -86,8 +87,9 @@ def _determine_acquire_status( fulfilment: Optional[ProviderFulfilment] = provider_metadata.get("provider_fulfilment") if fulfilment is None: + provider_name = request.provider_name or "unknown" raise ProviderContractError( - f"Provider {getattr(request, 'provider_name', 'unknown')} did not emit " + f"Provider {provider_name} did not emit " "ProviderFulfilment for acquire request. Every provider's " "check_hosts_status must return CheckHostsStatusResult with fulfilment." ) @@ -121,6 +123,9 @@ def _determine_return_status( ) -> Tuple[Optional[str], Optional[str]]: """Determine return request status from machine termination states.""" db_machine_count = len(db_machines) + follow_up_pending_message = "Return in progress: awaiting provider follow-up cleanup" + follow_up_context = get_request_follow_up_context(request) + termination_follow_up_pending = follow_up_context.get("follow_up_kind") == "termination" # For return requests: empty provider_machines *with* DB records means all # instances are gone from AWS — genuinely terminated. But if we have @@ -130,6 +135,8 @@ def _determine_return_status( # prematurely stamping COMPLETED when provider_machines came back empty # before the instances ever appeared. if not provider_machines: + if termination_follow_up_pending: + return RequestStatus.IN_PROGRESS.value, follow_up_pending_message if db_machines: # We had machines on record, now provider reports none — genuinely terminated. return ( @@ -159,6 +166,8 @@ def _determine_return_status( effectively_done_count = terminated_count if effectively_done_count >= completion_target and running_count == 0: + if termination_follow_up_pending: + return RequestStatus.IN_PROGRESS.value, follow_up_pending_message return ( RequestStatus.COMPLETED.value, f"Return request completed: {terminated_count} terminated, " @@ -205,64 +214,62 @@ async def update_request_status( Downgrades (COMPLETED -> PARTIAL/FAILED, CANCELLED -> anything, etc.) remain blocked. """ - if request.status.is_terminal(): - # Allow PARTIAL -> COMPLETED upgrade; everything else stays put. - new_status_enum: Optional[RequestStatus] = None - try: - new_status_enum = RequestStatus(status) - except ValueError as exc: - self.logger.debug( - "Unknown status %r on terminal-state upgrade check: %s; rejecting upgrade", - status, - exc, - ) - new_status_enum = None - is_upgrade_to_complete = ( - request.status == RequestStatus.PARTIAL - and new_status_enum == RequestStatus.COMPLETED - ) - if not is_upgrade_to_complete: - return request try: status_enum = RequestStatus(status) - updated_request = request.update_status(status_enum, message) - - # Reconcile the persisted counters with reality. The acquire - # fulfilment path transitions directly via ``update_status``, - # which does not touch ``successful_count`` — it is only - # bumped by ``update_with_provisioning_result``. That works - # for batched-instance providers but not for instant fulfilment - # (e.g. EC2Fleet instant) where the provider reports - # "fulfilled" without emitting instance_ids. Use the request's - # own machine_ids list — which is the authoritative count of - # machines associated with this request — as the source of - # truth for ``successful_count`` whenever it disagrees with - # the persisted value. - if status_enum in ( - RequestStatus.COMPLETED, - RequestStatus.PARTIAL, - RequestStatus.IN_PROGRESS, - ): - actual_count = len(updated_request.machine_ids) - if actual_count and actual_count != updated_request.successful_count: - updated_request = updated_request.model_copy( - update={"successful_count": actual_count} - ) - - # Cache the latest ProviderFulfilment snapshot so DTO callers can surface it. - if provider_metadata: - fulfilment = provider_metadata.get("provider_fulfilment") - if fulfilment is not None: - updated_request = updated_request.with_last_fulfilment( - dataclasses.asdict(fulfilment) - ) # Save updated request with self.uow_factory.create_unit_of_work() as uow: + persisted_request = uow.requests.get_by_id(request.request_id) + current_request = ( + persisted_request if isinstance(persisted_request, Request) else request + ) + + if current_request.status.is_terminal(): + is_upgrade_to_complete = ( + current_request.status == RequestStatus.PARTIAL + and status_enum == RequestStatus.COMPLETED + ) + if not is_upgrade_to_complete: + return current_request + + updated_request = current_request.update_status(status_enum, message) + + # Reconcile the persisted counters with reality. The acquire + # fulfilment path transitions directly via ``update_status``, + # which does not touch ``successful_count`` — it is only + # bumped by ``update_with_provisioning_result``. That works + # for batched-instance providers but not for instant fulfilment + # (e.g. EC2Fleet instant) where the provider reports + # "fulfilled" without emitting instance_ids. Use the request's + # own machine_ids list — which is the authoritative count of + # machines associated with this request — as the source of + # truth for ``successful_count`` whenever it disagrees with + # the persisted value. + if status_enum in ( + RequestStatus.COMPLETED, + RequestStatus.PARTIAL, + RequestStatus.IN_PROGRESS, + ): + actual_count = len(updated_request.machine_ids) + if actual_count and actual_count != updated_request.successful_count: + updated_request = updated_request.model_copy( + update={"successful_count": actual_count} + ) + + # Cache the latest ProviderFulfilment snapshot so DTO callers can surface it. + if provider_metadata: + fulfilment = provider_metadata.get("provider_fulfilment") + if fulfilment is not None: + updated_request = updated_request.with_last_fulfilment( + dataclasses.asdict(fulfilment) + ) + uow.requests.save(updated_request) - self.logger.info(f"Updated request {request.request_id.value} status to {status}") - return updated_request + self.logger.info( + f"Updated request {current_request.request_id.value} status to {status}" + ) + return updated_request except Exception as e: self.logger.error(f"Failed to update request status: {e}") diff --git a/src/orb/application/services/spot_placement_execution.py b/src/orb/application/services/spot_placement_execution.py new file mode 100644 index 000000000..179d49583 --- /dev/null +++ b/src/orb/application/services/spot_placement_execution.py @@ -0,0 +1,252 @@ +"""Provider-agnostic execution helpers for spot placement plans.""" + +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Callable, Mapping + +from orb.application.services.spot_placement_planner import PlacementPlanEntry +from orb.domain.base.exceptions import DomainException + + +@dataclass(frozen=True) +class SpotPlacementExecutionSummary: + """Aggregated execution outcome for a placement plan.""" + + resource_ids: list[str] + instances: list[dict[str, Any]] + child_results: list[dict[str, Any]] + failed_subplans: list[dict[str, Any]] + unfulfilled_count: int + terminated_early: bool = False + terminal_error_message: str | None = None + + @property + def provider_data(self) -> dict[str, Any]: + return { + "child_results": self.child_results, + "failed_subplans": self.failed_subplans, + "unfulfilled_count": self.unfulfilled_count, + "terminated_early": self.terminated_early, + "terminal_error_message": self.terminal_error_message, + } + + +def serialize_placement_plan(plan: list[PlacementPlanEntry]) -> list[dict[str, Any]]: + """Convert plan entries into JSON-friendly metadata.""" + return [ + { + "candidate_id": entry.score.candidate.candidate_id, + "instance_type": entry.score.candidate.instance_type, + "region": entry.score.candidate.region, + "zone": entry.score.candidate.zone, + "raw_score": entry.score.raw_score, + "normalized_score": entry.score.normalized_score, + "approximate": entry.score.approximate, + "planned_count": entry.planned_count, + "metadata": entry.score.metadata, + } + for entry in plan + ] + + +def create_acquire_request( + template_id: str, + count: int, + provider_type: str, + provider_name: str | None, + provider_api: str, + request_metadata: dict[str, Any], + request_id: str | None = None, + parent_request_id: str | None = None, + plan_entry_index: int | None = None, +) -> Any: + """Create a standard acquire request for a child placement launch.""" + from orb.domain.request.aggregate import Request + from orb.domain.request.request_types import RequestType + + child_metadata = dict(request_metadata) + if parent_request_id: + child_metadata["parent_request_id"] = parent_request_id + if plan_entry_index is not None: + child_metadata["spot_placement_plan_entry_index"] = plan_entry_index + + request = Request.create_new_request( + request_type=RequestType.ACQUIRE, + template_id=template_id, + machine_count=count, + provider_type=provider_type, + provider_name=provider_name, + metadata=child_metadata, + request_id=request_id, + ) + request.provider_api = provider_api + return request + + +def build_planned_execution_metadata( + plan: list[PlacementPlanEntry], + summary: SpotPlacementExecutionSummary, +) -> dict[str, Any]: + """Build common metadata payload for planned execution results.""" + return { + "placement_plan": serialize_placement_plan(plan), + **summary.provider_data, + } + + +class SpotPlacementExecutionService: + """Execute a placement plan via provider callbacks.""" + + def execute_plan( + self, + plan: list[PlacementPlanEntry], + total_count: int, + build_child_template: Callable[[PlacementPlanEntry], Any], + build_child_request: Callable[[int, int], Any], + launch_child: Callable[[Any, Any], Mapping[str, Any]], + is_capacity_like_failure: Callable[[dict[str, Any]], bool], + ) -> SpotPlacementExecutionSummary: + resource_ids: list[str] = [] + instances: list[dict[str, Any]] = [] + child_results: list[dict[str, Any]] = [] + failed_subplans: list[dict[str, Any]] = [] + carryover = 0 + fulfilled = 0 + terminated_early = False + terminal_error_message: str | None = None + + for idx, plan_entry in enumerate(plan): + requested_for_entry = plan_entry.planned_count + carryover + if requested_for_entry <= 0: + continue + + child_template = build_child_template(plan_entry) + child_request = build_child_request(requested_for_entry, idx) + try: + raw_result = launch_child(child_request, child_template) + except DomainException as exc: + raw_result = self._domain_exception_child_result(exc) + child_result = self._normalize_child_result( + plan_entry=plan_entry, + requested_count=requested_for_entry, + raw_result=raw_result, + ) + child_results.append(child_result) + + if child_result["success"]: + resource_ids.extend(child_result["resource_ids"]) + instances.extend(child_result["instances"]) + fulfilled_for_child = child_result["fulfilled_count"] + fulfilled += fulfilled_for_child + carryover = max(requested_for_entry - fulfilled_for_child, 0) + if carryover == 0: + continue + + failed_subplans.append(child_result) + if is_capacity_like_failure(child_result): + continue + + terminated_early = True + terminal_error_message = child_result["error_message"] + break + + failed_subplans.append(child_result) + if is_capacity_like_failure(child_result): + carryover = requested_for_entry + continue + + if resource_ids or instances: + terminated_early = True + terminal_error_message = child_result["error_message"] + break + + return SpotPlacementExecutionSummary( + resource_ids=[], + instances=[], + child_results=child_results, + failed_subplans=failed_subplans, + unfulfilled_count=total_count, + terminated_early=True, + terminal_error_message=child_result["error_message"], + ) + + return SpotPlacementExecutionSummary( + resource_ids=resource_ids, + instances=instances, + child_results=child_results, + failed_subplans=failed_subplans, + unfulfilled_count=max(total_count - fulfilled, carryover), + terminated_early=terminated_early, + terminal_error_message=terminal_error_message, + ) + + @staticmethod + def _domain_exception_child_result(exc: DomainException) -> dict[str, Any]: + """Normalize a domain exception into the child-launch result contract.""" + return { + "success": False, + "resource_ids": [], + "instances": [], + "error_message": SpotPlacementExecutionService._format_launch_error(exc), + "provider_data": { + "error_codes": [exc.error_code] if exc.error_code else [], + }, + } + + @staticmethod + def _format_launch_error(exc: DomainException) -> str: + """Format a launch exception while keeping the visible error code prefix.""" + error_code = exc.error_code + message = str(exc) + if error_code and not message.startswith(f"{error_code}:"): + return f"{error_code}: {message}" + return message + + @staticmethod + def _normalize_child_result( + plan_entry: PlacementPlanEntry, + requested_count: int, + raw_result: Mapping[str, Any], + ) -> dict[str, Any]: + result = dict(raw_result) + success = result.get("success", True) + error_message = result.get("error_message") + resource_ids = result.get("resource_ids", []) + instances = result.get("instances", []) + provider_data = result.get("provider_data") or {} + raw_fulfilled_count = result.get("fulfilled_count") + if success: + fulfilled_count = requested_count if raw_fulfilled_count is None else int(raw_fulfilled_count) + else: + fulfilled_count = 0 if raw_fulfilled_count is None else int(raw_fulfilled_count) + fulfilled_count = min(max(fulfilled_count, 0), requested_count) + + return { + "candidate_id": plan_entry.score.candidate.candidate_id, + "requested_count": requested_count, + "fulfilled_count": fulfilled_count, + "success": success, + "resource_ids": resource_ids, + "instances": instances, + "error_message": error_message, + "error_codes": SpotPlacementExecutionService._extract_error_codes( + provider_data=provider_data, + ), + "provider_data": provider_data, + } + + @staticmethod + def _extract_error_codes( + provider_data: dict[str, Any], + ) -> list[str]: + error_codes: list[str] = [] + + direct_error_codes = provider_data.get("error_codes", []) + if isinstance(direct_error_codes, (list, tuple, set)): + for error_code in direct_error_codes: + if error_code: + error_codes.append(str(error_code)) + elif direct_error_codes: + error_codes.append(str(direct_error_codes)) + + return list(dict.fromkeys(error_codes)) diff --git a/src/orb/application/services/spot_placement_planner.py b/src/orb/application/services/spot_placement_planner.py new file mode 100644 index 000000000..aa723443d --- /dev/null +++ b/src/orb/application/services/spot_placement_planner.py @@ -0,0 +1,177 @@ +"""Generic spot placement score planning service.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from math import ceil +from typing import Any, Protocol + +from orb.domain.base.value_objects import PlacementSplitStrategy + + +@dataclass(frozen=True) +class PlacementCandidate: + """Provider-neutral placement candidate.""" + + candidate_id: str + instance_type: str + region: str | None = None + zone: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PlacementScore: + """Placement score for a candidate.""" + + candidate: PlacementCandidate + raw_score: Any + normalized_score: float + approximate: bool = False + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class PlacementPlanEntry: + """Concrete count allocation for a placement candidate.""" + + score: PlacementScore + planned_count: int + + +class SpotPlacementScoreAdapter(Protocol): + """Provider-specific scoring adapter contract.""" + + def score_candidates( + self, + requested_count: int, + template: Any, + ) -> list[PlacementScore]: + """Return normalized placement scores for the provider's candidates.""" + ... + + +class SpotPlacementPlanner: + """Build a placement plan from normalized candidate scores.""" + + def create_plan( + self, + requested_count: int, + scores: list[PlacementScore], + split_strategy: PlacementSplitStrategy, + primary_share_percent: int, + ) -> list[PlacementPlanEntry]: + """Create an ordered placement plan.""" + if requested_count <= 0: + return [] + + ranked_scores = self._rank_scores(scores) + if not ranked_scores: + return [] + + if split_strategy == PlacementSplitStrategy.GREEDY or len(ranked_scores) == 1: + return self._create_greedy_plan(requested_count, ranked_scores) + + return self._create_hybrid_plan( + requested_count=requested_count, + scores=ranked_scores, + primary_share_percent=primary_share_percent, + ) + + def _rank_scores(self, scores: list[PlacementScore]) -> list[PlacementScore]: + ranked = [score for score in scores if score.normalized_score > 0] + ranked.sort( + key=lambda score: ( + score.normalized_score, + self._raw_score_sort_value(score.raw_score), + score.candidate.candidate_id, + ), + reverse=True, + ) + return ranked + + @staticmethod + def _raw_score_sort_value(raw_score: Any) -> float: + if isinstance(raw_score, (int, float)): + return float(raw_score) + if isinstance(raw_score, str): + mapping = {"low": 1.0, "medium": 2.0, "high": 3.0} + return mapping.get(raw_score.lower(), 0.0) + return 0.0 + + @staticmethod + def _create_greedy_plan( + requested_count: int, + scores: list[PlacementScore], + ) -> list[PlacementPlanEntry]: + return [ + PlacementPlanEntry( + score=scores[0], + planned_count=requested_count, + ), + *[ + PlacementPlanEntry(score=score, planned_count=0) + for score in scores[1:] + ], + ] + + def _create_hybrid_plan( + self, + requested_count: int, + scores: list[PlacementScore], + primary_share_percent: int, + ) -> list[PlacementPlanEntry]: + top_score = scores[0] + remainder_scores = scores[1:] + + top_count = ceil(requested_count * primary_share_percent / 100) + top_count = min(top_count, requested_count) + remainder_count = requested_count - top_count + + plan_entries = [PlacementPlanEntry(score=top_score, planned_count=top_count)] + if remainder_count <= 0 or not remainder_scores: + return plan_entries + [ + PlacementPlanEntry(score=score, planned_count=0) for score in remainder_scores + ] + + total_weight = sum(score.normalized_score for score in remainder_scores) + if total_weight <= 0: + return self._create_greedy_plan(requested_count, scores) + + raw_allocations: list[tuple[PlacementScore, int, float]] = [] + assigned = 0 + for score in remainder_scores: + exact_count = remainder_count * (score.normalized_score / total_weight) + allocated = int(exact_count) + raw_allocations.append((score, allocated, exact_count - allocated)) + assigned += allocated + + leftovers = remainder_count - assigned + if leftovers > 0: + raw_allocations.sort( + key=lambda item: ( + item[2], + item[0].normalized_score, + self._raw_score_sort_value(item[0].raw_score), + item[0].candidate.candidate_id, + ), + reverse=True, + ) + for idx in range(leftovers): + score, allocated, fraction = raw_allocations[idx] + raw_allocations[idx] = (score, allocated + 1, fraction) + + raw_allocations.sort( + key=lambda item: ( + item[0].normalized_score, + self._raw_score_sort_value(item[0].raw_score), + item[0].candidate.candidate_id, + ), + reverse=True, + ) + + plan_entries.extend( + PlacementPlanEntry(score=score, planned_count=allocated) + for score, allocated, _ in raw_allocations + ) + return plan_entries diff --git a/src/orb/bootstrap/__init__.py b/src/orb/bootstrap/__init__.py index 233941da9..9f8c72d61 100644 --- a/src/orb/bootstrap/__init__.py +++ b/src/orb/bootstrap/__init__.py @@ -175,17 +175,31 @@ async def initialize(self, dry_run: bool = False) -> bool: def _register_configured_providers(self) -> None: """Register providers from configuration with registry.""" try: - provider_config = self._config_manager.get_provider_config() - if provider_config: - for provider_instance in provider_config.get_active_providers(): - if not self._provider_registry.is_provider_instance_registered( - provider_instance.name - ): - self._provider_registry.ensure_provider_instance_registered_from_config( - provider_instance + try: + provider_config = self._config_manager.get_provider_config() + except AttributeError: + return + + if not provider_config: + return + + for provider_instance in provider_config.get_active_providers(): + if not self._provider_registry.is_provider_instance_registered( + provider_instance.name + ): + registered = self._provider_registry.ensure_provider_instance_registered_from_config( + provider_instance + ) + if not registered: + # Fail startup loudly — a configured provider that + # cannot register is a misconfiguration, not a soft + # warning to silently continue past. + raise RuntimeError( + f"Failed to register configured provider '{provider_instance.name}'" ) except Exception as e: self.logger.error("Failed to register configured providers: %s", e, exc_info=True) + raise async def start_daemon_services(self) -> bool: """Start every registered provider strategy's daemon services. diff --git a/src/orb/bootstrap/domain_services.py b/src/orb/bootstrap/domain_services.py index 6f182685d..eb03c7c1d 100644 --- a/src/orb/bootstrap/domain_services.py +++ b/src/orb/bootstrap/domain_services.py @@ -10,7 +10,6 @@ from orb.domain.base.ports.logging_port import LoggingPort from orb.domain.base.ports.provider_registry_port import ProviderRegistryPort from orb.domain.base.ports.provider_selection_port import ProviderSelectionPort -from orb.domain.constants import PROVIDER_TYPE_AWS from orb.domain.services.filter_service import FilterService from orb.domain.services.generic_filter_service import GenericFilterService from orb.domain.services.template_validation_domain_service import TemplateValidationDomainService @@ -29,7 +28,8 @@ def create_template_validation_service(c): # Inject dependencies after creation config = c.get(ConfigurationPort) logger = c.get(LoggingPort) - service.inject_dependencies(config, logger) + provider_registry = c.get(ProviderRegistryPort) + service.inject_dependencies(config, logger, provider_registry) return service container.register_singleton( @@ -70,16 +70,10 @@ def create_deprovisioning_orchestrator(c): # Provider validation service (SRP refactoring) def create_provider_validation_service(c): - validator = None - try: - validator = c.get(ProviderRegistryPort).create_validator(PROVIDER_TYPE_AWS) - except Exception as e: - c.get(LoggingPort).debug("Could not create AWS provider validator: %s", e) return ProviderValidationService( container=c.get(ContainerPort), logger=c.get(LoggingPort), provider_selection_port=c.get(ProviderSelectionPort), - validator=validator, ) container.register_singleton(ProviderValidationService, create_provider_validation_service) diff --git a/src/orb/cli/field_mapping.py b/src/orb/cli/field_mapping.py index 64ce4af33..ffcb2692c 100644 --- a/src/orb/cli/field_mapping.py +++ b/src/orb/cli/field_mapping.py @@ -50,8 +50,13 @@ def get_template_field_mapping() -> dict[str, list[str]]: "name": ["name"], "description": ["description"], "provider_api": ["provider_api", "providerApi"], + "provider_type": ["provider_type", "providerType"], "instance_type": ["instance_type", "vmType"], "image_id": ["image_id", "imageId"], + "project_id": ["project_id", "projectId"], + "network": ["network"], + "subnetwork": ["subnetwork"], + "zones": ["zones"], "max_instances": ["max_instances", "maxNumber"], "subnet_ids": ["subnet_ids", "subnetIds"], "security_group_ids": ["security_group_ids", "securityGroupIds"], diff --git a/src/orb/cli/main.py b/src/orb/cli/main.py index e3f38e37a..5150400a4 100644 --- a/src/orb/cli/main.py +++ b/src/orb/cli/main.py @@ -217,6 +217,8 @@ async def main() -> None: if not args.quiet: print_success(f"Output written to {args.output}") else: + # Rich console output can wrap long structured strings, which may break + # consumers that try to parse stdout as exact JSON. print(formatted_output) if exit_code != 0: diff --git a/src/orb/config/schemas/provider_settings_registry.py b/src/orb/config/schemas/provider_settings_registry.py index f9daa481b..de1fabea3 100644 --- a/src/orb/config/schemas/provider_settings_registry.py +++ b/src/orb/config/schemas/provider_settings_registry.py @@ -1,23 +1,23 @@ -"""Registry for provider-specific BaseSettings classes.""" +"""Registry for provider-specific config model classes.""" from typing import Type -from pydantic_settings import BaseSettings +from orb.infrastructure.interfaces.provider import BaseProviderConfig class ProviderSettingsRegistry: - """Registry for provider-specific BaseSettings classes.""" + """Registry for provider-specific config model classes.""" - _settings_classes = { + _settings_classes: dict[str, type[BaseProviderConfig]] = { # Provider settings classes will be registered dynamically # "aws": AWSProviderSettings, # Will be added when AWS provider is registered } @classmethod def register_provider_settings( - cls, provider_type: str, settings_class: Type[BaseSettings] + cls, provider_type: str, settings_class: Type[BaseProviderConfig] ) -> None: - """Register a provider-specific settings class.""" + """Register a provider-specific config model class.""" cls._settings_classes[provider_type] = settings_class @classmethod @@ -26,5 +26,5 @@ def get_registered_provider_types(cls) -> list[str]: return list(cls._settings_classes.keys()) @classmethod - def get_settings_class(cls, provider_type: str) -> Type[BaseSettings]: - return cls._settings_classes.get(provider_type, BaseSettings) + def get_settings_class(cls, provider_type: str) -> Type[BaseProviderConfig]: + return cls._settings_classes.get(provider_type, BaseProviderConfig) diff --git a/src/orb/domain/base/ports/provider_registry_port.py b/src/orb/domain/base/ports/provider_registry_port.py index 811419bf6..53d6f60f2 100644 --- a/src/orb/domain/base/ports/provider_registry_port.py +++ b/src/orb/domain/base/ports/provider_registry_port.py @@ -1,12 +1,61 @@ """Port interface for provider registry operations used by the application layer.""" from abc import ABC, abstractmethod -from typing import Any, Optional +from typing import Any, Optional, Protocol from orb.domain.base.results import ProviderSelectionResult from orb.domain.template.template_aggregate import Template +class ProviderStrategyClass(Protocol): + """Class-level provider hooks used before a strategy instance exists.""" + + @classmethod + def get_available_credential_sources(cls) -> list[dict[str, Any]]: + """Return visible credential sources for interactive init.""" + ... + + @classmethod + def test_credentials(cls, credential_source: Optional[str] = None, **kwargs: Any) -> dict: + """Verify credentials for interactive init.""" + ... + + @classmethod + def get_credential_requirements(cls) -> dict: + """Return pre-auth credential fields required by the provider.""" + ... + + @classmethod + def get_operational_requirements(cls) -> dict: + """Return post-auth operational fields required by the provider.""" + ... + + @classmethod + def get_cli_provider_config(cls, args: Any) -> dict[str, Any]: + """Extract non-interactive provider config from CLI args.""" + ... + + @classmethod + def get_cli_infrastructure_defaults(cls, args: Any) -> dict[str, Any]: + """Extract non-interactive infrastructure defaults from CLI args.""" + ... + + @classmethod + def get_cli_extra_config_keys(cls) -> set[str]: + """Return infrastructure default keys that belong in provider config.""" + ... + + @classmethod + def generate_provider_name(cls, config: dict[str, Any]) -> str: + """Generate a provider instance name from provider config.""" + ... + + @classmethod + def get_ui_column_schema(cls) -> list[Any]: + """Return provider-specific UI column descriptors.""" + ... + + class ProviderRegistryPort(ABC): """Port that the application layer uses to interact with the provider registry. @@ -90,8 +139,8 @@ def create_strategy_by_type(self, provider_type: str, config: Any = None) -> Any pass # type: ignore[return] @abstractmethod - def create_validator(self, provider_type: str) -> Optional[Any]: - """Create a template validator using the registered factory for the given provider type.""" + def create_validator(self, provider_type: str, config: Any = None) -> Optional[Any]: + """Create a template validator using provider config data for the given provider type.""" pass # type: ignore[return] @abstractmethod @@ -101,3 +150,8 @@ def get_default_api(self, provider_type: str) -> Optional[str]: Returns None if the provider type is not registered or has no default API. """ pass # type: ignore[return] + + @abstractmethod + def get_strategy_class(self, provider_type: str) -> Optional[type[ProviderStrategyClass]]: + """Return the registered strategy class for a provider type, if available.""" + pass # type: ignore[return] diff --git a/src/orb/domain/base/value_objects.py b/src/orb/domain/base/value_objects.py index 5283208bb..4ecea8f80 100644 --- a/src/orb/domain/base/value_objects.py +++ b/src/orb/domain/base/value_objects.py @@ -298,3 +298,21 @@ class PriceType(str, Enum): SPOT = "spot" RESERVED = "reserved" HETEROGENEOUS = "heterogeneous" # Mix of different pricing types + + +class AllocationStrategy(str, Enum): + """Allocation strategy enumeration.""" + + LOWEST_PRICE = "lowestPrice" + DIVERSIFIED = "diversified" + CAPACITY_OPTIMIZED = "capacityOptimized" + CAPACITY_OPTIMIZED_PRIORITIZED = "capacityOptimizedPrioritized" + PRICE_CAPACITY_OPTIMIZED = "priceCapacityOptimized" + SPOT_PLACEMENT_SCORE = "spotPlacementScore" + + +class PlacementSplitStrategy(str, Enum): + """Placement-plan split strategy enumeration.""" + + GREEDY = "greedy" + HYBRID = "hybrid" diff --git a/src/orb/domain/services/template_validation_domain_service.py b/src/orb/domain/services/template_validation_domain_service.py index 1c5c6ea8a..d73d250fd 100644 --- a/src/orb/domain/services/template_validation_domain_service.py +++ b/src/orb/domain/services/template_validation_domain_service.py @@ -1,13 +1,16 @@ """Template validation domain service.""" from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from orb.domain.base.exceptions import ConfigurationError, EntityNotFoundError from orb.domain.base.ports.configuration_port import ConfigurationPort from orb.domain.base.ports.logging_port import LoggingPort from orb.domain.base.results import ValidationLevel, ValidationResult +if TYPE_CHECKING: + from orb.domain.base.ports.provider_registry_port import ProviderRegistryPort + @dataclass class _ProviderCapabilities: @@ -30,6 +33,8 @@ class TemplateValidationDomainService: def __init__(self): self._config = None self._logger = None + self._provider_registry = None + self._initialized = False @property def config(self): @@ -41,10 +46,16 @@ def logger(self): # Return None if not available - service should handle gracefully return self._logger - def inject_dependencies(self, config: ConfigurationPort, logger: LoggingPort): + def inject_dependencies( + self, + config: ConfigurationPort, + logger: LoggingPort, + provider_registry: "ProviderRegistryPort | None" = None, + ): """Inject dependencies after container is ready.""" self._config = config self._logger = logger + self._provider_registry = provider_registry self._initialized = False def _ensure_initialized(self): @@ -126,6 +137,28 @@ def _get_config_based_capabilities(self, provider_instance: str) -> _ProviderCap effective_handlers = provider_config.get_effective_handlers(provider_defaults) supported_apis = list(effective_handlers.keys()) + if not supported_apis: + configured_capabilities = list(getattr(provider_config, "capabilities", None) or []) + if configured_capabilities: + return _ProviderCapabilities( + provider_type=provider_config.type, + supported_apis=configured_capabilities, + features={"api_capabilities": {}}, + ) + + # If this provider instance explicitly overrides handlers, respect the + # empty result as a real config choice rather than falling back to + # provider-type defaults from the strategy implementation. + if getattr(provider_config, "handlers", None) is None and getattr( + provider_config, "handler_overrides", None + ) is None: + fallback_capabilities = self._get_validator_based_capabilities( + provider_instance, + provider_config, + ) + if fallback_capabilities is not None: + return fallback_capabilities + api_capabilities: dict[str, Any] = {} for api_name, handler_cfg in effective_handlers.items(): extra = getattr(handler_cfg, "model_extra", None) or {} @@ -143,6 +176,48 @@ def _get_config_based_capabilities(self, provider_instance: str) -> _ProviderCap features={"api_capabilities": api_capabilities}, ) + def _get_validator_based_capabilities( + self, + provider_instance: str, + provider_config: Any, + ) -> _ProviderCapabilities | None: + """Fallback to validator-reported capabilities when config defaults are absent.""" + if self._provider_registry is None: + return None + + try: + validator = self._provider_registry.create_validator( + provider_config.type, + provider_config.config, + ) + if validator is None: + return None + + supported_apis = list(getattr(validator, "get_supported_provider_apis", lambda: [])() or []) + if not supported_apis: + return None + + api_capabilities: dict[str, Any] = {} + for supported_api in supported_apis: + capability_resolver = getattr(validator, "get_api_capabilities", None) + if capability_resolver is None: + continue + api_capabilities[supported_api] = dict(capability_resolver(supported_api) or {}) + + return _ProviderCapabilities( + provider_type=provider_config.type, + supported_apis=supported_apis, + features={"api_capabilities": api_capabilities}, + ) + except Exception as exc: + if self.logger: + self.logger.debug( + "Could not resolve validator capabilities for %s: %s", + provider_instance, + exc, + ) + return None + def _validate_api_support(self, template: Any, capabilities: Any, result: Any) -> None: """Validate that provider supports the required API.""" if not template.provider_api: diff --git a/src/orb/domain/template/factory.py b/src/orb/domain/template/factory.py index 2837ee71b..05fce2753 100644 --- a/src/orb/domain/template/factory.py +++ b/src/orb/domain/template/factory.py @@ -100,22 +100,23 @@ def create_template( # Create provider-specific template if available if provider_type and provider_type in self._provider_template_classes: + template_class = self._provider_template_classes[provider_type] try: - template_class = self._provider_template_classes[provider_type] template = template_class(**template_data) - - if self._logger: - self._logger.debug( - "Created %s template: %s", provider_type, template.template_id - ) - - return template except Exception as e: if self._logger: self._logger.error("Failed to create %s template: %s", provider_type, e) - # Fall back to core template + # Re-raise rather than silently falling back to base Template, + # which would discard all provider-specific fields. + raise + + if self._logger: + self._logger.debug( + "Created %s template: %s", provider_type, template.template_id + ) + return template - # Fall back to core template + # Fall back to core template when no provider-specific class is registered try: template = Template(**template_data) diff --git a/src/orb/domain/template/template_aggregate.py b/src/orb/domain/template/template_aggregate.py index a41969894..b1a9d8a7a 100644 --- a/src/orb/domain/template/template_aggregate.py +++ b/src/orb/domain/template/template_aggregate.py @@ -5,6 +5,8 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator +from orb.domain.base.value_objects import PlacementSplitStrategy + class Template(BaseModel): """Template configuration value object with both snake_case and camelCase support via aliases.""" @@ -22,6 +24,8 @@ class Template(BaseModel): # Instance configuration instance_type: Optional[str] = None + vm_size: Optional[str] = None + vm_sizes: list[str] = Field(default_factory=list) image_id: Optional[str] = None max_instances: int = 1 @@ -33,6 +37,10 @@ class Template(BaseModel): price_type: str = "ondemand" allocation_strategy: Optional[str] = None # Will be set based on price_type max_price: Optional[float] = None + placement_split_strategy: PlacementSplitStrategy = PlacementSplitStrategy.HYBRID + placement_primary_share_percent: int = 80 + placement_regions: list[str] = Field(default_factory=list) + placement_zones: list[str] = Field(default_factory=list) # Machine types configuration (unified for all providers) machine_types: dict[str, int] = Field(default_factory=dict) @@ -125,6 +133,9 @@ def validate_template(self) -> "Template": f"{', '.join(sorted(reserved_keys))}" ) + if self.placement_primary_share_percent < 0 or self.placement_primary_share_percent > 100: + raise ValueError("placement_primary_share_percent must be between 0 and 100") + return self @model_validator(mode="after") diff --git a/src/orb/infrastructure/adapters/template_configuration_adapter.py b/src/orb/infrastructure/adapters/template_configuration_adapter.py index 6e6a198ad..35a88154d 100644 --- a/src/orb/infrastructure/adapters/template_configuration_adapter.py +++ b/src/orb/infrastructure/adapters/template_configuration_adapter.py @@ -38,7 +38,7 @@ def get_template_config(self, template_id: str) -> Optional[dict[str, Any]]: """Get configuration for specific template.""" template = self._template_manager.get_template(template_id) if template: - return template.model_dump() + return template.to_template_config() return None def validate_template_config(self, config: dict[str, Any]) -> list[str]: diff --git a/src/orb/infrastructure/resilience/retry_decorator.py b/src/orb/infrastructure/resilience/retry_decorator.py index 811e23e02..1022419ac 100644 --- a/src/orb/infrastructure/resilience/retry_decorator.py +++ b/src/orb/infrastructure/resilience/retry_decorator.py @@ -1,5 +1,6 @@ """Integrated retry decorator supporting multiple strategies.""" +import inspect import time from functools import wraps from typing import Any, Callable, TypeVar @@ -80,6 +81,11 @@ def decorator(func: Callable[..., T]) -> Callable[..., T]: @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> T: """Execute function with retry logic.""" + if inspect.iscoroutinefunction(func): + raise TypeError( + "retry decorator does not support coroutine functions; " + "use an async-aware retry boundary instead" + ) attempt = 0 while True: diff --git a/src/orb/infrastructure/scheduler/base/strategy.py b/src/orb/infrastructure/scheduler/base/strategy.py index 1f6ae7fae..b9a1048b3 100644 --- a/src/orb/infrastructure/scheduler/base/strategy.py +++ b/src/orb/infrastructure/scheduler/base/strategy.py @@ -16,11 +16,13 @@ from orb.domain.constants import PROVIDER_TYPE_AWS from orb.domain.template.ports.template_defaults_port import TemplateDefaultsPort from orb.infrastructure.template.dtos import TemplateDTO +from orb.infrastructure.utilities.common.string_utils import extract_provider_type if TYPE_CHECKING: from orb.application.services.provider_registry_service import ProviderRegistryService from orb.domain.base.ports.configuration_port import ConfigurationPort from orb.domain.base.ports.logging_port import LoggingPort + from orb.domain.template.template_aggregate import Template class BaseSchedulerStrategy(SchedulerPort, ABC): @@ -76,14 +78,32 @@ def _get_active_provider_type(self) -> str: """Get the active provider type.""" try: if self._provider_registry_service is None: - return PROVIDER_TYPE_AWS + return self._infer_provider_type_from_config() selection_result = self._provider_registry_service.select_active_provider() provider_type = selection_result.provider_type self.logger.debug("Active provider type: %s", provider_type) return provider_type except Exception as e: - self.logger.warning("Failed to get active provider type, defaulting to 'aws': %s", e) - return PROVIDER_TYPE_AWS + inferred_provider_type = self._infer_provider_type_from_config() + self.logger.warning( + "Failed to get active provider type, falling back to '%s': %s", + inferred_provider_type, + e, + ) + return inferred_provider_type + + def _infer_provider_type_from_config(self) -> str: + """Infer provider type from configured active provider when the registry is unavailable.""" + try: + if self._config_manager is not None and hasattr(self._config_manager, "app_config"): + config = self._config_manager.app_config.model_dump() + provider_config = config.get("provider", {}) + active_provider = provider_config.get("active_provider") + if active_provider: + return extract_provider_type(str(active_provider)) + except Exception as exc: + self.logger.debug("Could not infer provider type from config: %s", exc) + return PROVIDER_TYPE_AWS def _load_single_file(self, template_path: str) -> list[dict[str, Any]]: """Load templates from a single file.""" @@ -228,7 +248,7 @@ def get_config_directory(self) -> str: return self._coalesce_directory( config_override=getattr(self.config_manager.app_config.scheduler, "config_dir", None), env_var_name="CONFIG_DIR", - default_factory=lambda: self._get_platform_config_dir(), + default_factory=self._get_platform_config_dir, ) def get_working_directory(self) -> str: @@ -236,7 +256,7 @@ def get_working_directory(self) -> str: return self._coalesce_directory( config_override=getattr(self.config_manager.app_config.scheduler, "work_dir", None), env_var_name="WORK_DIR", - default_factory=lambda: self._get_platform_work_dir(), + default_factory=self._get_platform_work_dir, ) def get_logs_directory(self) -> str: @@ -244,7 +264,7 @@ def get_logs_directory(self) -> str: return self._coalesce_directory( config_override=getattr(self.config_manager.app_config.scheduler, "log_dir", None), env_var_name="LOG_DIR", - default_factory=lambda: self._get_platform_logs_dir(), + default_factory=self._get_platform_logs_dir, ) def get_log_level(self) -> str: @@ -397,9 +417,11 @@ def format_template_for_display(self, template: TemplateDTO) -> dict[str, Any]: """Default implementation - clean to_dict without scheduler-specific formatting.""" return template.to_dict() - def format_template_for_provider(self, template: TemplateDTO) -> dict[str, Any]: - """Default implementation - clean to_dict without scheduler-specific formatting.""" - return template.to_dict() + def format_template_for_provider(self, template: "TemplateDTO | Template") -> dict[str, Any]: + """Format either a stored template DTO or a domain template for provider operations.""" + if isinstance(template, TemplateDTO): + return template.to_template_config() + return template.model_dump(mode="json", exclude_none=True) @staticmethod def _unwrap_request_id(value: Any) -> str | None: diff --git a/src/orb/infrastructure/template/dtos.py b/src/orb/infrastructure/template/dtos.py index 52bf122f8..e32eac625 100644 --- a/src/orb/infrastructure/template/dtos.py +++ b/src/orb/infrastructure/template/dtos.py @@ -91,34 +91,66 @@ def _serialize_provider_config(self, value: Optional[BaseModel]) -> Optional[dic """Serialise the typed provider_config to a plain dict for model_dump() consumers.""" if value is None: return None - return value.model_dump() + return value.model_dump(exclude_none=True) @model_validator(mode="before") @classmethod def _set_defaults(cls, data: Any) -> Any: - """Set default values for optional fields derived from other fields.""" + """Set defaults and validate provider-specific configuration.""" if isinstance(data, dict): + data = dict(data) if not data.get("name"): data["name"] = data.get("template_id") + + provider_config = data.get("provider_config") + provider_type = data.get("provider_type") + if provider_config is not None: + if not provider_type: + raise ValueError("provider_type is required when provider_config is supplied") + + provider_type_key = str(provider_type) + extension_class = TemplateExtensionRegistry.get_extension_class(provider_type_key) + if extension_class is None: + raise ValueError( + f"provider_config supplied for unregistered provider {provider_type_key!r}" + ) + + if isinstance(provider_config, dict): + data["provider_config"] = extension_class.model_validate(provider_config) + elif not isinstance(provider_config, extension_class): + raise ValueError( + "provider_config type does not match the registered " + f"extension for provider {provider_type_key!r}" + ) return data + def to_template_config(self) -> dict[str, Any]: + """Convert this DTO to flat template data for ``TemplateFactory``.""" + data = self.model_dump(mode="python", exclude_none=True) + provider_config = data.pop("provider_config", None) + if provider_config is None: + return data + + if isinstance(provider_config, BaseModel): + provider_config_data = provider_config.model_dump(exclude_none=True) + else: + provider_config_data = { + key: value for key, value in dict(provider_config).items() if value is not None + } + + return {**provider_config_data, **data} + @classmethod def from_domain(cls, template) -> "TemplateDTO": """Convert domain template to DTO.""" - # Delegate provider-specific field extraction to the extension registry. - # Each provider registers an extension class that knows which fields to - # capture. Infrastructure layer stays provider-agnostic. - _provider_type = getattr(template, "provider_type", None) - _provider_config: Optional[BaseModel] = None - if _provider_type: - # Serialise the domain object to a plain dict so the extension class - # can pick up its own fields (with extra="ignore"). - if hasattr(template, "model_dump"): - _template_data = template.model_dump() - else: - _template_data = vars(template) - _provider_config = TemplateExtensionRegistry.create_extension_config( - _provider_type, _template_data + provider_type = getattr(template, "provider_type", None) + provider_config: Optional[BaseModel] = None + if provider_type: + template_data = ( + template.model_dump() if hasattr(template, "model_dump") else vars(template) + ) + provider_config = TemplateExtensionRegistry.create_extension_config( + str(provider_type), template_data ) return cls( @@ -129,20 +161,19 @@ def from_domain(cls, template) -> "TemplateDTO": # Instance configuration image_id=getattr(template, "image_id", None), max_instances=getattr(template, "max_instances", 1), - # Machine types configuration (unified) + # Machine types configuration machine_types=getattr(template, "machine_types", {}), machine_types_ondemand=getattr(template, "machine_types_ondemand", {}), machine_types_priority=getattr(template, "machine_types_priority", {}), # Network configuration subnet_ids=getattr(template, "subnet_ids", []), security_group_ids=getattr(template, "security_group_ids", []), + network_zones=getattr(template, "network_zones", []), + public_ip_assignment=getattr(template, "public_ip_assignment", None), # Pricing and allocation price_type=getattr(template, "price_type", "ondemand"), allocation_strategy=getattr(template, "allocation_strategy", None), max_price=getattr(template, "max_price", None), - # Network configuration - network_zones=getattr(template, "network_zones", []), - public_ip_assignment=getattr(template, "public_ip_assignment", None), # Storage configuration root_device_volume_size=getattr(template, "root_device_volume_size", None), volume_type=getattr(template, "volume_type", None), @@ -156,14 +187,13 @@ def from_domain(cls, template) -> "TemplateDTO": instance_profile=getattr(template, "instance_profile", None), # Advanced configuration monitoring_enabled=getattr(template, "monitoring_enabled", None), - # Tags and metadata (cross-provider opaque data only) + # Metadata tags=getattr(template, "tags", {}), metadata=getattr(template, "metadata", {}), - # Typed provider-specific configuration (populated via registry) - provider_config=_provider_config, + provider_config=provider_config, provider_data=getattr(template, "provider_data", {}), # Provider identification - provider_type=_provider_type, + provider_type=provider_type, provider_name=getattr(template, "provider_name", None), provider_api=getattr(template, "provider_api", None), # Timestamps diff --git a/src/orb/infrastructure/template/services/template_storage_service.py b/src/orb/infrastructure/template/services/template_storage_service.py index 7713c9067..df845d22b 100644 --- a/src/orb/infrastructure/template/services/template_storage_service.py +++ b/src/orb/infrastructure/template/services/template_storage_service.py @@ -70,7 +70,7 @@ async def save_template(self, template: TemplateDTO) -> None: existing_templates = await self._load_templates_from_file(target_file) # Convert to scheduler-native format before writing - template_dict = template.model_dump(exclude_none=True) + template_dict = template.to_template_config() template_dict = self.scheduler_strategy.serialize_template_for_storage(template_dict) # Update or add the template (on-disk entries may use native camelCase keys) diff --git a/src/orb/interface/init_command_handler.py b/src/orb/interface/init_command_handler.py index 4a04dfd08..c017c2e28 100644 --- a/src/orb/interface/init_command_handler.py +++ b/src/orb/interface/init_command_handler.py @@ -4,7 +4,7 @@ import platform import shutil from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Protocol, runtime_checkable from orb.config.platform_dirs import ( get_config_location, @@ -13,7 +13,7 @@ get_work_location, ) from orb.domain.base.ports.console_port import ConsolePort -from orb.domain.base.ports.provider_registry_port import ProviderRegistryPort +from orb.domain.base.ports.provider_registry_port import ProviderRegistryPort, ProviderStrategyClass from orb.infrastructure.di.container import get_container from orb.infrastructure.logging.logger import get_logger from orb.infrastructure.registry.cli_spec_registry import CLISpecRegistry @@ -21,6 +21,24 @@ logger = get_logger(__name__) +@runtime_checkable +class _OperationalParamChoicesProvider(Protocol): + """Optional class-level hook for constrained init prompt choices.""" + + def get_operational_param_choices(self, param: str) -> list[tuple[str, str]]: + """Return selectable values for an operational init parameter.""" + ... + + +@runtime_checkable +class _OperationalParamDefaultProvider(Protocol): + """Optional class-level hook for init prompt defaults.""" + + def get_operational_param_default(self, param: str) -> str: + """Return the default value for an operational init parameter.""" + ... + + async def handle_init(args) -> int: """Handle orb init command.""" console = get_container().get(ConsolePort) @@ -446,7 +464,9 @@ def _configure_additional_provider() -> Optional[Dict[str, Any]]: return None -def _get_provider_strategy(provider_type: str, registry: Any = None) -> Optional[type]: +def _get_provider_strategy( + provider_type: str, registry: Any = None +) -> Optional[type[ProviderStrategyClass]]: """Return the strategy CLASS for a provider type. The credential inquiry methods (``get_available_credential_sources``, @@ -463,14 +483,14 @@ def _get_provider_strategy(provider_type: str, registry: Any = None) -> Optional registry = get_container().get(ProviderRegistryPort) # Ensure the provider type is registered so its class is available. registry.ensure_provider_type_registered(provider_type) - reg = registry._get_type_registration(provider_type) - strategy_class = getattr(reg, "strategy_class", None) - return strategy_class + return registry.get_strategy_class(provider_type) except Exception: return None -def _prompt_operational_params(strategy_class: Optional[type]) -> dict[str, Any]: +def _prompt_operational_params( + strategy_class: Optional[type[ProviderStrategyClass]], +) -> dict[str, Any]: """Interactively collect operational parameters from the operator. Calls ``get_operational_requirements()`` on the strategy class to discover @@ -505,14 +525,14 @@ def _prompt_operational_params(strategy_class: Optional[type]) -> dict[str, Any] choices: list[tuple[str, str]] = [] default_value: str = "" try: - if hasattr(strategy_class, "get_operational_param_choices"): + if isinstance(strategy_class, _OperationalParamChoicesProvider): choices = strategy_class.get_operational_param_choices(param) or [] except Exception: # Strategy hook raised or returned a broken shape; free-text prompt # is the safe fallback so init keeps working with degraded UX. choices = [] try: - if hasattr(strategy_class, "get_operational_param_default"): + if isinstance(strategy_class, _OperationalParamDefaultProvider): default_value = strategy_class.get_operational_param_default(param) or "" except Exception: # Strategy hook raised or returned a broken shape; empty default diff --git a/src/orb/providers/aws/domain/template/aws_template_aggregate.py b/src/orb/providers/aws/domain/template/aws_template_aggregate.py index f628d9e2f..b48cffd11 100644 --- a/src/orb/providers/aws/domain/template/aws_template_aggregate.py +++ b/src/orb/providers/aws/domain/template/aws_template_aggregate.py @@ -12,8 +12,10 @@ model_validator, ) +from orb.domain.base.value_objects import AllocationStrategy from orb.domain.template.template_aggregate import Template from orb.providers.aws.domain.template.value_objects import ( + AWSAllocationStrategy, AWSConfiguration, AWSFleetType, AWSInstanceType, @@ -21,8 +23,8 @@ AWSSubnetId, AWSTags, ProviderApi, + normalise_allocation_strategy, ) -from orb.providers.aws.value_objects import AWSAllocationStrategy, normalise_allocation_strategy class AWSOptionalIntegerRange(BaseModel): @@ -202,9 +204,6 @@ class AWSTemplate(Template): model_config = ConfigDict(arbitrary_types_allowed=True) - # Typed provider-config forwarded from TemplateDTO (round-trip via model_dump). - # Accepts a dict (serialised AWSTemplateDTOConfig) or None; values are promoted - # to their respective fields in validate_aws_template before use. provider_config: Optional[dict[str, Any]] = None # AWS-specific fields @@ -257,38 +256,47 @@ def validate_aws_template(self) -> "AWSTemplate": # (generic/example templates may have empty subnet_ids/image_id, filled at runtime # from provider.template_defaults via _coalesce_merge) - # Promote AWS-specific fields from provider_config dict (DTO round-trip path). - # provider_config is injected by TemplateDTO.from_domain via AWSTemplateDTOConfig. - _pc = getattr(self, "provider_config", None) - if isinstance(_pc, dict): + provider_config = self.provider_config + if isinstance(provider_config, dict): if not self.fleet_type: - _ft = _pc.get("fleet_type") - if _ft: + fleet_type = provider_config.get("fleet_type") + if fleet_type: try: - object.__setattr__(self, "fleet_type", AWSFleetType(str(_ft).lower())) + object.__setattr__(self, "fleet_type", AWSFleetType(str(fleet_type).lower())) except (ValueError, TypeError): pass if not self.fleet_role: - _fr = _pc.get("fleet_role") - if _fr: - object.__setattr__(self, "fleet_role", _fr) + fleet_role = provider_config.get("fleet_role") + if fleet_role: + object.__setattr__(self, "fleet_role", fleet_role) if self.percent_on_demand is None: - _pod = _pc.get("percent_on_demand") - if _pod is not None: - object.__setattr__(self, "percent_on_demand", int(_pod)) + percent_on_demand = provider_config.get("percent_on_demand") + if percent_on_demand is not None: + object.__setattr__(self, "percent_on_demand", int(percent_on_demand)) if not self.launch_template_id: - _ltid = _pc.get("launch_template_id") - if _ltid: - object.__setattr__(self, "launch_template_id", _ltid) + launch_template_id = provider_config.get("launch_template_id") + if launch_template_id: + object.__setattr__(self, "launch_template_id", launch_template_id) if self.abis_instance_requirements is None: - _abis = _pc.get("abis_instance_requirements") - if _abis is not None: + instance_requirements = provider_config.get("abis_instance_requirements") + if instance_requirements is not None: object.__setattr__( self, "abis_instance_requirements", - ABISInstanceRequirements.model_validate(_abis), + ABISInstanceRequirements.model_validate(instance_requirements), ) + if self.allocation_strategy == AllocationStrategy.SPOT_PLACEMENT_SCORE.value: + if self.price_type != "spot": + raise ValueError("spotPlacementScore allocation strategy requires price_type='spot'") + candidate_types = list((self.machine_types or {}).keys()) + if self.instance_type and self.instance_type not in candidate_types: + candidate_types.insert(0, self.instance_type) + if len(candidate_types) < 2: + raise ValueError( + "spotPlacementScore allocation strategy requires at least two candidate instance types" + ) + # Auto-assign default fleet_type if not provided # Set fleet_type from metadata if not already set if not self.fleet_type: diff --git a/src/orb/providers/aws/infrastructure/handlers/ec2_fleet/handler.py b/src/orb/providers/aws/infrastructure/handlers/ec2_fleet/handler.py index b434d9f50..07d126b00 100644 --- a/src/orb/providers/aws/infrastructure/handlers/ec2_fleet/handler.py +++ b/src/orb/providers/aws/infrastructure/handlers/ec2_fleet/handler.py @@ -41,7 +41,7 @@ from orb.infrastructure.resilience import CircuitBreakerOpenError from orb.providers.aws.aws_fleet_capacity import FleetCapacityFulfilment from orb.providers.aws.domain.template.aws_template_aggregate import AWSTemplate -from orb.providers.aws.domain.template.value_objects import AWSFleetType +from orb.providers.aws.domain.template.value_objects import AWSAllocationStrategy, AWSFleetType from orb.providers.aws.exceptions.aws_exceptions import ( AWSEntityNotFoundError, AWSInfrastructureError, @@ -63,7 +63,10 @@ AWSLaunchTemplateManager, ) from orb.providers.aws.utilities.aws_operations import AWSOperations -from orb.providers.aws.value_objects import AWSAllocationStrategy +from orb.providers.infrastructure.error_codes import ( + ProviderErrorEntry, + collect_provider_error_codes, +) @injectable @@ -190,17 +193,7 @@ def _acquire_hosts_internal( "resource_type": "ec2_fleet", "fleet_type": fleet_type_value, "fleet_errors": fleet_errors, - # ``requires_async_polling`` — True means the caller must - # continue polling the provider to observe further - # fulfillment before considering this request settled. - # INSTANT fleets return instance IDs synchronously but - # those instances are still in ``pending`` state at create - # time, so the create call is NOT the final answer — the - # polling loop must observe the running state. - # MAINTAIN / REQUEST fleets return only a fleet ID and no - # instances yet (instance arrival is purely a polling - # concern), so the create call IS the final synchronous - # answer for those types and no further polling is needed. + "error_codes": collect_provider_error_codes(fleet_errors), "requires_async_polling": fleet_type is AWSFleetType.INSTANT, "capacity_constrained": capacity_constrained, }, @@ -211,6 +204,7 @@ def _acquire_hosts_internal( "resource_ids": [], "instances": [], "error_message": str(e), + "provider_data": {"error_codes": []}, } def _create_fleet_internal(self, request: Request, aws_template: AWSTemplate) -> dict[str, Any]: @@ -308,38 +302,44 @@ def _extract_instant_instance_ids(self, response: dict[str, Any]) -> list[str]: instance_ids.append(instance_id) return instance_ids - def _extract_fleet_errors(self, response: dict[str, Any]) -> list[dict[str, Any]]: + def _extract_fleet_errors(self, response: dict[str, Any]) -> list[ProviderErrorEntry]: """Normalize EC2 Fleet error payloads for logging and persistence.""" errors = response.get("Errors") or [] if isinstance(errors, dict): errors = [errors] if not isinstance(errors, list): - return [{"error_code": "Unknown", "error_message": str(errors)}] + unknown_error: ProviderErrorEntry = { + "error_code": "Unknown", + "error_message": str(errors), + } + return [unknown_error] - normalized: list[dict[str, Any]] = [] + normalized: list[ProviderErrorEntry] = [] for error in errors: if not isinstance(error, dict): - normalized.append( - {"error_code": "Unknown", "error_message": str(error), "lifecycle": None} - ) + normalized_error: ProviderErrorEntry = { + "error_code": "Unknown", + "error_message": str(error), + "lifecycle": None, + } + normalized.append(normalized_error) continue lt_overrides = error.get("LaunchTemplateAndOverrides", {}) or {} lt_spec = lt_overrides.get("LaunchTemplateSpecification", {}) or {} overrides = lt_overrides.get("Overrides", {}) or {} - normalized.append( - { - "error_code": error.get("ErrorCode", "Unknown"), - "error_message": error.get("ErrorMessage", "No message"), - "lifecycle": error.get("Lifecycle"), - "launch_template_id": lt_spec.get("LaunchTemplateId"), - "launch_template_version": lt_spec.get("Version"), - "subnet_id": overrides.get("SubnetId"), - "instance_type": overrides.get("InstanceType"), - "instance_requirements": overrides.get("InstanceRequirements"), - } - ) + normalized_error: ProviderErrorEntry = { + "error_code": error.get("ErrorCode", "Unknown"), + "error_message": error.get("ErrorMessage", "No message"), + "lifecycle": error.get("Lifecycle"), + "launch_template_id": lt_spec.get("LaunchTemplateId"), + "launch_template_version": lt_spec.get("Version"), + "subnet_id": overrides.get("SubnetId"), + "instance_type": overrides.get("InstanceType"), + "instance_requirements": overrides.get("InstanceRequirements"), + } + normalized.append(normalized_error) return normalized @@ -347,7 +347,7 @@ def _record_fleet_error_details( self, request: Request, fleet_id: str, - errors: list[dict[str, Any]], + errors: list[ProviderErrorEntry], response: dict[str, Any], instance_ids: list[str], ) -> dict[str, Any]: @@ -364,6 +364,7 @@ def _record_fleet_error_details( return {"metadata_updates": metadata_updates} def _default_provider_api(self) -> str: + """Return the handler default provider API.""" return "EC2Fleet" def _create_fleet_config( @@ -382,22 +383,12 @@ def _create_fleet_config( ) def check_hosts_status(self, request: Request) -> CheckHostsStatusResult: - """Check the status of instances in the fleet. - - Fulfilment semantics (per fleet type): - - Instant: same as RunInstances — running_count >= requested_count - and failed_count == 0 → fulfilled. ``requires_async_polling=True`` - so partial/failed can be detected when pending reaches zero. - - Maintain / Request: FulfilledCapacity >= TargetCapacity AND - pending_count == 0 AND failed_count == 0 → fulfilled. This is the - weighted-fleet path that fixes the live test timeout. - """ + """Check the status of instances in the fleet.""" self._logger.debug(f" check_hosts_status {request}") if not request.resource_ids: raise AWSInfrastructureError("No Fleet ID found in request") - all_instances: list[dict] = [] - # For multi-fleet requests collect instances; compute combined fulfilment at the end. + all_instances: list[dict[str, Any]] = [] fleet_results: list[CheckHostsStatusResult] = [] for fleet_id in request.resource_ids: try: @@ -414,50 +405,36 @@ def check_hosts_status(self, request: Request) -> CheckHostsStatusResult: instances=[], fulfilment=ProviderFulfilment( state="in_progress", - message="No fleet status available — will retry", + message="No fleet status available; will retry", ), ) - # For a single fleet (typical case) return its result directly. if len(fleet_results) == 1: return CheckHostsStatusResult( instances=all_instances, fulfilment=fleet_results[0].fulfilment, ) - # Multiple fleets: aggregate — all must be fulfilled for overall fulfilled. - # Priority order matters here: - # 1. all fulfilled -> fulfilled - # 2. ANY in_progress -> in_progress (transient — wait) - # 3. all failed (or only fail+partial with no progress signal) - # -> failed - # 4. any partial (no in_progress) -> partial (terminal partial) - # 5. fallback -> in_progress - # - # in_progress is checked BEFORE partial because we don't want a - # request to flip to terminal-partial while another fleet is still - # booting; that classification can only be made once every fleet - # has reached a terminal verdict. - states = [r.fulfilment.state for r in fleet_results] - if all(s == "fulfilled" for s in states): + states = [result.fulfilment.state for result in fleet_results] + if all(state == "fulfilled" for state in states): combined_state = "fulfilled" - combined_msg = f"All {len(fleet_results)} fleets fulfilled" - elif any(s == "in_progress" for s in states): + combined_message = f"All {len(fleet_results)} fleets fulfilled" + elif any(state == "in_progress" for state in states): combined_state = "in_progress" - combined_msg = "One or more fleets still provisioning" - elif any(s == "failed" for s in states): + combined_message = "One or more fleets still provisioning" + elif any(state == "failed" for state in states): combined_state = "failed" - combined_msg = "One or more fleets failed" - elif any(s == "partial" for s in states): + combined_message = "One or more fleets failed" + elif any(state == "partial" for state in states): combined_state = "partial" - combined_msg = "One or more fleets partially fulfilled" + combined_message = "One or more fleets partially fulfilled" else: combined_state = "in_progress" - combined_msg = "Waiting for fleet(s) to fulfil" + combined_message = "Waiting for fleet fulfilment" return CheckHostsStatusResult( instances=all_instances, - fulfilment=ProviderFulfilment(state=combined_state, message=combined_msg), + fulfilment=ProviderFulfilment(state=combined_state, message=combined_message), ) def _check_single_fleet_status(self, fleet_id: str, request: Request) -> CheckHostsStatusResult: @@ -505,7 +482,6 @@ def _check_single_fleet_status(self, fleet_id: str, request: Request) -> CheckHo self._logger.debug(f" check_hosts_status final fleet_type: {fleet_type}") - # Read capacity data from DescribeFleets (already called above — no extra API call) capacity = self._fetch_ec2_fleet_capacity(fleet) target_capacity = capacity.target_capacity_units fulfilled_capacity = float(capacity.fulfilled_capacity_units) @@ -552,19 +528,6 @@ def _check_single_fleet_status(self, fleet_id: str, request: Request) -> CheckHo f" check_hosts_status instance_ids: {fleet_id} :: {instance_ids}" ) - # Per-fleet requested_count: AWS describe_fleets is the canonical - # source. TargetCapacitySpecification.TotalTargetCapacity tells us - # exactly how many instances this fleet was asked to provision — - # independent of the ORB request total (which is the SUM across - # all fleets and only meaningful for single-fleet requests). - # - # Without this, a request split across N fleets would have each - # fleet's running count compared against the request total, so a - # fully-running N-way split would look only 1/N fulfilled per - # fleet and the aggregator would emit a wrong partial verdict. - # - # Fallback to request.requested_count is for the rare case where - # AWS returns the fleet without TargetCapacitySpecification. per_fleet_requested = ( int(target_capacity) if target_capacity is not None else request.requested_count ) @@ -611,20 +574,7 @@ def _fetch_ec2_fleet_capacity( fleet: dict[str, Any], active_instance_count: int = 0, ) -> FleetCapacityFulfilment: - """Extract a typed capacity snapshot from a DescribeFleets fleet entry. - - Args: - fleet: One element from the ``DescribeFleets.Fleets`` list. - active_instance_count: Number of instances currently observed in - active lifecycle states. Used as ``provisioned_instance_count``. - For INSTANT fleets this is derived from the create-fleet - response and is not available at describe time; callers may - pass 0 and the field is informational only. - - Returns: - A :class:`FleetCapacityFulfilment` with the normalised capacity - data for this fleet. - """ + """Extract normalized capacity from a DescribeFleets entry.""" spec = fleet.get("TargetCapacitySpecification") or {} target_capacity: int | None = spec.get("TotalTargetCapacity") fulfilled_raw: float = fleet.get("FulfilledCapacity") or 0.0 @@ -645,19 +595,17 @@ def _compute_ec2fleet_fulfilment( fulfilled_capacity: float, requested_count: int, ) -> ProviderFulfilment: - """Compute ProviderFulfilment for an EC2 Fleet request. - - Instant fleets: running_count >= requested_count — same as RunInstances. - Maintain/Request fleets: FulfilledCapacity >= TargetCapacity AND - pending_count == 0 AND failed_count == 0. - """ - running_count = sum(1 for i in instances if i.get("status") == "running") - pending_count = sum(1 for i in instances if i.get("status") in ("pending", "starting")) - failed_count = sum(1 for i in instances if i.get("status") in ("failed", "error")) + """Compute ProviderFulfilment for an EC2 Fleet request.""" + running_count = sum(1 for instance in instances if instance.get("status") == "running") + pending_count = sum( + 1 for instance in instances if instance.get("status") in ("pending", "starting") + ) + failed_count = sum( + 1 for instance in instances if instance.get("status") in ("failed", "error") + ) target_units = target_capacity if target_capacity is not None else requested_count if fleet_type == AWSFleetType.INSTANT: - # Instant fleet: synchronous result, count-based (same as RunInstances) if running_count >= requested_count and failed_count == 0: return ProviderFulfilment( state="fulfilled", @@ -668,28 +616,32 @@ def _compute_ec2fleet_fulfilment( pending_count=pending_count, failed_count=failed_count, ) - elif pending_count > 0: + if pending_count > 0: return ProviderFulfilment( state="in_progress", - message=f"Instant fleet: {running_count}/{requested_count} running, {pending_count} pending", + message=( + f"Instant fleet: {running_count}/{requested_count} running, " + f"{pending_count} pending" + ), target_units=target_units, fulfilled_units=running_count, running_count=running_count, pending_count=pending_count, failed_count=failed_count, ) - # requires_async_polling=True for instant — pending state must be observed - elif running_count > 0: + if running_count > 0: return ProviderFulfilment( state="partial", - message=f"Instant fleet: {running_count}/{requested_count} instance(s) running", + message=( + f"Instant fleet: {running_count}/{requested_count} instance(s) running" + ), target_units=target_units, fulfilled_units=running_count, running_count=running_count, pending_count=pending_count, failed_count=failed_count, ) - elif not instances: + if not instances: return ProviderFulfilment( state="in_progress", message="Instant fleet: waiting for instances", @@ -699,27 +651,25 @@ def _compute_ec2fleet_fulfilment( pending_count=0, failed_count=0, ) - else: - return ProviderFulfilment( - state="failed", - message="Instant fleet: all instances failed", - target_units=target_units, - fulfilled_units=0, - running_count=running_count, - pending_count=pending_count, - failed_count=failed_count, - ) - else: - # Maintain / Request fleet: capacity-unit based fulfilment - return compute_capacity_based_fulfilment( - target_capacity=target_capacity, - fulfilled_capacity=fulfilled_capacity, + return ProviderFulfilment( + state="failed", + message="Instant fleet: all instances failed", + target_units=target_units, + fulfilled_units=0, running_count=running_count, pending_count=pending_count, failed_count=failed_count, - provider_label="Fleet", ) + return compute_capacity_based_fulfilment( + target_capacity=target_capacity, + fulfilled_capacity=fulfilled_capacity, + running_count=running_count, + pending_count=pending_count, + failed_count=failed_count, + provider_label="Fleet", + ) + def release_hosts( self, machine_ids: list[str], @@ -951,7 +901,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with instant fulfillment using on-demand instances", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=10, price_type="ondemand", subnet_ids=[], security_group_ids=[], @@ -964,7 +914,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with instant fulfillment using spot instances", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=10, price_type="spot", max_price=0.10, subnet_ids=[], @@ -978,7 +928,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with instant fulfillment using mixed pricing", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=10, price_type="heterogeneous", percent_on_demand=30, allocation_strategy="diversified", @@ -995,7 +945,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with request fulfillment using on-demand instances", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=15, price_type="ondemand", subnet_ids=[], security_group_ids=[], @@ -1008,7 +958,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with request fulfillment using spot instances", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=20, price_type="spot", allocation_strategy="capacityOptimized", max_price=0.10, @@ -1023,7 +973,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with request fulfillment using mixed pricing", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.large": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=25, price_type="heterogeneous", percent_on_demand=40, allocation_strategy="diversified", @@ -1041,7 +991,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with maintain capacity using on-demand instances", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=12, price_type="ondemand", subnet_ids=[], security_group_ids=[], @@ -1054,7 +1004,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with maintain capacity using spot instances", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=30, price_type="spot", allocation_strategy="priceCapacityOptimized", max_price=0.10, @@ -1069,7 +1019,7 @@ def get_example_templates(cls) -> list[Template]: description="EC2 Fleet with maintain capacity using mixed pricing", provider_api="EC2Fleet", machine_types={"t3.medium": 2, "t3.large": 2, "t3.xlarge": 4}, - max_instances=100, + max_instances=50, price_type="heterogeneous", percent_on_demand=50, allocation_strategy="capacityOptimized", diff --git a/src/orb/providers/aws/infrastructure/services/spot_placement_score_adapter.py b/src/orb/providers/aws/infrastructure/services/spot_placement_score_adapter.py new file mode 100644 index 000000000..55d4f0bbd --- /dev/null +++ b/src/orb/providers/aws/infrastructure/services/spot_placement_score_adapter.py @@ -0,0 +1,109 @@ +"""AWS spot placement score adapter.""" + +from __future__ import annotations + +from typing import Any + +from orb.application.services.spot_placement_planner import ( + PlacementCandidate, + PlacementScore, + SpotPlacementScoreAdapter, +) +from orb.domain.base.ports import LoggingPort +from orb.providers.aws.infrastructure.aws_client import AWSClient + + +class AWSSpotPlacementScoreAdapter(SpotPlacementScoreAdapter): + """Approximate AWS candidate scoring using GetSpotPlacementScores.""" + + def __init__(self, aws_client: AWSClient, logger: LoggingPort, region: str) -> None: + self._aws_client = aws_client + self._logger = logger + self._region = region + + def score_candidates(self, requested_count: int, template: Any) -> list[PlacementScore]: + instance_types = list((template.machine_types or {}).keys()) + if template.instance_type and template.instance_type not in instance_types: + instance_types.insert(0, template.instance_type) + + if len(instance_types) < 2: + return [] + + scores: list[PlacementScore] = [] + for instance_type in instance_types: + candidate = PlacementCandidate( + candidate_id=f"aws:{self._region}:{instance_type}", + instance_type=instance_type, + region=self._region, + ) + score_entry = self._get_score_for_candidate( + candidate=candidate, + requested_count=requested_count, + ) + raw_score = self._score_value(score_entry) + availability_zone = score_entry.get("AvailabilityZone") + if availability_zone: + candidate = PlacementCandidate( + candidate_id=f"aws:{self._region}:{availability_zone}:{instance_type}", + instance_type=instance_type, + region=self._region, + zone=str(availability_zone), + ) + scores.append( + PlacementScore( + candidate=candidate, + raw_score=raw_score, + normalized_score=self._normalize_score(raw_score), + approximate=True, + metadata={"score_entry": score_entry}, + ) + ) + + return scores + + def _get_score_for_candidate( + self, + candidate: PlacementCandidate, + requested_count: int, + ) -> dict[str, Any]: + try: + response = self._aws_client.ec2_client.get_spot_placement_scores( + InstanceTypes=[candidate.instance_type], + TargetCapacity=requested_count, + TargetCapacityUnitType="units", + SingleAvailabilityZone=True, + RegionNames=[candidate.region or self._region], + ) + except Exception as exc: + self._logger.warning( + "AWS spot placement score lookup failed for %s: %s", + candidate.instance_type, + exc, + ) + return {} + + placement_scores = response.get("SpotPlacementScores", []) + if not isinstance(placement_scores, list): + return {} + matching_scores = [ + score_entry + for score_entry in placement_scores + if isinstance(score_entry, dict) + and score_entry.get("Region") == (candidate.region or self._region) + ] + if not matching_scores: + return {} + return max(matching_scores, key=self._score_value) + + @staticmethod + def _score_value(score_entry: dict[str, Any]) -> int: + try: + return int(score_entry.get("Score", 0) or 0) + except (TypeError, ValueError): + return 0 + + @staticmethod + def _normalize_score(raw_score: int) -> float: + if raw_score <= 0: + return 0.0 + return min(max(raw_score / 100.0, 0.0), 1.0) diff --git a/src/orb/providers/aws/registration.py b/src/orb/providers/aws/registration.py index 4d3b43a6c..0b3b73494 100644 --- a/src/orb/providers/aws/registration.py +++ b/src/orb/providers/aws/registration.py @@ -10,9 +10,10 @@ from orb.domain.base.ports import LoggingPort from orb.providers.registry import ProviderRegistry -# Template extension imports for our new functionality -from orb.domain.template.factory import TemplateFactory from orb.infrastructure.registry.template_extension_registry import TemplateExtensionRegistry +from orb.domain.template.factory import TemplateFactory +from orb.infrastructure.registry.cli_spec_registry import CLISpecRegistry +from orb.providers.aws.cli.aws_cli_spec import AWSCLISpec from orb.providers.aws.configuration.template_extension import AWSTemplateExtensionConfig from orb.providers.aws.domain.template.aws_template_dto_config import AWSTemplateDTOConfig @@ -96,11 +97,11 @@ def create_aws_strategy(provider_config: Any) -> Any: if strategy.aws_client is not None: health_check = get_container().get(HealthCheckPort) - _storage_strategy = "json" + storage_strategy = "json" if config_port is not None: with suppress(Exception): - _storage_strategy = config_port.get_storage_strategy() - register_aws_health_checks(health_check, strategy.aws_client, _storage_strategy) + storage_strategy = config_port.get_storage_strategy() + register_aws_health_checks(health_check, strategy.aws_client, storage_strategy) # Set provider name for identification if hasattr(strategy, "name") and provider_name: @@ -205,11 +206,7 @@ def create_aws_validator(provider_config: Any = None) -> Any: def _load_aws_default_api() -> Optional[str]: - """Extract the default provider API from aws_defaults.json. - - Returns the value at provider.provider_defaults.aws.template_defaults.provider_api, - or None if the file cannot be read or the key is absent. - """ + """Extract the configured default provider API from aws_defaults.json.""" try: defaults_file = Path(__file__).parent / "config" / "aws_defaults.json" data = json.loads(defaults_file.read_text()) @@ -244,6 +241,7 @@ def register_aws_provider( try: from orb.providers.aws.strategy.aws_provider_strategy import AWSProviderStrategy + CLISpecRegistry.register("aws", AWSCLISpec()) if instance_name: # Register as named instance @@ -369,18 +367,9 @@ def register_aws_provider_instance(provider_instance, logger=None) -> bool: return True except Exception as e: - # Extract the config snippet that was attempted so operators can diagnose - # the failure without grepping code. - config_data = getattr(provider_instance, "config", None) or {} if logger: logger.error( - "Failed to register AWS provider instance '%s': %s " - "(config keys attempted: region=%r, profile=%r)", - provider_instance.name, - e, - config_data.get("region"), - config_data.get("profile"), - exc_info=True, + "Failed to register AWS provider instance '%s': %s", provider_instance.name, str(e) ) return False @@ -395,9 +384,7 @@ def register_aws_extensions(logger: Optional["LoggingPort"] = None) -> None: logger: Optional logger for registration messages """ try: - # Register AWS DTO config as the typed provider_config class for TemplateDTO - # serialisation. AWSTemplateDTOConfig covers the fields that were previously - # split between the top-level TemplateDTO and the opaque metadata dict. + # Register AWS template extension configuration TemplateExtensionRegistry.register_extension("aws", AWSTemplateDTOConfig) if logger: @@ -451,15 +438,8 @@ def get_aws_extension_defaults() -> dict: return default_config.to_template_defaults() -def register_aws_auth_strategies(logger: "Optional[LoggingPort]" = None) -> None: - """Register AWS authentication strategies with the auth registry. - - Registers the ``iam`` and ``cognito`` strategies so that ``AuthRegistry`` - can resolve them without server.py importing provider-specific classes. - - Args: - logger: Optional logger for registration messages - """ +def register_aws_auth_strategies(logger: Optional["LoggingPort"] = None) -> None: + """Register AWS authentication strategies with the auth registry.""" try: from orb.infrastructure.auth.registry import get_auth_registry @@ -479,12 +459,10 @@ def register_aws_auth_strategies(logger: "Optional[LoggingPort]" = None) -> None if logger: logger.debug("AWS Cognito auth strategy registered") - except ImportError as e: - if logger: - logger.warning("AWS auth strategies not available: %s", e) except Exception as e: + error_msg = f"Failed to register AWS auth strategies: {e}" if logger: - logger.error("Failed to register AWS auth strategies: %s", e, exc_info=True) + logger.error(error_msg, exc_info=True) raise @@ -521,7 +499,6 @@ def initialize_aws_provider( CLISpecRegistry.register("aws", AWSCLISpec()) - # Register AWS HostFactory field-mapping adapter from orb.infrastructure.scheduler.hostfactory.field_mapping_registry import ( FieldMappingRegistry, ) @@ -529,7 +506,6 @@ def initialize_aws_provider( FieldMappingRegistry.register("aws", AWSFieldMapping()) - # Register AWS defaults loader from orb.providers.aws.defaults_loader import AWSDefaultsLoader from orb.providers.registry.defaults_loader_registry import DefaultsLoaderRegistry @@ -582,7 +558,7 @@ def create_aws_template_adapter(c): # Register the AWS template example generator into the per-provider registry. # The handler factory is constructed with no AWS client because - # generate_example_templates only calls handler classmethods — no live + # generate_example_templates only calls handler classmethods; no live # AWS connection is needed. from orb.infrastructure.registry.template_example_generator_registry import ( TemplateExampleGeneratorRegistry, diff --git a/src/orb/providers/aws/strategy/aws_provider_strategy.py b/src/orb/providers/aws/strategy/aws_provider_strategy.py index 6fb5e2b35..1fa0b48ee 100644 --- a/src/orb/providers/aws/strategy/aws_provider_strategy.py +++ b/src/orb/providers/aws/strategy/aws_provider_strategy.py @@ -9,8 +9,17 @@ import asyncio import time -from typing import TYPE_CHECKING, Any, Callable, Optional +from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional +from orb.application.services.spot_placement_execution import ( + SpotPlacementExecutionService, + build_planned_execution_metadata, + create_acquire_request, +) +from orb.application.services.spot_placement_planner import ( + PlacementPlanEntry, + SpotPlacementPlanner, +) from orb.domain.base.dependency_injection import injectable from orb.domain.base.operation_outcome import Accepted, Completed, Failed, OperationOutcome from orb.domain.base.ports import LoggingPort @@ -18,7 +27,11 @@ # Import AWS-specific components from orb.providers.aws.configuration.config import AWSProviderConfig +from orb.providers.aws.exceptions.aws_exceptions import AWSConfigurationError from orb.providers.aws.infrastructure.aws_client import AWSClient +from orb.providers.aws.infrastructure.services.spot_placement_score_adapter import ( + AWSSpotPlacementScoreAdapter, +) from orb.providers.aws.services.capability_service import AWSCapabilityService from orb.providers.aws.services.handler_registry import AWSHandlerRegistry from orb.providers.aws.services.health_check_service import AWSHealthCheckService @@ -94,6 +107,8 @@ def __init__( self._infrastructure_service: Optional[AWSInfrastructureDiscoveryService] = None self._handler_registry: Optional[AWSHandlerRegistry] = None self._capability_service: Optional[AWSCapabilityService] = None + self._spot_placement_planner = SpotPlacementPlanner() + self._spot_placement_execution = SpotPlacementExecutionService() _API_ALIASES: dict[str, str] = { "AutoScalingGroup": "ASG", @@ -390,6 +405,9 @@ async def execute_operation(self, operation: ProviderOperation) -> ProviderResul async def _execute_operation_internal(self, operation: ProviderOperation) -> ProviderResult: """Route operations to appropriate services.""" if operation.operation_type == ProviderOperationType.CREATE_INSTANCES: + template_config = operation.parameters.get("template_config", {}) + if template_config.get("allocation_strategy") == "spotPlacementScore": + return self._execute_planned_spot_launches(operation) handlers = self._get_handler_registry().get_available_handlers() return await self._get_instance_service().create_instances(operation, handlers) elif operation.operation_type == ProviderOperationType.TERMINATE_INSTANCES: @@ -421,6 +439,160 @@ async def _execute_operation_internal(self, operation: ProviderOperation) -> Pro f"Unsupported operation: {operation.operation_type}", "UNSUPPORTED_OPERATION" ) + def _build_spot_placement_plan(self, aws_template: Any, count: int) -> list[PlacementPlanEntry]: + aws_client = self.aws_client + if aws_client is None: + raise AWSConfigurationError("AWS client is required for spot placement scoring") + + adapter = AWSSpotPlacementScoreAdapter( + aws_client=aws_client, + logger=self._logger, + region=self._aws_config.region, + ) + scores = adapter.score_candidates(requested_count=count, template=aws_template) + return self._spot_placement_planner.create_plan( + requested_count=count, + scores=scores, + split_strategy=aws_template.placement_split_strategy, + primary_share_percent=aws_template.placement_primary_share_percent, + ) + + @staticmethod + def _is_capacity_like_failure(child_result: dict[str, Any]) -> bool: + error_codes = set(child_result.get("error_codes", [])) + return bool( + error_codes + & { + "InsufficientInstanceCapacity", + "UnfulfillableCapacity", + "MaxSpotInstanceCountExceeded", + "SpotMaxPriceTooLow", + } + ) + + @staticmethod + def _clone_template_for_plan_entry(aws_template: Any, plan_entry: PlacementPlanEntry) -> Any: + from orb.providers.aws.domain.template.aws_template_aggregate import AWSTemplate + + cloned_data = aws_template.model_dump(mode="json", exclude_none=True) + selected_instance_type = plan_entry.score.candidate.instance_type + selected_weight = (aws_template.machine_types or {}).get(selected_instance_type, 1) + cloned_data["instance_type"] = selected_instance_type + cloned_data["machine_types"] = {selected_instance_type: selected_weight} + cloned_data["allocation_strategy"] = "capacityOptimized" + cloned_data["placement_regions"] = [] + cloned_data["placement_zones"] = [] + return AWSTemplate.model_validate(cloned_data) + + @staticmethod + def _planned_child_result_with_fulfillment( + *, + provider_api: str, + requested_count: int, + raw_result: Mapping[str, Any], + ) -> dict[str, Any]: + result = dict(raw_result) + if "fulfilled_count" in result: + return result + + if not result.get("success", True): + result["fulfilled_count"] = 0 + return result + + if provider_api == "RunInstances": + instance_ids = result.get("instance_ids") + result["fulfilled_count"] = len(instance_ids) if isinstance(instance_ids, list) else 0 + return result + + if provider_api == "EC2Fleet": + provider_data = result.get("provider_data") + fleet_errors = ( + provider_data.get("fleet_errors") if isinstance(provider_data, Mapping) else None + ) + if fleet_errors: + instance_ids = result.get("instance_ids") + result["fulfilled_count"] = ( + len(instance_ids) if isinstance(instance_ids, list) else 0 + ) + else: + result["fulfilled_count"] = requested_count + return result + + result["fulfilled_count"] = requested_count + return result + + def _execute_planned_spot_launches(self, operation: ProviderOperation) -> ProviderResult: + template_config = operation.parameters.get("template_config", {}) + count = operation.parameters.get("count", 1) + provider_api = template_config.get("provider_api", "RunInstances") + + handler = self.get_handler(provider_api) + if not handler: + return ProviderResult.error_result( + f"No handler available for provider_api: {provider_api}", + "HANDLER_NOT_FOUND", + ) + + from orb.providers.aws.domain.template.aws_template_aggregate import AWSTemplate + + aws_template = AWSTemplate.model_validate(template_config) + plan = self._build_spot_placement_plan(aws_template, count) + if not plan: + return ProviderResult.error_result( + "No viable spot placement candidates returned scores", + "NO_PLACEMENT_CANDIDATES", + ) + + request_metadata = dict(operation.parameters.get("request_metadata", {}) or {}) + base_request_id = operation.parameters.get("request_id") or ( + operation.context.get("request_id") if operation.context else None + ) + + summary = self._spot_placement_execution.execute_plan( + plan=plan, + total_count=count, + build_child_template=lambda plan_entry: self._clone_template_for_plan_entry( + aws_template, plan_entry + ), + build_child_request=lambda requested_for_entry, idx: create_acquire_request( + template_id=aws_template.template_id, + count=requested_for_entry, + provider_type=self.provider_type, + provider_name=self._provider_name, + provider_api=provider_api, + request_metadata=request_metadata, + parent_request_id=base_request_id, + plan_entry_index=idx, + ), + launch_child=lambda child_request, child_template: self._planned_child_result_with_fulfillment( + provider_api=provider_api, + requested_count=child_request.requested_count, + raw_result=handler.acquire_hosts(child_request, child_template), + ), + is_capacity_like_failure=self._is_capacity_like_failure, + ) + + if not summary.resource_ids and summary.terminal_error_message: + return ProviderResult.error_result( + summary.terminal_error_message, + "PLANNED_SPOT_ACQUIRE_ERROR", + metadata=build_planned_execution_metadata(plan, summary), + ) + + return ProviderResult.success_result( + { + "resource_ids": summary.resource_ids, + "instances": summary.instances, + "provider_api": provider_api, + "count": count, + "template_id": aws_template.template_id, + }, + { + "method": "planned_handler", + "provider_data": build_planned_execution_metadata(plan, summary), + }, + ) + def get_capabilities(self) -> ProviderCapabilities: """Get AWS provider capabilities.""" return self._get_capability_service().get_capabilities() @@ -638,12 +810,7 @@ async def _handle_describe_resource_instances( ) request.resource_ids = resource_ids - # check_hosts_status makes blocking boto3 I/O calls (describe_fleet_instances, - # describe_instances, etc.). Running it directly in an async handler blocks - # the uvicorn event loop, which prevents uvicorn from accepting any further - # connections until the call completes. For large requests (e.g. 100 instances) - # this starvation causes all concurrent polls to fail with ConnectionError. - # Offloading to a thread pool executor keeps the event loop responsive. + # check_hosts_status performs blocking boto3 I/O; keep it off the async event loop. check_result = await asyncio.to_thread(handler.check_hosts_status, request) instance_details = check_result.instances fulfilment: ProviderFulfilment = check_result.fulfilment @@ -890,10 +1057,8 @@ def _create_image_resolution_service(self): async def acquire(self, request: Request) -> OperationOutcome: """Submit an acquisition request to AWS. - AWS provider operations are asynchronous: the API call (EC2Fleet, - SpotFleet, RunInstances, ASG) returns a request/fleet ID immediately - while instances transition through ``pending``. The outcome is - therefore always ``Accepted`` on success. + AWS provider operations return provider-side resource IDs while + instances may still be transitioning. Args: request: Domain request describing resources to acquire. diff --git a/src/orb/providers/config_builder.py b/src/orb/providers/config_builder.py index ad2e8ca24..dd16b9ac3 100644 --- a/src/orb/providers/config_builder.py +++ b/src/orb/providers/config_builder.py @@ -53,4 +53,4 @@ def build_config(self, instance_config: ProviderInstanceConfig) -> Any: "Each provider must register a config factory via the provider registry." ) - return config_factory(instance_config) + return config_factory(instance_config.config) diff --git a/src/orb/providers/infrastructure/__init__.py b/src/orb/providers/infrastructure/__init__.py new file mode 100644 index 000000000..37a76a7ef --- /dev/null +++ b/src/orb/providers/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Shared provider infrastructure helpers.""" diff --git a/src/orb/providers/infrastructure/error_codes.py b/src/orb/providers/infrastructure/error_codes.py new file mode 100644 index 000000000..b4110eff8 --- /dev/null +++ b/src/orb/providers/infrastructure/error_codes.py @@ -0,0 +1,34 @@ +"""Helpers for canonical provider error-code payloads.""" + +from __future__ import annotations + +from typing import TypedDict + + +class ProviderErrorEntry(TypedDict, total=False): + """Normalized infrastructure error payload used by provider handlers.""" + + error_code: str + error_message: str + instance_id: str + resource_id: str + node_array: str + cc_state: str + lifecycle: str | None + launch_template_id: str | None + launch_template_version: str | None + subnet_id: str | None + instance_type: str | None + instance_requirements: object + status_code: str | None + status_level: str | None + + +def collect_provider_error_codes(errors: list[ProviderErrorEntry]) -> list[str]: + """Return unique canonical error codes from normalized provider errors.""" + error_codes: list[str] = [] + for error in errors: + error_code = error.get("error_code") + if error_code and error_code not in error_codes: + error_codes.append(str(error_code)) + return error_codes diff --git a/src/orb/providers/registry/provider_registry.py b/src/orb/providers/registry/provider_registry.py index 0b65c3b08..1d1527d5c 100644 --- a/src/orb/providers/registry/provider_registry.py +++ b/src/orb/providers/registry/provider_registry.py @@ -3,7 +3,7 @@ import importlib import re import threading -from typing import Any, Callable, List, Optional +from typing import Any, Callable, List, Optional, cast # Only allow simple snake_case identifiers as provider types to prevent # module-injection via crafted provider_type strings (e.g. containing dots @@ -13,7 +13,7 @@ from orb.domain.base.exceptions import ConfigurationError from orb.domain.base.ports.configuration_port import ConfigurationPort -from orb.domain.base.ports.provider_registry_port import ProviderRegistryPort +from orb.domain.base.ports.provider_registry_port import ProviderRegistryPort, ProviderStrategyClass from orb.domain.base.results import ProviderSelectionResult from orb.infrastructure.registry.base_registry import BaseRegistration, BaseRegistry, RegistryMode from orb.infrastructure.utilities.common.string_utils import extract_provider_type @@ -314,6 +314,17 @@ def get_default_api(self, provider_type: str) -> Optional[str]: pass return None + def get_strategy_class(self, provider_type: str) -> Optional[type[ProviderStrategyClass]]: + """Return the registered strategy class for a provider type, if available.""" + try: + registration = self._get_type_registration(provider_type) + except (ValueError, KeyError): + return None + return cast( + Optional[type[ProviderStrategyClass]], + getattr(registration, "strategy_class", None), + ) + def register_provider( self, provider_type: str, @@ -431,17 +442,18 @@ def create_resolver(self, provider_type: str) -> Optional[Any]: """ return self.create_additional_component(provider_type, "resolver_factory") - def create_validator(self, provider_type: str) -> Optional[Any]: + def create_validator(self, provider_type: str, config: Any = None) -> Optional[Any]: """ Create a template validator using registered factory. Args: provider_type: Type identifier for the provider + config: Optional provider config data to pass to the factory Returns: Created template validator instance or None if not registered """ - return self.create_additional_component(provider_type, "validator_factory") + return self.create_additional_component(provider_type, "validator_factory", config) def unregister_provider(self, provider_type: str) -> bool: """ diff --git a/tests/config/test_template_defaults.py b/tests/config/test_template_defaults.py index e3b4b6596..66ef1b26e 100644 --- a/tests/config/test_template_defaults.py +++ b/tests/config/test_template_defaults.py @@ -158,6 +158,25 @@ def test_resolve_provider_api_default_hierarchy( ) assert result == "EC2Fleet" # From provider type defaults + def test_resolve_template_defaults_keeps_global_max_number_in_shared_output( + self, + template_defaults_service, + mock_config_manager, + sample_provider_config, + sample_template_config, + ): + """Shared defaults service keeps config-layer max_number in resolved output.""" + mock_config_manager.get_template_config.return_value = sample_template_config + mock_config_manager.get_provider_config.return_value = sample_provider_config + + result = template_defaults_service.resolve_template_defaults( + {"template_id": "azure-template", "max_instances": 3}, + None, + ) + + assert result["max_instances"] == 3 + assert result["max_number"] == 10 + def test_get_effective_template_defaults( self, template_defaults_service, diff --git a/tests/providers/aws/mocked/test_template_pipeline.py b/tests/providers/aws/mocked/test_template_pipeline.py index 43af10696..e63f68ab8 100644 --- a/tests/providers/aws/mocked/test_template_pipeline.py +++ b/tests/providers/aws/mocked/test_template_pipeline.py @@ -180,20 +180,24 @@ def test_default_strategy_loads_snake_case_passthrough(tmp_path): def test_default_strategy_delegates_hf_file_to_hf_strategy(tmp_path): """Default strategy detects scheduler_type=hostfactory and delegates via registry when registered.""" - from orb.infrastructure.scheduler.registration import ( - register_default_scheduler, - register_symphony_hostfactory_scheduler, - ) from orb.infrastructure.scheduler.registry import get_scheduler_registry registry = get_scheduler_registry() - # Use the canonical registration helpers so strategy_class is populated correctly. - # register_type is idempotent, so these are safe to call even if a prior test already - # registered the types via the DI container boot path. + # Register both types so delegation can resolve the HF strategy class if not registry.is_registered("hostfactory"): - register_symphony_hostfactory_scheduler(registry) + registry.register( + "hostfactory", + HostFactorySchedulerStrategy, + lambda c: None, + strategy_class=HostFactorySchedulerStrategy, + ) if not registry.is_registered("default"): - register_default_scheduler(registry) + registry.register( + "default", + DefaultSchedulerStrategy, + lambda c: None, + strategy_class=DefaultSchedulerStrategy, + ) tpl_file = tmp_path / "aws_templates.json" _write_hf_file(tpl_file, [_MINIMAL_HF_TEMPLATE]) diff --git a/tests/providers/aws/test_aws_strategy.py b/tests/providers/aws/test_aws_strategy.py new file mode 100644 index 000000000..2c8f03154 --- /dev/null +++ b/tests/providers/aws/test_aws_strategy.py @@ -0,0 +1,204 @@ +import asyncio +from unittest.mock import MagicMock + +from orb.application.services.spot_placement_planner import ( + PlacementCandidate, + PlacementPlanEntry, + PlacementScore, +) +from orb.providers.aws.configuration.config import AWSProviderConfig +from orb.providers.aws.domain.template.aws_template_aggregate import AWSTemplate +from orb.providers.aws.infrastructure.services.spot_placement_score_adapter import ( + AWSSpotPlacementScoreAdapter, +) +from orb.providers.aws.strategy.aws_provider_strategy import AWSProviderStrategy +from orb.providers.base.strategy import ProviderOperation, ProviderOperationType + + +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() + + +def test_create_instances_uses_planned_handler_path(monkeypatch): + strategy = AWSProviderStrategy( + config=AWSProviderConfig(region="eu-west-1", profile="default"), + logger=MagicMock(), + ) + strategy.initialize() + + handler = MagicMock() + handler.acquire_hosts.side_effect = [ + { + "success": False, + "error_message": "AWS Error: InsufficientInstanceCapacity - no capacity available", + "provider_data": {"error_codes": ["InsufficientInstanceCapacity"]}, + }, + {"success": True, "resource_ids": ["fleet-b"], "instances": []}, + ] + monkeypatch.setattr(strategy, "get_handler", lambda provider_api: handler) + + monkeypatch.setattr( + strategy, + "_build_spot_placement_plan", + lambda template, count: [ + PlacementPlanEntry( + score=PlacementScore( + candidate=PlacementCandidate( + candidate_id="aws:eu-west-1:m7i.large", + instance_type="m7i.large", + region="eu-west-1", + ), + raw_score=9, + normalized_score=0.9, + approximate=True, + ), + planned_count=2, + ), + PlacementPlanEntry( + score=PlacementScore( + candidate=PlacementCandidate( + candidate_id="aws:eu-west-1:m7i.xlarge", + instance_type="m7i.xlarge", + region="eu-west-1", + ), + raw_score=7, + normalized_score=0.7, + approximate=True, + ), + planned_count=1, + ), + ], + ) + + op = ProviderOperation( + operation_type=ProviderOperationType.CREATE_INSTANCES, + parameters={ + "count": 2, + "template_config": { + "template_id": "tmpl-aws", + "provider_api": "EC2Fleet", + "image_id": "ami-12345678", + "subnet_ids": ["subnet-12345678"], + "security_group_ids": ["sg-12345678"], + "price_type": "spot", + "allocation_strategy": "spotPlacementScore", + "machine_types": { + "m7i.large": 1, + "m7i.xlarge": 1, + }, + "fleet_type": "request", + }, + }, + ) + + result = _run(strategy.execute_operation(op)) + + assert result.success + assert result.data["resource_ids"] == ["fleet-b"] + assert result.metadata["method"] == "planned_handler" + assert result.metadata["provider_data"]["unfulfilled_count"] == 0 + assert len(result.metadata["provider_data"]["child_results"]) == 2 + + +def test_spot_placement_score_adapter_uses_canonical_machine_types(): + ec2_client = MagicMock() + ec2_client.get_spot_placement_scores.return_value = { + "SpotPlacementScores": [{"Region": "eu-west-1", "Score": 8}] + } + aws_client = MagicMock() + aws_client.ec2_client = ec2_client + + template = AWSTemplate.model_validate( + { + "template_id": "tmpl-aws", + "provider_api": "EC2Fleet", + "provider_type": "aws", + "provider_name": "aws-default", + "image_id": "ami-12345678", + "subnet_ids": ["subnet-12345678"], + "security_group_ids": ["sg-12345678"], + "price_type": "spot", + "allocation_strategy": "spotPlacementScore", + "machine_types": { + "m7i.large": 1, + "m7i.xlarge": 1, + }, + "fleet_type": "request", + } + ) + + adapter = AWSSpotPlacementScoreAdapter( + aws_client=aws_client, + logger=MagicMock(), + region="eu-west-1", + ) + + scores = adapter.score_candidates(requested_count=2, template=template) + + assert [score.candidate.instance_type for score in scores] == ["m7i.large", "m7i.xlarge"] + assert ec2_client.get_spot_placement_scores.call_count == 2 + assert [ + call.kwargs["InstanceTypes"] + for call in ec2_client.get_spot_placement_scores.call_args_list + ] == [["m7i.large"], ["m7i.xlarge"]] + assert all( + call.kwargs["SingleAvailabilityZone"] is True + for call in ec2_client.get_spot_placement_scores.call_args_list + ) + + +def test_spot_placement_score_adapter_preserves_aws_score_granularity(): + ec2_client = MagicMock() + ec2_client.get_spot_placement_scores.side_effect = [ + { + "SpotPlacementScores": [ + {"Region": "eu-west-1", "AvailabilityZone": "eu-west-1a", "Score": 40}, + {"Region": "eu-west-1", "AvailabilityZone": "eu-west-1b", "Score": 90}, + ] + }, + { + "SpotPlacementScores": [ + {"Region": "eu-west-1", "AvailabilityZone": "eu-west-1a", "Score": 25} + ] + }, + ] + aws_client = MagicMock() + aws_client.ec2_client = ec2_client + + template = AWSTemplate.model_validate( + { + "template_id": "tmpl-aws", + "provider_api": "EC2Fleet", + "provider_type": "aws", + "provider_name": "aws-default", + "image_id": "ami-12345678", + "subnet_ids": ["subnet-12345678"], + "security_group_ids": ["sg-12345678"], + "price_type": "spot", + "allocation_strategy": "spotPlacementScore", + "machine_types": { + "m7i.large": 1, + "m7i.xlarge": 1, + }, + "fleet_type": "request", + } + ) + + adapter = AWSSpotPlacementScoreAdapter( + aws_client=aws_client, + logger=MagicMock(), + region="eu-west-1", + ) + + scores = adapter.score_candidates(requested_count=2, template=template) + + assert len(scores) == 2 + assert scores[0].raw_score == 90 + assert scores[0].normalized_score == 0.9 + assert scores[0].candidate.zone == "eu-west-1b" + assert scores[0].metadata["score_entry"]["AvailabilityZone"] == "eu-west-1b" + assert scores[1].raw_score == 25 diff --git a/tests/providers/aws/unit/test_base_context_mixin.py b/tests/providers/aws/unit/test_base_context_mixin.py index b9e0ea5a8..4f9f87658 100644 --- a/tests/providers/aws/unit/test_base_context_mixin.py +++ b/tests/providers/aws/unit/test_base_context_mixin.py @@ -27,7 +27,7 @@ def setup_method(self): self.template.subnet_ids = ["subnet-123", "subnet-456"] self.template.security_group_ids = ["sg-123", "sg-456"] self.template.tags = {"Environment": "test", "Project": "hostfactory"} - self.template.instance_types = {"t3.medium": 1, "t3.large": 2} + self.template.machine_types = {"t3.medium": 1, "t3.large": 2} self.template.percent_on_demand = None self.request_id = "req-test-456" @@ -213,7 +213,7 @@ def test_prepare_standard_flags_minimal(self): """Test standard flag preparation with minimal template.""" self.template.subnet_ids = None self.template.security_group_ids = None - self.template.instance_types = None + self.template.machine_types = None result = self.mixin._prepare_standard_flags(self.template) diff --git a/tests/providers/aws/unit/test_handler_merge_integration.py b/tests/providers/aws/unit/test_handler_merge_integration.py index 6a5e84ee1..af3f78a26 100644 --- a/tests/providers/aws/unit/test_handler_merge_integration.py +++ b/tests/providers/aws/unit/test_handler_merge_integration.py @@ -14,7 +14,6 @@ def _create_mock_template(self): template = Mock() template.tags = {} template.template_id = "test-template" - template.instance_types = [] template.subnet_ids = [] template.image_id = "ami-123" template.security_group_ids = [] diff --git a/tests/unit/api/test_provider_schema_router.py b/tests/unit/api/test_provider_schema_router.py index f570947ff..fd060a415 100644 --- a/tests/unit/api/test_provider_schema_router.py +++ b/tests/unit/api/test_provider_schema_router.py @@ -277,22 +277,13 @@ class TestProviderSchemaEndpoint: """Tests for GET /providers/{name}/schema.""" def _make_mock_registry(self, *, registered: bool, schema: list | None = None): - from orb.providers.registry.types import ProviderRegistration - registry = MagicMock() registry.is_provider_registered.return_value = registered if registered and schema is not None: mock_strategy_class = MagicMock() - mock_instance = MagicMock() - mock_instance.get_ui_column_schema.return_value = schema - mock_strategy_class.return_value = mock_instance - - reg = MagicMock(spec=ProviderRegistration) - reg.strategy_class = mock_strategy_class - - # _get_type_registration is called inside _get_schema_for_provider_type - registry._get_type_registration.return_value = reg + mock_strategy_class.get_ui_column_schema.return_value = schema + registry.get_strategy_class.return_value = mock_strategy_class return registry def test_returns_404_for_unknown_provider(self): diff --git a/tests/unit/application/commands/test_request_creation_handlers.py b/tests/unit/application/commands/test_request_creation_handlers.py new file mode 100644 index 000000000..a935f9d10 --- /dev/null +++ b/tests/unit/application/commands/test_request_creation_handlers.py @@ -0,0 +1,46 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from orb.application.commands.request_creation_handlers import CreateMachineRequestHandler +from orb.application.dto.queries import GetTemplateQuery +from orb.domain.template.factory import TemplateFactory +from orb.domain.template.template_aggregate import Template +from orb.infrastructure.template.dtos import TemplateDTO + + +@pytest.mark.asyncio +async def test_load_template_converts_template_dto_to_domain_template() -> None: + query_bus = Mock() + query_bus.execute = AsyncMock( + return_value=TemplateDTO( + template_id="azure-cheapest-vmss", + name="azure-cheapest-vmss", + provider_type="azure", + provider_name="azure-default", + provider_api="VMSS", + network_zones=["eastus2"], + ) + ) + container = Mock() + container.get.return_value = TemplateFactory() + + handler = CreateMachineRequestHandler( + uow_factory=Mock(), + logger=Mock(), + container=container, + event_publisher=Mock(), + error_handler=Mock(), + query_bus=query_bus, + provider_selection_port=Mock(), + provisioning_service=Mock(), + provider_validation_service=Mock(), + ) + + template = await handler._load_template("azure-cheapest-vmss") + + query_bus.execute.assert_awaited_once_with(GetTemplateQuery(template_id="azure-cheapest-vmss")) + assert isinstance(template, Template) + assert template.template_id == "azure-cheapest-vmss" + assert template.provider_api == "VMSS" + assert "provider_config" not in template.model_dump(mode="json", exclude_none=True) diff --git a/tests/unit/application/commands/test_request_sync_handlers.py b/tests/unit/application/commands/test_request_sync_handlers.py index 0ea940a85..8acfa5724 100644 --- a/tests/unit/application/commands/test_request_sync_handlers.py +++ b/tests/unit/application/commands/test_request_sync_handlers.py @@ -177,3 +177,70 @@ async def _execute_operation(provider_name: str, operation: Operation) -> Operat await handler.execute_command(PopulateMachineIdsCommand(request_id=_VALID_REQUEST_ID)) request.update_machine_ids.assert_called_once_with(["i-111", "i-222"]) + + +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_populate_machine_ids_forwards_provider_context_for_azure(): + """Azure async path: request_metadata must merge metadata with follow_up_context from provider_data.""" + uow = MagicMock() + uow.__enter__.return_value = uow + uow.__exit__.return_value = None + + request = MagicMock() + request.needs_machine_id_population.return_value = True + request.resource_ids = ["vmss-demo"] + request.provider_api = "VMSS" + request.template_id = "azure-cheapest-vmss" + request.provider_name = "azure-default" + request.request_id = "req-00000000-0000-0000-0000-000000000001" + request.metadata = {"provider_selection_reason": "configured-default"} + request.provider_data = { + "follow_up_context": { + "resource_group": "orb-test-rg", + "resource_id": "vmss-demo", + } + } + + updated_request = MagicMock() + request.update_machine_ids.return_value = updated_request + uow.requests.get_by_id.return_value = request + + uow_factory = MagicMock() + uow_factory.create_unit_of_work.return_value = uow + + provider_selection_port = MagicMock() + provider_selection_port.execute_operation = AsyncMock( + return_value=MagicMock(success=True, data={"instances": [{"instance_id": "vm-1"}]}) + ) + + container = MagicMock() + logger = MagicMock() + event_publisher = MagicMock() + error_handler = MagicMock() + + handler = PopulateMachineIdsHandler( + uow_factory=uow_factory, + logger=logger, + container=container, + event_publisher=event_publisher, + error_handler=error_handler, + provider_selection_port=provider_selection_port, + ) + + await handler.execute_command(PopulateMachineIdsCommand(request_id=str(request.request_id))) + + operation = provider_selection_port.execute_operation.await_args.args[1] + assert operation.operation_type == OperationType.DESCRIBE_RESOURCE_INSTANCES + assert operation.parameters["resource_ids"] == ["vmss-demo"] + assert operation.parameters["provider_api"] == "VMSS" + assert operation.parameters["template_id"] == "azure-cheapest-vmss" + assert operation.parameters["request_metadata"]["resource_group"] == "orb-test-rg" + assert operation.parameters["request_metadata"]["resource_id"] == "vmss-demo" + assert ( + operation.parameters["request_metadata"]["provider_selection_reason"] + == "configured-default" + ) + request.update_machine_ids.assert_called_once_with(["vm-1"]) + uow.requests.save.assert_called_once_with(updated_request) diff --git a/tests/unit/application/commands/test_template_command_handlers.py b/tests/unit/application/commands/test_template_command_handlers.py index 9edc1c2e4..a610df67c 100644 --- a/tests/unit/application/commands/test_template_command_handlers.py +++ b/tests/unit/application/commands/test_template_command_handlers.py @@ -1,6 +1,7 @@ """Unit tests for template command handlers — TDD for store-unification fix.""" from unittest.mock import AsyncMock, Mock +from typing import Any import pytest @@ -21,6 +22,12 @@ LoggingPort, ) from orb.infrastructure.template.dtos import TemplateDTO +from orb.providers.aws.domain.template.aws_template_dto_config import AWSTemplateDTOConfig + + +def _provider_config_dict(dto: TemplateDTO) -> dict[str, Any]: + assert isinstance(dto.provider_config, AWSTemplateDTOConfig) + return dto.provider_config.model_dump(exclude_none=True) def _make_dto(**kwargs: object) -> TemplateDTO: @@ -103,6 +110,33 @@ async def test_create_saves_via_manager(): assert command.created is True +@pytest.mark.asyncio +async def test_create_preserves_provider_specific_configuration_in_provider_config(): + manager = _make_manager(existing=None) + port = _make_template_port(manager) + handler = _make_create_handler(port) + + command = CreateTemplateCommand( + template_id="aws-ec2-fleet", + provider_api="EC2Fleet", + image_id="ami-000", + configuration={ + "provider_type": "aws", + "provider_name": "aws-default", + "fleet_type": "maintain", + "fleet_role": "arn:aws:iam::123456789012:role/SpotFleetRole", + }, + ) + + await handler.handle(command) + + saved: TemplateDTO = manager.save_template.call_args[0][0] + provider_config = _provider_config_dict(saved) + assert saved.provider_type == "aws" + assert provider_config["fleet_type"] == "maintain" + assert provider_config["fleet_role"] == "arn:aws:iam::123456789012:role/SpotFleetRole" + + @pytest.mark.asyncio async def test_create_duplicate_raises(): """BusinessRuleError is raised when template already exists.""" @@ -231,10 +265,38 @@ async def test_update_validation_errors_sets_updated_false() -> None: command = UpdateTemplateCommand( template_id="tpl-1", - configuration={"bad_field": "x"}, + image_id="ami-invalid", ) await handler.handle(command) assert command.updated is False assert command.validation_errors == ["invalid field"] manager.save_template.assert_not_called() + + +@pytest.mark.asyncio +async def test_update_validates_promoted_provider_specific_configuration() -> None: + existing = TemplateDTO.model_validate( + { + "template_id": "aws-ec2-fleet", + "name": "aws-ec2-fleet", + "provider_api": "EC2Fleet", + "provider_type": "aws", + "provider_name": "aws-default", + "provider_config": {"fleet_type": "maintain", "fleet_role": "old-role"}, + } + ) + manager = _make_manager(existing=existing) + port = _make_template_port(manager) + handler = _make_update_handler(port) + + command = UpdateTemplateCommand( + template_id="aws-ec2-fleet", + configuration={"fleet_role": "new-role"}, + ) + await handler.handle(command) + + validated_config = port.validate_template_config.call_args.args[0] + assert validated_config["fleet_type"] == "maintain" + assert validated_config["fleet_role"] == "new-role" + assert "provider_config" not in validated_config diff --git a/tests/unit/application/queries/test_get_request_handler.py b/tests/unit/application/queries/test_get_request_handler.py index 2f7751988..c4d221aa8 100644 --- a/tests/unit/application/queries/test_get_request_handler.py +++ b/tests/unit/application/queries/test_get_request_handler.py @@ -146,29 +146,13 @@ async def test_get_request_falls_back_to_stored_state_on_sync_error(): @pytest.mark.asyncio async def test_get_request_returns_synced_dto_on_success(): - """Normal (no-error) path returns a DTO; cache is written when request transitions to terminal. + """Normal (no-error) path returns a DTO and writes to cache.""" + request = _make_request(_ID_SUCCESS) - Sequence under test: - 1. First get_request() call → IN_PROGRESS (not short-circuited, sync runs). - 2. Sync drives the status to COMPLETED. - 3. Second get_request() call after sync → COMPLETED. - 4. request.status.is_terminal() is True → cache_request() is called once. - """ - in_progress_request = _make_request(_ID_SUCCESS) - in_progress_request = in_progress_request.update_status(RequestStatus.IN_PROGRESS, "syncing") - completed_request = in_progress_request.update_status(RequestStatus.COMPLETED, "done") - - handler, mock_query_service, mock_cache_service = _make_handler( - in_progress_request, sync_side_effect=None - ) + handler, mock_query_service, mock_cache_service = _make_handler(request, sync_side_effect=None) - # get_request is called three times: once for the initial fetch, once - # to refresh after populate_missing_machine_ids (so the status-update - # path sees up-to-date machine_ids), and once after update_status to - # observe the COMPLETED state. - mock_query_service.get_request = AsyncMock( - side_effect=[in_progress_request, in_progress_request, completed_request] - ) + # After sync the handler re-fetches the request; return the same object for simplicity + mock_query_service.get_request = AsyncMock(return_value=request) query = GetRequestQuery(request_id=_ID_SUCCESS) result = await handler.execute_query(query) @@ -176,31 +160,36 @@ async def test_get_request_returns_synced_dto_on_success(): assert isinstance(result, RequestDTO) assert result.request_id == _ID_SUCCESS - # Cache SHOULD be written once the request reaches a terminal state + # Cache SHOULD be written on the success path mock_cache_service.cache_request.assert_called_once() @pytest.mark.asyncio -async def test_get_request_does_not_cache_non_terminal(): - """Non-terminal requests (PENDING, IN_PROGRESS) are never cached. +async def test_get_request_ignores_cache_for_syncing_queries(): + request = _make_request(_ID_SUCCESS) + handler, _, mock_cache_service = _make_handler(request, sync_side_effect=None) + cached = MagicMock(spec=RequestDTO) + mock_cache_service.get_cached_request.return_value = cached - Caching non-terminal states prevents the sync loop from observing provider - state transitions and keeps the request stuck at the cached status. - """ - pending_request = _make_request(_ID_SUCCESS) # PENDING by default + query = GetRequestQuery(request_id=_ID_SUCCESS, lightweight=False) + result = await handler.execute_query(query) - handler, mock_query_service, mock_cache_service = _make_handler( - pending_request, sync_side_effect=None - ) + assert isinstance(result, RequestDTO) + mock_cache_service.get_cached_request.assert_not_called() - mock_query_service.get_request = AsyncMock(return_value=pending_request) - query = GetRequestQuery(request_id=_ID_SUCCESS) +@pytest.mark.asyncio +async def test_get_request_uses_cache_for_lightweight_queries(): + request = _make_request(_ID_SUCCESS) + handler, _, mock_cache_service = _make_handler(request, sync_side_effect=None) + cached = MagicMock(spec=RequestDTO) + mock_cache_service.get_cached_request.return_value = cached + + query = GetRequestQuery(request_id=_ID_SUCCESS, lightweight=True) result = await handler.execute_query(query) - assert isinstance(result, RequestDTO) - # Non-terminal → cache_request must NOT be called - mock_cache_service.cache_request.assert_not_called() + assert result is cached + mock_cache_service.get_cached_request.assert_called_once_with(_ID_SUCCESS) @pytest.mark.asyncio diff --git a/tests/unit/application/queries/test_request_status_capacity.py b/tests/unit/application/queries/test_request_status_capacity.py index e182411f4..0892f008c 100644 --- a/tests/unit/application/queries/test_request_status_capacity.py +++ b/tests/unit/application/queries/test_request_status_capacity.py @@ -274,3 +274,47 @@ def test_provisioning_failure_metadata_forces_failed(): [], [], request, {"provider_fulfilment": fulfilment} ) assert new_status == RequestStatus.IN_PROGRESS.value + + +@pytest.mark.unit +def test_provider_fleet_errors_force_failed_without_persisted_metadata(): + service = _make_service() + request = _request( + RequestType.ACQUIRE, + RequestStatus.IN_PROGRESS, + requested_count=2, + ) + provider_metadata = { + "fleet_errors": [ + { + "error_code": "ProvisioningStateFailed", + "error_message": "VMSS provisioning failed", + } + ] + } + + new_status, msg = service.determine_status_from_machines([], [], request, provider_metadata) + assert new_status == RequestStatus.FAILED.value + assert "VMSS provisioning failed" in msg + + +@pytest.mark.unit +def test_failed_provider_capacity_state_forces_failed_without_instances(): + service = _make_service() + request = _request( + RequestType.ACQUIRE, + RequestStatus.IN_PROGRESS, + requested_count=2, + ) + provider_metadata = { + "fleet_capacity_fulfilment": { + "target_capacity_units": 2, + "fulfilled_capacity_units": 0, + "provisioned_instance_count": 0, + "state": "Failed", + } + } + + new_status, msg = service.determine_status_from_machines([], [], request, provider_metadata) + assert new_status == RequestStatus.FAILED.value + assert "failed state" in msg diff --git a/tests/unit/application/queries/test_template_query_handlers.py b/tests/unit/application/queries/test_template_query_handlers.py index e61af50d4..b2b4e935d 100644 --- a/tests/unit/application/queries/test_template_query_handlers.py +++ b/tests/unit/application/queries/test_template_query_handlers.py @@ -190,6 +190,55 @@ def _get(service_type): assert result["template_id"] == "tpl-stored" +@pytest.mark.asyncio +async def test_validate_handler_preserves_azure_vm_size_candidates_from_stored_template() -> None: + dto = TemplateDTO( + template_id="azure-spot-placement-score-vmss", + name="azure-spot-placement-score-vmss", + provider_type="azure", + provider_name="azure-default", + provider_api="VMSS", + price_type="spot", + allocation_strategy="spotPlacementScore", + vm_size="Standard_D4s_v5", + vm_sizes=["Standard_D8s_v5", "Standard_D16s_v5"], + provider_config={ + "resource_group": "rg", + "location": "eastus2", + "image": { + "publisher": "Canonical", + "offer": "0001-com-ubuntu-server-jammy", + "sku": "22_04-lts-gen2", + "version": "latest", + }, + "ssh_public_keys": [ + { + "path": "/home/azureuser/.ssh/authorized_keys", + "key_data": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCtest azure@example", + } + ], + }, + ) + template_manager = MagicMock() + template_manager.get_template_by_id = AsyncMock(return_value=dto) + template_manager.validate_template_config = MagicMock(return_value=[]) + + container = MagicMock() + container.get.return_value = template_manager + + handler = _make_validate_handler(container) + result = await handler.execute_query( + ValidateTemplateQuery(template_id="azure-spot-placement-score-vmss") + ) + + validated_config = template_manager.validate_template_config.call_args.args[0] + assert validated_config["vm_size"] == "Standard_D4s_v5" + assert validated_config["vm_sizes"] == ["Standard_D8s_v5", "Standard_D16s_v5"] + assert validated_config["image"]["publisher"] == "Canonical" + assert validated_config["metadata"] == {} + assert result["valid"] is True + + @pytest.mark.asyncio async def test_validate_handler_not_found_propagates() -> None: """EntityNotFoundError from storage is re-raised, not swallowed.""" diff --git a/tests/unit/application/services/orchestration/test_acquire_machines_orchestrator.py b/tests/unit/application/services/orchestration/test_acquire_machines_orchestrator.py index 31977766e..cbd9280c7 100644 --- a/tests/unit/application/services/orchestration/test_acquire_machines_orchestrator.py +++ b/tests/unit/application/services/orchestration/test_acquire_machines_orchestrator.py @@ -95,6 +95,8 @@ async def set_request_id(cmd): query = mock_query_bus.execute.call_args[0][0] assert isinstance(query, GetRequestQuery) assert query.request_id == "req-123" + assert query.lightweight is False + assert query.verbose is True assert result.status == "completed" @pytest.mark.asyncio diff --git a/tests/unit/application/services/orchestration/test_return_machines_orchestrator.py b/tests/unit/application/services/orchestration/test_return_machines_orchestrator.py index e65c8c5fa..ff202dde0 100644 --- a/tests/unit/application/services/orchestration/test_return_machines_orchestrator.py +++ b/tests/unit/application/services/orchestration/test_return_machines_orchestrator.py @@ -7,6 +7,7 @@ import pytest from orb.application.dto.commands import CreateReturnRequestCommand +from orb.application.dto.queries import GetRequestQuery from orb.application.dto.queries import ListMachinesQuery from orb.application.services.orchestration.dtos import ReturnMachinesInput, ReturnMachinesOutput from orb.application.services.orchestration.return_machines import ReturnMachinesOrchestrator @@ -191,48 +192,29 @@ async def _set_request_ids(cmd): assert result.request_id == "ret-req-001" @pytest.mark.asyncio - async def test_output_machine_ids_populated_from_explicit_input( - self, orchestrator, mock_command_bus + async def test_execute_wait_true_uses_syncing_request_status_query( + self, orchestrator, mock_command_bus, mock_query_bus ): - """machine_ids in output must reflect the machines submitted for return.""" - - async def _set_request_ids(cmd): + async def set_request_ids(cmd): cmd.created_request_ids = ["ret-req-001"] - mock_command_bus.execute.side_effect = _set_request_ids - input = ReturnMachinesInput(machine_ids=["i-aaa", "i-bbb", "i-ccc"]) - result = await orchestrator.execute(input) - assert isinstance(result, ReturnMachinesOutput) - assert result.machine_ids == ["i-aaa", "i-bbb", "i-ccc"] - - @pytest.mark.asyncio - async def test_output_machine_ids_populated_from_all_machines_path( - self, orchestrator, mock_query_bus, mock_command_bus - ): - """machine_ids in output must reflect resolved IDs from --all path.""" - mock_query_bus.execute.return_value = [ - MagicMock(machine_id="i-001"), - MagicMock(machine_id="i-002"), - ] + mock_command_bus.execute.side_effect = set_request_ids - async def _set_request_ids(cmd): - cmd.created_request_ids = ["ret-req-001"] + poll_result = MagicMock() + poll_result.status = MagicMock() + poll_result.status.value = "completed" + mock_query_bus.execute.return_value = poll_result - mock_command_bus.execute.side_effect = _set_request_ids - input = ReturnMachinesInput(all_machines=True) - result = await orchestrator.execute(input) - assert sorted(result.machine_ids) == ["i-001", "i-002"] + input = ReturnMachinesInput(machine_ids=["m-001"], wait=True, timeout_seconds=10) - @pytest.mark.asyncio - async def test_output_machine_ids_empty_when_no_op(self, orchestrator, mock_command_bus): - """machine_ids must be empty (default) when no request was created.""" + from unittest.mock import patch - async def _set_empty(cmd): - cmd.created_request_ids = [] - cmd.skipped_machines = ["i-001"] + with patch("asyncio.sleep", new_callable=AsyncMock): + result = await orchestrator.execute(input) - mock_command_bus.execute.side_effect = _set_empty - input = ReturnMachinesInput(machine_ids=["i-001"]) - result = await orchestrator.execute(input) - assert result.status == "no_op" - assert result.machine_ids == [] + query = mock_query_bus.execute.call_args[0][0] + assert isinstance(query, GetRequestQuery) + assert query.request_id == "ret-req-001" + assert query.lightweight is False + assert query.verbose is True + assert result.status == "completed" diff --git a/tests/unit/application/services/test_deprovisioning_orchestrator.py b/tests/unit/application/services/test_deprovisioning_orchestrator.py new file mode 100644 index 000000000..f6a5ea7d3 --- /dev/null +++ b/tests/unit/application/services/test_deprovisioning_orchestrator.py @@ -0,0 +1,83 @@ +"""Behavior tests for DeprovisioningOrchestrator.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from orb.application.services.deprovisioning_orchestrator import DeprovisioningOrchestrator +from orb.domain.base.operations import OperationResult, OperationType + + +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_process_resource_group_forwards_merged_request_metadata(): + uow_factory = MagicMock() + logger = MagicMock() + container = MagicMock() + query_bus = MagicMock() + provider_selection_port = MagicMock() + + scheduler = MagicMock() + scheduler.format_template_for_provider.return_value = { + "template_id": "azure-vmss-test", + "provider_api": "VMSS", + } + config_manager = MagicMock() + container.get.side_effect = [scheduler, config_manager] + + template = MagicMock() + template.template_id = "azure-vmss-test" + query_bus.execute = AsyncMock(return_value=template) + provider_selection_port.execute_operation = AsyncMock( + return_value=OperationResult.success_result( + data={"success": True}, + metadata={"provider_data": {}}, + ) + ) + + orchestrator = DeprovisioningOrchestrator( + uow_factory=uow_factory, + logger=logger, + container=container, + query_bus=query_bus, + provider_selection_port=provider_selection_port, + ) + + machine = MagicMock() + machine.machine_id.value = "vm-1" + machine.template_id = "azure-vmss-test" + machine.request_id = "req-origin" + + request = MagicMock() + request.request_id = "ret-1" + request.metadata = {"provider_selection_reason": "configured-default"} + request.provider_data = { + "follow_up_context": { + "resource_group": "orb-test-rg", + "cyclecloud_credential_secret_path": "op://vault/item/field", + } + } + + result = await orchestrator._process_resource_group( + provider_name="azure-default", + provider_api="VMSS", + resource_id="vmss-demo", + machines=[machine], + request=request, + ) + + assert result["success"] is True + operation = provider_selection_port.execute_operation.await_args.args[1] + assert operation.operation_type == OperationType.TERMINATE_INSTANCES + assert operation.parameters["request_metadata"]["resource_group"] == "orb-test-rg" + assert ( + operation.parameters["request_metadata"]["cyclecloud_credential_secret_path"] + == "op://vault/item/field" + ) + assert ( + operation.parameters["request_metadata"]["provider_selection_reason"] + == "configured-default" + ) diff --git a/tests/unit/application/services/test_machine_sync_service.py b/tests/unit/application/services/test_machine_sync_service.py index c067e4902..be9f877f6 100644 --- a/tests/unit/application/services/test_machine_sync_service.py +++ b/tests/unit/application/services/test_machine_sync_service.py @@ -1,145 +1,223 @@ -"""Unit tests for MachineSyncService — basic behaviour and OperationOutcome awareness. +"""Behavior tests for MachineSyncService request-context propagation.""" -The sync service does not directly call acquire/return_machines/get_status; it uses -execute_operation via ProviderRegistryService. These tests cover the core paths -that interact with the outcome-aware request status logic. -""" +from __future__ import annotations -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest from orb.application.services.machine_sync_service import MachineSyncService -from orb.domain.machine.machine_status import MachineStatus +from orb.domain.base.operations import OperationType -def _make_service() -> MachineSyncService: +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_fetch_provider_machines_replays_persisted_request_metadata(): command_bus = MagicMock() uow_factory = MagicMock() config_port = MagicMock() + config_port.get_provider_instance_config.return_value = MagicMock() logger = MagicMock() - return MachineSyncService( + provider_registry_service = MagicMock() + provider_registry_service.execute_operation = AsyncMock( + return_value=MagicMock(success=True, data={"instances": []}, metadata={}) + ) + + service = MachineSyncService( command_bus=command_bus, uow_factory=uow_factory, config_port=config_port, logger=logger, + provider_registry_service=provider_registry_service, ) - -def _make_request(request_type: str = "acquire", provider_api: str = "RunInstances"): - req = MagicMock() - req.request_id = MagicMock() - req.request_id.__str__ = lambda self: "req-sync-test" - req.request_type.value = request_type - req.provider_name = "aws_default_us-east-1" - req.provider_api = provider_api - req.template_id = "tmpl-1" - req.resource_ids = ["fleet-1"] - req.machine_ids = [] - req.metadata = {} - return req - - -def _make_machine(mid: str, status: MachineStatus = MachineStatus.RUNNING): - m = MagicMock() - m.machine_id.value = mid - m.status = status - return m + request = MagicMock() + request.request_type.value = "acquire" + request.resource_ids = ["vmss-demo"] + request.machine_ids = [] + request.provider_api = "VMSS" + request.template_id = "azure-cheapest-vmss" + request.request_id = "req-00000000-0000-0000-0000-000000000001" + request.provider_name = "azure-default" + request.provider_type = "azure" + request.metadata = {"provider_selection_reason": "configured-default"} + request.provider_data = { + "follow_up_context": { + "resource_group": "orb-test-rg", + "deployment_name": "dep-1234", + } + } + + await service.fetch_provider_machines(request, db_machines=[]) + + operation = provider_registry_service.execute_operation.await_args.args[1] + assert operation.operation_type == OperationType.DESCRIBE_RESOURCE_INSTANCES + assert operation.parameters["request_metadata"]["resource_group"] == "orb-test-rg" + assert operation.parameters["request_metadata"]["deployment_name"] == "dep-1234" + assert ( + operation.parameters["request_metadata"]["provider_selection_reason"] + == "configured-default" + ) @pytest.mark.unit -class TestFetchProviderMachinesNoRegistryService: - """fetch_provider_machines returns (db_machines, {}) when no registry service.""" - - @pytest.mark.asyncio - async def test_returns_db_machines_when_registry_service_missing(self): - svc = _make_service() - db_machines = [_make_machine("i-1")] - req = _make_request() +@pytest.mark.application +@pytest.mark.asyncio +async def test_fetch_provider_machines_for_return_forwards_resource_id(): + command_bus = MagicMock() + uow_factory = MagicMock() + config_port = MagicMock() + config_port.get_provider_instance_config.return_value = MagicMock() + logger = MagicMock() + provider_registry_service = MagicMock() + provider_registry_service.execute_operation = AsyncMock( + return_value=MagicMock(success=True, data={"instances": []}, metadata={}) + ) - # No provider_registry_service injected → should raise RuntimeError - # which is caught internally and returns (db_machines, {}) - machines, meta = await svc.fetch_provider_machines(req, db_machines) # type: ignore[arg-type] - assert machines == db_machines - assert meta == {} + service = MachineSyncService( + command_bus=command_bus, + uow_factory=uow_factory, + config_port=config_port, + logger=logger, + provider_registry_service=provider_registry_service, + ) + request = MagicMock() + request.request_type.value = "return" + request.resource_ids = [] + request.machine_ids = ["vmss-demo_000001"] + request.provider_api = "VMSS" + request.template_id = "azure-cheapest-vmss" + request.request_id = "ret-00000000-0000-0000-0000-000000000001" + request.provider_name = "azure-default" + request.provider_type = "azure" + request.metadata = {"provider_selection_reason": "configured-default"} + request.provider_data = {"follow_up_context": {"resource_group": "orb-test-rg"}} -@pytest.mark.unit -class TestFetchProviderMachinesEmpty: - """fetch_provider_machines returns ([], {}) when no machine context.""" + db_machine = MagicMock() + db_machine.machine_id.value = "vmss-demo_000001" + db_machine.resource_id = "vmss-demo" - @pytest.mark.asyncio - async def test_no_resource_ids_and_no_db_machines_returns_empty(self): - svc = _make_service() - req = _make_request() - req.resource_ids = [] - req.machine_ids = [] + await service.fetch_provider_machines(request, db_machines=[db_machine]) - machines, meta = await svc.fetch_provider_machines(req, []) - assert machines == [] - assert meta == {} + operation = provider_registry_service.execute_operation.await_args.args[1] + assert operation.operation_type == OperationType.GET_INSTANCE_STATUS + assert operation.parameters["provider_api"] == "VMSS" + assert operation.parameters["resource_id"] == "vmss-demo" + assert "resource_mapping" not in operation.parameters + assert operation.parameters["request_metadata"]["resource_group"] == "orb-test-rg" @pytest.mark.unit -class TestFetchProviderMachinesWithMockRegistry: - """fetch_provider_machines propagates provider instance data.""" - - @pytest.mark.asyncio - async def test_acquire_path_calls_registry_service(self): - """fetch_provider_machines calls the registry service for acquire requests.""" - from orb.providers.base.strategy.provider_strategy import ProviderResult - - svc = _make_service() - - captured: list = [] - - async def _capture(provider_name, operation): - captured.append(operation.operation_type.value) - return ProviderResult.success_result(data={"instances": []}) - - registry_svc = MagicMock() - registry_svc.execute_operation = _capture - svc._provider_registry_service = registry_svc - - req = _make_request(request_type="acquire") - db_machines: list = [] - - machines, _meta = await svc.fetch_provider_machines(req, db_machines) - # The registry was called and returned empty instances → machines is empty - assert "describe_resource_instances" in captured - assert machines == [] - - @pytest.mark.asyncio - async def test_return_path_uses_instance_status_operation(self): - """For return requests with machine_ids, the GET_INSTANCE_STATUS operation is used.""" - from orb.providers.base.strategy.provider_strategy import ProviderResult - - svc = _make_service() +@pytest.mark.application +@pytest.mark.asyncio +async def test_fetch_provider_machines_for_return_resolves_resource_id_from_follow_up_context(): + command_bus = MagicMock() + uow_factory = MagicMock() + config_port = MagicMock() + config_port.get_provider_instance_config.return_value = MagicMock() + logger = MagicMock() + provider_registry_service = MagicMock() + provider_registry_service.execute_operation = AsyncMock( + return_value=MagicMock(success=True, data={"instances": []}, metadata={}) + ) - called_operations: list = [] + service = MachineSyncService( + command_bus=command_bus, + uow_factory=uow_factory, + config_port=config_port, + logger=logger, + provider_registry_service=provider_registry_service, + ) - async def _capture(provider_name, operation): - called_operations.append(operation.operation_type.value) - return ProviderResult.success_result( - data={ - "instances": [ - { - "instance_id": "i-1", - "status": "shutting-down", - "instance_type": "m5.large", - } - ] - }, - ) + request = MagicMock() + request.request_type.value = "return" + request.resource_ids = [] + request.machine_ids = ["vmss-demo_000001"] + request.provider_api = "VMSS" + request.template_id = "azure-cheapest-vmss" + request.request_id = "ret-00000000-0000-0000-0000-000000000002" + request.provider_name = "azure-default" + request.provider_type = "azure" + request.metadata = {"provider_selection_reason": "configured-default"} + request.provider_data = { + "follow_up_context": { + "resource_group": "orb-test-rg", + "termination_requests": [ + { + "pending_resource_cleanup": { + "resource_group": "orb-test-rg", + "resource_id": "vmss-demo", + "machine_ids": ["vmss-demo_000001"], + "delete_vmss_when_empty": True, + } + } + ], + } + } + + await service.fetch_provider_machines(request, db_machines=[]) + + operation = provider_registry_service.execute_operation.await_args.args[1] + assert operation.operation_type == OperationType.GET_INSTANCE_STATUS + assert operation.parameters["provider_api"] == "VMSS" + assert operation.parameters["resource_id"] == "vmss-demo" + assert "resource_mapping" not in operation.parameters + assert operation.parameters["request_metadata"]["resource_group"] == "orb-test-rg" - registry_svc = MagicMock() - registry_svc.execute_operation = _capture - svc._provider_registry_service = registry_svc - req = _make_request(request_type="return") - req.machine_ids = ["i-1"] +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_fetch_provider_machines_preserves_instance_owned_resource_id_for_multi_resource_requests(): + command_bus = MagicMock() + uow_factory = MagicMock() + config_port = MagicMock() + config_port.get_provider_instance_config.return_value = MagicMock() + logger = MagicMock() + provider_registry_service = MagicMock() + provider_registry_service.execute_operation = AsyncMock( + return_value=MagicMock( + success=True, + data={ + "instances": [ + { + "instance_id": "vmss-b_000001", + "status": "running", + "instance_type": "Standard_D4s_v5", + "provider_type": "azure", + "provider_data": { + "resource_id": "vmss-b", + }, + } + ] + }, + metadata={}, + ) + ) - db_machines = [_make_machine("i-1", MachineStatus.RUNNING)] - await svc.fetch_provider_machines(req, db_machines) # type: ignore[arg-type] + service = MachineSyncService( + command_bus=command_bus, + uow_factory=uow_factory, + config_port=config_port, + logger=logger, + provider_registry_service=provider_registry_service, + ) - assert "get_instance_status" in called_operations + request = MagicMock() + request.request_type.value = "acquire" + request.resource_ids = ["vmss-a", "vmss-b"] + request.machine_ids = [] + request.provider_api = "VMSS" + request.template_id = "azure-cheapest-vmss" + request.request_id = "req-00000000-0000-0000-0000-000000000003" + request.provider_name = "azure-default" + request.provider_type = "azure" + request.metadata = {} + request.provider_data = {} + + provider_machines, _metadata = await service.fetch_provider_machines(request, db_machines=[]) + + assert len(provider_machines) == 1 + assert provider_machines[0].resource_id == "vmss-b" diff --git a/tests/unit/application/services/test_provider_api_fallback_removal.py b/tests/unit/application/services/test_provider_api_fallback_removal.py index d3f080030..f7d1a8f92 100644 --- a/tests/unit/application/services/test_provider_api_fallback_removal.py +++ b/tests/unit/application/services/test_provider_api_fallback_removal.py @@ -148,6 +148,7 @@ def _make_machine(self, machine_id, provider_name, provider_api, resource_id="re m.provider_name = provider_name m.provider_api = provider_api m.resource_id = resource_id + m.provider_data = {} return m def _setup_uow(self, machines_by_id: dict): @@ -215,7 +216,6 @@ def test_mixed_machines_raise_on_missing_provider_api(self): self.logger.warning.assert_called_once() assert "i-bbb" in str(self.logger.warning.call_args) - # --------------------------------------------------------------------------- # TemplateDefaultsService: raises ValueError instead of returning 'EC2Fleet' # --------------------------------------------------------------------------- diff --git a/tests/unit/application/services/test_provisioning_orchestration_service.py b/tests/unit/application/services/test_provisioning_orchestration_service.py index a6d880d81..265cc161b 100644 --- a/tests/unit/application/services/test_provisioning_orchestration_service.py +++ b/tests/unit/application/services/test_provisioning_orchestration_service.py @@ -1,355 +1,325 @@ -"""Unit tests for ProvisioningOrchestrationService — OperationOutcome integration. +"""Behavior tests for ProvisioningOrchestrationService.""" -Covers: -- ProvisioningResult.outcome is set for all return paths -- ProvisioningResult.is_final is correctly derived from outcome via __post_init__ -- assert_never exhaustiveness compiles (no missed union branches) -""" +from __future__ import annotations -from typing import assert_never +import asyncio from unittest.mock import AsyncMock, MagicMock import pytest +from orb.application.ports.scheduler_port import SchedulerPort from orb.application.services.provisioning_orchestration_service import ( ProvisioningOrchestrationService, - ProvisioningResult, -) -from orb.domain.base.operation_outcome import ( - Accepted, - Completed, - Failed, - OperationOutcome, - RequiresFollowUp, ) +from orb.providers.base.strategy.provider_strategy import ProviderResult from orb.domain.base.results import ProviderSelectionResult +from orb.domain.request.aggregate import Request +from orb.domain.request.value_objects import RequestType -# --------------------------------------------------------------------------- -# ProvisioningResult — outcome/is_final derivation -# --------------------------------------------------------------------------- - - -class TestProvisioningResultOutcomeDerivation: - """is_final must be derived from outcome when outcome is present.""" - - def test_accepted_sets_is_final_false(self): - result = ProvisioningResult( - success=True, - resource_ids=["r-1"], - machine_ids=[], - instances=[], - provider_data={}, - outcome=Accepted(request_id="req-1", pending_resource_ids=["r-1"]), - ) - assert result.is_final is False - - def test_completed_sets_is_final_true(self): - result = ProvisioningResult( - success=True, - resource_ids=["r-1"], - machine_ids=["i-1"], - instances=[{"id": "i-1"}], - provider_data={}, - outcome=Completed(resource_ids=["r-1"]), - ) - assert result.is_final is True - - def test_failed_sets_is_final_true(self): - result = ProvisioningResult( - success=False, - resource_ids=[], - machine_ids=[], - instances=[], - provider_data={}, - outcome=Failed(error="something went wrong"), - ) - assert result.is_final is True - - def test_recoverable_failed_sets_is_final_true(self): - result = ProvisioningResult( - success=False, - resource_ids=[], - machine_ids=[], - instances=[], - provider_data={}, - outcome=Failed(error="timeout", recoverable=True), - ) - assert result.is_final is True - def test_requires_follow_up_sets_is_final_false(self): - from orb.application.services.request_follow_up_context import ( - TerminationFollowUpContext, - ) +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_async_submitted_create_is_not_retried(): + container = MagicMock() + scheduler = MagicMock() + scheduler.format_template_for_provider.return_value = { + "template_id": "azure-vmss-test", + "provider_api": "VMSS", + } + container.get.return_value = scheduler - ctx = TerminationFollowUpContext(pending_instance_ids=["i-1"]) - result = ProvisioningResult( - success=True, - resource_ids=[], - machine_ids=[], - instances=[], - provider_data={}, - outcome=RequiresFollowUp(context=ctx), - ) - assert result.is_final is False - - def test_no_outcome_honours_explicit_is_final_true(self): - """Legacy path: when outcome is None, explicit is_final is preserved.""" - result = ProvisioningResult( - success=True, - resource_ids=[], - machine_ids=[], - instances=[], - provider_data={}, - is_final=True, - ) - assert result.is_final is True - - def test_no_outcome_honours_explicit_is_final_false(self): - """Legacy path: when outcome is None, explicit is_final=False is preserved.""" - result = ProvisioningResult( - success=True, - resource_ids=[], - machine_ids=[], - instances=[], - provider_data={}, - is_final=False, + provider_selection_port = MagicMock() + provider_selection_port.execute_operation = AsyncMock( + return_value=ProviderResult.success_result( + data={ + "resource_ids": ["vmss-azure-vmss-test-1234"], + "instances": [], + }, + metadata={ + "provider_data": { + "operation_status": "submitted", + "fulfillment_final": True, + } + }, ) - assert result.is_final is False - - -# --------------------------------------------------------------------------- -# OperationOutcome exhaustiveness — assert_never -# --------------------------------------------------------------------------- - - -class TestOperationOutcomeExhaustiveness: - """assert_never must cover all union branches without static errors.""" - - def test_all_union_branches_handled(self): - """Exhaustive match over all OperationOutcome variants must not raise.""" - outcomes: list[OperationOutcome] = [ - Accepted(request_id="req-1"), - Completed(resource_ids=["r-1"]), - Failed(error="oops"), - ] - results = [] - for outcome in outcomes: - match outcome: - case Accepted(request_id=rid): - results.append(f"accepted:{rid}") - case Completed(resource_ids=ids): - results.append(f"completed:{ids}") - case RequiresFollowUp(): - results.append("follow_up") - case Failed(error=msg): - results.append(f"failed:{msg}") - case _ as unreachable: - assert_never(unreachable) - - assert results == ["accepted:req-1", "completed:['r-1']", "failed:oops"] - - -# --------------------------------------------------------------------------- -# _dispatch_single_attempt — outcome attached on success path -# --------------------------------------------------------------------------- - + ) -def _make_service() -> ProvisioningOrchestrationService: - container = MagicMock() - logger = MagicMock() - provider_selection_port = MagicMock() provider_config_port = MagicMock() config_port = MagicMock() - circuit_breaker_factory = MagicMock() - config_port.get_request_config.return_value = { - "dispatch_timeout_seconds": 10.0, + "fulfillment_max_retries": 3, + "fulfillment_timeout_seconds": 300, + "fulfillment_batch_size": 1000, } - cb = MagicMock() - cb.has_state.return_value = False - circuit_breaker_factory.return_value = cb - - return ProvisioningOrchestrationService( + service = ProvisioningOrchestrationService( container=container, - logger=logger, + logger=MagicMock(), provider_selection_port=provider_selection_port, provider_config_port=provider_config_port, config_port=config_port, - circuit_breaker_factory=circuit_breaker_factory, + circuit_breaker_factory=lambda _key: MagicMock(has_state=MagicMock(return_value=False)), ) - -def _make_request(count: int = 2): - req = MagicMock() - req.request_id = "req-outcome-test" - req.requested_count = count - req.metadata = {} - req.update_metadata = lambda d: req - return req - - -def _make_selection_result() -> ProviderSelectionResult: - return ProviderSelectionResult( - provider_name="aws_default_us-east-1", - provider_type="aws", + request = Request.create_new_request( + request_type=RequestType.ACQUIRE, + template_id="azure-vmss-test", + machine_count=1, + provider_type="azure", + provider_name="azure-default", + request_id="req-12345678-1234-1234-1234-123456789012", + ) + template = MagicMock() + template.template_id = "azure-vmss-test" + selection_result = ProviderSelectionResult( + provider_type="azure", + provider_name="azure-default", selection_reason="test", - confidence=1.0, ) + result = await service.execute_provisioning(template, request, selection_result) -class TestDispatchSingleAttemptOutcome: - """_dispatch_single_attempt must attach an OperationOutcome to the result.""" - - @pytest.mark.asyncio - async def test_partial_fulfillment_yields_accepted_outcome(self): - """Fewer instances than requested with async polling → Accepted (still processing).""" - from orb.providers.base.strategy.provider_strategy import ProviderResult - - svc = _make_service() - provider_result = ProviderResult.success_result( - data={ - "resource_ids": ["fleet-1"], - "instances": [{"id": "i-1"}], # only 1 of 2 requested - "instance_ids": ["i-1"], - }, - metadata={"requires_async_polling": True}, - ) - svc._provider_selection_port.execute_operation = AsyncMock(return_value=provider_result) - - scheduler = MagicMock() - scheduler.format_template_for_provider.return_value = {} - svc._container.get.return_value = scheduler + assert result.success is True + assert result.resource_ids == ["vmss-azure-vmss-test-1234"] + assert result.instances == [] + assert result.is_final is True + assert provider_selection_port.execute_operation.await_count == 1 + provider_config_port.get_provider_instance_config.assert_called_once_with("azure-default") + container.get.assert_any_call(SchedulerPort) - result = await svc._dispatch_single_attempt( - MagicMock(template_id="t-1"), - _make_request(count=2), - _make_selection_result(), - count=2, - ) - assert result.success is True - assert isinstance(result.outcome, Accepted) - assert result.is_final is False - - @pytest.mark.asyncio - async def test_full_fulfillment_with_no_polling_signal_yields_completed(self): - """Synchronous provider (requires_async_polling=False) + full count → Completed.""" - from orb.providers.base.strategy.provider_strategy import ProviderResult +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_planned_async_submitted_create_is_not_retried(): + container = MagicMock() + scheduler = MagicMock() + scheduler.format_template_for_provider.return_value = { + "template_id": "azure-spot-placement-score-vmss", + "provider_api": "VMSS", + "allocation_strategy": "spotPlacementScore", + } + container.get.return_value = scheduler - svc = _make_service() - provider_result = ProviderResult.success_result( + provider_selection_port = MagicMock() + provider_selection_port.execute_operation = AsyncMock( + return_value=ProviderResult.success_result( data={ - "resource_ids": ["fleet-1"], - "instances": [{"id": "i-1"}, {"id": "i-2"}], - "instance_ids": ["i-1", "i-2"], + "resource_ids": ["vmss-azure-spot-placement-score-vmss-1234"], + "instances": [], + }, + metadata={ + "provider_data": { + "placement_plan": [ + { + "candidate_id": "azure:eastus2:3:Standard_D2s_v5", + "planned_count": 1, + } + ], + "child_results": [ + { + "candidate_id": "azure:eastus2:3:Standard_D2s_v5", + "requested_count": 1, + "success": True, + "resource_ids": ["vmss-azure-spot-placement-score-vmss-1234"], + "instances": [], + } + ], + "failed_subplans": [], + "unfulfilled_count": 0, + "terminal_error_message": None, + "fulfillment_final": True, + } }, - metadata={"requires_async_polling": False}, ) - svc._provider_selection_port.execute_operation = AsyncMock(return_value=provider_result) + ) - scheduler = MagicMock() - scheduler.format_template_for_provider.return_value = {} - svc._container.get.return_value = scheduler + provider_config_port = MagicMock() + config_port = MagicMock() + config_port.get_request_config.return_value = { + "fulfillment_max_retries": 3, + "fulfillment_timeout_seconds": 300, + "fulfillment_batch_size": 1000, + } - result = await svc._dispatch_single_attempt( - MagicMock(template_id="t-1"), - _make_request(count=2), - _make_selection_result(), - count=2, - ) + service = ProvisioningOrchestrationService( + container=container, + logger=MagicMock(), + provider_selection_port=provider_selection_port, + provider_config_port=provider_config_port, + config_port=config_port, + circuit_breaker_factory=lambda _key: MagicMock(has_state=MagicMock(return_value=False)), + ) - assert result.success is True - assert isinstance(result.outcome, Completed) - assert result.is_final is True + request = Request.create_new_request( + request_type=RequestType.ACQUIRE, + template_id="azure-spot-placement-score-vmss", + machine_count=1, + provider_type="azure", + provider_name="azure-default", + request_id="req-12345678-1234-1234-1234-123456789012", + ) + template = MagicMock() + template.template_id = "azure-spot-placement-score-vmss" + selection_result = ProviderSelectionResult( + provider_type="azure", + provider_name="azure-default", + selection_reason="test", + ) - @pytest.mark.asyncio - async def test_full_fulfillment_with_polling_signal_yields_accepted(self): - """Async provider (requires_async_polling=True) + full count → Accepted. + result = await service.execute_provisioning(template, request, selection_result) + + assert result.success is True + assert result.resource_ids == ["vmss-azure-spot-placement-score-vmss-1234"] + assert result.instances == [] + assert result.is_final is True + assert result.provider_data == { + "placement_plan": [ + { + "candidate_id": "azure:eastus2:3:Standard_D2s_v5", + "planned_count": 1, + } + ], + "child_results": [ + { + "candidate_id": "azure:eastus2:3:Standard_D2s_v5", + "requested_count": 1, + "success": True, + "resource_ids": ["vmss-azure-spot-placement-score-vmss-1234"], + "instances": [], + } + ], + "failed_subplans": [], + "unfulfilled_count": 0, + "terminal_error_message": None, + "fulfillment_final": True, + } + assert provider_selection_port.execute_operation.await_count == 1 - Until the provider signals no more polling is needed, the orchestrator - must keep polling. Instances exist but may still be pending. This guards - the Accepted-vs-Completed branch from collapsing into a constant: a - future regression where the orchestrator wrongly emits Completed on - every success would break here. - """ - from orb.providers.base.strategy.provider_strategy import ProviderResult - svc = _make_service() - provider_result = ProviderResult.success_result( - data={ - "resource_ids": ["fleet-1"], - "instances": [{"id": "i-1"}, {"id": "i-2"}], - "instance_ids": ["i-1", "i-2"], - }, - metadata={"requires_async_polling": True}, +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_provider_error_with_no_result_data_preserves_error_message(): + container = MagicMock() + scheduler = MagicMock() + scheduler.format_template_for_provider.return_value = { + "template_id": "azure-single-vm-test", + "provider_api": "SingleVM", + } + container.get.return_value = scheduler + + provider_selection_port = MagicMock() + provider_selection_port.execute_operation = AsyncMock( + return_value=ProviderResult.error_result( + "Provisioning failed: insufficient capacity", + "PROVISIONING_ADAPTER_ERROR", + metadata={"provider_data": {"fleet_errors": [{"error_code": "AllocationFailed"}]}}, ) - svc._provider_selection_port.execute_operation = AsyncMock(return_value=provider_result) + ) - scheduler = MagicMock() - scheduler.format_template_for_provider.return_value = {} - svc._container.get.return_value = scheduler + provider_config_port = MagicMock() + config_port = MagicMock() + config_port.get_request_config.return_value = { + "fulfillment_max_retries": 3, + "fulfillment_timeout_seconds": 300, + "fulfillment_batch_size": 1000, + } - result = await svc._dispatch_single_attempt( - MagicMock(template_id="t-1"), - _make_request(count=2), - _make_selection_result(), - count=2, - ) + service = ProvisioningOrchestrationService( + container=container, + logger=MagicMock(), + provider_selection_port=provider_selection_port, + provider_config_port=provider_config_port, + config_port=config_port, + circuit_breaker_factory=lambda _key: MagicMock(has_state=MagicMock(return_value=False)), + ) - assert result.success is True - assert isinstance(result.outcome, Accepted) - assert result.is_final is False + request = Request.create_new_request( + request_type=RequestType.ACQUIRE, + template_id="azure-single-vm-test", + machine_count=1, + provider_type="azure", + provider_name="azure-default", + request_id="req-12345678-1234-1234-1234-123456789012", + ) + template = MagicMock() + template.template_id = "azure-single-vm-test" + selection_result = ProviderSelectionResult( + provider_type="azure", + provider_name="azure-default", + selection_reason="test", + ) - @pytest.mark.asyncio - async def test_provider_failure_yields_failed_outcome(self): - """Provider-side error → Failed outcome attached.""" - from orb.providers.base.strategy.provider_strategy import ProviderResult + result = await service.execute_provisioning(template, request, selection_result) - svc = _make_service() - provider_result = ProviderResult.error_result("InsufficientCapacity", "CAPACITY_ERROR") - svc._provider_selection_port.execute_operation = AsyncMock(return_value=provider_result) + assert result.success is False + assert result.error_message == "Provisioning failed: insufficient capacity" + assert result.provider_data == { + "fleet_errors": [{"error_code": "AllocationFailed"}] + } - scheduler = MagicMock() - scheduler.format_template_for_provider.return_value = {} - svc._container.get.return_value = scheduler - result = await svc._dispatch_single_attempt( - MagicMock(template_id="t-1"), - _make_request(count=2), - _make_selection_result(), - count=2, - ) +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_execute_provisioning_times_out_hung_provider_dispatch(): + container = MagicMock() + scheduler = MagicMock() + scheduler.format_template_for_provider.return_value = { + "template_id": "azure-single-vm-test", + "provider_api": "SingleVM", + } + container.get.return_value = scheduler - assert result.success is False - assert isinstance(result.outcome, Failed) - assert result.is_final is True + provider_selection_port = MagicMock() - @pytest.mark.asyncio - async def test_timeout_yields_recoverable_failed_outcome(self): - """Dispatch timeout → Failed(recoverable=True).""" - import asyncio + async def _hang(*_args, **_kwargs): + await asyncio.sleep(1) - svc = _make_service() + provider_selection_port.execute_operation = AsyncMock(side_effect=_hang) - async def _hang(*_a, **_kw): - await asyncio.sleep(60) + provider_config_port = MagicMock() + config_port = MagicMock() + config_port.get_request_config.return_value = { + "fulfillment_max_retries": 3, + "fulfillment_timeout_seconds": 0.01, + "fulfillment_batch_size": 1000, + } + logger = MagicMock() - svc._provider_selection_port.execute_operation = _hang + service = ProvisioningOrchestrationService( + container=container, + logger=logger, + provider_selection_port=provider_selection_port, + provider_config_port=provider_config_port, + config_port=config_port, + circuit_breaker_factory=lambda _key: MagicMock(has_state=MagicMock(return_value=False)), + ) - scheduler = MagicMock() - scheduler.format_template_for_provider.return_value = {} - svc._container.get.return_value = scheduler + request = Request.create_new_request( + request_type=RequestType.ACQUIRE, + template_id="azure-single-vm-test", + machine_count=1, + provider_type="azure", + provider_name="azure-default", + request_id="req-12345678-1234-1234-1234-123456789012", + ) + template = MagicMock() + selection_result = ProviderSelectionResult( + provider_type="azure", + provider_name="azure-default", + selection_reason="test", + ) - result = await svc._dispatch_single_attempt( - MagicMock(template_id="t-1"), - _make_request(count=2), - _make_selection_result(), - count=2, - dispatch_timeout_seconds=0.05, - ) + result = await service.execute_provisioning(template, request, selection_result) - assert result.success is False - assert isinstance(result.outcome, Failed) - assert result.outcome.recoverable is True - assert result.is_final is True + assert result.success is False + assert result.error_message == ( + "Provisioning operation timed out; provider submission status is unknown" + ) + assert result.provider_data["operation_status"] == "timeout" + assert result.provider_data["submission_status"] == "unknown" + assert result.provider_data["timed_out"] is True + logger.warning.assert_called() diff --git a/tests/unit/application/services/test_provisioning_telemetry_flow.py b/tests/unit/application/services/test_provisioning_telemetry_flow.py index f20f9214d..e6e3ff943 100644 --- a/tests/unit/application/services/test_provisioning_telemetry_flow.py +++ b/tests/unit/application/services/test_provisioning_telemetry_flow.py @@ -90,7 +90,11 @@ async def test_routing_info_from_aws_strategy_reaches_provider_data(): cast(MagicMock, svc._container).get.return_value = scheduler result = await svc._dispatch_single_attempt( - _make_template(), _make_request(), _make_selection_result(), 1 + _make_template(), + _make_request(), + _make_selection_result(), + 1, + operation_timeout_seconds=60.0, ) assert result.success is True @@ -126,7 +130,11 @@ async def test_routing_info_from_fallback_strategy_reaches_provider_data(): cast(MagicMock, svc._container).get.return_value = scheduler result = await svc._dispatch_single_attempt( - _make_template(), _make_request(), _make_selection_result(), 1 + _make_template(), + _make_request(), + _make_selection_result(), + 1, + operation_timeout_seconds=60.0, ) assert result.success is True @@ -166,7 +174,11 @@ async def test_routing_info_from_composite_strategy_reaches_provider_data(): cast(MagicMock, svc._container).get.return_value = scheduler result = await svc._dispatch_single_attempt( - _make_template(), _make_request(), _make_selection_result(), 1 + _make_template(), + _make_request(), + _make_selection_result(), + 1, + operation_timeout_seconds=60.0, ) assert result.success is True @@ -197,7 +209,11 @@ async def test_no_routing_info_does_not_break_provider_data(): cast(MagicMock, svc._container).get.return_value = scheduler result = await svc._dispatch_single_attempt( - _make_template(), _make_request(), _make_selection_result(), 1 + _make_template(), + _make_request(), + _make_selection_result(), + 1, + operation_timeout_seconds=60.0, ) assert result.success is True diff --git a/tests/unit/application/services/test_request_query_service.py b/tests/unit/application/services/test_request_query_service.py new file mode 100644 index 000000000..e3c46fe1f --- /dev/null +++ b/tests/unit/application/services/test_request_query_service.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from orb.application.services.request_query_service import RequestQueryService +from orb.domain.request.request_types import RequestType + + +@pytest.mark.unit +@pytest.mark.application +@pytest.mark.asyncio +async def test_return_request_prefers_request_machine_ids_over_return_request_link(): + uow = MagicMock() + uow.__enter__.return_value = uow + uow.__exit__.return_value = None + + uow_factory = MagicMock() + uow_factory.create_unit_of_work.return_value = uow + + logger = MagicMock() + service = RequestQueryService(uow_factory, logger) + + request = MagicMock() + request.request_type = RequestType.RETURN + request.request_id.value = "ret-123" + request.machine_ids = ["machine-1", "machine-2"] + + expected = [MagicMock(), MagicMock()] + uow.machines.find_by_ids.return_value = expected + + result = await service.get_machines_for_request(request) + + assert result == expected + uow.machines.find_by_ids.assert_called_once_with(["machine-1", "machine-2"]) + uow.machines.find_by_return_request_id.assert_not_called() diff --git a/tests/unit/application/services/test_request_status_management_service.py b/tests/unit/application/services/test_request_status_management_service.py index 4ea3f8852..5d174e77f 100644 --- a/tests/unit/application/services/test_request_status_management_service.py +++ b/tests/unit/application/services/test_request_status_management_service.py @@ -1,8 +1,7 @@ """Unit tests for RequestStatusManagementService._update_request_status logic.""" -from unittest.mock import MagicMock - import pytest +from unittest.mock import MagicMock from orb.application.services.provisioning_orchestration_service import ProvisioningResult from orb.application.services.request_status_management_service import ( @@ -50,15 +49,7 @@ def test_full_success_sets_completed(self): call_args = req.update_status.call_args[0] assert call_args[0] == RequestStatus.COMPLETED - def test_full_count_with_errors_sets_completed(self): - """Fleet errors are advisory when all requested capacity is met. - - EC2Fleet returns success-with-errors when one AZ was skipped but - another absorbed the capacity. Marking the request PARTIAL in that - case is misleading and locks the request in a non-success - terminal state. Errors are still persisted under - request.metadata['fleet_errors']. - """ + def test_full_count_with_errors_sets_partial(self): req = _make_request(requested_count=2) errors = [{"error_code": "InsufficientCapacity", "error_message": "No capacity"}] self.svc._update_request_status( @@ -69,7 +60,7 @@ def test_full_count_with_errors_sets_completed(self): provider_errors=errors, ) call_args = req.update_status.call_args[0] - assert call_args[0] == RequestStatus.COMPLETED + assert call_args[0] == RequestStatus.PARTIAL def test_partial_count_no_errors_sets_partial(self): req = _make_request(requested_count=5) @@ -98,6 +89,7 @@ def test_partial_count_with_errors_sets_partial(self): def test_zero_instances_sets_in_progress(self): req = _make_request(requested_count=3) + req.resource_ids = ["i-pending"] self.svc._update_request_status( request=req, instance_count=0, @@ -108,29 +100,21 @@ def test_zero_instances_sets_in_progress(self): call_args = req.update_status.call_args[0] assert call_args[0] == RequestStatus.IN_PROGRESS - def test_zero_instances_no_resource_ids_sets_failed(self): + def test_zero_instances_with_errors_sets_failed(self): req = _make_request(requested_count=3) req.resource_ids = [] + errors = [{"error_code": "GCPQuotaExceededError", "error_message": "Quota exceeded"}] self.svc._update_request_status( request=req, instance_count=0, requested_count=3, - has_api_errors=False, - provider_errors=[], + has_api_errors=True, + provider_errors=errors, ) call_args = req.update_status.call_args[0] assert call_args[0] == RequestStatus.FAILED - def test_full_count_with_errors_message_signals_non_blocking_warnings(self): - """Full fulfillment + errors → status_message says provisioning OK - but flags warnings; the error codes themselves live on - request.metadata['fleet_errors'], not the status_message. - - Verifies the status_message contract here; the metadata persistence - contract is tested below at the higher entry point - (``_update_request_status_from_result``) where the actual - ``update_metadata`` call happens. - """ + def test_error_summary_included_in_message(self): req = _make_request(requested_count=2) errors = [{"error_code": "InsufficientCapacity", "error_message": "No capacity"}] self.svc._update_request_status( @@ -141,54 +125,7 @@ def test_full_count_with_errors_message_signals_non_blocking_warnings(self): provider_errors=errors, ) call_args = req.update_status.call_args[0] - assert "provisioned" in call_args[1].lower() - assert "warning" in call_args[1].lower() - - @pytest.mark.asyncio - async def test_provisioning_result_with_fleet_errors_persists_them_in_metadata(self): - """fleet_errors from provider_data must be persisted on request.metadata. - - Without this the error codes are user-invisible — only the - status_message would reflect them, and that's deliberately a - non-blocking summary not the error list. Tests the public entry - point that does the metadata write. - """ - from unittest.mock import MagicMock - - req = _make_request(requested_count=2) - req.metadata = {} - req.provider_data = {} - captured: dict = {} - - def _capture_metadata(patch): - captured.update(patch) - req.metadata = {**req.metadata, **patch} - return req - - req.update_metadata = MagicMock(side_effect=_capture_metadata) - req.set_provider_data = MagicMock(return_value=req) - req.add_resource_id = MagicMock(return_value=req) - req.add_machine_ids = MagicMock(return_value=req) - - provisioning_result = MagicMock( - resource_ids=["fleet-1"], - # Empty instances list — exercises the metadata-persistence path - # without dragging in the create-machine-aggregate side-effect. - instances=[], - machine_ids=[], - provider_data={ - "fleet_errors": [ - {"error_code": "InsufficientCapacity", "error_message": "No capacity"} - ] - }, - success=True, - is_final=True, - ) - - await self.svc.update_request_from_provisioning(req, provisioning_result) - - assert "fleet_errors" in captured - assert captured["fleet_errors"][0]["error_code"] == "InsufficientCapacity" + assert "InsufficientCapacity" in call_args[1] class TestHandleProvisioningFailure: @@ -238,6 +175,33 @@ def _make_result(**kwargs) -> ProvisioningResult: return ProvisioningResult(**defaults) +class TestUpdateRequestFromProvisioning: + @pytest.mark.asyncio + async def test_provider_data_is_persisted_via_follow_up_context(self): + svc = _make_service() + req = _make_request(requested_count=1) + req.set_provider_data = MagicMock(return_value=req) + + result = _make_result( + resource_ids=["req-123"], + provider_data={ + "cluster_name": "cc-cluster", + "cyclecloud_credential_path": "secret://cyclecloud", + }, + ) + + updated = await svc.update_request_from_provisioning(req, result) + + req.set_provider_data.assert_called_once() + provider_data = req.set_provider_data.call_args.args[0] + assert provider_data["follow_up_context"]["cluster_name"] == "cc-cluster" + assert ( + provider_data["follow_up_context"]["cyclecloud_credential_path"] + == "secret://cyclecloud" + ) + assert updated is req + + class TestExtractMachineIds: def setup_method(self): self.svc = _make_service() diff --git a/tests/unit/application/services/test_request_status_service.py b/tests/unit/application/services/test_request_status_service.py index b8800d9f2..7d3873675 100644 --- a/tests/unit/application/services/test_request_status_service.py +++ b/tests/unit/application/services/test_request_status_service.py @@ -1,9 +1,6 @@ -"""Unit tests for RequestStatusService.determine_status_from_machines. - -The acquire path now trusts ProviderFulfilment exclusively. -The return path continues to use machine-state counting. -""" +"""Unit tests for RequestStatusService.""" +from contextlib import AbstractContextManager from unittest.mock import MagicMock import pytest @@ -12,18 +9,21 @@ from orb.domain.base.exceptions import ProviderContractError from orb.domain.base.provider_fulfilment import ProviderFulfilment from orb.domain.machine.machine_status import MachineStatus +from orb.domain.request.aggregate import Request from orb.domain.request.request_types import RequestStatus +from orb.domain.request.value_objects import RequestId, RequestType def _make_service(): return RequestStatusService(uow_factory=MagicMock(), logger=MagicMock()) -def _make_request(request_type="return", requested_count=2): +def _make_request(request_type="return"): req = MagicMock() req.request_type.value = request_type - req.requested_count = requested_count - req.provider_name = "aws-test" + req.provider_name = "test-provider" + req.requested_count = 2 + req.provider_data = {} return req @@ -33,127 +33,6 @@ def _make_machine(status: MachineStatus): return m -def _fulfilment(state, message="test", **kwargs) -> dict: - """Return metadata dict with a ProviderFulfilment as the acquire path expects.""" - return {"provider_fulfilment": ProviderFulfilment(state=state, message=message, **kwargs)} - - -# --------------------------------------------------------------------------- -# Acquire path — ProviderFulfilment state map -# --------------------------------------------------------------------------- - - -class TestAcquireFulfilmentStatemap: - """Acquire path: each ProviderFulfilment state maps to the right RequestStatus.""" - - def setup_method(self): - self.svc = _make_service() - - def test_fulfilled_maps_to_completed(self): - req = _make_request("acquire", requested_count=4) - machines = [_make_machine(MachineStatus.RUNNING)] * 2 - status, msg = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata=_fulfilment( - "fulfilled", - "Fleet fulfilled", - target_units=4, - fulfilled_units=4, - running_count=2, - pending_count=0, - failed_count=0, - ), - ) - assert status == RequestStatus.COMPLETED.value - assert msg == "Fleet fulfilled" - - def test_in_progress_maps_to_in_progress(self): - req = _make_request("acquire", requested_count=4) - machines = [_make_machine(MachineStatus.PENDING)] * 4 - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata=_fulfilment("in_progress", "waiting"), - ) - assert status == RequestStatus.IN_PROGRESS.value - - def test_partial_maps_to_partial(self): - req = _make_request("acquire", requested_count=4) - machines = [_make_machine(MachineStatus.RUNNING)] * 2 - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata=_fulfilment("partial", "only 2 of 4"), - ) - assert status == RequestStatus.PARTIAL.value - - def test_failed_maps_to_failed(self): - req = _make_request("acquire", requested_count=4) - machines = [_make_machine(MachineStatus.RUNNING)] * 0 - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata=_fulfilment("failed", "all failed"), - ) - assert status == RequestStatus.FAILED.value - - -class TestAcquireMissingFulfilmentRaisesContractError: - """Missing ProviderFulfilment raises ProviderContractError — no silent fallback.""" - - def setup_method(self): - self.svc = _make_service() - - def test_raises_contract_error_when_fulfilment_absent(self): - req = _make_request("acquire", requested_count=2) - machines = [_make_machine(MachineStatus.RUNNING)] * 2 - with pytest.raises(ProviderContractError): - self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata={}, # no provider_fulfilment key - ) - - def test_raises_contract_error_when_fulfilment_is_none(self): - req = _make_request("acquire", requested_count=2) - machines = [_make_machine(MachineStatus.RUNNING)] * 2 - with pytest.raises(ProviderContractError): - self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata={"provider_fulfilment": None}, - ) - - def test_legacy_fleet_capacity_key_ignored(self): - """Old fleet_capacity_fulfilment key must NOT unlock a legacy path.""" - req = _make_request("acquire", requested_count=2) - machines = [_make_machine(MachineStatus.RUNNING)] * 2 - with pytest.raises(ProviderContractError): - self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata={ - "fleet_capacity_fulfilment": { - "target_capacity_units": 2, - "fulfilled_capacity_units": 2.0, - } - }, - ) - - -# --------------------------------------------------------------------------- -# Return path — machine-state counting (unchanged) -# --------------------------------------------------------------------------- - - class TestReturnRequestCompletion: def setup_method(self): self.svc = _make_service() @@ -215,171 +94,166 @@ def test_return_request_with_all_stopped_is_complete(self): ) assert status == RequestStatus.COMPLETED.value - def test_return_request_mix_shutting_down_and_running_is_in_progress(self): - """Some shutting-down, some running → IN_PROGRESS (not complete).""" - machines = [ - _make_machine(MachineStatus.SHUTTING_DOWN), - _make_machine(MachineStatus.RUNNING), - ] - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=self.req, - provider_metadata={}, - ) - assert status == RequestStatus.IN_PROGRESS.value + def test_return_request_without_provider_machines_stays_in_progress_while_follow_up_pending(self): + self.req.provider_data = {"follow_up_context": {"follow_up_kind": "termination"}} - def test_return_request_empty_provider_machines_is_complete(self): - """No instances visible in provider → all gone, COMPLETED.""" - db_machines = [_make_machine(MachineStatus.TERMINATED)] - status, msg = self.svc.determine_status_from_machines( - db_machines=db_machines, # type: ignore[arg-type] + status, message = self.svc.determine_status_from_machines( + db_machines=[], provider_machines=[], request=self.req, provider_metadata={}, ) - assert status == RequestStatus.COMPLETED.value - assert "no longer visible" in (msg or "") - - -class TestPrematureCompletedRegression: - """Regression guard: COMPLETED must NOT be written when termination is merely accepted.""" - - def setup_method(self): - self.svc = _make_service() - self.req = _make_request("return") - - def test_shutting_down_instance_yields_in_progress_not_completed(self): - machines = [_make_machine(MachineStatus.SHUTTING_DOWN)] - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=self.req, - provider_metadata={}, - ) - assert status != RequestStatus.COMPLETED.value assert status == RequestStatus.IN_PROGRESS.value + assert "follow-up cleanup" in message - def test_mix_shutting_down_terminated_yields_in_progress(self): - machines = [ - _make_machine(MachineStatus.SHUTTING_DOWN), - _make_machine(MachineStatus.TERMINATED), - _make_machine(MachineStatus.SHUTTING_DOWN), - ] - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=self.req, - provider_metadata={}, - ) - assert status == RequestStatus.IN_PROGRESS.value + def test_return_request_with_all_terminated_stays_in_progress_while_follow_up_pending(self): + self.req.provider_data = {"follow_up_context": {"follow_up_kind": "termination"}} - def test_all_terminated_yields_completed(self): machines = [ _make_machine(MachineStatus.TERMINATED), _make_machine(MachineStatus.TERMINATED), - _make_machine(MachineStatus.TERMINATED), ] - status, _ = self.svc.determine_status_from_machines( + status, message = self.svc.determine_status_from_machines( db_machines=machines, # type: ignore[arg-type] provider_machines=machines, # type: ignore[arg-type] request=self.req, provider_metadata={}, ) - assert status == RequestStatus.COMPLETED.value - - -class TestReturnPartialDescribeGuard: - """Regression guard: COMPLETED must NOT fire when describe returns fewer machines.""" - - def setup_method(self): - self.svc = _make_service() - - def test_partial_describe_terminated_not_complete(self): - """3 terminated visible, requested_count=4 → IN_PROGRESS (1 not yet in response).""" - req = _make_request("return", requested_count=4) - machines = [ - _make_machine(MachineStatus.TERMINATED), - _make_machine(MachineStatus.TERMINATED), - _make_machine(MachineStatus.TERMINATED), - ] - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata={}, - ) - assert status != RequestStatus.COMPLETED.value - assert status == RequestStatus.IN_PROGRESS.value - - def test_all_requested_terminated_is_completed(self): - req = _make_request("return", requested_count=4) - machines = [_make_machine(MachineStatus.TERMINATED)] * 4 - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata={}, - ) - assert status == RequestStatus.COMPLETED.value - - def test_more_terminated_than_requested_is_completed(self): - req = _make_request("return", requested_count=2) - machines = [_make_machine(MachineStatus.TERMINATED)] * 3 - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata={}, - ) - assert status == RequestStatus.COMPLETED.value - - def test_one_terminated_one_shutting_down_not_complete(self): - req = _make_request("return", requested_count=2) - machines = [ - _make_machine(MachineStatus.TERMINATED), - _make_machine(MachineStatus.SHUTTING_DOWN), - ] - status, _ = self.svc.determine_status_from_machines( - db_machines=machines, # type: ignore[arg-type] - provider_machines=machines, # type: ignore[arg-type] - request=req, - provider_metadata={}, - ) assert status == RequestStatus.IN_PROGRESS.value + assert "follow-up cleanup" in message + + +class _FakeUnitOfWork(AbstractContextManager): + def __init__(self, requests_repo) -> None: + self.requests = requests_repo + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +@pytest.mark.asyncio +async def test_update_request_status_preserves_newer_persisted_machine_ids(): + stale_request = Request( + request_id=RequestId(value="req-00000000-0000-0000-0000-000000000099"), + request_type=RequestType.ACQUIRE, + provider_type="azure", + template_id="tmpl-1", + requested_count=1, + status=RequestStatus.IN_PROGRESS, + resource_ids=["req-00000000-0000-0000-0000-000000000099"], + machine_ids=[], + ) + current_request = stale_request.update_machine_ids(["node-1"]) + + requests_repo = MagicMock() + requests_repo.get_by_id.return_value = current_request + requests_repo.save = MagicMock() + + uow_factory = MagicMock() + uow_factory.create_unit_of_work.return_value = _FakeUnitOfWork(requests_repo) + + service = RequestStatusService(uow_factory=uow_factory, logger=MagicMock()) + + updated = await service.update_request_status( + stale_request, + RequestStatus.COMPLETED.value, + "All instances running successfully", + ) + + requests_repo.save.assert_called_once() + saved_request = requests_repo.save.call_args.args[0] + assert saved_request.machine_ids == ["node-1"] + assert updated.machine_ids == ["node-1"] + assert updated.status == RequestStatus.COMPLETED + + +def test_acquire_request_with_pending_machine_does_not_complete_from_fulfillment_metadata(): + svc = _make_service() + req = _make_request("acquire") + req.requested_count = 1 + + pending_machine = _make_machine(MachineStatus.PENDING) + status, message = svc.determine_status_from_machines( + db_machines=[], + provider_machines=[pending_machine], # type: ignore[arg-type] + request=req, + provider_metadata={ + "provider_fulfilment": ProviderFulfilment( + state="in_progress", + message="0/1 instances running, waiting for 1 more", + target_units=1, + fulfilled_units=0, + pending_count=1, + ) + }, + ) + + assert status == RequestStatus.IN_PROGRESS.value + assert message == "0/1 instances running, waiting for 1 more" + + +def test_acquire_request_with_terminal_planned_shortfall_becomes_partial(): + svc = _make_service() + req = _make_request("acquire") + req.requested_count = 2 + + running_machine = _make_machine(MachineStatus.RUNNING) + status, message = svc.determine_status_from_machines( + db_machines=[], + provider_machines=[running_machine], # type: ignore[arg-type] + request=req, + provider_metadata={ + "provider_fulfilment": ProviderFulfilment( + state="partial", + message="1/2 instances running: OperationNotAllowed: quota exceeded", + target_units=2, + fulfilled_units=1, + running_count=1, + failed_count=1, + ) + }, + ) + + assert status == RequestStatus.PARTIAL.value + assert message == "1/2 instances running: OperationNotAllowed: quota exceeded" + + +def test_acquire_request_with_terminal_planned_shortfall_and_no_instances_becomes_failed(): + svc = _make_service() + req = _make_request("acquire") + req.requested_count = 2 + + status, message = svc.determine_status_from_machines( + db_machines=[], + provider_machines=[], + request=req, + provider_metadata={ + "provider_fulfilment": ProviderFulfilment( + state="failed", + message="OperationNotAllowed: quota exceeded", + target_units=2, + fulfilled_units=0, + failed_count=2, + ) + }, + ) + assert status == RequestStatus.FAILED.value + assert message == "OperationNotAllowed: quota exceeded" -class TestReturnEmptyProviderMachinesPositiveEvidence: - """Regression tests: COMPLETED requires positive termination evidence. - - provider_machines=[] alone is not sufficient — we must also have db_machines - to confirm we ever had instances to terminate. An empty-both state means - the provider hasn't reported anything yet, not that termination completed. - """ - def setup_method(self): - self.svc = _make_service() - self.req = _make_request("return", requested_count=2) +def test_acquire_request_without_provider_fulfilment_raises_contract_error(): + svc = _make_service() + req = _make_request("acquire") + req.requested_count = 2 - def test_empty_provider_and_empty_db_machines_is_in_progress(self): - """No DB records + no provider records → IN_PROGRESS, not COMPLETED.""" - status, msg = self.svc.determine_status_from_machines( + with pytest.raises(ProviderContractError, match="ProviderFulfilment"): + svc.determine_status_from_machines( db_machines=[], provider_machines=[], - request=self.req, - provider_metadata={}, - ) - assert status == RequestStatus.IN_PROGRESS.value - assert status != RequestStatus.COMPLETED.value - - def test_empty_provider_with_db_machines_is_completed(self): - """DB has records + provider reports none → genuinely terminated, COMPLETED.""" - db_machines = [_make_machine(MachineStatus.RUNNING)] - status, msg = self.svc.determine_status_from_machines( - db_machines=db_machines, # type: ignore[arg-type] - provider_machines=[], - request=self.req, + request=req, provider_metadata={}, ) - assert status == RequestStatus.COMPLETED.value - assert "no longer visible" in (msg or "") diff --git a/tests/unit/application/services/test_spot_placement_execution.py b/tests/unit/application/services/test_spot_placement_execution.py new file mode 100644 index 000000000..06b1d7bb9 --- /dev/null +++ b/tests/unit/application/services/test_spot_placement_execution.py @@ -0,0 +1,92 @@ +from orb.application.services.spot_placement_execution import SpotPlacementExecutionService +from orb.application.services.spot_placement_planner import ( + PlacementCandidate, + PlacementPlanEntry, + PlacementScore, +) +from orb.domain.base.exceptions import DomainException + + +def _plan_entry(candidate_id: str, planned_count: int = 1) -> PlacementPlanEntry: + region = "eastus2" + zone = "1" + instance_type = "Standard_D4s_v5" + return PlacementPlanEntry( + score=PlacementScore( + candidate=PlacementCandidate( + candidate_id=candidate_id, + instance_type=instance_type, + region=region, + zone=zone, + ), + raw_score="High", + normalized_score=1.0, + ), + planned_count=planned_count, + ) + + +def test_extract_error_codes_prefers_structured_provider_data(): + error_codes = SpotPlacementExecutionService._extract_error_codes( + provider_data={"error_codes": ["AllocationFailed"], "fleet_errors": []}, + ) + + assert error_codes == ["AllocationFailed"] + + +def test_extract_error_codes_deduplicates_structured_provider_data(): + error_codes = SpotPlacementExecutionService._extract_error_codes( + provider_data={"error_codes": ["AllocationFailed", "AllocationFailed", "SkuNotAvailable"]}, + ) + + assert error_codes == ["AllocationFailed", "SkuNotAvailable"] + + +def test_execute_plan_propagates_domain_exception_error_code(): + service = SpotPlacementExecutionService() + plan = [_plan_entry("azure:eastus2:1:Standard_D4s_v5")] + + summary = service.execute_plan( + plan=plan, + total_count=1, + build_child_template=lambda plan_entry: {"candidate_id": plan_entry.score.candidate.candidate_id}, + build_child_request=lambda requested_count, idx: {"count": requested_count, "index": idx}, + launch_child=lambda child_request, child_template: (_ for _ in ()).throw( + DomainException("No capacity in selected zone", error_code="AllocationFailed") + ), + is_capacity_like_failure=lambda child_result: "AllocationFailed" in child_result["error_codes"], + ) + + assert summary.unfulfilled_count == 1 + assert summary.failed_subplans[0]["error_codes"] == ["AllocationFailed"] + assert summary.child_results[0]["provider_data"]["error_codes"] == ["AllocationFailed"] + + +def test_execute_plan_keeps_shortfall_when_child_succeeds_partially(): + service = SpotPlacementExecutionService() + plan = [_plan_entry("azure:eastus2:1:Standard_D4s_v5", planned_count=2)] + + summary = service.execute_plan( + plan=plan, + total_count=2, + build_child_template=lambda plan_entry: {"candidate_id": plan_entry.score.candidate.candidate_id}, + build_child_request=lambda requested_count, idx: {"count": requested_count, "index": idx}, + launch_child=lambda child_request, child_template: { + "success": True, + "resource_ids": ["vmss-a"], + "fulfilled_count": 1, + "instances": [], + "provider_data": { + "fleet_errors": [{"error_code": "AllocationFailed"}], + "error_codes": ["AllocationFailed"], + }, + }, + is_capacity_like_failure=lambda child_result: "AllocationFailed" in child_result["error_codes"], + ) + + assert summary.resource_ids == ["vmss-a"] + assert summary.unfulfilled_count == 1 + assert summary.terminated_early is False + assert summary.failed_subplans == [summary.child_results[0]] + assert summary.child_results[0]["fulfilled_count"] == 1 + diff --git a/tests/unit/application/services/test_spot_placement_planner.py b/tests/unit/application/services/test_spot_placement_planner.py new file mode 100644 index 000000000..7179dbb11 --- /dev/null +++ b/tests/unit/application/services/test_spot_placement_planner.py @@ -0,0 +1,74 @@ +from orb.application.services.spot_placement_planner import ( + PlacementCandidate, + PlacementScore, + SpotPlacementPlanner, +) +from orb.domain.base.value_objects import PlacementSplitStrategy + + +def _score(candidate_id: str, normalized_score: float, raw_score): + return PlacementScore( + candidate=PlacementCandidate(candidate_id=candidate_id, instance_type=candidate_id), + raw_score=raw_score, + normalized_score=normalized_score, + ) + + +def test_greedy_plan_allocates_all_to_top_candidate(): + planner = SpotPlacementPlanner() + + plan = planner.create_plan( + requested_count=10, + scores=[ + _score("small", 0.4, 4), + _score("large", 0.9, 9), + _score("medium", 0.7, 7), + ], + split_strategy=PlacementSplitStrategy.GREEDY, + primary_share_percent=80, + ) + + assert [(entry.score.candidate.candidate_id, entry.planned_count) for entry in plan] == [ + ("large", 10), + ("medium", 0), + ("small", 0), + ] + + +def test_hybrid_plan_reserves_primary_share_and_distributes_remainder(): + planner = SpotPlacementPlanner() + + plan = planner.create_plan( + requested_count=10, + scores=[ + _score("c1", 1.0, "High"), + _score("c2", 0.6, "Medium"), + _score("c3", 0.2, "Low"), + ], + split_strategy=PlacementSplitStrategy.HYBRID, + primary_share_percent=70, + ) + + assert [(entry.score.candidate.candidate_id, entry.planned_count) for entry in plan] == [ + ("c1", 7), + ("c2", 2), + ("c3", 1), + ] + + +def test_zero_score_candidates_are_filtered_out(): + planner = SpotPlacementPlanner() + + plan = planner.create_plan( + requested_count=5, + scores=[ + _score("c1", 0.0, 0), + _score("c2", 0.9, 9), + ], + split_strategy=PlacementSplitStrategy.HYBRID, + primary_share_percent=80, + ) + + assert len(plan) == 1 + assert plan[0].score.candidate.candidate_id == "c2" + assert plan[0].planned_count == 5 diff --git a/tests/unit/application/test_return_validation_fix.py b/tests/unit/application/test_return_validation_fix.py index a1c6fa3be..cf7f82a23 100644 --- a/tests/unit/application/test_return_validation_fix.py +++ b/tests/unit/application/test_return_validation_fix.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 -"""Test for the return validation fix - filter instead of block.""" +"""Test return-request validation and lifecycle behavior.""" -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import pytest from orb.application.commands.request_handlers import CreateReturnRequestHandler from orb.application.dto.commands import CreateReturnRequestCommand +from orb.domain.request.request_types import RequestStatus class TestReturnValidationFix: @@ -50,7 +51,7 @@ async def test_filters_machines_with_pending_return_requests(self): machine2 = Mock() machine2.machine_id = "machine-002" - machine2.return_request_id = "existing-return-req-123" # Invalid - has pending return + machine2.return_request_id = "ret-00000000-0000-0000-0000-000000000123" machine3 = Mock() machine3.machine_id = "machine-003" @@ -58,11 +59,12 @@ async def test_filters_machines_with_pending_return_requests(self): # Mock repository responses def mock_get_by_id(machine_id): - if machine_id == "machine-001": + key = getattr(machine_id, "value", machine_id) + if key == "machine-001": return machine1 - elif machine_id == "machine-002": + elif key == "machine-002": return machine2 - elif machine_id == "machine-003": + elif key == "machine-003": return machine3 return None @@ -76,40 +78,10 @@ def mock_get_by_id(machine_id): # Validation should pass (no exception for multiple machines) await self.handler.validate_command(command) - # Test the filtering logic directly by calling the filtering part - # This simulates what happens in execute_command - is_single_machine = len(command.machine_ids) == 1 - assert not is_single_machine # Should be multiple machines - - # Simulate the filtering logic from execute_command - valid_machine_ids = [] - skipped_machines = [] - - with self.mock_context_manager: - for machine_id in command.machine_ids: - machine = self.mock_uow.machines.get_by_id(machine_id) - if not machine: - skipped_machines.append( - {"machine_id": machine_id, "reason": "Machine not found"} - ) - continue - - if machine.return_request_id: - skipped_machines.append( - { - "machine_id": machine_id, - "reason": f"Machine already has pending return request: {machine.return_request_id}", - } - ) - continue - - valid_machine_ids.append(machine_id) + valid_machines, skipped_machines = self.handler._filter_machines(command.machine_ids) # Verify filtering worked correctly - assert len(valid_machine_ids) == 2 # machine-001 and machine-003 - assert "machine-001" in valid_machine_ids - assert "machine-003" in valid_machine_ids - assert "machine-002" not in valid_machine_ids + assert valid_machines == ["machine-001", "machine-003"] assert len(skipped_machines) == 1 assert skipped_machines[0]["machine_id"] == "machine-002" @@ -121,7 +93,7 @@ async def test_single_machine_return_still_validates_properly(self): # For single machine operations, we should still validate strictly machine = Mock() machine.machine_id = "machine-001" - machine.return_request_id = "existing-return-req-123" + machine.return_request_id = "ret-00000000-0000-0000-0000-000000000123" self.mock_uow.machines.get_by_id.return_value = machine @@ -139,16 +111,17 @@ async def test_all_machines_invalid_filters_all(self): # All machines have pending return requests machine1 = Mock() machine1.machine_id = "machine-001" - machine1.return_request_id = "existing-return-req-123" + machine1.return_request_id = "ret-00000000-0000-0000-0000-000000000123" machine2 = Mock() machine2.machine_id = "machine-002" - machine2.return_request_id = "existing-return-req-456" + machine2.return_request_id = "ret-00000000-0000-0000-0000-000000000456" def mock_get_by_id(machine_id): - if machine_id == "machine-001": + key = getattr(machine_id, "value", machine_id) + if key == "machine-001": return machine1 - elif machine_id == "machine-002": + elif key == "machine-002": return machine2 return None @@ -159,37 +132,80 @@ def mock_get_by_id(machine_id): # Validation should pass (no exception for multiple machines) await self.handler.validate_command(command) - # Test the filtering logic directly - is_single_machine = len(command.machine_ids) == 1 - assert not is_single_machine # Should be multiple machines - - # Simulate the filtering logic from execute_command - valid_machine_ids = [] - skipped_machines = [] - - with self.mock_context_manager: - for machine_id in command.machine_ids: - machine = self.mock_uow.machines.get_by_id(machine_id) - if not machine: - skipped_machines.append( - {"machine_id": machine_id, "reason": "Machine not found"} - ) - continue - - if machine.return_request_id: - skipped_machines.append( + valid_machines, skipped_machines = self.handler._filter_machines(command.machine_ids) + + # All machines should be filtered out + assert valid_machines == [] + assert len(skipped_machines) == 2 + + def test_update_machines_to_pending_sets_shutting_down(self): + machine = Mock() + machine.machine_id = "machine-001" + + shutting_down_machine = Mock() + machine.update_status.return_value = shutting_down_machine + + self.mock_uow.machines.get_by_id.return_value = machine + + self.handler._update_machines_to_pending(["machine-001"]) + + machine.update_status.assert_called_once() + self.mock_uow.machines.save.assert_called_once_with(shutting_down_machine) + + @pytest.mark.asyncio + async def test_successful_deprovisioning_persists_followup_context_and_stays_in_progress(self): + request = Mock() + request.request_id = "ret-001" + request.provider_data = {} + + persisted_request = Mock() + persisted_request.request_id = "ret-001" + persisted_request.provider_data = {} + updated_request = Mock() + persisted_request.set_provider_data.return_value = updated_request + self.mock_uow.requests.get_by_id.return_value = persisted_request + self.mock_uow.requests.save.return_value = [] + + command_bus = Mock() + command_bus.execute = AsyncMock() + self.mock_container.get.return_value = command_bus + + self.handler._deprovisioning_orchestrator.execute_deprovisioning = AsyncMock( + return_value={ + "success": True, + "provider_data": { + "termination_requests": [ { - "machine_id": machine_id, - "reason": f"Machine already has pending return request: {machine.return_request_id}", + "pending_resource_cleanup": { + "resource_group": "test-rg", + "resource_id": "vmss-demo", + "machine_ids": ["machine-001"], + "delete_vmss_when_empty": True, + } } - ) - continue + ] + }, + } + ) + self.handler._machine_grouping_service.group_by_resource = Mock(return_value=({}, [])) + self.handler._update_machines_to_pending = Mock() - valid_machine_ids.append(machine_id) + await self.handler._execute_deprovisioning_for_request( + ["machine-001"], request, "azure-default" + ) - # All machines should be filtered out - assert len(valid_machine_ids) == 0 - assert len(skipped_machines) == 2 + persisted_request.set_provider_data.assert_called_once() + request.set_provider_data.assert_not_called() + persisted_provider_data = persisted_request.set_provider_data.call_args.args[0] + assert persisted_provider_data["follow_up_context"]["termination_requests"][0][ + "pending_resource_cleanup" + ]["resource_id"] == "vmss-demo" + saved_request = self.mock_uow.requests.save.call_args[0][0] + assert saved_request is updated_request + statuses = [call.args[0].status for call in command_bus.execute.await_args_list] + assert statuses == [RequestStatus.IN_PROGRESS, RequestStatus.IN_PROGRESS] + messages = [call.args[0].message for call in command_bus.execute.await_args_list] + assert messages[-1] == "Termination accepted: waiting for instances to reach terminated state" if __name__ == "__main__": diff --git a/tests/unit/architecture/test_application_import_boundaries.py b/tests/unit/architecture/test_application_import_boundaries.py index 54981b4ac..610aa9b81 100644 --- a/tests/unit/architecture/test_application_import_boundaries.py +++ b/tests/unit/architecture/test_application_import_boundaries.py @@ -34,6 +34,10 @@ { ("application/queries/template_query_handlers.py", "orb.infrastructure.template.dtos"), ("application/commands/template_handlers.py", "orb.infrastructure.template.dtos"), + ( + "application/commands/request_creation_handlers.py", + "orb.infrastructure.template.dtos", + ), ( "application/services/orchestration/refresh_templates.py", "orb.infrastructure.template.dtos", diff --git a/tests/unit/architecture/test_provider_leak_detection.py b/tests/unit/architecture/test_provider_leak_detection.py index 05f100ed8..20c42014d 100644 --- a/tests/unit/architecture/test_provider_leak_detection.py +++ b/tests/unit/architecture/test_provider_leak_detection.py @@ -35,15 +35,9 @@ _KNOWN_VIOLATIONS: frozenset[tuple[str, str]] = frozenset( { ("bootstrap/core_services.py", "orb.providers.registry"), - ("bootstrap/infrastructure_services.py", "orb.providers.registration"), - ("bootstrap/infrastructure_services.py", "orb.providers.k8s"), - ("bootstrap/infrastructure_services.py", "orb.providers.k8s.registration"), - ( - "application/services/orchestration/dashboard_summary.py", - "orb.providers.registry", - ), + ("bootstrap/infrastructure_services.py", "orb.providers.aws.registration"), ("bootstrap/provider_services.py", "orb.providers.registry"), - ("bootstrap/provider_services.py", "orb.providers.registration"), + ("bootstrap/provider_services.py", "orb.providers.aws.registration"), ("bootstrap/services.py", "orb.providers.registration"), ("interface/health_command_handler.py", "orb.providers.registry"), ("interface/system_command_handlers.py", "orb.providers.registry"), @@ -54,6 +48,8 @@ ("interface/init_command_handler.py", "orb.providers.factory"), ("interface/machine_command_handlers.py", "orb.providers.base.strategy"), ("interface/mcp/server/core.py", "orb.providers.registry"), + ("api/server.py", "orb.providers.aws.auth.iam_strategy"), + ("api/server.py", "orb.providers.aws.auth.cognito_strategy"), ("config/managers/provider_manager.py", "orb.providers.registry"), ("application/services/provider_registry_service.py", "orb.providers.registry"), ("infrastructure/template/configuration_manager.py", "orb.providers.base.strategy"), @@ -61,27 +57,41 @@ ("infrastructure/adapters/provider_discovery_adapter.py", "orb.providers.registry"), # DI wiring — intentional: storage registration must wire AWS provider ("infrastructure/storage/registration.py", "orb.providers.aws.storage.registration"), - # The cpu/ram lookup (derive_cpu_ram_from_instance_type) has been moved - # into providers/aws/scheduler/hostfactory_field_mapping.py. The shared - # hostfactory infrastructure no longer imports from orb.providers.aws.*. + # hostfactory is inherently AWS/HPC-specific — provider import is expected + ( + "infrastructure/scheduler/hostfactory/field_mapper.py", + "orb.providers.aws.utilities.ec2.instances", + ), + ( + "infrastructure/scheduler/hostfactory/hostfactory_strategy.py", + "orb.providers.aws.utilities.ec2.instances", + ), ("config/schemas/cleanup_schema.py", "orb.providers.aws.configuration.cleanup_config"), # loader collects strategy-contributed defaults at load time — intentional bootstrap wiring ("config/loader.py", "orb.providers.registry"), - ("config/loader.py", "orb.providers"), - ("config/loader.py", "orb.providers.registration"), - # CLI spec bootstrap: build_parser triggers lightweight CLI-spec registration - # so that provider flags (e.g. --aws-profile) are available before app init. - ("cli/args.py", "orb.providers.registration"), - # Provider schema endpoints: the UI column schema is a pure metadata read - # with no side effects; the registry is queried read-only to enumerate - # registered strategy classes and call get_ui_column_schema() on them. - # TODO: extract to an application service when a suitable one exists. - ("api/routers/providers.py", "orb.providers.registry.provider_registry"), - ("api/routers/providers.py", "orb.providers.registry.types"), + # Loader's static defaults loader imports each provider's defaults function directly + # so collecting defaults stays side-effect-free (no full ConfigurationManager wiring). + ("config/loader.py", "orb.providers.aws.strategy.aws_provider_strategy"), } ) +def _is_provider_registration_wiring(rel: str, import_string: str) -> bool: + """Return whether an import is intentional provider registration wiring.""" + if rel not in { + "bootstrap/infrastructure_services.py", + "config/loader.py", + }: + return False + parts = import_string.split(".") + return ( + len(parts) == 4 + and parts[0] == "orb" + and parts[1] == "providers" + and parts[3] == "registration" + ) + + @pytest.mark.parametrize("filepath", _NON_PROVIDER_FILES, ids=lambda p: str(p.relative_to(SRC_ORB))) @pytest.mark.unit @pytest.mark.architecture @@ -94,6 +104,7 @@ def test_no_new_provider_leak(filepath: Path) -> None: for imp in imports if (imp == "orb.providers" or imp.startswith("orb.providers.")) and (rel, imp) not in _KNOWN_VIOLATIONS + and not _is_provider_registration_wiring(rel, imp) ] assert new_violations == [], ( f"{rel} has NEW provider leaks (not in known-violations whitelist): {new_violations}" diff --git a/tests/unit/bootstrap/test_domain_services.py b/tests/unit/bootstrap/test_domain_services.py new file mode 100644 index 000000000..7d978d4b9 --- /dev/null +++ b/tests/unit/bootstrap/test_domain_services.py @@ -0,0 +1,22 @@ +"""Tests for domain service registration.""" + +from unittest.mock import MagicMock + +from orb.application.services.provider_validation_service import ProviderValidationService +from orb.domain.base.ports import ContainerPort, LoggingPort, ProviderSelectionPort +from orb.infrastructure.di.container import DIContainer + + +def test_provider_validation_service_is_not_prewired_to_aws_validator(): + from orb.bootstrap.domain_services import register_domain_services + + container = DIContainer() + container.register_instance(ContainerPort, container) + container.register_instance(LoggingPort, MagicMock()) + container.register_instance(ProviderSelectionPort, MagicMock()) + + register_domain_services(container) + + service = container.get(ProviderValidationService) + + assert service._validator is None diff --git a/tests/unit/cli/test_args.py b/tests/unit/cli/test_args.py index 962b878ec..e5fa6a079 100644 --- a/tests/unit/cli/test_args.py +++ b/tests/unit/cli/test_args.py @@ -221,6 +221,83 @@ def test_yes_flag(self): assert ns.yes is True +class TestProviderAzureArgs: + """Azure provider CLI arguments for add/update commands.""" + + def test_providers_add_accepts_azure_fields(self): + ns = _parse( + [ + "providers", + "add", + "--provider-type", + "azure", + "--azure-subscription-id", + "12345678-1234-1234-1234-123456789012", + "--azure-resource-group", + "orb-test-rg", + "--azure-location", + "eastus2", + "--azure-cyclecloud-credential-path", + "op://vault/item/cred", + "--azure-cyclecloud-no-verify-ssl", + ] + ) + + assert ns.provider_type == "azure" + assert ns.azure_subscription_id == "12345678-1234-1234-1234-123456789012" + assert ns.azure_resource_group == "orb-test-rg" + assert ns.azure_location == "eastus2" + assert ns.azure_cyclecloud_credential_path == "op://vault/item/cred" + assert ns.azure_cyclecloud_no_verify_ssl is True + + def test_providers_update_accepts_azure_fields(self): + ns = _parse( + [ + "providers", + "update", + "azure-default", + "--azure-resource-group", + "orb-updated-rg", + "--azure-location", + "westeurope", + ] + ) + + assert ns.provider_name == "azure-default" + assert ns.azure_resource_group == "orb-updated-rg" + assert ns.azure_location == "westeurope" + + +class TestInitAzureArgs: + """Azure init CLI arguments.""" + + def test_init_accepts_azure_fields(self): + ns = _parse( + [ + "init", + "--non-interactive", + "--provider", + "azure", + "--azure-subscription-id", + "12345678-1234-1234-1234-123456789012", + "--azure-resource-group", + "orb-test-rg", + "--azure-location", + "eastus2", + "--azure-client-id", + "managed-identity-client-id", + "--azure-cyclecloud-no-verify-ssl", + ] + ) + + assert ns.provider == "azure" + assert ns.azure_subscription_id == "12345678-1234-1234-1234-123456789012" + assert ns.azure_resource_group == "orb-test-rg" + assert ns.azure_location == "eastus2" + assert ns.azure_client_id == "managed-identity-client-id" + assert ns.azure_cyclecloud_no_verify_ssl is True + + class TestRequestsWatch: """requests watch positional and flag arguments.""" diff --git a/tests/unit/domain/test_template_validation_domain_service.py b/tests/unit/domain/test_template_validation_domain_service.py index 96ba457e0..d69ab2b75 100644 --- a/tests/unit/domain/test_template_validation_domain_service.py +++ b/tests/unit/domain/test_template_validation_domain_service.py @@ -113,13 +113,13 @@ def test_provider_instance_not_found_fails(self): assert len(result.errors) > 0 def test_no_config_fails(self): - # Service with no config injected - _initialized attr missing causes AttributeError - # before the try/except, so the service raises rather than returning a result. + # Service with no config injected should fail validation cleanly. svc = TemplateValidationDomainService() template = _make_template() - with pytest.raises(AttributeError): - with patch(PATCH_TARGET, side_effect=_patched_validation_result): - svc.validate_template_requirements(template, "aws-prod") + with patch(PATCH_TARGET, side_effect=_patched_validation_result): + result = svc.validate_template_requirements(template, "aws-prod") + assert result.is_valid is False + assert any("Validation error" in e for e in result.errors) def test_instance_limit_exceeded_fails(self): caps = _ProviderCapabilities( @@ -144,3 +144,106 @@ def test_exception_during_validation_returns_invalid(self): result = self._run(template) assert result.is_valid is False assert any("Validation error" in e for e in result.errors) + + def test_falls_back_to_validator_capabilities_when_handler_defaults_are_unwired(self): + provider_instance_config = MagicMock() + provider_instance_config.name = "azure-default" + provider_instance_config.type = "azure" + provider_instance_config.config = {"subscription_id": "sub-123"} + provider_instance_config.handlers = None + provider_instance_config.handler_overrides = None + provider_instance_config.capabilities = None + provider_instance_config.get_effective_handlers = MagicMock(return_value={}) + + provider_config_root = MagicMock() + provider_config_root.provider_defaults = {} + + config = MagicMock() + config.get_provider_instance_config.return_value = provider_instance_config + config.get_provider_config.return_value = provider_config_root + + validator = MagicMock() + validator.get_supported_provider_apis.return_value = ["VMSS", "SingleVM"] + validator.get_api_capabilities.side_effect = lambda api: { + "VMSS": {"supports_spot": True, "supports_on_demand": True, "max_instances": 1000}, + "SingleVM": {"supports_spot": True, "supports_on_demand": True, "max_instances": 1000}, + }[api] + provider_registry = MagicMock() + provider_registry.create_validator.return_value = validator + + self.svc.inject_dependencies(config, self.logger, provider_registry) + + template = _make_template(provider_api="VMSS") + result = self._run(template, provider_instance="azure-default") + + assert result.is_valid is True + assert result.errors == [] + provider_registry.create_validator.assert_called_once_with( + "azure", + {"subscription_id": "sub-123"}, + ) + validator.get_api_capabilities.assert_any_call("VMSS") + + def test_validator_capability_fallback_preserves_spot_validation(self): + provider_instance_config = MagicMock() + provider_instance_config.name = "azure-default" + provider_instance_config.type = "azure" + provider_instance_config.config = {"subscription_id": "sub-123"} + provider_instance_config.handlers = None + provider_instance_config.handler_overrides = None + provider_instance_config.capabilities = None + provider_instance_config.get_effective_handlers = MagicMock(return_value={}) + + provider_config_root = MagicMock() + provider_config_root.provider_defaults = {} + + config = MagicMock() + config.get_provider_instance_config.return_value = provider_instance_config + config.get_provider_config.return_value = provider_config_root + + validator = MagicMock() + validator.get_supported_provider_apis.return_value = ["VMSS"] + validator.get_api_capabilities.return_value = { + "supports_spot": True, + "supports_on_demand": True, + "max_instances": 1000, + } + provider_registry = MagicMock() + provider_registry.create_validator.return_value = validator + + self.svc.inject_dependencies(config, self.logger, provider_registry) + + template = _make_template(provider_api="VMSS", price_type="spot") + result = self._run(template, provider_instance="azure-default") + + assert result.is_valid is True + assert result.errors == [] + assert "Pricing: Spot instances" in result.supported_features + + def test_explicit_empty_handler_override_does_not_fall_back_to_validator_capabilities(self): + provider_instance_config = MagicMock() + provider_instance_config.name = "azure-default" + provider_instance_config.type = "azure" + provider_instance_config.config = {"subscription_id": "sub-123"} + provider_instance_config.handlers = None + provider_instance_config.handler_overrides = {"VMSS": None} + provider_instance_config.capabilities = None + provider_instance_config.get_effective_handlers = MagicMock(return_value={}) + + provider_config_root = MagicMock() + provider_config_root.provider_defaults = {} + + config = MagicMock() + config.get_provider_instance_config.return_value = provider_instance_config + config.get_provider_config.return_value = provider_config_root + + provider_registry = MagicMock() + + self.svc.inject_dependencies(config, self.logger, provider_registry) + + template = _make_template(provider_api="VMSS") + result = self._run(template, provider_instance="azure-default") + + assert result.is_valid is False + assert any("Provider does not support API 'VMSS'" in e for e in result.errors) + provider_registry.create_validator.assert_not_called() diff --git a/tests/unit/infrastructure/resilience/test_retry_decorator.py b/tests/unit/infrastructure/resilience/test_retry_decorator.py new file mode 100644 index 000000000..db9938ee1 --- /dev/null +++ b/tests/unit/infrastructure/resilience/test_retry_decorator.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import pytest + +from orb.infrastructure.resilience.retry_decorator import retry + + +def test_retry_decorator_rejects_coroutine_functions() -> None: + @retry() + async def _async_operation() -> str: + return "ok" + + with pytest.raises(TypeError, match="does not support coroutine functions"): + _async_operation() diff --git a/tests/unit/infrastructure/scheduler/test_field_mapping.py b/tests/unit/infrastructure/scheduler/test_field_mapping.py index a61738df2..794134bd7 100644 --- a/tests/unit/infrastructure/scheduler/test_field_mapping.py +++ b/tests/unit/infrastructure/scheduler/test_field_mapping.py @@ -82,6 +82,18 @@ def test_hf_map_input_instance_tags_dict_passthrough(): assert result["tags"] == {"env": "prod"} +def test_hf_map_input_azure_vm_type_maps_to_vm_size(): + mapper = HostFactoryFieldMapper("azure") + result = mapper.map_input_fields({"vmType": "Standard_D4s_v5"}) + assert result["vm_size"] == "Standard_D4s_v5" + + +def test_hf_map_input_azure_key_name_maps_to_ssh_key_name(): + mapper = HostFactoryFieldMapper("azure") + result = mapper.map_input_fields({"keyName": "azure-ssh-key"}) + assert result["ssh_key_name"] == "azure-ssh-key" + + # --------------------------------------------------------------------------- # HF mapper — output direction (snake_case → camelCase) # --------------------------------------------------------------------------- diff --git a/tests/unit/infrastructure/scheduler/test_hostfactory_strategy_defaults.py b/tests/unit/infrastructure/scheduler/test_hostfactory_strategy_defaults.py index b95231f33..aab0718b3 100644 --- a/tests/unit/infrastructure/scheduler/test_hostfactory_strategy_defaults.py +++ b/tests/unit/infrastructure/scheduler/test_hostfactory_strategy_defaults.py @@ -24,7 +24,7 @@ def setup_method(self): self.strategy = make_strategy() # Provide a minimal field mapper that returns the input unchanged mock_mapper = MagicMock() - mock_mapper.map_input_fields.side_effect = lambda t: dict(t) + mock_mapper.map_input_fields.side_effect = dict self.strategy._field_mapper = mock_mapper def _minimal_template(self): diff --git a/tests/unit/infrastructure/scheduler/test_strategy_loading.py b/tests/unit/infrastructure/scheduler/test_strategy_loading.py index d0870018b..a4e656906 100644 --- a/tests/unit/infrastructure/scheduler/test_strategy_loading.py +++ b/tests/unit/infrastructure/scheduler/test_strategy_loading.py @@ -35,11 +35,6 @@ } -def _no_op_config(_container: Any) -> None: - """Config factory used by the scheduler-registry tests below.""" - return None - - # --------------------------------------------------------------------------- # load_templates_from_path — both schedulers # --------------------------------------------------------------------------- @@ -153,14 +148,14 @@ def test_default_load_delegates_hf_file_to_hf_strategy(tmp_path): registry.register( "hostfactory", HostFactorySchedulerStrategy, - _no_op_config, + lambda c: None, strategy_class=HostFactorySchedulerStrategy, ) if not registry.is_registered("default"): registry.register( "default", DefaultSchedulerStrategy, - _no_op_config, + lambda c: None, strategy_class=DefaultSchedulerStrategy, ) @@ -181,14 +176,14 @@ def test_hf_load_delegates_default_file_to_default_strategy(tmp_path): registry.register( "hostfactory", HostFactorySchedulerStrategy, - _no_op_config, + lambda c: None, strategy_class=HostFactorySchedulerStrategy, ) if not registry.is_registered("default"): registry.register( "default", DefaultSchedulerStrategy, - _no_op_config, + lambda c: None, strategy_class=DefaultSchedulerStrategy, ) @@ -421,3 +416,20 @@ def test_default_round_trip_generate_write_load(tmp_path): assert len(loaded) == 1 assert loaded[0]["template_id"] == _MINIMAL_SNAKE_TEMPLATE["template_id"] assert loaded[0]["max_instances"] == _MINIMAL_SNAKE_TEMPLATE["max_instances"] + + +def test_base_strategy_infers_provider_type_from_config_when_registry_missing(): + strategy = HostFactorySchedulerStrategy() + + class _AppConfig: + @staticmethod + def model_dump(): + return {"provider": {"active_provider": "azure-default"}} + + class _ConfigManager: + app_config = _AppConfig() + + strategy._config_manager = _ConfigManager() + strategy._provider_registry_service = None + + assert strategy._get_active_provider_type() == "azure" diff --git a/tests/unit/infrastructure/template/test_template_dto_no_aws_fields.py b/tests/unit/infrastructure/template/test_template_dto_no_aws_fields.py index c4ee61271..bd90f1db8 100644 --- a/tests/unit/infrastructure/template/test_template_dto_no_aws_fields.py +++ b/tests/unit/infrastructure/template/test_template_dto_no_aws_fields.py @@ -3,14 +3,17 @@ import ast import os +import pytest +from pydantic import ValidationError + from orb.infrastructure.template.dtos import TemplateDTO +from orb.providers.aws.domain.template.aws_template_dto_config import AWSTemplateDTOConfig from orb.providers.aws.domain.template.aws_template_aggregate import ( ABISInstanceRequirements, AWSFleetType, AWSRequiredIntegerRange, AWSTemplate, ) -from orb.providers.aws.domain.template.aws_template_dto_config import AWSTemplateDTOConfig # --------------------------------------------------------------------------- # AST scan — no top-level AWS fields on TemplateDTO @@ -20,13 +23,7 @@ os.path.dirname(__file__), "../../../../src/orb/infrastructure/template/dtos.py", ) -_AWS_FIELDS = { - "fleet_role", - "fleet_type", - "percent_on_demand", - "abis_instance_requirements", - "launch_template_id", -} +_AWS_FIELDS = {"fleet_role", "fleet_type", "percent_on_demand", "abis_instance_requirements"} def _get_template_dto_field_names() -> set[str]: @@ -72,27 +69,14 @@ def test_abis_instance_requirements_not_a_top_level_field(self): "abis_instance_requirements is an AWS-specific field and must not be a top-level TemplateDTO attribute" ) - def test_launch_template_id_not_a_top_level_field(self): - fields = _get_template_dto_field_names() - assert "launch_template_id" not in fields, ( - "launch_template_id is an AWS-specific field and must not be a top-level TemplateDTO attribute" - ) - def test_no_aws_fields_at_all(self): fields = _get_template_dto_field_names() present = _AWS_FIELDS & fields assert not present, f"AWS-specific fields still declared on TemplateDTO: {present}" - def test_provider_config_field_present(self): - """TemplateDTO must expose a typed provider_config field.""" - fields = _get_template_dto_field_names() - assert "provider_config" in fields, ( - "TemplateDTO must have a provider_config field for typed provider-specific configuration" - ) - # --------------------------------------------------------------------------- -# from_domain() populates typed provider_config +# from_domain() packs AWS fields into provider_config # --------------------------------------------------------------------------- @@ -109,62 +93,54 @@ def _make_aws_template(**kwargs) -> AWSTemplate: return AWSTemplate(**defaults) -class TestFromDomainPopulatesProviderConfig: - """TemplateDTO.from_domain() must move AWS fields into the typed provider_config.""" +def _provider_config_dict(dto: TemplateDTO) -> dict: + assert isinstance(dto.provider_config, AWSTemplateDTOConfig) + return dto.provider_config.model_dump(exclude_none=True) - def test_provider_config_is_aws_dto_config_type(self): + +class TestFromDomainPacksAWSFieldsIntoProviderConfig: + """TemplateDTO.from_domain() must move AWS fields into provider_config.""" + + def test_provider_config_is_typed(self): template = _make_aws_template(fleet_type=AWSFleetType.MAINTAIN) dto = TemplateDTO.from_domain(template) - assert isinstance(dto.provider_config, AWSTemplateDTOConfig), ( - "provider_config must be an AWSTemplateDTOConfig instance for AWS templates" - ) + assert isinstance(dto.provider_config, AWSTemplateDTOConfig) - def test_fleet_type_in_provider_config(self): + def test_fleet_type_in_metadata(self): template = _make_aws_template(fleet_type=AWSFleetType.MAINTAIN) dto = TemplateDTO.from_domain(template) - assert isinstance(dto.provider_config, AWSTemplateDTOConfig) - assert dto.provider_config.fleet_type == "maintain", ( + assert _provider_config_dict(dto).get("fleet_type") == "maintain", ( "fleet_type must be stored in provider_config, not as a top-level field" ) - def test_fleet_role_in_provider_config(self): + def test_fleet_role_in_metadata(self): template = _make_aws_template(fleet_role="arn:aws:iam::123:role/MyRole") dto = TemplateDTO.from_domain(template) - assert isinstance(dto.provider_config, AWSTemplateDTOConfig) - assert dto.provider_config.fleet_role == "arn:aws:iam::123:role/MyRole" + assert _provider_config_dict(dto).get("fleet_role") == "arn:aws:iam::123:role/MyRole" - def test_percent_on_demand_in_provider_config(self): + def test_percent_on_demand_in_metadata(self): template = _make_aws_template(price_type="heterogeneous", percent_on_demand=40) dto = TemplateDTO.from_domain(template) - assert isinstance(dto.provider_config, AWSTemplateDTOConfig) - assert dto.provider_config.percent_on_demand == 40 + assert _provider_config_dict(dto).get("percent_on_demand") == 40 - def test_launch_template_id_in_provider_config(self): - template = _make_aws_template(launch_template_id="lt-0abc123def456") - dto = TemplateDTO.from_domain(template) - assert isinstance(dto.provider_config, AWSTemplateDTOConfig) - assert dto.provider_config.launch_template_id == "lt-0abc123def456" - - def test_abis_instance_requirements_in_provider_config(self): + def test_abis_instance_requirements_in_metadata(self): abis = ABISInstanceRequirements( VCpuCount=AWSRequiredIntegerRange(Min=2, Max=8), MemoryMiB=AWSRequiredIntegerRange(Min=4096, Max=16384), ) template = _make_aws_template(abis_instance_requirements=abis) dto = TemplateDTO.from_domain(template) - assert isinstance(dto.provider_config, AWSTemplateDTOConfig) - stored = dto.provider_config.abis_instance_requirements + stored = _provider_config_dict(dto).get("abis_instance_requirements") assert stored is not None, "abis_instance_requirements must be stored in provider_config" - # model_dump() serialises ABISInstanceRequirements with snake_case field names assert stored["vcpu_count"]["min"] == 2 - def test_none_fleet_type_not_polluting_metadata(self): - """When fleet_type is None on the domain object, metadata must stay clean.""" + def test_none_fleet_type_not_in_provider_config(self): + """When fleet_type is None it must not pollute provider_config.""" template = _make_aws_template(provider_api="RunInstances") dto = TemplateDTO.from_domain(template) - assert "fleet_type" not in dto.metadata, ( - "fleet_type must not pollute the cross-provider metadata dict" - ) + assert "fleet_type" not in _provider_config_dict(dto) + assert "fleet_type" not in dto.to_template_config() + assert not hasattr(dto, "fleet_type") or "fleet_type" not in dto.model_fields def test_existing_metadata_preserved(self): """from_domain must not discard pre-existing metadata on the domain object.""" @@ -173,32 +149,30 @@ def test_existing_metadata_preserved(self): metadata={"custom_key": "custom_value"}, ) dto = TemplateDTO.from_domain(template) - assert dto.metadata.get("custom_key") == "custom_value", ( - "Pre-existing metadata keys must be preserved" - ) - # fleet_type must now be in provider_config, NOT metadata - assert "fleet_type" not in dto.metadata, ( - "fleet_type must not pollute the cross-provider metadata dict" - ) - assert isinstance(dto.provider_config, AWSTemplateDTOConfig) - assert dto.provider_config.fleet_type == "request" - - def test_metadata_stays_clean_of_aws_fields(self): - """metadata dict must not contain any AWS-specific keys after from_domain.""" - aws_only_keys = { - "fleet_type", - "fleet_role", - "percent_on_demand", - "abis_instance_requirements", - } - template = _make_aws_template( - fleet_type=AWSFleetType.MAINTAIN, - fleet_role="arn:aws:iam::123:role/R", - percent_on_demand=50, - ) - dto = TemplateDTO.from_domain(template) - leaked = aws_only_keys & set(dto.metadata.keys()) - assert not leaked, f"AWS fields leaked into metadata: {leaked}" + assert dto.metadata.get("custom_key") == "custom_value" + assert _provider_config_dict(dto).get("fleet_type") == "request" + + +class TestProviderConfigValidation: + """Provider config must be backed by a registered provider extension model.""" + + def test_unregistered_provider_config_dict_fails_validation(self): + with pytest.raises(ValidationError): + TemplateDTO.model_validate( + { + "template_id": "tpl-unregistered", + "provider_type": "unregistered-provider", + "provider_config": {"provider_specific_field": "value"}, + } + ) + + def test_typed_provider_config_must_match_registered_provider_type(self): + with pytest.raises(ValidationError): + TemplateDTO( + template_id="tpl-unregistered", + provider_type="unregistered-provider", + provider_config=AWSTemplateDTOConfig.model_validate({}), + ) # --------------------------------------------------------------------------- @@ -207,39 +181,33 @@ def test_metadata_stays_clean_of_aws_fields(self): class TestAWSTemplateRoundTrip: - """AWSTemplate -> TemplateDTO -> AWSTemplate must preserve AWS fields.""" + """AWSTemplate → TemplateDTO → AWSTemplate must preserve AWS fields.""" def test_fleet_type_maintain_roundtrip(self): original = _make_aws_template(fleet_type=AWSFleetType.MAINTAIN) dto = TemplateDTO.from_domain(original) - restored = AWSTemplate.model_validate(dto.model_dump()) + restored = AWSTemplate.model_validate(dto.to_template_config()) assert restored.fleet_type == AWSFleetType.MAINTAIN def test_fleet_type_request_roundtrip(self): original = _make_aws_template(fleet_type=AWSFleetType.REQUEST) dto = TemplateDTO.from_domain(original) - restored = AWSTemplate.model_validate(dto.model_dump()) + restored = AWSTemplate.model_validate(dto.to_template_config()) assert restored.fleet_type == AWSFleetType.REQUEST def test_fleet_role_roundtrip(self): role = "arn:aws:iam::123456789:role/SpotFleetRole" original = _make_aws_template(fleet_role=role) dto = TemplateDTO.from_domain(original) - restored = AWSTemplate.model_validate(dto.model_dump()) + restored = AWSTemplate.model_validate(dto.to_template_config()) assert restored.fleet_role == role def test_percent_on_demand_roundtrip(self): original = _make_aws_template(price_type="heterogeneous", percent_on_demand=60) dto = TemplateDTO.from_domain(original) - restored = AWSTemplate.model_validate(dto.model_dump()) + restored = AWSTemplate.model_validate(dto.to_template_config()) assert restored.percent_on_demand == 60 - def test_launch_template_id_roundtrip(self): - original = _make_aws_template(launch_template_id="lt-0deadbeef") - dto = TemplateDTO.from_domain(original) - restored = AWSTemplate.model_validate(dto.model_dump()) - assert restored.launch_template_id == "lt-0deadbeef" - def test_abis_roundtrip(self): abis = ABISInstanceRequirements( VCpuCount=AWSRequiredIntegerRange(Min=4, Max=16), @@ -247,7 +215,7 @@ def test_abis_roundtrip(self): ) original = _make_aws_template(abis_instance_requirements=abis) dto = TemplateDTO.from_domain(original) - restored = AWSTemplate.model_validate(dto.model_dump()) + restored = AWSTemplate.model_validate(dto.to_template_config()) assert restored.abis_instance_requirements is not None assert restored.abis_instance_requirements.vcpu_count.min == 4 diff --git a/tests/unit/infrastructure/test_config_driven_provider_registration.py b/tests/unit/infrastructure/test_config_driven_provider_registration.py index 071dfa5c4..61a6d10a6 100644 --- a/tests/unit/infrastructure/test_config_driven_provider_registration.py +++ b/tests/unit/infrastructure/test_config_driven_provider_registration.py @@ -11,6 +11,42 @@ class TestConfigDrivenProviderRegistration: """Test config-driven provider registration functionality.""" + def test_register_all_provider_types_includes_aws(self): + """Canonical provider bootstrap must register built-in AWS support.""" + from orb.providers.registration import register_all_provider_types + from orb.providers.registry import get_provider_registry + + registry = get_provider_registry() + registry.clear_registrations() + + register_all_provider_types() + + assert registry.is_provider_registered("aws") is True + + def test_provider_config_builder_accepts_aws_provider_instance_config(self): + """AWS config creation must use the provider instance's config mapping.""" + from orb.providers.config_builder import ProviderConfigBuilder + from orb.providers.registration import register_all_provider_types + from orb.providers.registry import get_provider_registry + + registry = get_provider_registry() + registry.clear_registrations() + register_all_provider_types() + + logger = MagicMock() + builder = ProviderConfigBuilder(logger, registry) + provider_instance = ProviderInstanceConfig( # type: ignore[call-arg] + name="aws-default", + type="aws", + enabled=True, + config={"region": "eu-west-1", "profile": "test-profile"}, + ) + + aws_config = builder.build_config(provider_instance) + + assert aws_config.region == "eu-west-1" + assert aws_config.profile == "test-profile" + def test_register_providers_with_valid_config(self): """Test provider registration with valid configuration.""" from orb.bootstrap.provider_services import register_provider_services diff --git a/tests/unit/infrastructure/test_provider_registry.py b/tests/unit/infrastructure/test_provider_registry.py new file mode 100644 index 000000000..9ac44aa30 --- /dev/null +++ b/tests/unit/infrastructure/test_provider_registry.py @@ -0,0 +1,283 @@ +"""Unit tests for Provider Registry.""" + +from unittest.mock import Mock + +import pytest + +from orb.config.schemas.provider_strategy_schema import ProviderInstanceConfig +from orb.domain.base.exceptions import ConfigurationError +from orb.providers.registry.provider_registry import ( + ProviderRegistry, + UnsupportedProviderError, + get_provider_registry, +) + + +@pytest.mark.unit +class TestProviderRegistry: + """Test cases for Provider Registry.""" + + def setup_method(self): + """Set up test fixtures.""" + # Create a fresh registry for each test + self.registry = ProviderRegistry() + # Clear any existing registrations from previous tests (singleton pattern) + self.registry.clear_registrations() + + # Mock factories + self.mock_strategy_factory = Mock(return_value="mock_strategy") + self.mock_config_factory = Mock(return_value="mock_config") + self.mock_resolver_factory = Mock(return_value="mock_resolver") + self.mock_validator_factory = Mock(return_value="mock_validator") + + def test_register_provider(self): + """Test provider registration.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + assert self.registry.is_provider_registered("test_provider") + assert "test_provider" in self.registry.get_registered_providers() + + def test_register_provider_with_optional_factories(self): + """Test provider registration with optional factories.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + resolver_factory=self.mock_resolver_factory, + validator_factory=self.mock_validator_factory, + ) + + assert self.registry.is_provider_registered("test_provider") + + def test_register_duplicate_provider_is_idempotent(self): + """Canonical registry path treats repeated provider registration as idempotent.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + assert self.registry.is_provider_registered("test_provider") + + def test_unregister_provider(self): + """Test provider unregistration.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + assert self.registry.unregister_provider("test_provider") + assert not self.registry.is_provider_registered("test_provider") + assert "test_provider" not in self.registry.get_registered_providers() + + def test_unregister_nonexistent_provider(self): + """Test unregistering non-existent provider.""" + assert not self.registry.unregister_provider("nonexistent_provider") + + def test_create_strategy(self): + """Test strategy creation.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + config = {"test": "config"} + strategy = self.registry.create_strategy("test_provider", config) + + assert strategy == "mock_strategy" + self.mock_strategy_factory.assert_called_once_with(config) + + def test_create_strategy_from_instance_requires_config_aware_factory(self): + """Instance strategy factories must accept the config argument passed by the registry.""" + # This mirrors the provider/DI wiring bug: DI registration stores a zero-arg + # closure, but the registry always invokes strategy factories as factory(config). + self.registry.register_provider_instance( + provider_type="azure", + instance_name="azure-primary", + strategy_factory=lambda: "mock_strategy", + config_factory=self.mock_config_factory, + ) + + with pytest.raises(ConfigurationError, match="Failed to create strategy"): + self.registry.create_strategy_by_instance( + "azure-primary", + {"subscription_id": "test-subscription"}, + ) + + def test_create_strategy_unsupported_provider(self): + """Unknown provider types resolve to no strategy when auto-registration fails.""" + assert self.registry.create_strategy("unsupported_provider", {}) is None + + def test_create_strategy_factory_error(self): + """Test strategy creation when factory raises error.""" + failing_factory = Mock(side_effect=Exception("Factory error")) + + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=failing_factory, + config_factory=self.mock_config_factory, + ) + + with pytest.raises(ConfigurationError, match="Failed to create strategy"): + self.registry.create_strategy("test_provider", {}) + + def test_create_config(self): + """Test configuration creation.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + data = {"test": "data"} + config = self.registry.create_config("test_provider", data) + + assert config == "mock_config" + self.mock_config_factory.assert_called_once_with(data) + + def test_create_config_unsupported_provider(self): + """Test config creation with unsupported provider.""" + with pytest.raises(UnsupportedProviderError, match="not registered"): + self.registry.create_config("unsupported_provider", {}) + + def test_create_resolver(self): + """Test resolver creation.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + resolver_factory=self.mock_resolver_factory, + ) + + resolver = self.registry.create_resolver("test_provider") + + assert resolver == "mock_resolver" + self.mock_resolver_factory.assert_called_once() + + def test_create_resolver_no_factory(self): + """Test resolver creation when no factory is registered.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + resolver = self.registry.create_resolver("test_provider") + assert resolver is None + + def test_create_resolver_unregistered_provider(self): + """Test resolver creation for unregistered provider.""" + with pytest.raises(ValueError, match="not registered"): + self.registry.create_resolver("unregistered_provider") + + def test_create_validator(self): + """Test validator creation.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + validator_factory=self.mock_validator_factory, + ) + + validator = self.registry.create_validator("test_provider") + + assert validator == "mock_validator" + self.mock_validator_factory.assert_called_once() + + def test_create_validator_passes_config_to_factory(self): + """Test validator creation forwards optional config.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + validator_factory=self.mock_validator_factory, + ) + + cfg = {"region": "us-east-1"} + validator = self.registry.create_validator("test_provider", cfg) + + assert validator == "mock_validator" + self.mock_validator_factory.assert_called_once_with(cfg) + + def test_create_validator_no_factory(self): + """Test validator creation when no factory is registered.""" + self.registry.register_provider( + provider_type="test_provider", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + validator = self.registry.create_validator("test_provider") + assert validator is None + + def test_clear_registrations(self): + """Test clearing all registrations.""" + self.registry.register_provider( + provider_type="test_provider1", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + self.registry.register_provider( + provider_type="test_provider2", + strategy_factory=self.mock_strategy_factory, + config_factory=self.mock_config_factory, + ) + + assert len(self.registry.get_registered_providers()) == 2 + + self.registry.clear_registrations() + + assert len(self.registry.get_registered_providers()) == 0 + + def test_singleton_behavior(self): + """Test that get_provider_registry returns singleton.""" + registry1 = get_provider_registry() + registry2 = get_provider_registry() + + assert registry1 is registry2 + + def test_thread_safety(self): + """Test thread-safe registration.""" + import threading + + results = [] + errors = [] + + def register_provider(provider_id): + try: + self.registry.register_provider( + provider_type=f"provider_{provider_id}", + strategy_factory=lambda x: f"strategy_{provider_id}", + config_factory=lambda x: f"config_{provider_id}", + ) + results.append(provider_id) + except Exception as e: + errors.append(e) + + # Create multiple threads registering providers + threads = [] + for i in range(10): + thread = threading.Thread(target=register_provider, args=(i,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Check results + assert len(errors) == 0 + assert len(results) == 10 + assert len(self.registry.get_registered_providers()) == 10 diff --git a/tests/unit/interface/mcp/test_mcp_resources_and_validate.py b/tests/unit/interface/mcp/test_mcp_resources_and_validate.py index 6715725c6..b02fc6ad8 100644 --- a/tests/unit/interface/mcp/test_mcp_resources_and_validate.py +++ b/tests/unit/interface/mcp/test_mcp_resources_and_validate.py @@ -36,7 +36,7 @@ async def test_resources_read_requests_uri_returns_contents(self): """resources/read with requests:// URI returns contents list with correct URI.""" server = _make_server() mock_result = {"requests": [{"request_id": "req-abc"}]} - server.tools["list_return_requests"] = AsyncMock(return_value=mock_result) + server.tools["list_requests"] = AsyncMock(return_value=mock_result) message = { "jsonrpc": "2.0", diff --git a/tests/unit/interface/test_aws_leak_cleanup.py b/tests/unit/interface/test_aws_leak_cleanup.py index 3c9c05945..683a24494 100644 --- a/tests/unit/interface/test_aws_leak_cleanup.py +++ b/tests/unit/interface/test_aws_leak_cleanup.py @@ -40,9 +40,26 @@ def test_no_hardcoded_default_profile_in_fallback(self): ) def test_no_aws_type_guard_in_overrides(self): - source = self._source() - # The type == 'aws' guard that restricted override logic to AWS only must be gone. - assert '== "aws"' not in source and "== 'aws'" not in source, ( + # Scope the check to the named function body — other functions in the + # file (e.g. _show_provider_infrastructure) intentionally dispatch on + # provider type for display formatting. + import ast + + tree = ast.parse(self._source()) + target = next( + ( + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and node.name == "_get_active_providers_with_overrides" + ), + None, + ) + assert target is not None, ( + "_get_active_providers_with_overrides function must exist in infrastructure_command_handler.py" + ) + body_source = ast.unparse(target) + assert '== "aws"' not in body_source and "== 'aws'" not in body_source, ( "_get_active_providers_with_overrides must not guard on provider type 'aws'" ) diff --git a/tests/unit/interface/test_get_provider_strategy_init.py b/tests/unit/interface/test_get_provider_strategy_init.py index b2d3bcab0..3eac9f0e6 100644 --- a/tests/unit/interface/test_get_provider_strategy_init.py +++ b/tests/unit/interface/test_get_provider_strategy_init.py @@ -30,10 +30,8 @@ def _mock_strategy_class(): def _mock_registry(strategy_class=None): """Build a mock registry that exposes the strategy class via registration.""" registry = MagicMock() - reg = MagicMock() - reg.strategy_class = strategy_class registry.ensure_provider_type_registered.return_value = True - registry._get_type_registration.return_value = reg + registry.get_strategy_class.return_value = strategy_class return registry @@ -45,7 +43,7 @@ def test_init_handler_returns_strategy_class() -> None: result = _mod._get_provider_strategy("aws", registry=registry) registry.ensure_provider_type_registered.assert_called_once_with("aws") - registry._get_type_registration.assert_called_once_with("aws") + registry.get_strategy_class.assert_called_once_with("aws") assert result is mock_cls @@ -80,10 +78,10 @@ def test_init_handler_returns_none_when_no_strategy_class() -> None: def test_init_handler_returns_none_on_registration_error() -> None: - """_get_provider_strategy returns None when _get_type_registration raises.""" + """_get_provider_strategy returns None when strategy class lookup raises.""" registry = MagicMock() registry.ensure_provider_type_registered.return_value = True - registry._get_type_registration.side_effect = ValueError("not registered") + registry.get_strategy_class.side_effect = ValueError("not registered") result = _mod._get_provider_strategy("notfound", registry=registry) diff --git a/tests/unit/interface/test_init_command_handler.py b/tests/unit/interface/test_init_command_handler.py index 5a9487636..f6e932b9c 100644 --- a/tests/unit/interface/test_init_command_handler.py +++ b/tests/unit/interface/test_init_command_handler.py @@ -10,11 +10,7 @@ def _make_strategy(regions=None, default_region="us-east-1"): strategy.get_available_regions.return_value = regions or [] strategy.get_default_region.return_value = default_region strategy.get_available_credential_sources.return_value = [ - { - "name": "default", - "description": "Default profile", - "config_delta": {"profile": "default"}, - } + {"name": "default", "description": "Default profile"} ] strategy.test_credentials.return_value = {"success": True} strategy.get_credential_requirements.return_value = {} @@ -31,9 +27,10 @@ def _mock_container(): return container -def test_discover_infrastructure_uses_create_strategy_by_type(): - """_discover_infrastructure must use create_strategy_by_type with the operator's config.""" +def test_discover_infrastructure_uses_fresh_strategy(): + """_discover_infrastructure must call create_strategy_by_type, not get_or_create_strategy.""" mock_registry = MagicMock() + mock_registry.ensure_provider_type_registered.return_value = True mock_strategy = MagicMock() mock_strategy.discover_infrastructure_interactive.return_value = {"vpc_id": "vpc-123"} mock_registry.create_strategy_by_type.return_value = mock_strategy @@ -52,7 +49,9 @@ def test_discover_infrastructure_uses_create_strategy_by_type(): ), ): result = _mod._discover_infrastructure( - "aws", {"region": "us-east-1", "profile": "my-profile"}, mock_registry + "aws", + {"region": "us-east-1", "profile": "my-profile"}, + mock_registry, ) mock_registry.create_strategy_by_type.assert_called_once_with( @@ -100,9 +99,10 @@ def test_operational_requirements_separate_from_credential_requirements(): assert cred_reqs != op_reqs -def test_discover_infrastructure_forwards_region_and_profile_to_strategy(): - """_discover_infrastructure forwards region + profile via the strategy's discover call.""" +def test_discover_infrastructure_passes_correct_region_and_profile(): + """_discover_infrastructure must pass region and profile to create_strategy_by_type.""" mock_registry = MagicMock() + mock_registry.ensure_provider_type_registered.return_value = True mock_strategy = MagicMock() mock_strategy.discover_infrastructure_interactive.return_value = {} mock_registry.create_strategy_by_type.return_value = mock_strategy @@ -112,16 +112,14 @@ def test_discover_infrastructure_forwards_region_and_profile_to_strategy(): patch("orb.interface.init_command_handler.get_container", return_value=_mock_container()), ): _mod._discover_infrastructure( - "aws", {"region": "eu-west-2", "profile": "my-prod-profile"}, mock_registry + "aws", + {"region": "eu-west-2", "profile": "my-prod-profile"}, + mock_registry, ) - mock_registry.create_strategy_by_type.assert_called_once_with( - "aws", {"region": "eu-west-2", "profile": "my-prod-profile"} - ) - full_config = mock_strategy.discover_infrastructure_interactive.call_args[0][0] - assert full_config["type"] == "aws" - assert full_config["config"]["region"] == "eu-west-2" - assert full_config["config"]["profile"] == "my-prod-profile" + call_args = mock_registry.create_strategy_by_type.call_args + assert call_args[0][1]["region"] == "eu-west-2" + assert call_args[0][1]["profile"] == "my-prod-profile" mock_registry.get_or_create_strategy.assert_not_called() @@ -133,9 +131,9 @@ def mock_test_creds(provider_type, source, **kwargs): call_order.append("test_credentials") return (True, "") - def mock_prompt_operational_params(strategy_class): - call_order.append("prompt_operational_params") - return {"region": "us-east-1"} + def mock_pick_region(regions, default): + call_order.append("pick_region") + return "us-east-1" # inputs: scheduler choice, provider choice, credential choice, discover infra, add another inputs = iter(["1", "1", "1", "N", "N"]) @@ -144,9 +142,7 @@ def mock_prompt_operational_params(strategy_class): with ( patch("builtins.input", side_effect=inputs), patch.object(_mod, "_test_provider_credentials", side_effect=mock_test_creds), - patch.object( - _mod, "_prompt_operational_params", side_effect=mock_prompt_operational_params - ), + patch.object(_mod, "_pick_region", side_effect=mock_pick_region), patch.object( _mod, "_get_available_schedulers", @@ -162,13 +158,7 @@ def mock_prompt_operational_params(strategy_class): patch.object( _mod, "_get_available_credential_sources", - return_value=[ - { - "name": "default", - "description": "Default", - "config_delta": {"profile": "default"}, - } - ], + return_value=[{"name": "default", "description": "Default"}], ), patch.object( _mod, @@ -180,8 +170,8 @@ def mock_prompt_operational_params(strategy_class): _mod._interactive_setup() assert "test_credentials" in call_order - assert "prompt_operational_params" in call_order - assert call_order.index("test_credentials") < call_order.index("prompt_operational_params") + assert "pick_region" in call_order + assert call_order.index("test_credentials") < call_order.index("pick_region") def test_interactive_setup_returns_empty_on_credential_failure(): @@ -209,13 +199,7 @@ def test_interactive_setup_returns_empty_on_credential_failure(): patch.object( _mod, "_get_available_credential_sources", - return_value=[ - { - "name": "default", - "description": "Default", - "config_delta": {"profile": "default"}, - } - ], + return_value=[{"name": "default", "description": "Default"}], ), patch("orb.interface.init_command_handler.get_container", return_value=_mock_container()), ): @@ -265,16 +249,12 @@ def test_write_config_file_fleet_role_in_config_subnet_ids_in_template_defaults( ], } - mock_strategy_class = MagicMock() - mock_strategy_class.get_cli_extra_config_keys.return_value = {"fleet_role"} - mock_strategy_class.generate_provider_name.return_value = "aws_instance-profile_us-east-1" - - mock_reg = MagicMock() - mock_reg.strategy_class = mock_strategy_class + mock_strategy = MagicMock() + mock_strategy.get_cli_extra_config_keys.return_value = {"fleet_role"} + mock_strategy.generate_provider_name.return_value = "aws_instance-profile_us-east-1" mock_factory = MagicMock() - mock_factory.ensure_provider_type_registered.return_value = True - mock_factory._get_type_registration.return_value = mock_reg + mock_factory.create_strategy_by_type.return_value = mock_strategy mock_container = MagicMock() mock_container.get.return_value = mock_factory @@ -323,159 +303,124 @@ def joinpath(self, name): ) -# --------------------------------------------------------------------------- -# _get_provider_strategy — init-path tests -# --------------------------------------------------------------------------- - - -def test_get_provider_strategy_returns_strategy_class() -> None: - """_get_provider_strategy must return the strategy CLASS (not an instance). - - The init flow has no provider config yet; classmethods can be called - directly on the class without constructing an instance. - """ - mock_cls = MagicMock() - mock_reg = MagicMock() - mock_reg.strategy_class = mock_cls - mock_registry = MagicMock() - mock_registry.ensure_provider_type_registered.return_value = True - mock_registry._get_type_registration.return_value = mock_reg - - result = _mod._get_provider_strategy("k8s", registry=mock_registry) - - mock_registry.ensure_provider_type_registered.assert_called_once_with("k8s") - mock_registry.get_or_create_strategy.assert_not_called() - assert result is mock_cls - - -def test_get_provider_strategy_k8s_does_not_touch_get_or_create_strategy() -> None: - """_get_provider_strategy must not call registry.get_or_create_strategy.""" - mock_cls = MagicMock() - mock_reg = MagicMock() - mock_reg.strategy_class = mock_cls - mock_registry = MagicMock() - mock_registry.ensure_provider_type_registered.return_value = True - mock_registry._get_type_registration.return_value = mock_reg - - _mod._get_provider_strategy("k8s", registry=mock_registry) - - mock_registry.get_or_create_strategy.assert_not_called() - - -def test_get_provider_strategy_aws_returns_strategy_class() -> None: - """All provider types go through the classmethod path (no special-casing).""" - mock_cls = MagicMock() - mock_reg = MagicMock() - mock_reg.strategy_class = mock_cls - mock_registry = MagicMock() - mock_registry.ensure_provider_type_registered.return_value = True - mock_registry._get_type_registration.return_value = mock_reg - - result = _mod._get_provider_strategy("aws", registry=mock_registry) - - mock_registry.ensure_provider_type_registered.assert_called_once_with("aws") - mock_registry.get_or_create_strategy.assert_not_called() - assert result is mock_cls - - -def test_get_provider_strategy_returns_none_on_error() -> None: - """_get_provider_strategy returns None when the type lookup raises.""" - mock_registry = MagicMock() - mock_registry.ensure_provider_type_registered.return_value = True - mock_registry._get_type_registration.side_effect = ValueError("not registered") - - result = _mod._get_provider_strategy("k8s", registry=mock_registry) - - assert result is None - - -# --------------------------------------------------------------------------- -# test_init_handler_uses_strategy_generate_provider_name -# --------------------------------------------------------------------------- - - -def _write_config_file_with_strategy(tmp_path, provider_data, strategy_name_return): - """Helper: run _write_config_file with a mocked strategy class.""" - import json - - config_file = tmp_path / "config.json" - user_config = {"scheduler_type": "default", "providers": [provider_data]} - - mock_strategy_class = MagicMock() - mock_strategy_class.get_cli_extra_config_keys.return_value = set() - mock_strategy_class.generate_provider_name.return_value = strategy_name_return - - mock_reg = MagicMock() - mock_reg.strategy_class = mock_strategy_class - - mock_factory = MagicMock() - mock_factory.ensure_provider_type_registered.return_value = True - mock_factory._get_type_registration.return_value = mock_reg - - mock_container = MagicMock() - mock_container.get.return_value = mock_factory - - mock_scheduler_registry = MagicMock() - mock_scheduler_registry.get_extra_config_for_type.return_value = {} - - fake_default = json.dumps({"scheduler": {}, "provider": {}}) - - class _FakeResource: - def read_text(self): - return fake_default - - class _FakeFiles: - def joinpath(self, name): - return _FakeResource() +def test_interactive_setup_preserves_azure_provider_config(): + """Interactive init must keep Azure-specific config instead of collapsing to profile/region.""" + inputs = iter( + [ + "1", # scheduler + "1", # provider + "1", # credentials + "12345678-1234-1234-1234-123456789012", # subscription_id + "orb-test-rg", # resource_group + "eastus2", # region + "managed-identity-client-id", # client_id + "N", # discover infrastructure + "N", # add another provider + ] + ) + strategy = MagicMock() + strategy.get_available_regions.return_value = [] + strategy.get_default_region.return_value = "eastus2" with ( - patch("orb.interface.init_command_handler.get_container", return_value=mock_container), - patch( - "orb.infrastructure.scheduler.registry.get_scheduler_registry", - return_value=mock_scheduler_registry, + patch("builtins.input", side_effect=inputs), + patch.object(_mod, "_test_provider_credentials", return_value=(True, "")), + patch.object( + _mod, + "_get_available_schedulers", + return_value=[{"type": "default", "display_name": "Default", "description": ""}], ), - patch("importlib.resources.files", return_value=_FakeFiles()), + patch.object( + _mod, + "_get_available_providers", + return_value=[{"type": "azure", "display_name": "Azure", "description": ""}], + ), + patch.object(_mod, "_get_provider_strategy", return_value=strategy), + patch.object(_mod, "_get_credential_requirements", return_value={}), + patch.object( + _mod, + "_get_available_credential_sources", + return_value=[{"name": None, "description": "Default"}], + ), + patch.object( + _mod, + "_get_operational_requirements", + return_value={ + "subscription_id": {"required": True, "description": "Azure subscription ID"}, + "resource_group": {"required": True, "description": "Azure resource group"}, + "region": {"required": True, "description": "Azure location"}, + "client_id": { + "required": False, + "prompt": True, + "description": "Managed identity client ID (optional)", + }, + }, + ), + patch("orb.interface.init_command_handler.get_container", return_value=_mock_container()), ): - _mod._write_config_file(config_file, user_config) + result = _mod._interactive_setup() - with open(config_file) as f: - written = json.load(f) + provider = result["providers"][0] + assert provider["type"] == "azure" + assert provider["config"]["subscription_id"] == "12345678-1234-1234-1234-123456789012" + assert provider["config"]["resource_group"] == "orb-test-rg" + assert provider["config"]["region"] == "eastus2" + assert provider["config"]["client_id"] == "managed-identity-client-id" + + +def test_get_default_config_preserves_azure_non_interactive_config(): + """Non-interactive init must keep Azure-specific config from CLI args.""" + args = MagicMock() + args.provider = "azure" + args.scheduler = "default" + args.region = None + args.profile = None + args.azure_subscription_id = "12345678-1234-1234-1234-123456789012" + args.azure_resource_group = "orb-test-rg" + args.azure_location = "eastus2" + args.azure_client_id = "managed-identity-client-id" + args.azure_cyclecloud_url = "https://cyclecloud.example.com" + args.azure_cyclecloud_credential_path = "op://vault/item/cred" + args.azure_cyclecloud_auth_mode = None + args.azure_cyclecloud_aad_scope = None + args.azure_cyclecloud_verify_ssl = False + args.azure_cyclecloud_no_verify_ssl = True - return written["provider"]["providers"][0], mock_strategy_class + strategy = MagicMock() + strategy.get_default_region.return_value = "eastus2" + strategy.get_cli_provider_config.return_value = { + "subscription_id": args.azure_subscription_id, + "resource_group": args.azure_resource_group, + "region": args.azure_location, + "client_id": args.azure_client_id, + "cyclecloud": { + "url": args.azure_cyclecloud_url, + "credential_path": args.azure_cyclecloud_credential_path, + "verify_ssl": False, + }, + } + strategy.get_cli_infrastructure_defaults.return_value = {} + with ( + patch.object( + _mod, + "_get_available_providers", + return_value=[{"type": "azure", "display_name": "Azure", "description": ""}], + ), + patch.object(_mod, "_get_provider_strategy", return_value=strategy), + ): + result = _mod._get_default_config(args) -def test_init_handler_uses_strategy_generate_provider_name(tmp_path) -> None: - """_write_config_file routes provider name generation through the strategy.""" - provider_data = { - "type": "k8s", - "profile": "ms-karpenter", - "region": "", - "is_default": True, - "infrastructure_defaults": {}, - } - p, mock_strategy = _write_config_file_with_strategy(tmp_path, provider_data, "k8s_ms-karpenter") - mock_strategy.generate_provider_name.assert_called_once() - assert p["name"] == "k8s_ms-karpenter" - - -def test_init_handler_uses_strategy_name_for_aws(tmp_path) -> None: - """generate_provider_name is called for AWS providers too.""" - provider_data = { - "type": "aws", - "profile": "my-profile", - "region": "eu-west-1", - "is_default": False, - "infrastructure_defaults": {}, - } - p, mock_strategy = _write_config_file_with_strategy( - tmp_path, provider_data, "aws_my-profile_eu-west-1" - ) - mock_strategy.generate_provider_name.assert_called_once() - assert p["name"] == "aws_my-profile_eu-west-1" + provider = result["providers"][0] + assert provider["config"]["subscription_id"] == args.azure_subscription_id + assert provider["config"]["resource_group"] == args.azure_resource_group + assert provider["config"]["region"] == args.azure_location + assert provider["config"]["client_id"] == args.azure_client_id + assert provider["config"]["cyclecloud"]["verify_ssl"] is False -def test_init_handler_fallback_name_when_strategy_unavailable(tmp_path) -> None: - """When the strategy cannot be created, _fallback_provider_name is used.""" +def test_write_config_file_preserves_azure_provider_config(tmp_path): + """init config writing must preserve Azure provider config fields.""" import json config_file = tmp_path / "config.json" @@ -483,20 +428,33 @@ def test_init_handler_fallback_name_when_strategy_unavailable(tmp_path) -> None: "scheduler_type": "default", "providers": [ { - "type": "aws", - "config": {"profile": "default", "region": "us-west-2"}, - "is_default": False, + "type": "azure", + "config": { + "subscription_id": "12345678-1234-1234-1234-123456789012", + "resource_group": "orb-test-rg", + "region": "eastus2", + "client_id": "managed-identity-client-id", + "cyclecloud": { + "url": "https://cyclecloud.example.com", + "credential_path": "op://vault/item/cred", + "verify_ssl": False, + }, + }, + "is_default": True, "infrastructure_defaults": {}, } ], } - mock_factory = MagicMock() - mock_factory.ensure_provider_type_registered.return_value = True - mock_factory._get_type_registration.side_effect = RuntimeError("no strategy") + mock_strategy = MagicMock() + mock_strategy.get_cli_extra_config_keys.return_value = set() + mock_strategy.generate_provider_name.return_value = "azure_12345678-1234-1234-1234-123456789012_eastus2" + + mock_registry = MagicMock() + mock_registry.create_strategy_by_type.return_value = mock_strategy mock_container = MagicMock() - mock_container.get.return_value = mock_factory + mock_container.get.return_value = mock_registry mock_scheduler_registry = MagicMock() mock_scheduler_registry.get_extra_config_for_type.return_value = {} @@ -524,8 +482,10 @@ def joinpath(self, name): with open(config_file) as f: written = json.load(f) - p = written["provider"]["providers"][0] - # Fallback: provider_type + sha256(sorted config) — provider-agnostic; the - # specific hash isn't stable across config-key additions but the shape is. - assert p["name"].startswith("aws_") - assert len(p["name"].split("_")[1]) == 8 # sha256 first-8-chars + provider = written["provider"]["providers"][0] + assert provider["name"] == "azure_12345678-1234-1234-1234-123456789012_eastus2" + assert provider["config"]["subscription_id"] == "12345678-1234-1234-1234-123456789012" + assert provider["config"]["resource_group"] == "orb-test-rg" + assert provider["config"]["region"] == "eastus2" + assert provider["config"]["client_id"] == "managed-identity-client-id" + assert provider["config"]["cyclecloud"]["credential_path"] == "op://vault/item/cred" diff --git a/tests/unit/providers/registry/test_create_strategy_for_init.py b/tests/unit/providers/registry/test_create_strategy_for_init.py index 754f741b9..03aede2c5 100644 --- a/tests/unit/providers/registry/test_create_strategy_for_init.py +++ b/tests/unit/providers/registry/test_create_strategy_for_init.py @@ -61,8 +61,7 @@ def get_default_region(cls) -> str: strategy_class=_StubStrategy, ) - reg = registry._get_type_registration("stubprovider") - assert reg.strategy_class is _StubStrategy + assert registry.get_strategy_class("stubprovider") is _StubStrategy # --------------------------------------------------------------------------- @@ -94,8 +93,8 @@ def generate_provider_name(cls, config: dict) -> str: strategy_class=_CredStrategy, ) - reg = registry._get_type_registration("aws") - strategy_class = reg.strategy_class + strategy_class = registry.get_strategy_class("aws") + assert strategy_class is _CredStrategy sources = strategy_class.get_available_credential_sources() assert isinstance(sources, list) diff --git a/tests/unit/test_field_mapping_coverage.py b/tests/unit/test_field_mapping_coverage.py index 0c75ef43b..49ce63755 100644 --- a/tests/unit/test_field_mapping_coverage.py +++ b/tests/unit/test_field_mapping_coverage.py @@ -44,6 +44,18 @@ "userDataScript", ] +REQUIRED_AZURE_FIELDS = [ + "vmType", + "vmTypes", + "keyName", + "resourceGroup", + "vmSize", + "vmSizes", + "sshKeyName", + "networkConfig", + "cyclecloudUrl", +] + class TestFieldMappingCoverage: """Verify HostFactoryFieldMappings covers all known HF-spec fields.""" @@ -67,6 +79,19 @@ def test_get_mappings_aws_includes_all_fields(self): missing = [f for f in all_required if f not in combined] assert not missing, f"get_mappings('aws') is missing fields: {missing}" + def test_azure_fields_present_in_azure_mappings(self): + """Every required Azure-specific field must exist in the azure mapping table.""" + azure = HostFactoryFieldMappings.MAPPINGS.get("azure", {}) + missing = [f for f in REQUIRED_AZURE_FIELDS if f not in azure] + assert not missing, f"Missing Azure field mappings: {missing}" + + def test_get_mappings_azure_includes_all_fields(self): + """get_mappings('azure') must return the union of generic and Azure fields.""" + combined = HostFactoryFieldMappings.get_mappings("azure") + all_required = REQUIRED_GENERIC_FIELDS + REQUIRED_AZURE_FIELDS + missing = [f for f in all_required if f not in combined] + assert not missing, f"get_mappings('azure') is missing fields: {missing}" + def test_generic_fields_map_to_non_empty_internal_names(self): """Every generic mapping must resolve to a non-empty internal field name.""" generic = HostFactoryFieldMappings.MAPPINGS.get("generic", {}) @@ -83,6 +108,10 @@ def test_aws_is_a_supported_provider(self): """'aws' must appear in the list of supported providers.""" assert "aws" in HostFactoryFieldMappings.get_supported_providers() + def test_azure_is_a_supported_provider(self): + """'azure' must appear in the list of supported providers.""" + assert "azure" in HostFactoryFieldMappings.get_supported_providers() + def test_no_overlap_between_generic_and_aws_keys(self): """Generic and AWS mapping tables must not share the same HF field name.""" generic_keys = set(HostFactoryFieldMappings.MAPPINGS.get("generic", {}).keys()) diff --git a/tests/unit/test_init_handler_no_aws.py b/tests/unit/test_init_handler_no_aws.py index c45306a86..da23be31c 100644 --- a/tests/unit/test_init_handler_no_aws.py +++ b/tests/unit/test_init_handler_no_aws.py @@ -88,6 +88,15 @@ def test_provider_strategy_has_get_cli_infrastructure_defaults(): ) +def test_provider_strategy_has_get_cli_provider_config(): + """Base ProviderStrategy must have get_cli_provider_config method.""" + from orb.providers.base.strategy.provider_strategy import ProviderStrategy + + assert hasattr(ProviderStrategy, "get_cli_provider_config"), ( + "get_cli_provider_config not on ProviderStrategy" + ) + + def test_provider_strategy_base_defaults(): """Base ProviderStrategy default implementations return empty/no-op values.""" from unittest.mock import MagicMock @@ -128,6 +137,7 @@ def cleanup(self) -> None: strategy = _ConcreteStrategy(config) assert strategy.get_cli_extra_config_keys() == set() + assert strategy.get_cli_provider_config(MagicMock()) == {} assert strategy.get_cli_infrastructure_defaults(MagicMock()) == {} assert strategy.get_cli_provider_config(MagicMock()) == {} diff --git a/tests/unit/test_providers_no_eager_imports.py b/tests/unit/test_providers_no_eager_imports.py index ea4222eeb..0b656bcd4 100644 --- a/tests/unit/test_providers_no_eager_imports.py +++ b/tests/unit/test_providers_no_eager_imports.py @@ -2,47 +2,46 @@ Importing orb.providers must not trigger loading of the provider factory or registry, which pull in heavy AWS dependencies at import time. + +The eager-load check runs in a subprocess so the test never has to mutate the +parent process's ``sys.modules`` — earlier attempts at evicting and restoring +modules left the parent ``orb.providers`` package without its submodule +attributes (e.g. ``orb.providers.azure``), which broke unrelated downstream +tests that rely on ``monkeypatch.setattr`` walking dotted import paths. """ +import subprocess import sys -from contextlib import contextmanager -@contextmanager -def _isolated_providers_import(): - """Temporarily evict orb.providers.* from sys.modules, then restore on exit. +def _run_isolated_check(module_to_check: str) -> bool: + """Spawn a clean Python that imports orb.providers and reports whether + ``module_to_check`` got loaded as a side effect. - Restoring the original module objects prevents downstream tests from seeing - a second (re-imported) copy of the same module, which would break isinstance - checks on exception classes defined in those modules. + Returns True if the module was loaded (i.e. eager-import violation), + False otherwise. """ - saved = {k: v for k, v in sys.modules.items() if k.startswith("orb.providers")} - for key in saved: - del sys.modules[key] - try: - yield - finally: - # Remove any modules loaded during the isolated import - for key in list(sys.modules): - if key.startswith("orb.providers"): - del sys.modules[key] - # Restore the original module objects - sys.modules.update(saved) + script = ( + "import sys\n" + "import orb.providers # noqa\n" + f"print('LOADED' if {module_to_check!r} in sys.modules else 'NOT_LOADED')\n" + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() == "LOADED" def test_providers_init_does_not_eagerly_load_factory(): - with _isolated_providers_import(): - import orb.providers # noqa: F401 # type: ignore[reportUnusedImport] - - assert "orb.providers.factory" not in sys.modules, ( - "orb.providers.__init__ must not eagerly import orb.providers.factory" - ) + assert not _run_isolated_check("orb.providers.factory"), ( + "orb.providers.__init__ must not eagerly import orb.providers.factory" + ) def test_providers_init_does_not_eagerly_load_registry(): - with _isolated_providers_import(): - import orb.providers # noqa: F401 # type: ignore[reportUnusedImport] - - assert "orb.providers.registry.provider_registry" not in sys.modules, ( - "orb.providers.__init__ must not eagerly import orb.providers.registry.provider_registry" - ) + assert not _run_isolated_check("orb.providers.registry.provider_registry"), ( + "orb.providers.__init__ must not eagerly import orb.providers.registry.provider_registry" + ) diff --git a/uv.lock b/uv.lock index 80fdd392f..5a51e9756 100644 --- a/uv.lock +++ b/uv.lock @@ -3681,6 +3681,7 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pyright", marker = "extra == 'ci'", specifier = ">=1.1.408,<2.0.0" }, { name = "pytest", marker = "extra == 'ci'", specifier = ">=7.4.3,<10.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'ci'", specifier = ">=0.21.1,<2.0.0" }, { name = "pytest-benchmark", marker = "extra == 'ci'", specifier = ">=5.1.0,<6.0.0" }, { name = "pytest-cov", marker = "extra == 'ci'", specifier = ">=4.1.0,<8.0.0" },