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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion scripts/lib/adapter_preflight.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@
re.MULTILINE | re.DOTALL,
)

# Auth probes must not hang forever (OPS-002 / PREFLIGHT-01). Override via
# FLEET_ADAPTER_AUTH_TIMEOUT_S (seconds); non-positive or invalid values fall
# back to the default.
DEFAULT_AUTH_TIMEOUT_S = 30.0
_AUTH_TIMEOUT_ENV = "FLEET_ADAPTER_AUTH_TIMEOUT_S"


def _auth_timeout_s(environ: Mapping[str, str] = os.environ) -> float:
raw = environ.get(_AUTH_TIMEOUT_ENV)
if raw is None or raw == "":
return DEFAULT_AUTH_TIMEOUT_S
try:
value = float(raw)
except ValueError:
return DEFAULT_AUTH_TIMEOUT_S
if value <= 0:
return DEFAULT_AUTH_TIMEOUT_S
return value


@dataclass(frozen=True)
class Intent:
Expand Down Expand Up @@ -124,7 +143,20 @@ def check(
continue
if entry.get("skip_if_intent") == intent_name:
continue
result = run(shlex.split(command), capture_output=True, text=True, check=False)
timeout_s = _auth_timeout_s(environ)
try:
result = run(
shlex.split(command),
capture_output=True,
text=True,
check=False,
timeout=timeout_s,
)
except subprocess.TimeoutExpired:
failures.append(
f"auth check timed out after {timeout_s:g}s: {command}"
)
continue
if result.returncode != 0:
failures.append(f"auth check failed ({result.returncode}): {command}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@
re.MULTILINE | re.DOTALL,
)

# Auth probes must not hang forever (OPS-002 / PREFLIGHT-01). Override via
# FLEET_ADAPTER_AUTH_TIMEOUT_S (seconds); non-positive or invalid values fall
# back to the default.
DEFAULT_AUTH_TIMEOUT_S = 30.0
_AUTH_TIMEOUT_ENV = "FLEET_ADAPTER_AUTH_TIMEOUT_S"


def _auth_timeout_s(environ: Mapping[str, str] = os.environ) -> float:
raw = environ.get(_AUTH_TIMEOUT_ENV)
if raw is None or raw == "":
return DEFAULT_AUTH_TIMEOUT_S
try:
value = float(raw)
except ValueError:
return DEFAULT_AUTH_TIMEOUT_S
if value <= 0:
return DEFAULT_AUTH_TIMEOUT_S
return value


@dataclass(frozen=True)
class Intent:
Expand Down Expand Up @@ -124,7 +143,20 @@ def check(
continue
if entry.get("skip_if_intent") == intent_name:
continue
result = run(shlex.split(command), capture_output=True, text=True, check=False)
timeout_s = _auth_timeout_s(environ)
try:
result = run(
shlex.split(command),
capture_output=True,
text=True,
check=False,
timeout=timeout_s,
)
except subprocess.TimeoutExpired:
failures.append(
f"auth check timed out after {timeout_s:g}s: {command}"
)
continue
if result.returncode != 0:
failures.append(f"auth check failed ({result.returncode}): {command}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": {
"emit_trace.py": "4425a4dd35f40b230eb566330bb2c28fd487892a717e440947c31327388805ad",
"lib/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"lib/adapter_preflight.py": "c0c5a7a57840374b92cca95e7ea043ecfcd499ab2f27d01cedc53f0ed72b8ed5",
"lib/adapter_preflight.py": "4cfd48fb36ce2c0216c9d5112d76864c5710dcafe561e7942ae1aa059c150b24",
"lib/analyze_cost.py": "e0359e9eabfc0c01dd5241bce8c1c124698da3a62a9fee9fbfcaa023a849b645",
"lib/analyze_seat.py": "ee0faf086e679fd50315f2f4b0293590e8e73a0961fcad9388899902700d1c67",
"lib/community_preflight.py": "94a6eb2d68397db7c0f8dad36d5ce88b40893f12fd40765a97d9aab0946f2d4c",
Expand Down
74 changes: 69 additions & 5 deletions tests/test_adapter_preflight.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
"""Tests for adapter requires-block preflight checks."""
from __future__ import annotations

import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace

import pytest

import sys

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

from lib.adapter_preflight import Intent, activity_hooks_advisory, check, load_requires # noqa: E402
from lib.adapter_preflight import ( # noqa: E402
DEFAULT_AUTH_TIMEOUT_S,
Intent,
activity_hooks_advisory,
check,
load_requires,
)


def _which_factory(found: set[str]):
Expand All @@ -21,15 +27,29 @@ def which(binary: str) -> str | None:
return which


def _runner(returncode: int, calls: list[list[str]]):
def _runner(returncode: int, calls: list[list[str]], *, timeout_s: float = DEFAULT_AUTH_TIMEOUT_S):
def run(args: list[str], **kwargs):
calls.append(args)
assert kwargs == {"capture_output": True, "text": True, "check": False}
assert kwargs == {
"capture_output": True,
"text": True,
"check": False,
"timeout": timeout_s,
}
return SimpleNamespace(returncode=returncode)

return run


def _timeout_runner(calls: list[list[str]], *, timeout_s: float = DEFAULT_AUTH_TIMEOUT_S):
def run(args: list[str], **kwargs):
calls.append(args)
assert kwargs["timeout"] == timeout_s
raise subprocess.TimeoutExpired(cmd=args, timeout=timeout_s)

return run


def test_wiring_only_skips_all_checks() -> None:
failures = check(
{
Expand Down Expand Up @@ -151,6 +171,50 @@ def test_auth_entry_with_empty_check_reports_failure() -> None:
assert calls == []


def test_auth_timeout_reports_clear_failure_via_injected_run() -> None:
calls: list[list[str]] = []
failures = check(
{"bins": [], "env": [], "auth": [{"check": "gh auth status"}]},
Intent(scm=True),
which=_which_factory(set()),
run=_timeout_runner(calls),
environ={},
)

assert failures == [
f"auth check timed out after {DEFAULT_AUTH_TIMEOUT_S:g}s: gh auth status"
]
assert calls == [["gh", "auth", "status"]]


def test_auth_timeout_honors_fleet_adapter_auth_timeout_env() -> None:
calls: list[list[str]] = []
failures = check(
{"bins": [], "env": [], "auth": [{"check": "gh auth status"}]},
Intent(scm=True),
which=_which_factory(set()),
run=_timeout_runner(calls, timeout_s=12.5),
environ={"FLEET_ADAPTER_AUTH_TIMEOUT_S": "12.5"},
)

assert failures == ["auth check timed out after 12.5s: gh auth status"]
assert calls == [["gh", "auth", "status"]]


def test_auth_timeout_invalid_env_falls_back_to_default() -> None:
calls: list[list[str]] = []
failures = check(
{"bins": [], "env": [], "auth": [{"check": "gh auth status"}]},
Intent(scm=True),
which=_which_factory({"gh"}),
run=_runner(0, calls),
environ={"FLEET_ADAPTER_AUTH_TIMEOUT_S": "nope"},
)

assert failures == []
assert calls == [["gh", "auth", "status"]]


def test_load_requires_reads_fenced_yaml_requires_block(tmp_path: Path) -> None:
adapter = tmp_path / "adapter"
adapter.mkdir()
Expand Down
Loading