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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/shared-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,13 @@ jobs:
except ImportError:
import tomli as tomllib # backport for Python < 3.11
import json
META = {'all', 'dev', 'ci', 'test-aws', 'test-k8s', 'all-providers', 'k8s-legacy', 'monitoring-aws'}
META = {'all', 'dev', 'ci', 'all-providers', 'k8s-legacy', 'monitoring-aws'}
with open('pyproject.toml', 'rb') as f:
data = tomllib.load(f)
extras = [k for k in data['project']['optional-dependencies'].keys() if k not in META]
extras = [
k for k in data['project']['optional-dependencies']
if k not in META and not k.startswith('test-')
]
print(json.dumps(extras if extras else ['cli', 'api', 'monitoring']))
")

Expand Down
13 changes: 7 additions & 6 deletions dev-tools/setup/run_tool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ set -e

TOOL_NAME="$1"
shift # Remove tool name from arguments
read -r -a UV_RUN_OPTIONS <<< "${RUN_TOOL_UV_OPTIONS:-}"

# Walk upward from $PWD looking for a UV/Python project root.
# Prints the path on success; prints nothing and returns 1 if not found.
Expand Down Expand Up @@ -105,12 +106,12 @@ run_tool() {
adjusted_args=$(adjust_relative_args "$project_root" "$@")
if [ -n "$adjusted_args" ]; then
# shellcheck disable=SC2086
(cd "$project_root" && uv run "${TOOL_NAME}" $adjusted_args)
(cd "$project_root" && uv run "${UV_RUN_OPTIONS[@]}" "${TOOL_NAME}" $adjusted_args)
else
(cd "$project_root" && uv run "${TOOL_NAME}")
(cd "$project_root" && uv run "${UV_RUN_OPTIONS[@]}" "${TOOL_NAME}")
fi
else
uv run "${TOOL_NAME}" "$@"
uv run "${UV_RUN_OPTIONS[@]}" "${TOOL_NAME}" "$@"
fi
elif [ -f ".venv/bin/${TOOL_NAME}" ] && venv_usable ".venv"; then
echo "Executing with venv..."
Expand All @@ -125,12 +126,12 @@ run_tool() {
adjusted_args=$(adjust_relative_args "$project_root" "$@")
if [ -n "$adjusted_args" ]; then
# shellcheck disable=SC2086
(cd "$project_root" && uv run python -m "${TOOL_NAME}" $adjusted_args)
(cd "$project_root" && uv run "${UV_RUN_OPTIONS[@]}" python -m "${TOOL_NAME}" $adjusted_args)
else
(cd "$project_root" && uv run python -m "${TOOL_NAME}")
(cd "$project_root" && uv run "${UV_RUN_OPTIONS[@]}" python -m "${TOOL_NAME}")
fi
else
uv run python -m "${TOOL_NAME}" "$@"
uv run "${UV_RUN_OPTIONS[@]}" python -m "${TOOL_NAME}" "$@"
fi
else
python3 -m "${TOOL_NAME}" "$@"
Expand Down
1 change: 1 addition & 0 deletions makefiles/ci.mk
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ci-quality-radon: ## Run radon complexity analysis
$(call run-tool,radon,cc $(PACKAGE) --min B --show-complexity)
$(call run-tool,radon,mi $(PACKAGE) --min B)

ci-quality-pyright: RUN_TOOL_UV_OPTIONS := --extra all-providers
ci-quality-pyright: ## Run pyright type checking
@echo "Running pyright type check..."
$(call run-tool,pyright,)
Expand Down
3 changes: 2 additions & 1 deletion makefiles/common.mk
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,9 @@ DOCS_BUILD_DIR := $(DOCS_DIR)/site

# Centralized tool execution function
# Usage: $(call run-tool,tool-name,arguments[,working-dir])
# Set RUN_TOOL_UV_OPTIONS for options that belong to `uv run`, not the tool.
define run-tool
$(if $(3),cd $(3) && ../dev-tools/setup/run_tool.sh $(1) $(2),@dev-tools/setup/run_tool.sh $(1) $(2))
$(if $(3),cd $(3) && RUN_TOOL_UV_OPTIONS="$(RUN_TOOL_UV_OPTIONS)" ../dev-tools/setup/run_tool.sh $(1) $(2),@RUN_TOOL_UV_OPTIONS="$(RUN_TOOL_UV_OPTIONS)" dev-tools/setup/run_tool.sh $(1) $(2))
endef

# Virtual environment setup (common dependency for all makefiles)
Expand Down
27 changes: 21 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,17 @@ k8s = [
"kubernetes",
]

# Placeholder entries for future providers (add when provider is implemented)
# azure = [
# "azure-mgmt-compute>=30.0.0",
# "azure-identity>=1.15.0",
# "azure-mgmt-network>=25.0.0",
# ]
# Azure provider runtime dependencies. Install with:
# pip install orb-py[azure]
azure = [
"azure-core>=1.38.2",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After rebase, please put the Azure SDKs under an [azure] optional-dependencies extra rather than the core [project.dependencies] list. Main has the pattern in place: [aws] exists as an opt-in alias for boto3, [all-providers] is a meta-extra. With 9 azure-* SDKs in core, every non-Azure deployment pulls in ~hundreds of MB of unused transitive deps. Add azure = [...] under [project.optional-dependencies] and extend all-providers to ["orb-py[aws,azure]"].

"azure-identity>=1.25.2",
"azure-mgmt-compute>=37.2.0",
"azure-mgmt-network>=30.2.0",
"azure-mgmt-resource>=25.0.0",
"azure-mgmt-resource-subscriptions>=1.0.0b1",
"httpx>=0.27.0",
]
# gcp = [
# "google-cloud-compute>=1.14.0",
# "google-auth>=2.23.0",
Expand All @@ -103,6 +108,7 @@ k8s = [
# Meta-extra: all currently implemented providers
all-providers = [
"orb-py[aws]",
"orb-py[azure]",
"orb-py[k8s]",
]

Expand Down Expand Up @@ -194,6 +200,15 @@ test-aws = [
"requests-mock>=1.11.0,<2.0.0",
]

# Azure SDK packages imported directly by the Azure tests. These overlap with
# the runtime extra because the tests assert behavior using real SDK exception
# and model types, but the test group does not install unrelated runtime SDKs.
test-azure = [
"azure-core>=1.38.2",
"azure-identity>=1.25.2",
"azure-mgmt-compute>=37.2.0",
]

# Kubernetes test dependencies — kmock is an in-process HTTP-level K8s API mock.
# Tests point the real kubernetes SDK client at the kmock URL so the full
# client stack is exercised without a live cluster.
Expand Down
4 changes: 2 additions & 2 deletions src/orb/domain/template/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ def create_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
raise

# Fall back to core template
# Use the core template only when no provider-specific class is registered.
try:
template = Template(**template_data)

Expand Down
1 change: 1 addition & 0 deletions src/orb/domain/template/template_aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class Template(BaseModel):
# Tags and metadata
tags: dict[str, Any] = Field(default_factory=dict)
metadata: dict[str, Any] = Field(default_factory=dict)
provider_data: dict[str, Any] = Field(default_factory=dict)

# Provider configuration (multi-provider support)
provider_type: Optional[str] = None
Expand Down
21 changes: 21 additions & 0 deletions src/orb/providers/azure/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Azure Provider implementation."""

from orb.providers.azure.configuration.config import AzureProviderConfig
from orb.providers.azure.configuration.template_extension import AzureTemplateExtensionConfig
from orb.providers.azure.registration import (
get_azure_extension_defaults,
initialize_azure_provider,
is_azure_provider_registered,
register_azure_extensions,
register_azure_template_factory,
)

__all__: list[str] = [
"AzureProviderConfig",
"AzureTemplateExtensionConfig",
"get_azure_extension_defaults",
"initialize_azure_provider",
"is_azure_provider_registered",
"register_azure_extensions",
"register_azure_template_factory",
]
1 change: 1 addition & 0 deletions src/orb/providers/azure/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Azure authentication utilities."""
143 changes: 143 additions & 0 deletions src/orb/providers/azure/auth/azure_auth_strategy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Azure DefaultAzureCredential authentication strategy.

Uses the shared Azure infrastructure credential factory so auth and
provider-runtime flows construct the same credential shape.
"""

import asyncio
from typing import Any, Optional

from orb.domain.base.ports import LoggingPort
from orb.infrastructure.adapters.ports.auth import (
AuthContext,
AuthPort,
AuthResult,
AuthStatus,
)
from orb.infrastructure.di.injectable import injectable
from orb.providers.azure.infrastructure.credential_factory import (
AsyncAzureAccessTokenProviderProtocol,
AsyncDefaultAzureAccessTokenProvider,
AzureAccessTokenProviderProtocol,
DefaultAzureAccessTokenProvider,
)


@injectable
class AzureAuthStrategy(AuthPort):
"""Authentication strategy using Azure DefaultAzureCredential."""

def __init__(
self,
logger: LoggingPort,
client_id: Optional[str] = None,
enabled: bool = True,
token_provider: Optional[AzureAccessTokenProviderProtocol] = None,
async_token_provider: Optional[AsyncAzureAccessTokenProviderProtocol] = None,
) -> None:
"""Initialise Azure auth with async-first token acquisition."""
self._logger = logger
self.client_id = client_id
self.enabled = enabled
self._token_provider = token_provider
self._async_token_provider = async_token_provider
if self._token_provider is None and self._async_token_provider is None:
self._async_token_provider = AsyncDefaultAzureAccessTokenProvider(
client_id=client_id,
logger=logger,
)

@classmethod
def from_auth_config(cls, auth_config: Any) -> "AzureAuthStrategy":
"""Build an Azure auth strategy from the shared AuthConfig object."""
from orb.infrastructure.adapters.logging_adapter import LoggingAdapter

provider_auth = getattr(auth_config, "provider_auth", None)
azure_cfg = getattr(provider_auth, "azure", None) if provider_auth is not None else None
client_id = getattr(azure_cfg, "client_id", None) if azure_cfg is not None else None
enabled = bool(getattr(auth_config, "enabled", True))
return cls(
logger=LoggingAdapter(),
client_id=client_id,
enabled=enabled,
)

def _auth_error_types(self) -> tuple[type[Exception], ...]:
"""Return the active provider's declared auth failure types."""
if self._async_token_provider is not None:
return self._async_token_provider.get_auth_error_types()
if self._token_provider is None:
return DefaultAzureAccessTokenProvider(
client_id=self.client_id,
logger=self._logger,
).get_auth_error_types()
return self._token_provider.get_auth_error_types()

async def _get_access_token(self, scope: str) -> str:
"""Resolve an ARM token without blocking the event loop."""
if self._async_token_provider is not None:
return await self._async_token_provider.get_access_token(scope)
if self._token_provider is None:
raise RuntimeError("Azure auth strategy has no token provider configured")
return await asyncio.to_thread(self._token_provider.get_access_token, scope)

async def authenticate(self, context: AuthContext) -> AuthResult:
if not self.enabled:
return AuthResult(
status=AuthStatus.FAILED,
error_message="Azure auth strategy disabled",
)
try:
token = await self._get_access_token("https://management.azure.com/.default")

return AuthResult(
status=AuthStatus.SUCCESS,
user_id=self.client_id or "azure-identity",
token=token,
user_roles=["provider"],
metadata={
"strategy": "azure_default_credential",
},
)
except self._auth_error_types() as exc:
self._logger.error("Azure authentication failed: %s", exc)
return AuthResult(
status=AuthStatus.FAILED,
error_message=f"Azure authentication failed: {exc}",
)

async def validate_token(self, token: str) -> AuthResult:
"""Token validation is handled by Azure SDK internally."""
return AuthResult(
status=AuthStatus.SUCCESS,
token=token,
metadata={"strategy": "azure_default_credential"},
)

async def refresh_token(self, refresh_token: str) -> AuthResult:
"""DefaultAzureCredential handles token refresh automatically."""
try:
token = await self._get_access_token("https://management.azure.com/.default")
return AuthResult(
status=AuthStatus.SUCCESS,
token=token,
metadata={"strategy": "azure_default_credential", "refreshed": True},
)
except self._auth_error_types() as exc:
return AuthResult(
status=AuthStatus.FAILED,
error_message=f"Token refresh failed: {exc}",
)

async def revoke_token(self, token: str) -> bool:
"""Azure ARM tokens cannot be revoked directly."""
self._logger.debug("Token revocation not supported for Azure ARM tokens")
return False

def get_strategy_name(self) -> str:
"""Return the identifier for the Azure default-credential strategy."""
return "azure_default_credential"

def is_enabled(self) -> bool:
"""Return whether this auth strategy is currently enabled."""
return self.enabled
48 changes: 48 additions & 0 deletions src/orb/providers/azure/capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Shared Azure provider capability metadata."""

from __future__ import annotations

from copy import deepcopy
from typing import Any

from orb.providers.azure.domain.template.value_objects import AzureProviderApi

_AZURE_API_CAPABILITIES: dict[str, dict[str, Any]] = {
AzureProviderApi.VMSS.value: {
"supported_fleet_types": [],
"supports_spot": True,
"supports_on_demand": True,
"max_instances": 1000,
},
AzureProviderApi.VMSS_UNIFORM.value: {
"supported_fleet_types": [],
"supports_spot": True,
"supports_on_demand": True,
"max_instances": 1000,
},
AzureProviderApi.SINGLE_VM.value: {
"supported_fleet_types": [],
"supports_spot": True,
"supports_on_demand": True,
"max_instances": 1000,
},
AzureProviderApi.CYCLECLOUD.value: {
"supported_fleet_types": [],
"supports_spot": False,
"supports_on_demand": True,
"requires_existing_cluster": True,
"required_create_fields": ["cluster_name", "node_array"],
"capacity_limit_source": "cluster_status.maxCount",
"supports_async_operations": True,
},
}


def get_supported_api_capabilities() -> dict[str, dict[str, Any]]:
"""Return Azure API capability metadata."""
return deepcopy(_AZURE_API_CAPABILITIES)


def get_supported_apis() -> list[str]:
"""Return the canonical list of Azure provider APIs."""
return list(_AZURE_API_CAPABILITIES.keys())
1 change: 1 addition & 0 deletions src/orb/providers/azure/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Azure CLI integration helpers."""
Loading