|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import asyncio |
| 6 | +from collections.abc import Mapping, Sequence |
6 | 7 | from dataclasses import dataclass |
7 | 8 | from typing import Any, Optional, Protocol, TypedDict |
8 | 9 |
|
9 | 10 | from orb.domain.base.ports import LoggingPort |
| 11 | +from orb.domain.base.provider_fulfilment import ( |
| 12 | + CheckHostsStatusResult, |
| 13 | + FulfilmentState, |
| 14 | + ProviderFulfilment, |
| 15 | +) |
| 16 | + |
| 17 | +_RUNNING_STATES = frozenset({"running"}) |
| 18 | +_PENDING_STATES = frozenset({"pending", "creating", "starting", "updating", "unknown"}) |
| 19 | +_FAILED_STATES = frozenset({"failed", "terminated", "stopped", "deallocated"}) |
10 | 20 |
|
11 | 21 |
|
12 | 22 | @dataclass |
@@ -271,3 +281,123 @@ def augment_shortfall_metadata(metadata: dict[str, Any]) -> None: |
271 | 281 | + (f"; causes={', '.join(likely_causes)}" if likely_causes else "") |
272 | 282 | ), |
273 | 283 | } |
| 284 | + |
| 285 | + def attach_provider_fulfilment( |
| 286 | + self, |
| 287 | + metadata: dict[str, Any], |
| 288 | + *, |
| 289 | + instances: Sequence[Mapping[str, Any]], |
| 290 | + target_units: int | None, |
| 291 | + ) -> CheckHostsStatusResult: |
| 292 | + """Attach and return the canonical provider status contract.""" |
| 293 | + status_result = CheckHostsStatusResult( |
| 294 | + instances=list(instances), |
| 295 | + fulfilment=_build_provider_fulfilment( |
| 296 | + metadata=metadata, |
| 297 | + instances=instances, |
| 298 | + target_units=target_units, |
| 299 | + ), |
| 300 | + ) |
| 301 | + metadata["provider_fulfilment"] = status_result.fulfilment |
| 302 | + return status_result |
| 303 | + |
| 304 | + |
| 305 | +def _build_provider_fulfilment( |
| 306 | + *, |
| 307 | + metadata: dict[str, Any], |
| 308 | + instances: Sequence[Mapping[str, Any]], |
| 309 | + target_units: int | None, |
| 310 | +) -> ProviderFulfilment: |
| 311 | + fleet_errors = metadata.get("fleet_errors") or [] |
| 312 | + # ProviderResult.metadata is the core provider result bag typed as dict[str, Any]. |
| 313 | + # Azure owns this key, but reading it back from the core metadata boundary |
| 314 | + # requires validating the shape before trusting the capacity fields. |
| 315 | + capacity = metadata.get("fleet_capacity_fulfilment") |
| 316 | + if isinstance(capacity, dict): |
| 317 | + target = capacity.get("target_capacity_units") |
| 318 | + fulfilled = capacity.get("fulfilled_capacity_units") |
| 319 | + if isinstance(target, int) and isinstance(fulfilled, int): |
| 320 | + capacity_state = str(capacity.get("state") or "").lower() |
| 321 | + if fulfilled >= target and target > 0 and not fleet_errors: |
| 322 | + return ProviderFulfilment( |
| 323 | + state="fulfilled", |
| 324 | + message=f"Azure capacity fulfilled: {fulfilled}/{target}", |
| 325 | + target_units=target, |
| 326 | + fulfilled_units=fulfilled, |
| 327 | + ) |
| 328 | + |
| 329 | + if capacity_state == "failed" or fleet_errors: |
| 330 | + capacity_fulfilment_state: FulfilmentState = ( |
| 331 | + "partial" if fulfilled > 0 else "failed" |
| 332 | + ) |
| 333 | + return ProviderFulfilment( |
| 334 | + state=capacity_fulfilment_state, |
| 335 | + message=f"Azure capacity shortfall: {fulfilled}/{target}", |
| 336 | + target_units=target, |
| 337 | + fulfilled_units=fulfilled, |
| 338 | + ) |
| 339 | + |
| 340 | + return ProviderFulfilment( |
| 341 | + state="in_progress", |
| 342 | + message=f"Azure capacity provisioning: {fulfilled}/{target}", |
| 343 | + target_units=target, |
| 344 | + fulfilled_units=fulfilled, |
| 345 | + ) |
| 346 | + |
| 347 | + # Handler-backed status, SingleVM, CycleCloud, and dry-run results do not |
| 348 | + # have VMSS capacity metadata; derive their verdict from observed statuses. |
| 349 | + target = target_units |
| 350 | + running_count = _count_statuses(instances, _RUNNING_STATES) |
| 351 | + pending_count = _count_statuses(instances, _PENDING_STATES) |
| 352 | + failed_count = _count_statuses(instances, _FAILED_STATES) |
| 353 | + |
| 354 | + if fleet_errors and running_count == 0: |
| 355 | + return ProviderFulfilment( |
| 356 | + state="failed", |
| 357 | + message="Azure provisioning failed before capacity became available", |
| 358 | + target_units=target, |
| 359 | + fulfilled_units=running_count, |
| 360 | + ) |
| 361 | + |
| 362 | + if target is not None and target > 0 and running_count >= target and failed_count == 0: |
| 363 | + return ProviderFulfilment( |
| 364 | + state="fulfilled", |
| 365 | + message=f"Azure instances running: {running_count}/{target}", |
| 366 | + target_units=target, |
| 367 | + fulfilled_units=running_count, |
| 368 | + ) |
| 369 | + |
| 370 | + if failed_count > 0 and pending_count == 0: |
| 371 | + state: FulfilmentState = "partial" if running_count > 0 else "failed" |
| 372 | + return ProviderFulfilment( |
| 373 | + state=state, |
| 374 | + message=( |
| 375 | + f"Azure instance shortfall: {running_count}/{target}" |
| 376 | + if target is not None |
| 377 | + else f"Azure instance failure: {running_count} running" |
| 378 | + ), |
| 379 | + target_units=target, |
| 380 | + fulfilled_units=running_count, |
| 381 | + ) |
| 382 | + |
| 383 | + return ProviderFulfilment( |
| 384 | + state="in_progress", |
| 385 | + message=( |
| 386 | + f"Azure instances provisioning: {running_count}/{target}" |
| 387 | + if target is not None |
| 388 | + else f"Azure instances provisioning: {running_count} running" |
| 389 | + ), |
| 390 | + target_units=target, |
| 391 | + fulfilled_units=running_count, |
| 392 | + ) |
| 393 | + |
| 394 | + |
| 395 | +def _count_statuses( |
| 396 | + instances: Sequence[Mapping[str, Any]], |
| 397 | + statuses: frozenset[str], |
| 398 | +) -> int: |
| 399 | + return sum( |
| 400 | + 1 |
| 401 | + for instance in instances |
| 402 | + if str(instance.get("status") or "").lower() in statuses |
| 403 | + ) |
0 commit comments