Skip to content
Closed
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
18 changes: 18 additions & 0 deletions src/aignostics/system/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,24 @@ def health(
)


@cli.command()
def online() -> None:
"""Check if the system is online and print status with exit code.

Exit codes:
0: System is online
1: System is offline
"""
is_online = _service.is_online()

if is_online:
console.print("[green]Online[/green]")
sys.exit(0)
else:
console.print("[red]Offline[/red]")
sys.exit(1)


@cli.command()
def info(
include_environ: Annotated[bool, typer.Option(help="Include environment variables")] = False,
Expand Down
38 changes: 38 additions & 0 deletions src/aignostics/system/_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import ssl
import sys
import time
import typing as t
from http import HTTPStatus
from pathlib import Path
Expand Down Expand Up @@ -72,6 +73,7 @@ class Service(BaseService):
"""System service."""

_settings: Settings
_online_cache: tuple[bool, float] | None = None # (is_online, timestamp)

def __init__(self) -> None:
"""Initialize service."""
Expand Down Expand Up @@ -149,6 +151,42 @@ def health(self) -> Health:
reason = None if self._is_healthy() else "System marked as unhealthy"
return Health(status=status, components=components, reason=reason)

def is_online(self) -> bool:
"""Check if the system is online using cached results.

This method checks connectivity to a well-known endpoint (api.ipify.org)
and caches the result for the duration specified in settings.online_cache_ttl.

Returns:
bool: True if the system is online (can reach the internet), False otherwise.
"""
current_time = time.time()

# Check if we have a valid cached result
if self._online_cache is not None:
cached_status, cached_time = self._online_cache
cache_age = current_time - cached_time

if cache_age < self._settings.online_cache_ttl:
logger.debug(
"Using cached online status: %s (age: %.2fs, TTL: %ds)",
cached_status,
cache_age,
self._settings.online_cache_ttl,
)
return cached_status

# Cache is invalid or expired, perform actual check
logger.debug("Performing online status check (cache expired or not present)")
network_health = self._determine_network_health()
is_online = network_health.status == Health.Code.UP

# Update cache
self._online_cache = (is_online, current_time)
logger.debug("Updated online status cache: %s", is_online)

return is_online

def is_token_valid(self, token: str) -> bool:
"""Check if the presented token is valid.

Expand Down
9 changes: 9 additions & 0 deletions src/aignostics/system/_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,12 @@ class Settings(OpaqueSettings):
default=None,
),
]

online_cache_ttl: Annotated[
int,
Field(
description="Time-to-live (in seconds) for caching online status checks",
default=60,
ge=0,
),
]
22 changes: 22 additions & 0 deletions tests/aignostics/system/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,25 @@ def test_cli_http_proxy(runner: CliRunner, silent_logging, tmp_path: Path) -> No
result = runner.invoke(cli, ["system", "config", "get", "CURL_CA_BUNDLE"])
assert result.exit_code == 0
assert "None" in result.output


@pytest.mark.scheduled
def test_cli_online_when_connected(runner: CliRunner) -> None:
"""Test that 'aignostics system online' returns exit code 0 and prints 'Online' when connected."""
from aignostics.system._service import Service

with patch.object(Service, "is_online", return_value=True):
result = runner.invoke(cli, ["system", "online"])
assert result.exit_code == 0
assert "Online" in result.output


@pytest.mark.scheduled
def test_cli_online_when_disconnected(runner: CliRunner) -> None:
"""Test that 'aignostics system online' returns exit code 1 and prints 'Offline' when disconnected."""
from aignostics.system._service import Service

with patch.object(Service, "is_online", return_value=False):
result = runner.invoke(cli, ["system", "online"])
assert result.exit_code == 1
assert "Offline" in result.output
138 changes: 138 additions & 0 deletions tests/aignostics/system/service_test.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
"""Tests of the system service."""

import os
import time
from unittest import mock

import pytest

from aignostics.system._service import Service
from aignostics.utils import Health


@pytest.mark.timeout(15)
Expand Down Expand Up @@ -339,3 +341,139 @@ def test_is_secret_key_real_world_examples() -> None:

for key in non_secret_examples:
assert not Service._is_secret_key(key), f"Expected '{key}' to NOT be identified as a secret key"


@pytest.mark.timeout(15)
def test_is_online_when_network_is_up() -> None:
"""Test that is_online returns True when network is available."""
with mock.patch.dict(os.environ, {"AIGNOSTICS_SYSTEM_ONLINE_CACHE_TTL": "60"}):
service = Service()

# Mock the network health check to return UP
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.UP)
):
assert service.is_online() is True


@pytest.mark.timeout(15)
def test_is_online_when_network_is_down() -> None:
"""Test that is_online returns False when network is unavailable."""
with mock.patch.dict(os.environ, {"AIGNOSTICS_SYSTEM_ONLINE_CACHE_TTL": "60"}):
service = Service()

# Mock the network health check to return DOWN
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.DOWN, reason="Network error")
):
assert service.is_online() is False


@pytest.mark.timeout(15)
def test_is_online_caching_within_ttl() -> None:
"""Test that is_online uses cache when called within TTL."""
with mock.patch.dict(os.environ, {"AIGNOSTICS_SYSTEM_ONLINE_CACHE_TTL": "60"}):
service = Service()

# Mock the network health check
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.UP)
) as mock_health:
# First call should check network
result1 = service.is_online()
assert result1 is True
assert mock_health.call_count == 1

# Second call within TTL should use cache
result2 = service.is_online()
assert result2 is True
assert mock_health.call_count == 1 # No additional call


@pytest.mark.timeout(15)
def test_is_online_cache_expiration() -> None:
"""Test that is_online refreshes cache after TTL expires."""
# Use a very short TTL for testing
with mock.patch.dict(os.environ, {"AIGNOSTICS_SYSTEM_ONLINE_CACHE_TTL": "1"}):
service = Service()

# Mock the network health check
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.UP)
) as mock_health:
# First call should check network
result1 = service.is_online()
assert result1 is True
assert mock_health.call_count == 1

# Wait for cache to expire
time.sleep(1.1)

# Second call after TTL should check network again
result2 = service.is_online()
assert result2 is True
assert mock_health.call_count == 2 # Additional call made


@pytest.mark.timeout(15)
def test_is_online_cache_status_change() -> None:
"""Test that is_online correctly caches different statuses."""
with mock.patch.dict(os.environ, {"AIGNOSTICS_SYSTEM_ONLINE_CACHE_TTL": "60"}):
service = Service()

# First check: network is UP
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.UP)
):
assert service.is_online() is True

# Second check within TTL: should return cached True (even though we're mocking DOWN)
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.DOWN)
):
assert service.is_online() is True # Still cached as True


@pytest.mark.timeout(15)
def test_is_online_respects_custom_ttl() -> None:
"""Test that is_online respects custom TTL from settings."""
# Use a TTL of 2 seconds
with mock.patch.dict(os.environ, {"AIGNOSTICS_SYSTEM_ONLINE_CACHE_TTL": "2"}):
service = Service()

# Mock the network health check
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.UP)
) as mock_health:
# First call
service.is_online()
assert mock_health.call_count == 1

# Call after 1 second (within 2s TTL)
time.sleep(1.0)
service.is_online()
assert mock_health.call_count == 1 # Still cached

# Call after 2.1 seconds total (beyond 2s TTL)
time.sleep(1.2)
service.is_online()
assert mock_health.call_count == 2 # Cache expired, new check


@pytest.mark.timeout(15)
def test_is_online_zero_ttl_disables_cache() -> None:
"""Test that setting TTL to 0 effectively disables caching."""
with mock.patch.dict(os.environ, {"AIGNOSTICS_SYSTEM_ONLINE_CACHE_TTL": "0"}):
service = Service()

# Mock the network health check
with mock.patch.object(
service, "_determine_network_health", return_value=Health(status=Health.Code.UP)
) as mock_health:
# First call
service.is_online()
assert mock_health.call_count == 1

# Second call immediately should still check network (TTL=0)
service.is_online()
assert mock_health.call_count == 2
Loading