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
2 changes: 2 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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
```

Expand All @@ -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
```

Expand All @@ -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
Expand All @@ -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

Expand Down
18 changes: 17 additions & 1 deletion src/fessctl/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions src/fessctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/fessctl/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
4 changes: 2 additions & 2 deletions tests/compose-fess15.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 10 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down
85 changes: 85 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand All @@ -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}
}
Expand All @@ -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}
}
Expand All @@ -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}
}
Expand All @@ -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"}
Expand All @@ -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"}
Expand All @@ -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": ""}
Expand All @@ -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"
)
Expand All @@ -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

Expand All @@ -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."""

Expand Down Expand Up @@ -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}
}
Expand All @@ -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}
}
Expand Down
Loading
Loading