diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 09e47ac..582806c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -18,6 +18,8 @@ jobs: opensearch_version: "2.19.1" - fess_version: "15.6.1" opensearch_version: "3.6.0" + - fess_version: "15.7.0" + opensearch_version: "3.7.0" steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index 835fd6a..27abd8d 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The easiest way to get started is using the pre-built Docker image: docker run --rm \ -e FESS_ENDPOINT=https://your-fess-server \ -e FESS_ACCESS_TOKEN=your_access_token_here \ - -e FESS_VERSION=15.6.1 \ + -e FESS_VERSION=15.7.0 \ ghcr.io/codelibs/fessctl:0.1.0 --help ``` @@ -35,13 +35,13 @@ Run actual commands: docker run --rm \ -e FESS_ENDPOINT=https://your-fess-server \ -e FESS_ACCESS_TOKEN=your_access_token_here \ - -e FESS_VERSION=15.6.1 \ + -e FESS_VERSION=15.7.0 \ ghcr.io/codelibs/fessctl:0.1.0 ping docker run --rm \ -e FESS_ENDPOINT=https://your-fess-server \ -e FESS_ACCESS_TOKEN=your_access_token_here \ - -e FESS_VERSION=15.6.1 \ + -e FESS_VERSION=15.7.0 \ ghcr.io/codelibs/fessctl:0.1.0 user list ``` @@ -61,7 +61,7 @@ Then run with your custom image: docker run --rm \ -e FESS_ENDPOINT=https://your-fess-server \ -e FESS_ACCESS_TOKEN=your_access_token_here \ - -e FESS_VERSION=15.6.1 \ + -e FESS_VERSION=15.7.0 \ fessctl:latest --help ``` @@ -86,7 +86,7 @@ uv pip install -e src ```bash export FESS_ACCESS_TOKEN=your_access_token_here export FESS_ENDPOINT=https://your-fess-server -export FESS_VERSION=15.6.1 +export FESS_VERSION=15.7.0 fessctl --help fessctl ping @@ -100,7 +100,7 @@ All three methods require the following environment variables: - `FESS_ENDPOINT`: The URL of your Fess server's API endpoint (default: `http://localhost:8080`) - `FESS_ACCESS_TOKEN`: Bearer token for API authentication (required) -- `FESS_VERSION`: Target Fess version for API compatibility (default: `15.6.1`) +- `FESS_VERSION`: Target Fess version for API compatibility (default: `15.7.0`). Set this to match your Fess server. Fess 14.x and 15.x are supported; the value controls version-specific behavior such as HTTP methods for CRUD operations and the health-check endpoint (`/api/v1/health` for versions before 15.7, `/api/v2/health` for 15.7 and later). ## License diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index 1acc431..4ea43ce 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -50,6 +50,16 @@ def _parse_version(self, version: str) -> tuple[int, int]: except Exception as e: raise ValueError(f"Invalid version format: '{version}'") from e + @property + def is_api_v2(self) -> bool: + """ + Whether the target Fess server exposes the unified ``/api/v2`` surface. + + Fess 15.7 removed the legacy ``/api/v1`` (and ``/api/chat``) endpoints and + replaced them with ``/api/v2``. The admin API (``/api/admin/*``) is unchanged. + """ + return (self._major_version, self._minor_version) >= (15, 7) + def send_request( self, action: Action, @@ -117,10 +127,16 @@ def ping(self) -> dict: """ Sends a GET request to the health endpoint of the API to check the service status. + Fess 15.7+ serves the health check at ``/api/v2/health`` (the legacy + ``/api/v1/health`` was removed); earlier versions use ``/api/v1/health``. + Returns: dict: The response from the health endpoint, typically containing service health information. """ - url = f"{self.base_url}/api/v1/health" + if self.is_api_v2: + url = f"{self.base_url}/api/v2/health" + else: + url = f"{self.base_url}/api/v1/health" return self.send_request(Action.GET, url, is_admin=False) # Role APIs diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index f84facc..2fa79b1 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -89,8 +89,23 @@ def ping( client = FessAPIClient(Settings()) try: result = client.ping() - status = result.get("data", {}).get("status", "unknown") - timed_out = result.get("data", {}).get("timed_out", True) + if client.is_api_v2: + # Fess 15.7+ /api/v2/health envelope: + # healthy: {"response": {"status": 0, "engine": {"status": "green", "ping_status": 0}}} + # red: {"response": {"status": 9, "error": {"message": "...", + # "details": {"engine": {"status": "red", "ping_status": N}}}}} + response = result.get("response", {}) + engine = response.get("engine") + if engine is None: + engine = response.get("error", {}).get("details", {}).get("engine", {}) + status = engine.get("status", "unknown") + # The v2 health endpoint does not report timed_out; cluster status is authoritative. + timed_out = False + message: str = response.get("error", {}).get("message", "") + else: + status = result.get("data", {}).get("status", "unknown") + timed_out = result.get("data", {}).get("timed_out", True) + message = result.get("response", {}).get("message", "") if output == "json": typer.echo(json.dumps(result, indent=2)) @@ -102,7 +117,6 @@ def ping( elif status == "yellow": typer.echo(format_result_markdown(True, f"Fess server status: {status} (timed_out: {timed_out})", "Server", "ping")) else: - message: str = result.get("response", {}).get("message", "") typer.echo(format_result_markdown(False, f"Fess server status: {status} (timed_out: {timed_out}) {message}", "Server", "ping")) raise typer.Exit(code=1) except typer.Exit: diff --git a/src/fessctl/config/settings.py b/src/fessctl/config/settings.py index 1cfa628..478817d 100644 --- a/src/fessctl/config/settings.py +++ b/src/fessctl/config/settings.py @@ -9,4 +9,4 @@ class Settings: access_token: str | None = field( default_factory=lambda: os.getenv("FESS_ACCESS_TOKEN", None)) fess_version: str = field( - default_factory=lambda: os.getenv("FESS_VERSION", "15.6.1")) + default_factory=lambda: os.getenv("FESS_VERSION", "15.7.0")) diff --git a/tests/compose-fess15.yaml b/tests/compose-fess15.yaml index 84e4445..b0f8612 100644 --- a/tests/compose-fess15.yaml +++ b/tests/compose-fess15.yaml @@ -1,11 +1,11 @@ services: fessctl_fess01: - image: ghcr.io/codelibs/fess:${FESS_VERSION:-15.6.1} + image: ghcr.io/codelibs/fess:${FESS_VERSION:-15.7.0} container_name: fessctl_fess01 environment: - "SEARCH_ENGINE_HTTP_URL=http://fessctl_search01:9200" - "FESS_DICTIONARY_PATH=${FESS_DICTIONARY_PATH:-/usr/share/opensearch/config/dictionary/}" -# - "FESS_PLUGINS=fess-ds-csv:${FESS_VERSION:-15.6.1}" +# - "FESS_PLUGINS=fess-ds-csv:${FESS_VERSION:-15.7.0}" volumes: # - fessctl_fess01_plugin:/usr/share/fess/app/WEB-INF/plugin - ./resources/access_token.bulk:/usr/share/fess/app/WEB-INF/classes/fess_indices/fess_config.access_token/access_token.bulk diff --git a/tests/conftest.py b/tests/conftest.py index 2fe9f16..ae9786e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ def fess_service(): print(f"Project root: {project_root}") # Determine which compose file to use based on FESS_VERSION - fess_version = os.getenv("FESS_VERSION", "15.6.1") + fess_version = os.getenv("FESS_VERSION", "15.7.0") if fess_version.startswith("15."): compose_file = "compose-fess15.yaml" else: @@ -29,7 +29,15 @@ def fess_service(): timeout_seconds = 60 start = time.time() - health_url = f"{endpoint}/api/v1/health" + # Fess 15.7+ serves the health check at /api/v2/health (the legacy /api/v1/health + # was removed); earlier versions use /api/v1/health. + try: + major, minor, *_ = (int(p) for p in fess_version.split(".")[:2]) + is_api_v2 = (major, minor) >= (15, 7) + except (ValueError, TypeError): + is_api_v2 = False + health_path = "/api/v2/health" if is_api_v2 else "/api/v1/health" + health_url = f"{endpoint}{health_path}" while True: try: res = requests.get(health_url, timeout=1) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 56c9ecf..ff11a43 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -24,6 +24,7 @@ class TestPingCommand: def test_ping_healthy_server_text_output(self, mock_client_class, runner): """Test ping with healthy server returns green status in text output.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "green", "timed_out": False} } @@ -39,6 +40,7 @@ def test_ping_healthy_server_text_output(self, mock_client_class, runner): def test_ping_healthy_server_json_output(self, mock_client_class, runner): """Test ping with healthy server returns JSON output.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "green", "timed_out": False} } @@ -54,6 +56,7 @@ def test_ping_healthy_server_json_output(self, mock_client_class, runner): def test_ping_healthy_server_yaml_output(self, mock_client_class, runner): """Test ping with healthy server returns YAML output.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "green", "timed_out": False} } @@ -68,6 +71,7 @@ def test_ping_healthy_server_yaml_output(self, mock_client_class, runner): def test_ping_yellow_status(self, mock_client_class, runner): """Test ping with yellow status shows warning.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "yellow", "timed_out": False} } @@ -82,6 +86,7 @@ def test_ping_yellow_status(self, mock_client_class, runner): def test_ping_red_status(self, mock_client_class, runner): """Test ping with red status returns error.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "red", "timed_out": False}, "response": {"message": "Cluster is unhealthy"} @@ -97,6 +102,7 @@ def test_ping_red_status(self, mock_client_class, runner): def test_ping_timed_out(self, mock_client_class, runner): """Test ping with timed_out=True returns error.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "green", "timed_out": True}, "response": {"message": "Request timed out"} @@ -112,6 +118,7 @@ def test_ping_timed_out(self, mock_client_class, runner): def test_ping_unknown_status(self, mock_client_class, runner): """Test ping with unknown status returns error.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "unknown", "timed_out": True}, "response": {"message": ""} @@ -126,6 +133,7 @@ def test_ping_unknown_status(self, mock_client_class, runner): def test_ping_connection_error(self, mock_client_class, runner): """Test ping with connection error.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.side_effect = FessAPIClientError( status_code=-1, content="Connection refused" ) @@ -140,6 +148,7 @@ def test_ping_connection_error(self, mock_client_class, runner): def test_ping_generic_exception(self, mock_client_class, runner): """Test ping with generic exception.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.side_effect = Exception("Unexpected error") mock_client_class.return_value = mock_client @@ -149,6 +158,80 @@ def test_ping_generic_exception(self, mock_client_class, runner): assert "Unexpected error" in result.stdout +class TestPingCommandV2: + """Tests for the ping command against Fess 15.7+ (/api/v2/health envelope).""" + + @patch("fessctl.cli.FessAPIClient") + def test_ping_healthy_server_text_output(self, mock_client_class, runner): + """Test v2 ping with a healthy (green) cluster.""" + mock_client = Mock() + mock_client.is_api_v2 = True + mock_client.ping.return_value = { + "response": {"status": 0, "engine": {"status": "green", "ping_status": 0}} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 0 + assert "healthy" in result.stdout.lower() + assert "green" in result.stdout.lower() + + @patch("fessctl.cli.FessAPIClient") + def test_ping_yellow_status(self, mock_client_class, runner): + """Test v2 ping with a yellow cluster shows a warning but succeeds.""" + mock_client = Mock() + mock_client.is_api_v2 = True + mock_client.ping.return_value = { + "response": {"status": 0, "engine": {"status": "yellow", "ping_status": 0}} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 0 + assert "yellow" in result.stdout.lower() + + @patch("fessctl.cli.FessAPIClient") + def test_ping_red_status(self, mock_client_class, runner): + """Test v2 ping with a red cluster (error envelope) returns an error.""" + mock_client = Mock() + mock_client.is_api_v2 = True + mock_client.ping.return_value = { + "response": { + "status": 9, + "error": { + "code": "service_unavailable", + "message": "search engine cluster is red", + "details": {"engine": {"status": "red", "ping_status": 2}}, + }, + } + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping"]) + + assert result.exit_code == 1 + assert "red" in result.stdout.lower() + assert "search engine cluster is red" in result.stdout + + @patch("fessctl.cli.FessAPIClient") + def test_ping_json_output(self, mock_client_class, runner): + """Test v2 ping JSON output echoes the raw v2 envelope.""" + mock_client = Mock() + mock_client.is_api_v2 = True + mock_client.ping.return_value = { + "response": {"status": 0, "engine": {"status": "green", "ping_status": 0}} + } + mock_client_class.return_value = mock_client + + result = runner.invoke(app, ["ping", "--output", "json"]) + + assert result.exit_code == 0 + response = json.loads(result.stdout) + assert response["response"]["engine"]["status"] == "green" + + class TestAppStructure: """Tests for the CLI app structure.""" @@ -185,6 +268,7 @@ class TestPingOutputFormats: def test_ping_json_output_is_valid_json(self, mock_client_class, runner): """Test that JSON output is valid JSON.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "green", "timed_out": False, "number_of_nodes": 3} } @@ -200,6 +284,7 @@ def test_ping_json_output_is_valid_json(self, mock_client_class, runner): def test_ping_short_output_flag(self, mock_client_class, runner): """Test that -o short flag works for output.""" mock_client = Mock() + mock_client.is_api_v2 = False mock_client.ping.return_value = { "data": {"status": "green", "timed_out": False} } diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e98bcfa..b9414bf 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -31,6 +31,16 @@ def mock_settings_v14(): return settings +@pytest.fixture +def mock_settings_v157(): + """Create a mock Settings object for Fess 15.7+ (unified /api/v2).""" + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://localhost:8080" + settings.access_token = "test-token" + settings.fess_version = "15.7.0" + return settings + + @pytest.fixture def client(mock_settings): """Create a FessAPIClient instance with mocked settings.""" @@ -43,6 +53,12 @@ def client_v14(mock_settings_v14): return FessAPIClient(mock_settings_v14) +@pytest.fixture +def client_v157(mock_settings_v157): + """Create a FessAPIClient instance for Fess 15.7+.""" + return FessAPIClient(mock_settings_v157) + + class TestFessAPIClientInit: """Tests for FessAPIClient initialization.""" @@ -302,6 +318,28 @@ def test_returns_json_response(self, mock_get, client): assert result == expected_data + @patch("httpx.get") + def test_non_200_json_body_is_returned_without_raising(self, mock_get, client): + """A non-2xx response with a JSON body (e.g. v2 red-cluster 503) is parsed, not raised.""" + envelope = { + "response": { + "status": 9, + "error": { + "code": "service_unavailable", + "message": "search engine cluster is red", + "details": {"engine": {"status": "red", "ping_status": 2}}, + }, + } + } + mock_response = Mock() + mock_response.status_code = 503 + mock_response.json.return_value = envelope + mock_get.return_value = mock_response + + result = client.send_request(Action.GET, "http://test/api") + + assert result == envelope + class TestSendRequestHeaders: """Tests for header handling in send_request.""" @@ -377,6 +415,70 @@ def test_ping_uses_search_headers(self, mock_get, client): call_kwargs = mock_get.call_args[1] assert "Authorization" not in call_kwargs["headers"] + @patch("httpx.get") + def test_ping_v157_calls_v2_health_endpoint(self, mock_get, client_v157): + """Test that ping targets /api/v2/health on Fess 15.7+.""" + mock_response = Mock() + mock_response.json.return_value = { + "response": {"status": 0, "engine": {"status": "green", "ping_status": 0}} + } + mock_get.return_value = mock_response + + client_v157.ping() + + mock_get.assert_called_once() + call_url = mock_get.call_args[0][0] + assert "/api/v2/health" in call_url + assert "/api/v1/health" not in call_url + + @patch("httpx.get") + def test_ping_v157_uses_search_headers(self, mock_get, client_v157): + """Test that v2 ping uses search headers (no auth).""" + mock_response = Mock() + mock_response.json.return_value = { + "response": {"status": 0, "engine": {"status": "green", "ping_status": 0}} + } + mock_get.return_value = mock_response + + client_v157.ping() + + call_kwargs = mock_get.call_args[1] + assert "Authorization" not in call_kwargs["headers"] + + +class TestIsApiV2: + """Tests for the is_api_v2 version gate (Fess 15.7+ uses /api/v2).""" + + @pytest.mark.parametrize( + "version,expected", + [ + ("14.19.2", False), + ("15.0.0", False), + ("15.6.1", False), + ("15.7.0", True), + ("15.7.3", True), + ("15.8.0", True), + ("16.0.0", True), + ("16.0.0-SNAPSHOT", True), + ], + ) + def test_is_api_v2(self, version, expected): + """Only Fess 15.7 and newer expose the unified /api/v2 surface.""" + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://localhost:8080" + settings.access_token = "test-token" + settings.fess_version = version + + assert FessAPIClient(settings).is_api_v2 is expected + + def test_default_settings_use_api_v2(self, monkeypatch): + """The default FESS_VERSION targets the unified /api/v2 surface (Fess 15.7+).""" + monkeypatch.delenv("FESS_VERSION", raising=False) + monkeypatch.delenv("FESS_ENDPOINT", raising=False) + monkeypatch.delenv("FESS_ACCESS_TOKEN", raising=False) + + assert FessAPIClient(Settings()).is_api_v2 is True + class TestRoleAPIs: """Tests for Role API methods.""" diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index b8b6400..3a260e1 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -21,7 +21,7 @@ def test_default_values(self, monkeypatch): assert settings.fess_endpoint == "http://localhost:8080" assert settings.access_token is None - assert settings.fess_version == "15.6.1" + assert settings.fess_version == "15.7.0" def test_endpoint_from_environment(self, monkeypatch): """Test that FESS_ENDPOINT environment variable is used."""