diff --git a/skills/fessctl/SKILL.md b/skills/fessctl/SKILL.md index a24fc0b..14d5398 100644 --- a/skills/fessctl/SKILL.md +++ b/skills/fessctl/SKILL.md @@ -1,6 +1,6 @@ --- name: fessctl -description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 25 resource types and cross-feature workflows like initial crawl setup. +description: Operate Fess via fessctl CLI (admin API client). Use when managing webconfigs, file/data configs, schedulers, users/roles/groups, dictionaries, access tokens, or any Fess admin resource from Claude. Covers 30 resource types and cross-feature workflows like initial crawl setup. license: Apache-2.0 version: 0.1.0 --- @@ -70,3 +70,8 @@ Each file documents one Fess admin feature: what it is, when to use it, fessctl | Path mapping | references/features/pathmap.md | | Duplicate host | references/features/duplicatehost.md | | Request header | references/features/reqheader.md | +| System info | references/features/systeminfo.md | +| Server stats | references/features/stats.md | +| Log files | references/features/log.md | +| Backup files | references/features/backup.md | +| Suggest words | references/features/suggest.md | diff --git a/skills/fessctl/references/features/backup.md b/skills/fessctl/references/features/backup.md new file mode 100644 index 0000000..ab7a828 --- /dev/null +++ b/skills/fessctl/references/features/backup.md @@ -0,0 +1,48 @@ +# Backup files (fessctl `backup`) + +## What it is +The files the Admin UI backup page offers for download: index dumps in Elasticsearch/OpenSearch bulk format (`fess_config.bulk`, `fess_basic_config.bulk`, `fess_user.bulk`), the `system.properties` file, the index mappings (`fess.json`, `doc.json`) and the search, click, favorite, user-info and chat logs as NDJSON. The admin API lists the ids and streams one file. It is download only; restoring a backup is not available through the admin API. + +Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0), with differences noted below. + +## When to use +- Take a copy of the Fess configuration (crawl configs, labels, users, system properties) before an upgrade or a risky change. +- Export the search log for analysis outside Fess. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `list` | List the downloadable files. | none | +| `download` | Write one file to a local file. Refuses to overwrite unless `--force` is given. | ` --dest PATH` | + +Always reconfirm with `fessctl backup --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "total": 2, + "files": [ + {"name": "fess_config.bulk", "id": "fess_config.bulk"}, + {"name": "search_log.ndjson", "id": "search_log.ndjson"} + ] + } +} +``` +The id equals the name. `download` with `--output json` returns `{"response": {"status": 0, "path": "...", "size": 6463}}`. An unknown id returns `status` 1 with `Could not find any backup index.` (`Could not find index for backup.` in 14.x) and creates no file. + +## Gotchas +- The dumps contain everything stored in the index, including access tokens and crawl credentials. Treat the files as secrets. +- An empty file is a valid result: a search log with no entries downloads as 0 bytes. +- Before 15.9.0 `fess.json` and `doc.json` are listed but download as empty files (the mapping export was added in 15.9.0), and `chat_log.ndjson` is listed only from 15.9.0. +- Fess answers a path whose last segment has an extension (`.../backup/file/fess_config.bulk`) with a bare `404 Not Found.` in 15.9 and a redirect in older versions, instead of running the action. fessctl requests the id with a trailing slash, which Fess serves. +- The file is written to a temporary `.part` file next to the destination and renamed on success, so a failed transfer leaves no partial file. Large dumps are streamed; the read timeout applies per chunk (60 s). + +## Examples +```bash +fessctl backup list +fessctl backup download fess_config.bulk --dest ./fess_config.bulk +fessctl backup download system.properties --dest ./system.properties --force +fessctl backup download search_log.ndjson --dest ./search_log.ndjson -o json +``` diff --git a/skills/fessctl/references/features/log.md b/skills/fessctl/references/features/log.md new file mode 100644 index 0000000..dcd7e48 --- /dev/null +++ b/skills/fessctl/references/features/log.md @@ -0,0 +1,45 @@ +# Log files (fessctl `log`) + +## What it is +The log files in the log directory of the Fess server (`audit.log`, `fess.log`, `fess-crawler.log`, ... plus rotated `*.log.gz`), as listed on the Admin UI log files page. The admin API lists them and streams one file. + +Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). Only names ending in `.log` or `.log.gz` are served. + +## When to use +- Fetch the server logs for a bug report without shell access to the server. +- Check the crawler, thumbnail or audit log after a failed job. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `list` | List the log files with their id and last-modified time. | none | +| `download` | Write one log file to a local file. Refuses to overwrite unless `--force` is given. | ` --dest PATH` | + +Always reconfirm with `fessctl log --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "total": 2, + "files": [ + {"name": "fess.log", "id": "ZmVzcy5sb2c=", "lastModified": "2026-10-16T12:23:38"}, + {"name": "audit.log", "id": "YXVkaXQubG9n", "lastModified": "2026-10-16T12:23:49"} + ] + } +} +``` +The `id` is the URL-safe Base64 of the file name. `lastModified` is in the server's local time without a zone (and camelCase, unlike the rest of the API). `download` with `--output json` returns `{"response": {"status": 0, "path": "...", "size": 981}}`. + +## Gotchas +- An empty log file downloads as an empty file; a log that Fess has not written to yet is common. Fess also answers an id that names no log file with an empty download, so `download` checks the id against `list` first and fails with `not found` instead. +- Fess decodes the id as standard Base64 while `list` hands out URL-safe Base64. The two differ only for `-` and `_` in an id, which appear only for file names containing characters such as `>`, `?`, `~` or non-ASCII; such a log file cannot be downloaded through the API (`Could not find ...`). +- The file is written to a temporary `.part` file next to the destination and renamed on success, so a failed transfer leaves no partial file. + +## Examples +```bash +fessctl log list +fessctl log download "$(fessctl log list -o json | jq -r '.response.files[] | select(.name == "fess.log") | .id')" --dest ./fess.log +fessctl log download YXVkaXQubG9n --dest ./audit.log --force +``` diff --git a/skills/fessctl/references/features/stats.md b/skills/fessctl/references/features/stats.md new file mode 100644 index 0000000..a09125b --- /dev/null +++ b/skills/fessctl/references/features/stats.md @@ -0,0 +1,46 @@ +# Stats (fessctl `stats`) + +## What it is +Runtime statistics of the Fess server: JVM memory, garbage collection and threads, OS memory and load, process file descriptors and CPU, the search engine cluster health, and the filesystem roots. + +Read only. Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). + +## When to use +- Check heap usage, thread counts or the load average of the Fess process. +- Check the search engine cluster status (`engine.status`) and shard counts, or the free disk space (`fs`). + +## Subcommand surface +`stats` takes no subcommand; it takes `--output text|json|yaml`. + +| Command | Purpose | +| --- | --- | +| `fessctl stats` | Show the statistics. Text output is a `NAME \| VALUE` table of dotted names such as `jvm.memory.heap.used`; list items are named by their key or path (`jvm.gc[young].count`, `fs[/].free`). `json`/`yaml` return the response unchanged. | + +Always reconfirm with `fessctl stats --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "stats": { + "jvm": {"memory": {"heap": {"used": 366768360, "max": 536870912, "percent": 68}}, "gc": [{"key": "young", "count": 10, "time": 48}], "threads": {"count": 37, "peak": 37}, "uptime": 26797}, + "os": {"memory": {"physical": {"free": 5921939456, "total": 50425147392}}, "cpu": {"percent": 0}, "load_averages": [2.12, 0.98, 0.86]}, + "process": {"file_fescriptor": {"open": 221, "max": 1048576}}, + "engine": {"status": "green", "cluster_name": "fess-search", "number_of_nodes": 1, "active_shards": 65, "unassigned_shards": 0}, + "fs": [{"path": "/", "used": 182361546752, "free": 101632278528, "total": 270233210880, "percent": 67}] + } + } +} +``` +Sizes are bytes, times are milliseconds. A few keys are spelled oddly by the server and passed through unchanged (for example `process.file_fescriptor`). + +## Gotchas +- When Fess cannot reach the search engine, the call still succeeds: `engine.status` is `red` and `engine.exception` carries the message. Check the field, not the exit code. + +## Examples +```bash +fessctl stats +fessctl stats -o json | jq '.response.stats.engine.status' +fessctl stats -o json | jq '.response.stats.jvm.memory.heap.percent' +``` diff --git a/skills/fessctl/references/features/suggest.md b/skills/fessctl/references/features/suggest.md new file mode 100644 index 0000000..4b45cb5 --- /dev/null +++ b/skills/fessctl/references/features/suggest.md @@ -0,0 +1,45 @@ +# Suggest words (fessctl `suggest`) + +## What it is +The suggest index holds the words behind the search box auto-complete. Fess fills it from two sources: the content of crawled documents (document words) and the queries users searched for (query words). The Admin UI Suggest page shows the counts and offers the deletes; the admin API offers the same. + +Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). + +## When to use +- Check whether suggestions exist (`info`): the counts are 0 until the **Suggest Indexer** scheduler job has run. +- Remove words after a change in the crawl targets, or remove query words that should not be suggested. + +## Subcommand surface +| Subcommand | Purpose | Required arguments | +| --- | --- | --- | +| `info` | Show `total_words_num`, `document_words_num` and `query_words_num`. | none | +| `delete-all` | Delete every word. Fess re-adds the elevate words afterwards. Asks for confirmation unless `--yes` is given. | none | +| `delete-document` | Delete the words that come from documents. Asks for confirmation unless `--yes` is given. | none | +| `delete-query` | Delete the words that come from search queries. Asks for confirmation unless `--yes` is given. | none | + +Always reconfirm with `fessctl suggest --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "setting": {"total_words_num": 17, "document_words_num": 17, "query_words_num": 4} + } +} +``` +The delete commands return only `status` (and `version`). + +## Gotchas +- A word can come from both sources, so `document_words_num` plus `query_words_num` can exceed `total_words_num`; `delete-document` removes the document origin of such a word and `delete-query` the query origin. +- Deleted words come back: the **Suggest Indexer** job (`fessctl scheduler start suggest_indexer`) re-creates the document words from the index and the query words from the search log. +- The counts are read from the search engine and can lag a moment behind an index run. +- None of these APIs needs the CodeLibs search engine plugins (the 15.9 `404` for plugin-less engines applies to the dictionary APIs only). + +## Examples +```bash +fessctl suggest info +fessctl suggest delete-query --yes +fessctl suggest delete-all --yes --output json +fessctl scheduler start suggest_indexer +``` diff --git a/skills/fessctl/references/features/systeminfo.md b/skills/fessctl/references/features/systeminfo.md new file mode 100644 index 0000000..49c2a3d --- /dev/null +++ b/skills/fessctl/references/features/systeminfo.md @@ -0,0 +1,44 @@ +# System info (fessctl `systeminfo`) + +## What it is +The system information of the running Fess server, the data of the Admin UI system information page: the properties to attach to a bug report, the environment variables, the `fess_config.properties` values in effect, and the JVM system properties. + +Read only. Available in every version fessctl supports (14.19.2, 15.6.1, 15.7.0, 15.8.0, 15.9.0). + +## When to use +- Attach the server environment to a bug report or support request. +- Check which configuration value Fess is really using (`fess_props`) or which environment variable reached the JVM (`env_props`). + +## Subcommand surface +`systeminfo` takes no subcommand; it takes `--output text|json|yaml`. + +| Command | Purpose | +| --- | --- | +| `fessctl systeminfo` | Show the four property groups. Text output is one `NAME \| VALUE` table per group (the Fess group alone has several hundred rows); `json`/`yaml` return the full response. | + +Always reconfirm with `fessctl systeminfo --help`. + +## Resource JSON shape +```json +{ + "response": { + "status": 0, + "version": "15.9", + "bug_report_props": [{"label": "java.runtime.version", "value": "21.0.12.1+1-LTS"}], + "env_props": [{"label": "FESS_PORT", "value": "8080"}], + "fess_props": [{"label": "domain.title", "value": "Fess"}], + "system_props": [{"label": "java.specification.version", "value": "21"}] + } +} +``` + +## Gotchas +- The output includes environment variables and configuration values of the server; review it before pasting it into a public issue. +- Use `--output json` with `jq` to pick one value: `fessctl systeminfo -o json | jq -r '.response.fess_props[] | select(.label == "domain.title") | .value'`. + +## Examples +```bash +fessctl systeminfo +fessctl systeminfo --output yaml +fessctl systeminfo -o json | jq -r '.response.bug_report_props[] | "\(.label)=\(.value)"' +``` diff --git a/skills/fessctl/references/workflows.md b/skills/fessctl/references/workflows.md index 750f348..69762f8 100644 --- a/skills/fessctl/references/workflows.md +++ b/skills/fessctl/references/workflows.md @@ -198,4 +198,4 @@ fessctl badword create --suggest-word internal-test ## Out of scope (today) -These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `failureurl`, `plugin`, `backup`, `searchlist`, `stats`, `storage`, `suggest`, `systeminfo`, or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes. +These recipes intentionally restrict themselves to features fessctl currently wraps. Operations that depend on `failureurl`, `plugin`, `searchlist`, `storage`, or `documents` are not represented here because fessctl 0.1.0 does not expose those subcommands. When fessctl adds them, update this file with the additional recipes. diff --git a/src/fessctl/api/client.py b/src/fessctl/api/client.py index 0d837c3..a461af0 100644 --- a/src/fessctl/api/client.py +++ b/src/fessctl/api/client.py @@ -1,5 +1,6 @@ import json from enum import Enum +from pathlib import Path from typing import Dict, List, Optional import httpx @@ -129,6 +130,45 @@ def send_request( content=f"Invalid JSON response (HTTP {response.status_code}): {response.text}" ) from e + def _download(self, url: str, dest: Path) -> dict: + """ + Streams a file download to ``dest``. + + Fess answers a failed download with a JSON error body, which is returned like any other + response; a successful download returns ``{"response": {"status": 0, "path": ..., "size": ...}}``. + The file is written next to ``dest`` first, so a failed transfer leaves no partial file. + """ + part = dest.with_name(dest.name + ".part") + headers = {"Authorization": self.admin_api_headers["Authorization"]} + try: + with httpx.stream( + "GET", url, headers=headers, timeout=httpx.Timeout(self.timeout, read=60.0) + ) as response: + # A backup may itself be application/x-ndjson, so match the JSON error type exactly. + if response.status_code != 200 or response.headers.get("content-type", "").startswith("application/json"): + response.read() + try: + return response.json() + except json.decoder.JSONDecodeError as e: + raise FessAPIClientError( + status_code=response.status_code, + content=f"Invalid JSON response (HTTP {response.status_code}): {response.text}" + ) from e + size = 0 + with part.open("wb") as f: + for chunk in response.iter_bytes(): + f.write(chunk) + size += len(chunk) + part.replace(dest) + except httpx.HTTPError as e: + raise FessAPIClientError( + status_code=-1, + content=f"Network error: {str(e)}" + ) from e + finally: + part.unlink(missing_ok=True) + return {"response": {"status": 0, "path": str(dest), "size": size}} + def ping(self) -> dict: """ Sends a GET request to the health endpoint of the API to check the service status. @@ -712,6 +752,86 @@ def install_theme(self, name: str, version: str) -> dict: url = f"{self.base_url}/api/admin/theme" return self.send_request(Action.CREATE, url, json_data={"name": name, "version": version}) + # SystemInfo / Stats APIs + + def get_systeminfo(self) -> dict: + """ + Retrieves the system information (bug report, environment, Fess and system properties). + """ + url = f"{self.base_url}/api/admin/systeminfo" + return self.send_request(Action.GET, url) + + def get_stats(self) -> dict: + """ + Retrieves the server statistics (JVM, OS, process, engine and filesystem). + """ + url = f"{self.base_url}/api/admin/stats" + return self.send_request(Action.GET, url) + + # Log APIs + + def get_log_files(self) -> dict: + """ + Retrieves the log files in the log directory of the Fess server. + """ + url = f"{self.base_url}/api/admin/log/files" + return self.send_request(Action.GET, url) + + def download_log_file(self, log_id: str, dest: Path) -> dict: + """ + Downloads a log file to ``dest``. ``log_id`` is the ``id`` returned by get_log_files. + """ + url = f"{self.base_url}/api/admin/log/file/{log_id}" + return self._download(url, dest) + + # Backup APIs + + def get_backup_files(self) -> dict: + """ + Retrieves the files that can be downloaded as a backup. + """ + url = f"{self.base_url}/api/admin/backup/files" + return self.send_request(Action.GET, url) + + def download_backup_file(self, backup_id: str, dest: Path) -> dict: + """ + Downloads a backup file to ``dest``. ``backup_id`` is the ``id`` returned by get_backup_files. + """ + # An id such as "fess_config.bulk" ends in an extension, and Fess answers such a path + # with a bare "404 Not Found." instead of running the action; the trailing slash avoids it. + url = f"{self.base_url}/api/admin/backup/file/{backup_id}/" + return self._download(url, dest) + + # Suggest APIs + + def get_suggest_info(self) -> dict: + """ + Retrieves the number of words in the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest" + return self.send_request(Action.GET, url) + + def delete_all_suggest_words(self) -> dict: + """ + Deletes every word from the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest/all" + return self.send_request(Action.DELETE, url) + + def delete_document_suggest_words(self) -> dict: + """ + Deletes the words that come from documents from the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest/document" + return self.send_request(Action.DELETE, url) + + def delete_query_suggest_words(self) -> dict: + """ + Deletes the words that come from search queries from the suggest index. + """ + url = f"{self.base_url}/api/admin/suggest/query" + return self.send_request(Action.DELETE, url) + # WebAuth APIs def create_webauth(self, config: dict) -> dict: diff --git a/src/fessctl/cli.py b/src/fessctl/cli.py index 9fbe1df..882bdfb 100644 --- a/src/fessctl/cli.py +++ b/src/fessctl/cli.py @@ -7,7 +7,7 @@ from fessctl.config.settings import Settings from fessctl.utils import format_result_markdown, output_error from fessctl.commands.accesstoken import accesstoken_app -# from fessctl.commands.backup import backup_app +from fessctl.commands.backup import backup_app from fessctl.commands.badword import badword_app from fessctl.commands.boostdoc import boostdoc_app from fessctl.commands.crawlinginfo import crawlinginfo_app @@ -23,7 +23,7 @@ from fessctl.commands.joblog import joblog_app from fessctl.commands.keymatch import keymatch_app from fessctl.commands.labeltype import labeltype_app -# from fessctl.commands.log import log_app +from fessctl.commands.log import log_app from fessctl.commands.pathmap import pathmap_app # from fessctl.commands.plugin import plugin_app from fessctl.commands.role import role_app @@ -32,12 +32,12 @@ from fessctl.commands.reqheader import reqheader_app # from fessctl.commands.searchlist import searchlist_app from fessctl.commands.scheduler import scheduler_app -# from fessctl.commands.stats import stats_app +from fessctl.commands.stats import stats_app # from fessctl.commands.storage import storage_app -# from fessctl.commands.suggest import suggest_app +from fessctl.commands.suggest import suggest_app from fessctl.commands.tagtype import tagtype_app from fessctl.commands.theme import theme_app -# from fessctl.commands.systeminfo import systeminfo_app +from fessctl.commands.systeminfo import systeminfo_app from fessctl.commands.user import user_app from fessctl.commands.webauth import webauth_app from fessctl.commands.webconfig import webconfig_app @@ -45,7 +45,7 @@ app = typer.Typer(no_args_is_help=True) app.add_typer(accesstoken_app, name="accesstoken") -# app.add_typer(backup_app, name="backup") +app.add_typer(backup_app, name="backup") app.add_typer(badword_app, name="badword") app.add_typer(boostdoc_app, name="boostdoc") app.add_typer(crawlinginfo_app, name="crawlinginfo") @@ -61,7 +61,7 @@ app.add_typer(joblog_app, name="joblog") app.add_typer(keymatch_app, name="keymatch") app.add_typer(labeltype_app, name="labeltype") -# app.add_typer(log_app, name="log") +app.add_typer(log_app, name="log") app.add_typer(pathmap_app, name="pathmap") # app.add_typer(plugin_app, name="plugin") app.add_typer(role_app, name="role") @@ -70,12 +70,12 @@ app.add_typer(reqheader_app, name="reqheader") # app.add_typer(searchlist_app, name="searchlist") app.add_typer(scheduler_app, name="scheduler") -# app.add_typer(stats_app, name="stats") +app.add_typer(stats_app, name="stats") # app.add_typer(storage_app, name="storage") -# app.add_typer(suggest_app, name="suggest") +app.add_typer(suggest_app, name="suggest") app.add_typer(tagtype_app, name="tagtype") app.add_typer(theme_app, name="theme") -# app.add_typer(systeminfo_app, name="systeminfo") +app.add_typer(systeminfo_app, name="systeminfo") app.add_typer(user_app, name="user") app.add_typer(webauth_app, name="webauth") app.add_typer(webconfig_app, name="webconfig") diff --git a/src/fessctl/commands/backup.py b/src/fessctl/commands/backup.py new file mode 100644 index 0000000..42e86de --- /dev/null +++ b/src/fessctl/commands/backup.py @@ -0,0 +1,92 @@ +import json +from pathlib import Path + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +backup_app = typer.Typer() + + +@backup_app.command("list") +def list_backup_files( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the files that can be downloaded as a backup (index dumps, search logs, settings). + """ + client = FessAPIClient(Settings()) + try: + result = client.get_backup_files() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + files = response.get("files", []) + if not files: + typer.echo("No Backup files found.") + else: + typer.echo(format_list_markdown("Backup files", files, [ + ("NAME", "name"), + ("ID", "id"), + ])) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list Backup files. {message} Status code: {status}", "Backup", "list")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Backup", "list") + raise typer.Exit(code=1) + + +@backup_app.command("download") +def download_backup_file( + backup_id: str = typer.Argument(..., help="Backup file ID (the id shown by `backup list`)"), + dest: Path = typer.Option(..., "--dest", "-d", help="File to write the backup file to"), + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite the destination file if it exists" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Download a backup file by ID. + """ + client = FessAPIClient(Settings()) + try: + if dest.exists() and not force: + raise FileExistsError(f"'{dest}' already exists. Use --force to overwrite it.") + + result = client.download_backup_file(backup_id, dest) + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_result_markdown(True, f"Backup file '{backup_id}' downloaded to '{dest}' ({response.get('size')} bytes).", "Backup", "download", backup_id)) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to download Backup file. {message} Status code: {status}", "Backup", "download")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Backup", "download") + raise typer.Exit(code=1) diff --git a/src/fessctl/commands/log.py b/src/fessctl/commands/log.py new file mode 100644 index 0000000..a98c642 --- /dev/null +++ b/src/fessctl/commands/log.py @@ -0,0 +1,101 @@ +import json +from pathlib import Path + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +log_app = typer.Typer() + + +@log_app.command("list") +def list_log_files( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + List the log files in the log directory of the Fess server. + """ + client = FessAPIClient(Settings()) + try: + result = client.get_log_files() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + files = response.get("files", []) + if not files: + typer.echo("No Log files found.") + else: + typer.echo(format_list_markdown("Log files", files, [ + ("NAME", "name"), + ("ID", "id"), + ("LAST MODIFIED", "lastModified"), + ])) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to list Log files. {message} Status code: {status}", "Log", "list")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Log", "list") + raise typer.Exit(code=1) + + +@log_app.command("download") +def download_log_file( + log_id: str = typer.Argument(..., help="Log file ID (the id shown by `log list`)"), + dest: Path = typer.Option(..., "--dest", "-d", help="File to write the log file to"), + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite the destination file if it exists" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Download a log file by ID. + """ + client = FessAPIClient(Settings()) + try: + if dest.exists() and not force: + raise FileExistsError(f"'{dest}' already exists. Use --force to overwrite it.") + + # Fess answers an id that names no log file with an empty download, which cannot be + # told from an empty log file, so check the id against the list first. + result = client.get_log_files() + if result.get("response", {}).get("status", 1) == 0: + ids = [f.get("id") for f in result["response"].get("files", [])] + if log_id in ids: + result = client.download_log_file(log_id, dest) + else: + result = {"response": {"status": 1, "message": f"Log file '{log_id}' not found. See `log list` for the IDs."}} + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_result_markdown(True, f"Log file '{log_id}' downloaded to '{dest}' ({response.get('size')} bytes).", "Log", "download", log_id)) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to download Log file. {message} Status code: {status}", "Log", "download")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Log", "download") + raise typer.Exit(code=1) diff --git a/src/fessctl/commands/stats.py b/src/fessctl/commands/stats.py new file mode 100644 index 0000000..055c614 --- /dev/null +++ b/src/fessctl/commands/stats.py @@ -0,0 +1,67 @@ +import json + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +stats_app = typer.Typer() + + +def flatten(value, prefix: str = "") -> list[dict]: + """ + Flatten nested stats into NAME/VALUE rows such as ``jvm.memory.heap.used``. + List items are named by their ``key`` or ``path`` (``jvm.gc[young].count``), else by position. + """ + if isinstance(value, dict): + return [row for k, v in value.items() for row in flatten(v, f"{prefix}.{k}" if prefix else k)] + if isinstance(value, list): + rows = [] + for i, item in enumerate(value): + if isinstance(item, dict): + id_field = next((f for f in ("key", "path") if item.get(f)), None) + if id_field: + rest = {k: v for k, v in item.items() if k != id_field} + rows.extend(flatten(rest, f"{prefix}[{item[id_field]}]")) + continue + rows.extend(flatten(item, f"{prefix}[{i}]")) + return rows + return [{"name": prefix, "value": value}] + + +@stats_app.callback(invoke_without_command=True) +def show_stats( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the statistics of the Fess server: JVM, OS, process, search engine and filesystem. + """ + client = FessAPIClient(Settings()) + try: + result = client.get_stats() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_list_markdown("Stats", flatten(response.get("stats", {})), [ + ("NAME", "name"), + ("VALUE", "value"), + ])) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to retrieve the stats. {message} Status code: {status}", "Stats", "show")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Stats", "show") + raise typer.Exit(code=1) diff --git a/src/fessctl/commands/suggest.py b/src/fessctl/commands/suggest.py new file mode 100644 index 0000000..795826b --- /dev/null +++ b/src/fessctl/commands/suggest.py @@ -0,0 +1,134 @@ +import json + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_detail_markdown, format_result_markdown, output_error + +suggest_app = typer.Typer() + + +def delete_suggest_words(call, action: str, prompt: str, success: str, yes: bool, output: str): + """ + Delete suggest words after confirmation; ``call`` runs the delete on the client. + """ + if not yes: + typer.confirm(prompt, abort=True) + + client = FessAPIClient(Settings()) + try: + result = call(client) + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_result_markdown(True, success, "Suggest", action)) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to delete the suggest words. {message} Status code: {status}", "Suggest", action)) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Suggest", action) + raise typer.Exit(code=1) + + +@suggest_app.command("info") +def suggest_info( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the number of words in the suggest index (all, from documents, from search queries). + """ + client = FessAPIClient(Settings()) + try: + result = client.get_suggest_info() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo(format_detail_markdown( + "Suggest", + response.get("setting", {}), + [ + ("total_words_num", "total_words_num"), + ("document_words_num", "document_words_num"), + ("query_words_num", "query_words_num"), + ], + )) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to retrieve the suggest info. {message} Status code: {status}", "Suggest", "info")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "Suggest", "info") + raise typer.Exit(code=1) + + +@suggest_app.command("delete-all") +def delete_all_suggest_words( + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete every word from the suggest index. Elevate words are restored by Fess afterwards; + the other words come back the next time the suggest job runs. + """ + delete_suggest_words( + lambda c: c.delete_all_suggest_words(), "delete-all", "Delete all suggest words?", + "All suggest words deleted successfully.", yes, output) + + +@suggest_app.command("delete-document") +def delete_document_suggest_words( + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete the suggest words that come from documents. + """ + delete_suggest_words( + lambda c: c.delete_document_suggest_words(), "delete-document", "Delete the suggest words from documents?", + "Suggest words from documents deleted successfully.", yes, output) + + +@suggest_app.command("delete-query") +def delete_query_suggest_words( + yes: bool = typer.Option( + False, "--yes", "-y", help="Skip the confirmation prompt" + ), + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Delete the suggest words that come from search queries. + """ + delete_suggest_words( + lambda c: c.delete_query_suggest_words(), "delete-query", "Delete the suggest words from search queries?", + "Suggest words from search queries deleted successfully.", yes, output) diff --git a/src/fessctl/commands/systeminfo.py b/src/fessctl/commands/systeminfo.py new file mode 100644 index 0000000..b352e5e --- /dev/null +++ b/src/fessctl/commands/systeminfo.py @@ -0,0 +1,58 @@ +import json + +import typer +import yaml + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings +from fessctl.utils import format_list_markdown, format_result_markdown, output_error + +systeminfo_app = typer.Typer() + +# (title, key in the response) of the property groups, in display order +SECTIONS = [ + ("Bug Report", "bug_report_props"), + ("Environment", "env_props"), + ("Fess", "fess_props"), + ("System", "system_props"), +] + + +@systeminfo_app.callback(invoke_without_command=True) +def show_systeminfo( + output: str = typer.Option( + "text", "--output", "-o", help="Output format (text, json, yaml)" + ), +): + """ + Show the system information of the Fess server: the bug report, environment, + Fess and system properties. + """ + client = FessAPIClient(Settings()) + try: + result = client.get_systeminfo() + response = result.get("response", {}) + status = response.get("status", 1) + + if output == "json": + typer.echo(json.dumps(result, indent=2)) + elif output == "yaml": + typer.echo(yaml.dump(result)) + elif status == 0: + typer.echo("\n\n".join( + format_list_markdown(title, response.get(key, []), [ + ("NAME", "label"), + ("VALUE", "value"), + ]) + for title, key in SECTIONS + )) + else: + message: str = response.get("message", "") + typer.echo(format_result_markdown(False, f"Failed to retrieve the system information. {message} Status code: {status}", "SystemInfo", "show")) + if status != 0: + raise typer.Exit(code=status) + except typer.Exit: + raise + except Exception as e: + output_error(output, e, "SystemInfo", "show") + raise typer.Exit(code=1) diff --git a/tests/commands/test_backup.py b/tests/commands/test_backup.py new file mode 100644 index 0000000..67cbbce --- /dev/null +++ b/tests/commands/test_backup.py @@ -0,0 +1,61 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.backup import backup_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_backup_list(runner, fess_service): + result = runner.invoke(backup_app, ["list", "--output", "json"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + ids = [f["id"] for f in resp.get("files", [])] + for expected in ("fess_config.bulk", "system.properties", "search_log.ndjson"): + assert expected in ids + + result = runner.invoke(backup_app, ["list"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + assert "| fess_config.bulk | fess_config.bulk |" in result.stdout + + +@pytest.mark.parametrize("backup_id", ["fess_config.bulk", "system.properties", "search_log.ndjson"]) +def test_backup_download(runner, fess_service, tmp_path, backup_id): + """ + An index dump, the system properties and a search log, all ids with an extension. + The search log may be empty, so only the transfer is checked. + """ + dest = tmp_path / backup_id + + result = runner.invoke(backup_app, ["download", backup_id, "--dest", str(dest), "--output", "json"]) + assert result.exit_code == 0, f"Download failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + assert dest.is_file() + assert resp["size"] == dest.stat().st_size + if backup_id == "fess_config.bulk": + assert '"_index":"fess_config' in dest.read_text() + + # An existing destination is not overwritten unless --force is given. + result = runner.invoke(backup_app, ["download", backup_id, "--dest", str(dest)]) + assert result.exit_code != 0 + assert "--force" in result.stdout + result = runner.invoke(backup_app, ["download", backup_id, "--dest", str(dest), "--force"]) + assert result.exit_code == 0, f"Forced download failed: {result.stdout}" + + +def test_backup_download_unknown_id(runner, fess_service, tmp_path): + dest = tmp_path / "no-such-backup" + result = runner.invoke(backup_app, ["download", "no-such-backup", "--dest", str(dest)]) + assert result.exit_code != 0 + assert "failed to download backup file" in result.stdout.lower() + assert not dest.exists() diff --git a/tests/commands/test_log.py b/tests/commands/test_log.py new file mode 100644 index 0000000..1deb1e7 --- /dev/null +++ b/tests/commands/test_log.py @@ -0,0 +1,67 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.log import log_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _fess_log(runner): + """ + The `fess.log` entry of the log list. Fess logs to it from startup, so it always exists. + """ + result = runner.invoke(log_app, ["list", "--output", "json"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + files = resp.get("files", []) + assert all({"name", "id", "lastModified"} <= f.keys() for f in files) + matches = [f for f in files if f["name"] == "fess.log"] + assert matches, f"fess.log is not listed: {[f['name'] for f in files]}" + return matches[0] + + +def test_log_list_text(runner, fess_service): + result = runner.invoke(log_app, ["list"]) + assert result.exit_code == 0, f"List failed: {result.stdout}" + assert "| fess.log |" in result.stdout + + +def test_log_download(runner, fess_service, tmp_path): + entry = _fess_log(runner) + dest = tmp_path / "fess.log" + + result = runner.invoke(log_app, ["download", entry["id"], "--dest", str(dest), "--output", "json"]) + assert result.exit_code == 0, f"Download failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + assert dest.is_file() + assert resp["size"] == dest.stat().st_size + + # An existing destination is not overwritten unless --force is given. + dest.write_text("keep") + result = runner.invoke(log_app, ["download", entry["id"], "--dest", str(dest)]) + assert result.exit_code != 0 + assert "--force" in result.stdout + assert dest.read_text() == "keep" + + result = runner.invoke(log_app, ["download", entry["id"], "--dest", str(dest), "--force"]) + assert result.exit_code == 0, f"Forced download failed: {result.stdout}" + assert dest.read_text() != "keep" + + +def test_log_download_unknown_id(runner, fess_service, tmp_path): + dest = tmp_path / "no-such.log" + # base64 of "no-such.log" + result = runner.invoke(log_app, ["download", "bm8tc3VjaC5sb2c=", "--dest", str(dest)]) + assert result.exit_code != 0 + assert "not found" in result.stdout.lower() + assert not dest.exists() diff --git a/tests/commands/test_stats.py b/tests/commands/test_stats.py new file mode 100644 index 0000000..8e6953b --- /dev/null +++ b/tests/commands/test_stats.py @@ -0,0 +1,33 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.stats import stats_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_stats_json(runner, fess_service): + result = runner.invoke(stats_app, ["--output", "json"]) + assert result.exit_code == 0, f"Stats failed: {result.stdout}" + response = json.loads(result.stdout).get("response", {}) + assert response.get("status") == 0 + stats = response.get("stats", {}) + for key in ("jvm", "os", "process", "engine", "fs"): + assert key in stats, f"{key} is missing" + assert stats["jvm"]["memory"]["heap"]["max"] > 0 + assert stats["engine"]["status"] in ("green", "yellow") + + +def test_stats_text(runner, fess_service): + result = runner.invoke(stats_app, []) + assert result.exit_code == 0, f"Stats failed: {result.stdout}" + assert "| jvm.memory.heap.used |" in result.stdout + assert "| engine.cluster_name |" in result.stdout diff --git a/tests/commands/test_suggest.py b/tests/commands/test_suggest.py new file mode 100644 index 0000000..ec41a2e --- /dev/null +++ b/tests/commands/test_suggest.py @@ -0,0 +1,58 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.suggest import suggest_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def _word_counts(runner): + result = runner.invoke(suggest_app, ["info", "--output", "json"]) + assert result.exit_code == 0, f"Info failed: {result.stdout}" + resp = json.loads(result.stdout).get("response", {}) + assert resp.get("status") == 0 + setting = resp["setting"] + assert all(isinstance(setting[k], int) for k in ("total_words_num", "document_words_num", "query_words_num")) + return setting + + +def test_suggest_info_text(runner, fess_service): + result = runner.invoke(suggest_app, ["info"]) + assert result.exit_code == 0, f"Info failed: {result.stdout}" + assert "| total_words_num |" in result.stdout + + +@pytest.mark.parametrize("command", ["delete-query", "delete-document", "delete-all"]) +def test_suggest_delete_asks_for_confirmation(runner, fess_service, command): + result = runner.invoke(suggest_app, [command], input="n\n") + assert result.exit_code != 0 + assert "[y/N]" in result.stdout + + +def test_suggest_delete(runner, fess_service): + """ + The suggest index holds nothing the other tests rely on, so every kind can be deleted; + the counts of a deleted kind are 0 afterwards (the suggest job may refill the others). + """ + result = runner.invoke(suggest_app, ["delete-query", "--yes", "--output", "json"]) + assert result.exit_code == 0, f"Delete query failed: {result.stdout}" + assert json.loads(result.stdout).get("response", {}).get("status") == 0 + assert _word_counts(runner)["query_words_num"] == 0 + + result = runner.invoke(suggest_app, ["delete-document", "--yes"]) + assert result.exit_code == 0, f"Delete document failed: {result.stdout}" + assert "deleted successfully" in result.stdout + assert _word_counts(runner)["document_words_num"] == 0 + + result = runner.invoke(suggest_app, ["delete-all", "--yes"]) + assert result.exit_code == 0, f"Delete all failed: {result.stdout}" + assert "deleted successfully" in result.stdout + assert _word_counts(runner)["total_words_num"] == 0 diff --git a/tests/commands/test_systeminfo.py b/tests/commands/test_systeminfo.py new file mode 100644 index 0000000..e002848 --- /dev/null +++ b/tests/commands/test_systeminfo.py @@ -0,0 +1,34 @@ +import json + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.systeminfo import systeminfo_app + + +@pytest.fixture(scope="module") +def runner(): + """ + Provides a CliRunner instance for invoking commands. + """ + return CliRunner() + + +def test_systeminfo_json(runner, fess_service): + result = runner.invoke(systeminfo_app, ["--output", "json"]) + assert result.exit_code == 0, f"Systeminfo failed: {result.stdout}" + response = json.loads(result.stdout).get("response", {}) + assert response.get("status") == 0 + for key in ("bug_report_props", "env_props", "fess_props", "system_props"): + props = response.get(key) + assert isinstance(props, list) and props, f"{key} is empty" + assert all("label" in p and "value" in p for p in props) + assert "java.runtime.version" in [p["label"] for p in response["bug_report_props"]] + + +def test_systeminfo_text(runner, fess_service): + result = runner.invoke(systeminfo_app, []) + assert result.exit_code == 0, f"Systeminfo failed: {result.stdout}" + for title in ("## Bug Report", "## Environment", "## Fess", "## System"): + assert title in result.stdout + assert "| java.runtime.version |" in result.stdout diff --git a/tests/unit/test_backup.py b/tests/unit/test_backup.py new file mode 100644 index 0000000..7e0930f --- /dev/null +++ b/tests/unit/test_backup.py @@ -0,0 +1,87 @@ +""" +Unit tests for the backup command. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.backup import backup_app + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestBackupList: + @patch("fessctl.commands.backup.FessAPIClient") + def test_list_text_output(self, mock_client_class, runner): + mock_client_class.return_value.get_backup_files.return_value = {"response": {"status": 0, "total": 1, "files": [ + {"name": "fess_config.bulk", "id": "fess_config.bulk"}, + ]}} + + result = runner.invoke(backup_app, ["list"]) + + assert result.exit_code == 0 + assert "| fess_config.bulk | fess_config.bulk |" in result.stdout + + @patch("fessctl.commands.backup.FessAPIClient") + def test_list_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.get_backup_files.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(backup_app, ["list"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestBackupDownload: + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_success(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_backup_file.return_value = {"response": {"status": 0, "path": "x", "size": 0}} + mock_client_class.return_value = mock_client + dest = tmp_path / "search_log.ndjson" + + result = runner.invoke(backup_app, ["download", "search_log.ndjson", "--dest", str(dest)]) + + assert result.exit_code == 0 + assert "(0 bytes)" in result.stdout + mock_client.download_backup_file.assert_called_once_with("search_log.ndjson", dest) + + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_unknown_id_exits_with_status(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_backup_file.return_value = {"response": {"status": 1, "message": "Could not find any backup index."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(backup_app, ["download", "nope", "-d", str(tmp_path / "nope")]) + + assert result.exit_code == 1 + assert "Could not find any backup index." in result.stdout + + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_refuses_to_overwrite(self, mock_client_class, runner, tmp_path): + dest = tmp_path / "fess.json" + dest.write_text("keep") + + result = runner.invoke(backup_app, ["download", "fess.json", "--dest", str(dest)]) + + assert result.exit_code == 1 + assert "--force" in result.stdout + assert dest.read_text() == "keep" + mock_client_class.return_value.download_backup_file.assert_not_called() + + @patch("fessctl.commands.backup.FessAPIClient") + def test_download_force_overwrites(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.download_backup_file.return_value = {"response": {"status": 0, "path": "x", "size": 3}} + mock_client_class.return_value = mock_client + dest = tmp_path / "fess.json" + dest.write_text("old") + + result = runner.invoke(backup_app, ["download", "fess.json", "--dest", str(dest), "-f"]) + + assert result.exit_code == 0 + mock_client.download_backup_file.assert_called_once_with("fess.json", dest) diff --git a/tests/unit/test_client_downloads.py b/tests/unit/test_client_downloads.py new file mode 100644 index 0000000..a182476 --- /dev/null +++ b/tests/unit/test_client_downloads.py @@ -0,0 +1,118 @@ +""" +Unit tests for the file downloads (log and backup) of fessctl.api.client, run against a local HTTP server. +""" +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from unittest.mock import Mock + +import pytest + +from fessctl.api.client import FessAPIClient, FessAPIClientError +from fessctl.config.settings import Settings + +# path -> (status, content type, body) of what the fake Fess answers +ROUTES = { + "/api/admin/log/file/ZmVzcy5sb2c=": (200, "application/octet-stream", b"log line\n"), + "/api/admin/log/file/YmFk": (400, "application/json;charset=UTF-8", b'{"response":{"message":"Could not find bad.","status":1}}'), + # an id with an extension is only served with the trailing slash + "/api/admin/backup/file/fess_config.bulk/": (200, "application/octet-stream", b'{"index":{}}\n{"a":1}\n'), + "/api/admin/backup/file/search_log.ndjson/": (200, "application/x-ndjson", b'{"search-word":"x"}\n'), + "/api/admin/backup/file/nope/": (400, "application/json;charset=UTF-8", b'{"response":{"message":"Could not find any backup index.","status":1}}'), +} + + +class Handler(BaseHTTPRequestHandler): + requests = [] + + def do_GET(self): + Handler.requests.append((self.path, self.headers.get("Authorization"))) + status, content_type, body = ROUTES.get(self.path, (404, "text/plain;charset=UTF-8", b"Not Found.")) + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + +@pytest.fixture(scope="module") +def endpoint(): + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + yield f"http://127.0.0.1:{server.server_port}" + server.shutdown() + + +@pytest.fixture +def client(endpoint): + settings = Mock(spec=Settings) + settings.fess_endpoint = endpoint + settings.access_token = "test-token" + settings.fess_version = "15.9.0" + Handler.requests.clear() + return FessAPIClient(settings) + + +def test_download_log_file_writes_the_body(client, tmp_path): + dest = tmp_path / "fess.log" + + result = client.download_log_file("ZmVzcy5sb2c=", dest) + + assert result == {"response": {"status": 0, "path": str(dest), "size": 9}} + assert dest.read_bytes() == b"log line\n" + assert list(tmp_path.iterdir()) == [dest] + assert Handler.requests == [("/api/admin/log/file/ZmVzcy5sb2c=", "Bearer test-token")] + + +def test_download_backup_file_requests_the_id_with_a_trailing_slash(client, tmp_path): + dest = tmp_path / "fess_config.bulk" + + result = client.download_backup_file("fess_config.bulk", dest) + + assert result["response"]["status"] == 0 + assert dest.read_bytes() == b'{"index":{}}\n{"a":1}\n' + assert Handler.requests == [("/api/admin/backup/file/fess_config.bulk/", "Bearer test-token")] + + +def test_download_backup_ndjson_is_not_mistaken_for_a_json_error(client, tmp_path): + dest = tmp_path / "search_log.ndjson" + + result = client.download_backup_file("search_log.ndjson", dest) + + assert result["response"]["status"] == 0 + assert dest.read_bytes() == b'{"search-word":"x"}\n' + + +def test_download_json_error_is_returned_and_writes_no_file(client, tmp_path): + dest = tmp_path / "nope" + + result = client.download_backup_file("nope", dest) + + assert result == {"response": {"message": "Could not find any backup index.", "status": 1}} + assert list(tmp_path.iterdir()) == [] + + +def test_download_non_json_error_raises(client, tmp_path): + dest = tmp_path / "x.log" + + with pytest.raises(FessAPIClientError) as e: + client.download_log_file("eA==", dest) + + assert e.value.status_code == 404 + assert "Not Found." in str(e.value) + assert list(tmp_path.iterdir()) == [] + + +def test_download_unreachable_server_raises_and_writes_no_file(tmp_path): + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://127.0.0.1:1" + settings.access_token = "test-token" + settings.fess_version = "15.9.0" + + with pytest.raises(FessAPIClientError) as e: + FessAPIClient(settings).download_log_file("ZmVzcy5sb2c=", tmp_path / "x.log") + + assert e.value.status_code == -1 + assert list(tmp_path.iterdir()) == [] diff --git a/tests/unit/test_client_ops.py b/tests/unit/test_client_ops.py new file mode 100644 index 0000000..3eecc32 --- /dev/null +++ b/tests/unit/test_client_ops.py @@ -0,0 +1,49 @@ +""" +Unit tests for the systeminfo, stats, log, backup and suggest listing/deleting methods of fessctl.api.client. +""" +from unittest.mock import Mock, patch + +import pytest + +from fessctl.api.client import FessAPIClient +from fessctl.config.settings import Settings + + +@pytest.fixture +def client(): + settings = Mock(spec=Settings) + settings.fess_endpoint = "http://localhost:8080" + settings.access_token = "test-token" + settings.fess_version = "15.9.0" + return FessAPIClient(settings) + + +@pytest.mark.parametrize("method,path", [ + ("get_systeminfo", "/api/admin/systeminfo"), + ("get_stats", "/api/admin/stats"), + ("get_log_files", "/api/admin/log/files"), + ("get_backup_files", "/api/admin/backup/files"), + ("get_suggest_info", "/api/admin/suggest"), +]) +@patch("httpx.get") +def test_get_methods_request_the_path(mock_get, client, method, path): + mock_get.return_value.json.return_value = {"response": {"status": 0}} + + assert getattr(client, method)() == {"response": {"status": 0}} + + assert mock_get.call_args[0][0] == f"http://localhost:8080{path}" + assert mock_get.call_args[1]["headers"]["Authorization"] == "Bearer test-token" + + +@pytest.mark.parametrize("method,path", [ + ("delete_all_suggest_words", "/api/admin/suggest/all"), + ("delete_document_suggest_words", "/api/admin/suggest/document"), + ("delete_query_suggest_words", "/api/admin/suggest/query"), +]) +@patch("httpx.delete") +def test_suggest_delete_methods_request_the_path(mock_delete, client, method, path): + mock_delete.return_value.json.return_value = {"response": {"status": 0}} + + assert getattr(client, method)() == {"response": {"status": 0}} + + assert mock_delete.call_args[0][0] == f"http://localhost:8080{path}" diff --git a/tests/unit/test_log.py b/tests/unit/test_log.py new file mode 100644 index 0000000..3a466aa --- /dev/null +++ b/tests/unit/test_log.py @@ -0,0 +1,128 @@ +""" +Unit tests for the log command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.api.client import FessAPIClientError +from fessctl.commands.log import log_app + +FILES = {"response": {"status": 0, "total": 2, "files": [ + {"name": "fess.log", "id": "ZmVzcy5sb2c=", "lastModified": "2026-10-16T12:23:38"}, + {"name": "audit.log", "id": "YXVkaXQubG9n", "lastModified": "2026-10-16T12:23:49"}, +]}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestLogList: + @patch("fessctl.commands.log.FessAPIClient") + def test_list_text_output(self, mock_client_class, runner): + mock_client_class.return_value.get_log_files.return_value = FILES + + result = runner.invoke(log_app, ["list"]) + + assert result.exit_code == 0 + assert "| fess.log | ZmVzcy5sb2c= | 2026-10-16T12:23:38 |" in result.stdout + + @patch("fessctl.commands.log.FessAPIClient") + def test_list_empty(self, mock_client_class, runner): + mock_client_class.return_value.get_log_files.return_value = {"response": {"status": 0, "files": []}} + + result = runner.invoke(log_app, ["list"]) + + assert result.exit_code == 0 + assert "No Log files found." in result.stdout + + @patch("fessctl.commands.log.FessAPIClient") + def test_list_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.get_log_files.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(log_app, ["list"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +class TestLogDownload: + @patch("fessctl.commands.log.FessAPIClient") + def test_download_success(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client.download_log_file.return_value = {"response": {"status": 0, "path": "x", "size": 12}} + mock_client_class.return_value = mock_client + dest = tmp_path / "fess.log" + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "--dest", str(dest)]) + + assert result.exit_code == 0 + assert "downloaded" in result.stdout + assert "(12 bytes)" in result.stdout + mock_client.download_log_file.assert_called_once_with("ZmVzcy5sb2c=", dest) + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_unknown_id_is_not_requested(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client_class.return_value = mock_client + + result = runner.invoke(log_app, ["download", "bm9wZS5sb2c=", "--dest", str(tmp_path / "x.log")]) + + assert result.exit_code == 1 + assert "not found" in result.stdout + mock_client.download_log_file.assert_not_called() + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_refuses_to_overwrite(self, mock_client_class, runner, tmp_path): + dest = tmp_path / "fess.log" + dest.write_text("keep") + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "--dest", str(dest)]) + + assert result.exit_code == 1 + assert "--force" in result.stdout + assert dest.read_text() == "keep" + mock_client_class.return_value.download_log_file.assert_not_called() + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_force_overwrites(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client.download_log_file.return_value = {"response": {"status": 0, "path": "x", "size": 1}} + mock_client_class.return_value = mock_client + dest = tmp_path / "fess.log" + dest.write_text("old") + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "--dest", str(dest), "--force"]) + + assert result.exit_code == 0 + mock_client.download_log_file.assert_called_once_with("ZmVzcy5sb2c=", dest) + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_server_error_exits_with_status(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.return_value = FILES + mock_client.download_log_file.return_value = {"response": {"status": 1, "message": "Could not find x."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "-d", str(tmp_path / "x.log")]) + + assert result.exit_code == 1 + assert "Could not find x." in result.stdout + + @patch("fessctl.commands.log.FessAPIClient") + def test_download_client_error_json_output(self, mock_client_class, runner, tmp_path): + mock_client = Mock() + mock_client.get_log_files.side_effect = FessAPIClientError(-1, "Network error: refused") + mock_client_class.return_value = mock_client + + result = runner.invoke(log_app, ["download", "ZmVzcy5sb2c=", "-d", str(tmp_path / "x.log"), "-o", "json"]) + + assert result.exit_code == 1 + assert json.loads(result.stdout)["status"] == "error" diff --git a/tests/unit/test_stats.py b/tests/unit/test_stats.py new file mode 100644 index 0000000..8ef14e8 --- /dev/null +++ b/tests/unit/test_stats.py @@ -0,0 +1,70 @@ +""" +Unit tests for the stats command. +""" +import json +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.stats import flatten, stats_app + +RESPONSE = {"response": {"status": 0, "stats": { + "jvm": { + "memory": {"heap": {"used": 10, "max": 20}}, + "gc": [{"key": "young", "count": 3, "time": 7}], + }, + "os": {"load_averages": [0.5, 0.25]}, + "fs": [{"path": "/", "free": 1, "total": 2}], +}}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_flatten_names_rows_by_path_and_list_item_key(): + rows = flatten(RESPONSE["response"]["stats"]) + + assert {r["name"]: r["value"] for r in rows} == { + "jvm.memory.heap.used": 10, + "jvm.memory.heap.max": 20, + "jvm.gc[young].count": 3, + "jvm.gc[young].time": 7, + "os.load_averages[0]": 0.5, + "os.load_averages[1]": 0.25, + "fs[/].free": 1, + "fs[/].total": 2, + } + + +@patch("fessctl.commands.stats.FessAPIClient") +def test_text_output(mock_client_class, runner): + mock_client_class.return_value.get_stats.return_value = RESPONSE + + result = runner.invoke(stats_app, []) + + assert result.exit_code == 0 + assert "| jvm.memory.heap.used | 10 |" in result.stdout + assert "| fs[/].free | 1 |" in result.stdout + + +@patch("fessctl.commands.stats.FessAPIClient") +def test_json_output_is_the_response(mock_client_class, runner): + mock_client_class.return_value.get_stats.return_value = RESPONSE + + result = runner.invoke(stats_app, ["-o", "json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == RESPONSE + + +@patch("fessctl.commands.stats.FessAPIClient") +def test_failure_exits_with_status(mock_client_class, runner): + mock_client_class.return_value.get_stats.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(stats_app, []) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout diff --git a/tests/unit/test_suggest.py b/tests/unit/test_suggest.py new file mode 100644 index 0000000..db4e798 --- /dev/null +++ b/tests/unit/test_suggest.py @@ -0,0 +1,90 @@ +""" +Unit tests for the suggest command. +""" +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.commands.suggest import suggest_app + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestSuggestInfo: + @patch("fessctl.commands.suggest.FessAPIClient") + def test_info_text_output(self, mock_client_class, runner): + mock_client_class.return_value.get_suggest_info.return_value = {"response": {"status": 0, "setting": { + "total_words_num": 7, "document_words_num": 5, "query_words_num": 2}}} + + result = runner.invoke(suggest_app, ["info"]) + + assert result.exit_code == 0 + assert "| total_words_num | 7 |" in result.stdout + assert "| document_words_num | 5 |" in result.stdout + assert "| query_words_num | 2 |" in result.stdout + + @patch("fessctl.commands.suggest.FessAPIClient") + def test_info_failure_exits_with_status(self, mock_client_class, runner): + mock_client_class.return_value.get_suggest_info.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(suggest_app, ["info"]) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +DELETES = [ + ("delete-all", "delete_all_suggest_words", "All suggest words deleted"), + ("delete-document", "delete_document_suggest_words", "Suggest words from documents deleted"), + ("delete-query", "delete_query_suggest_words", "Suggest words from search queries deleted"), +] + + +class TestSuggestDelete: + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_aborts_without_confirmation(self, mock_client_class, command, method, message, runner): + result = runner.invoke(suggest_app, [command], input="n\n") + + assert result.exit_code != 0 + getattr(mock_client_class.return_value, method).assert_not_called() + + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_with_yes(self, mock_client_class, command, method, message, runner): + mock_client = Mock() + getattr(mock_client, method).return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(suggest_app, [command, "--yes"]) + + assert result.exit_code == 0 + assert message in result.stdout + getattr(mock_client, method).assert_called_once_with() + + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_confirmed_at_the_prompt(self, mock_client_class, command, method, message, runner): + mock_client = Mock() + getattr(mock_client, method).return_value = {"response": {"status": 0}} + mock_client_class.return_value = mock_client + + result = runner.invoke(suggest_app, [command], input="y\n") + + assert result.exit_code == 0 + getattr(mock_client, method).assert_called_once_with() + + @pytest.mark.parametrize("command,method,message", DELETES) + @patch("fessctl.commands.suggest.FessAPIClient") + def test_delete_failure_exits_with_status(self, mock_client_class, command, method, message, runner): + mock_client = Mock() + getattr(mock_client, method).return_value = {"response": {"status": 1, "message": "Failed to delete."}} + mock_client_class.return_value = mock_client + + result = runner.invoke(suggest_app, [command, "-y", "-o", "json"]) + + assert result.exit_code == 1 diff --git a/tests/unit/test_systeminfo.py b/tests/unit/test_systeminfo.py new file mode 100644 index 0000000..4ddf713 --- /dev/null +++ b/tests/unit/test_systeminfo.py @@ -0,0 +1,75 @@ +""" +Unit tests for the systeminfo command. +""" +import json +from unittest.mock import Mock, patch + +import pytest +from typer.testing import CliRunner + +from fessctl.api.client import FessAPIClientError +from fessctl.commands.systeminfo import systeminfo_app + +RESPONSE = {"response": { + "status": 0, + "bug_report_props": [{"label": "os.name", "value": "Linux"}], + "env_props": [{"label": "FESS_PORT", "value": "8080"}], + "fess_props": [{"label": "domain.title", "value": "Fess"}], + "system_props": [{"label": "java.specification.version", "value": "21"}], +}} + + +@pytest.fixture +def runner(): + return CliRunner() + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_text_output_lists_each_group(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = RESPONSE + + result = runner.invoke(systeminfo_app, []) + + assert result.exit_code == 0 + for line in ("## Bug Report", "| os.name | Linux |", "## Environment", "| FESS_PORT | 8080 |", + "## Fess", "| domain.title | Fess |", "## System", "| java.specification.version | 21 |"): + assert line in result.stdout + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_json_output_is_the_response(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = RESPONSE + + result = runner.invoke(systeminfo_app, ["--output", "json"]) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == RESPONSE + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_failure_exits_with_status(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(systeminfo_app, []) + + assert result.exit_code == 3 + assert "Unauthorized request." in result.stdout + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_failure_exits_non_zero_in_json_mode(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.return_value = {"response": {"status": 3, "message": "Unauthorized request."}} + + result = runner.invoke(systeminfo_app, ["-o", "json"]) + + assert result.exit_code == 3 + + +@patch("fessctl.commands.systeminfo.FessAPIClient") +def test_client_error_exits_1(mock_client_class, runner): + mock_client_class.return_value.get_systeminfo.side_effect = FessAPIClientError(-1, "Network error: refused") + + result = runner.invoke(systeminfo_app, []) + + assert result.exit_code == 1 + assert "Network error: refused" in result.stdout