From 04dea01202eead4cc6a664f96da19ee0e3964f8a Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sat, 22 Aug 2026 20:40:59 +0900 Subject: [PATCH] Support Fess 15.8.0 Fess 15.8.0 pairs with OpenSearch 3.8.0. The admin API - the only surface fessctl calls apart from the health check - is unchanged from 15.7.0: no endpoint was added or removed, and every EditBody/SearchBody diff between the fess-15.7.0 and fess-15.8.0 tags is a `super()` removal. So the release itself needs no request-shape change. Verifying against a live 15.8.0 server did surface an existing bug. `/api/v2/health` reports the cluster status as the OpenSearch enum name ("GREEN"), while the legacy `/api/v1` body spelled it in lower case. `ping` compared against the lower-case literals, so a healthy 15.7+ server was reported as an error with exit code 1. HealthHandler and PingResponse are byte-identical between the two tags, and ClusterHealthStatus overrides toString() in neither OpenSearch 3.7.0 nor 3.8.0, so this has been broken since 15.7 support landed - confirmed live against both versions. - Compare the cluster status case-insensitively in `ping`, and report the status the server actually sent instead of a hardcoded "green". - Add tests/commands/test_ping.py so the health path is exercised against a live server and not only against mocks. - Change the default FESS_VERSION from 15.7.0 to 15.8.0, and bump the compose defaults to fess 15.8.0 / fess-opensearch 3.8.0. - Add Fess 15.8.0 (OpenSearch 3.8.0) to the CI matrix. - Refresh the README and skill docs, and re-sync uv.lock with the 0.3.0.dev0 project version. Verified locally: the full suite (169 tests) is green against 15.8.0/3.8.0, 15.7.0/3.7.0 and 15.6.1/3.6.0. The 14.19.2 entry is left to CI. --- .github/workflows/run-tests.yml | 2 + README.md | 14 +++--- skills/fessctl/SKILL.md | 2 +- skills/fessctl/references/authentication.md | 4 +- skills/fessctl/references/installation.md | 2 +- src/fessctl/cli.py | 15 ++++-- src/fessctl/config/settings.py | 2 +- tests/commands/test_ping.py | 41 ++++++++++++++++ tests/compose-fess15.yaml | 6 +-- tests/conftest.py | 2 +- tests/unit/test_cli.py | 53 +++++++++++++++++++++ tests/unit/test_settings.py | 2 +- uv.lock | 2 +- 13 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 tests/commands/test_ping.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 582806c..51dfd84 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,6 +20,8 @@ jobs: opensearch_version: "3.6.0" - fess_version: "15.7.0" opensearch_version: "3.7.0" + - fess_version: "15.8.0" + opensearch_version: "3.8.0" steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index cbd806d..a32bf78 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ uv tool install fessctl ```bash export FESS_ACCESS_TOKEN=your_access_token_here export FESS_ENDPOINT=https://your-fess-server -export FESS_VERSION=15.7.0 +export FESS_VERSION=15.8.0 fessctl --help fessctl ping @@ -54,7 +54,7 @@ If you prefer a container-based workflow, use 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.7.0 \ + -e FESS_VERSION=15.8.0 \ ghcr.io/codelibs/fessctl:0.2.0 --help ``` @@ -64,13 +64,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.7.0 \ + -e FESS_VERSION=15.8.0 \ ghcr.io/codelibs/fessctl:0.2.0 ping docker run --rm \ -e FESS_ENDPOINT=https://your-fess-server \ -e FESS_ACCESS_TOKEN=your_access_token_here \ - -e FESS_VERSION=15.7.0 \ + -e FESS_VERSION=15.8.0 \ ghcr.io/codelibs/fessctl:0.2.0 user list ``` @@ -90,7 +90,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.7.0 \ + -e FESS_VERSION=15.8.0 \ fessctl:latest --help ``` @@ -115,7 +115,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.7.0 +export FESS_VERSION=15.8.0 fessctl --help fessctl ping @@ -129,7 +129,7 @@ All four 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.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). +- `FESS_VERSION`: Target Fess version for API compatibility (default: `15.8.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/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index c55c003..c06f24e 100644 --- a/skills/fessctl/SKILL.md +++ b/skills/fessctl/SKILL.md @@ -20,7 +20,7 @@ See `references/installation.md` for the exact wrappers. - `FESS_ENDPOINT` (default `http://localhost:8080`) - `FESS_ACCESS_TOKEN` (required for any non-`ping` call) -- `FESS_VERSION` (e.g. `15.6.0`; must match the target Fess server) +- `FESS_VERSION` (e.g. `15.8.0`; must match the target Fess server) See `references/authentication.md` for token issuance. diff --git a/skills/fessctl/references/authentication.md b/skills/fessctl/references/authentication.md index c25f30c..a9aab03 100644 --- a/skills/fessctl/references/authentication.md +++ b/skills/fessctl/references/authentication.md @@ -8,7 +8,7 @@ Every fessctl call other than `ping` is authenticated with a Fess access token s |----------|----------|---------|-------| | `FESS_ENDPOINT` | optional | `http://localhost:8080` | Base URL of the target Fess server. Include scheme; do not include a trailing `/`. | | `FESS_ACCESS_TOKEN` | **yes** for any non-`ping` call | none | Bearer token issued from the Fess admin UI or via `fessctl accesstoken create`. | -| `FESS_VERSION` | optional | `15.4.0` (as of fessctl 0.1.0) | Must match the major.minor of the target Fess server so request shapes line up. Set it explicitly — do not rely on the default. | +| `FESS_VERSION` | optional | `15.8.0` | Must match the major.minor of the target Fess server so request shapes line up. Set it explicitly — do not rely on the default. | Defaults live in `src/fessctl/config/settings.py`. The defaults are conservative and may lag the latest Fess release; for any non-trivial work, set `FESS_ENDPOINT` and `FESS_VERSION` explicitly. @@ -39,7 +39,7 @@ Pick the option that matches how you run fessctl. ```bash export FESS_ENDPOINT=http://localhost:8080 export FESS_ACCESS_TOKEN=eyJhbGciOi... - export FESS_VERSION=15.6.0 + export FESS_VERSION=15.8.0 ``` Add `.envrc` to `.gitignore`. Run `direnv allow` to activate. diff --git a/skills/fessctl/references/installation.md b/skills/fessctl/references/installation.md index 46d00a6..62e9acd 100644 --- a/skills/fessctl/references/installation.md +++ b/skills/fessctl/references/installation.md @@ -64,7 +64,7 @@ Two networking notes for reaching a Fess server running on the **host**: ## Choosing the Docker tag -The Docker image is published at `ghcr.io/codelibs/fessctl`. Pin a tag rather than `latest` for reproducible runs. The convention is to keep the image tag close to the Fess version it has been validated against — if you are talking to a Fess 15.6 server, prefer the tag whose `FESS_VERSION` default matches. Inspect available tags at if unsure. +The Docker image is published at `ghcr.io/codelibs/fessctl`. Pin a tag rather than `latest` for reproducible runs. The convention is to keep the image tag close to the Fess version it has been validated against — if you are talking to a Fess 15.8 server, prefer the tag whose `FESS_VERSION` default matches. Inspect available tags at if unsure. ## Verifying the install diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 2fa79b1..82b1f97 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -91,9 +91,9 @@ def ping( result = client.ping() if client.is_api_v2: # Fess 15.7+ /api/v2/health envelope: - # healthy: {"response": {"status": 0, "engine": {"status": "green", "ping_status": 0}}} + # healthy: {"response": {"status": 0, "engine": {"status": "GREEN", "ping_status": 0}}} # red: {"response": {"status": 9, "error": {"message": "...", - # "details": {"engine": {"status": "red", "ping_status": N}}}}} + # "details": {"engine": {"status": "RED", "ping_status": N}}}}} response = result.get("response", {}) engine = response.get("engine") if engine is None: @@ -107,14 +107,19 @@ def ping( timed_out = result.get("data", {}).get("timed_out", True) message = result.get("response", {}).get("message", "") + # The /api/v2 envelope carries the OpenSearch enum name ("GREEN"/"YELLOW"/"RED"), while + # the legacy /api/v1 body spelled the same value in lower case. Compare case-insensitively + # so a healthy 15.7+ server is not reported as an error. + normalized_status = str(status).lower() + if output == "json": typer.echo(json.dumps(result, indent=2)) elif output == "yaml": typer.echo(yaml.dump(result)) else: - if status == "green" and not timed_out: - typer.echo(format_result_markdown(True, "Fess server is healthy (status: green).", "Server", "ping")) - elif status == "yellow": + if normalized_status == "green" and not timed_out: + typer.echo(format_result_markdown(True, f"Fess server is healthy (status: {status}).", "Server", "ping")) + elif normalized_status == "yellow": typer.echo(format_result_markdown(True, f"Fess server status: {status} (timed_out: {timed_out})", "Server", "ping")) else: typer.echo(format_result_markdown(False, f"Fess server status: {status} (timed_out: {timed_out}) {message}", "Server", "ping")) diff --git a/src/fessctl/config/settings.py b/src/fessctl/config/settings.py index 478817d..465a690 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.7.0")) + default_factory=lambda: os.getenv("FESS_VERSION", "15.8.0")) diff --git a/tests/commands/test_ping.py b/tests/commands/test_ping.py new file mode 100644 index 0000000..852fb80 --- /dev/null +++ b/tests/commands/test_ping.py @@ -0,0 +1,41 @@ +import json +import pytest +from typer.testing import CliRunner + +from fessctl.api.client import FessAPIClient +from fessctl.cli import app +from fessctl.config.settings import Settings + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_ping_reports_a_healthy_server(runner, fess_service): + """ + A server the test harness already waited for must be reported as healthy, + whatever spelling of the cluster status the running Fess version uses. + """ + result = runner.invoke(app, ["ping"]) + assert result.exit_code == 0, f"Ping failed: {result.stdout}" + assert "error" not in result.stdout.lower(), result.stdout + + +def test_ping_json_reports_a_usable_cluster(runner, fess_service): + """ + The raw health payload must carry a green or yellow cluster status. + """ + result = runner.invoke(app, ["ping", "--output", "json"]) + assert result.exit_code == 0, f"Ping failed: {result.stdout}" + payload = json.loads(result.stdout) + + if FessAPIClient(Settings()).is_api_v2: + status = payload["response"]["engine"]["status"] + else: + status = payload["data"]["status"] + + assert status.lower() in ("green", "yellow"), f"Unexpected cluster status: {status}" diff --git a/tests/compose-fess15.yaml b/tests/compose-fess15.yaml index b0f8612..c87d7d2 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.7.0} + image: ghcr.io/codelibs/fess:${FESS_VERSION:-15.8.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.7.0}" +# - "FESS_PLUGINS=fess-ds-csv:${FESS_VERSION:-15.8.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 @@ -23,7 +23,7 @@ services: restart: unless-stopped fessctl_search01: - image: ghcr.io/codelibs/fess-opensearch:${OPENSEARCH_VERSION:-3.6.0} + image: ghcr.io/codelibs/fess-opensearch:${OPENSEARCH_VERSION:-3.8.0} container_name: fessctl_search01 environment: - node.name=fessctl_search01 diff --git a/tests/conftest.py b/tests/conftest.py index ae9786e..1ab4a25 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.7.0") + fess_version = os.getenv("FESS_VERSION", "15.8.0") if fess_version.startswith("15."): compose_file = "compose-fess15.yaml" else: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ff11a43..483cf3a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -215,6 +215,59 @@ def test_ping_red_status(self, mock_client_class, runner): assert "red" in result.stdout.lower() assert "search engine cluster is red" in result.stdout + @patch("fessctl.cli.FessAPIClient") + def test_ping_uppercase_green_status(self, mock_client_class, runner): + """A real server spells the cluster status with the OpenSearch enum name (GREEN).""" + 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 + + @patch("fessctl.cli.FessAPIClient") + def test_ping_uppercase_yellow_status(self, mock_client_class, runner): + """An upper-case YELLOW is a warning, not an error.""" + 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 + + @patch("fessctl.cli.FessAPIClient") + def test_ping_uppercase_red_status(self, mock_client_class, runner): + """An upper-case RED still fails the command.""" + 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 + @patch("fessctl.cli.FessAPIClient") def test_ping_json_output(self, mock_client_class, runner): """Test v2 ping JSON output echoes the raw v2 envelope.""" diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 3a260e1..1c93bde 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.7.0" + assert settings.fess_version == "15.8.0" def test_endpoint_from_environment(self, monkeypatch): """Test that FESS_ENDPOINT environment variable is used.""" diff --git a/uv.lock b/uv.lock index fff2152..bd78d6f 100644 --- a/uv.lock +++ b/uv.lock @@ -102,7 +102,7 @@ wheels = [ [[package]] name = "fessctl" -version = "0.2.0" +version = "0.3.0.dev0" source = { editable = "." } dependencies = [ { name = "httpx" },