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
40 changes: 40 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,27 @@ permissions:
contents: write

jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Run tests
run: pytest -q

build-windows:
needs: tests
runs-on: windows-latest

steps:
Expand Down Expand Up @@ -44,6 +64,26 @@ jobs:
--collect-submodules waitress `
app.py

# Optional Authenticode signing via Azure Trusted Signing.
# No-ops until the repo variable AZURE_SIGNING_ENABLED is set to "true"
# and the corresponding secrets/variables are configured. Signing here
# (before zipping/hashing) ensures the published checksums cover the
# signed binary, and removes the Windows SmartScreen warning on first run.
- name: Sign .exe (Azure Trusted Signing)
if: vars.AZURE_SIGNING_ENABLED == 'true'
uses: azure/trusted-signing-action@v0.5.9
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ vars.AZURE_SIGNING_PROFILE }}
files-folder: ${{ github.workspace }}\dist\FCInventoryTool
files-folder-filter: exe
files-folder-recurse: true
file-digest: SHA256

- name: Get version from tag
id: version
shell: bash
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: CI

# Run the test suite on every push and pull request so regressions are caught
# before a release tag is ever cut.

on:
push:
branches:
- "**"
tags-ignore:
- "v*.*.*"
pull_request:

jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Run tests
run: pytest -q
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.0] - 2026-06-12

### Added
- **Headless CLI mode**: `FCInventoryTool.exe collect --host <ip> --username <user> [--port 7443] [--out file.xlsx]`
runs a single collection and writes the Excel file without opening the web UI.
Ideal for scheduled snapshots via Windows Task Scheduler. Password is supplied
via `--password`, the `FC_INVENTORY_PASSWORD` environment variable, or an
interactive prompt (never echoed into command-line history when using env/prompt).
- **Update notifications**: the web UI checks GitHub for a newer release and shows
a non-intrusive "Version X is available" link in the footer. Still no
auto-update and no telemetry — it only reads the public releases API and can be
turned off entirely with `FC_INVENTORY_DISABLE_UPDATE_CHECK`.
- `/api/update-check` endpoint backing the notice (degrades silently when offline).
- `web` and `collect` subcommands plus `--version` on the executable.
- Test suite (`pytest`) covering version comparison, collector field mapping, and
the Excel builder; runs in CI on every push and pull request, and gates releases.
- `requirements-dev.txt` for the developer/test toolchain.

### Changed
- The release workflow now runs the test suite before building, and supports
**optional Authenticode code signing** through Azure Trusted Signing. Signing is
off by default and activates only when `AZURE_SIGNING_ENABLED` and the related
secrets are configured, so the build keeps working without it. Published SHA-256
checksums now cover the signed binary.

## [1.0.0] - 2026-04-10

First production-ready release.
Expand Down
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,31 @@ A web-based inventory collector for Huawei FusionCompute, similar to RVTools for

The Excel file is also saved next to `FCInventoryTool.exe` for safekeeping.

### Option C: Headless / command-line mode (automation)

Run a one-off collection without the web UI — useful for scheduled snapshots
(e.g. Windows Task Scheduler):

```cmd
FCInventoryTool.exe collect --host 192.168.1.100 --username admin --port 7443 --out C:\reports\fc.xlsx
```

The password is read from (in order): the `--password` flag, the
`FC_INVENTORY_PASSWORD` environment variable, or an interactive prompt. Prefer
the environment variable so the password never lands in command history or the
Task Scheduler argument list:

```cmd
set FC_INVENTORY_PASSWORD=yourpassword
FCInventoryTool.exe collect --host 192.168.1.100 --username admin
```

If `--out` is omitted the file is written to `FC_Inventory_<timestamp>.xlsx` in
the current directory. The full output path is printed on success, and the
process exits non-zero on failure so it plays nicely with scripts.

Running `FCInventoryTool.exe` with no arguments (or `web`) launches the UI as before.

### Option B: Run from source (developers)

```bash
Expand All @@ -88,6 +113,33 @@ build.bat

Output goes to `dist\FCInventoryTool\`. Distribute the entire folder (~80 MB).

### Running the tests

```bash
pip install -r requirements-dev.txt
pytest -q
```

The suite runs automatically in CI on every push and pull request, and gates
the release build.

### Code signing (optional)

The official release workflow can Authenticode-sign `FCInventoryTool.exe` via
[Azure Trusted Signing](https://learn.microsoft.com/azure/trusted-signing/),
which removes the Windows SmartScreen "unknown publisher" warning on first run.
Signing is **off by default** and stays off until configured, so forks and
local builds keep working unchanged.

To enable it on your fork, set the repository **variable**
`AZURE_SIGNING_ENABLED` to `true` and provide:

- Secrets: `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`
- Variables: `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_SIGNING_PROFILE`

Signing happens before the zip and checksums are produced, so the published
SHA-256 values cover the signed binary.

---

## Configuration
Expand All @@ -98,6 +150,8 @@ Environment variables (optional):
|---|---|---|
| `FC_INVENTORY_BIND` | `127.0.0.1` | Bind address. Set to `0.0.0.0` to expose on LAN (see Security below) |
| `FC_INVENTORY_PORT` | `5000` | Local web UI port |
| `FC_INVENTORY_PASSWORD` | _(unset)_ | Password for headless `collect` mode (avoids putting it on the command line) |
| `FC_INVENTORY_DISABLE_UPDATE_CHECK` | _(unset)_ | Set to any value to disable the "new version available" check in the web UI |

Example:
```cmd
Expand Down
142 changes: 138 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import os
import sys
import socket
import argparse
import getpass
import logging
import logging.handlers
import threading
Expand All @@ -16,8 +18,9 @@

from collector import InventoryCollector
from excel_builder import build_excel
from version_utils import GITHUB_REPO, get_latest_release, is_newer

__version__ = "1.0.0"
__version__ = "1.1.0"

# Configure logging - console + file
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s - %(message)s"
Expand Down Expand Up @@ -167,6 +170,33 @@ def version():
return jsonify({"version": __version__})


@app.route("/api/update-check")
def update_check():
"""
Check GitHub for a newer release. Returns whether an update is available
so the web UI can show a non-intrusive notice. Never auto-updates, never
sends any data about the user. Disable with FC_INVENTORY_DISABLE_UPDATE_CHECK.
"""
if os.environ.get("FC_INVENTORY_DISABLE_UPDATE_CHECK"):
return jsonify({"enabled": False, "current": __version__})

try:
latest = get_latest_release(GITHUB_REPO)
tag = latest["tag"]
available = bool(tag) and is_newer(tag, __version__)
return jsonify({
"enabled": True,
"current": __version__,
"latest": tag.lstrip("vV"),
"update_available": available,
"url": latest["url"],
})
except Exception as e:
# Offline / rate-limited / no releases yet: degrade silently.
logging.info(f"Update check skipped: {e}")
return jsonify({"enabled": True, "current": __version__, "error": str(e)})


@app.route("/changelog")
def changelog_page():
return render_template("changelog.html", version=__version__)
Expand Down Expand Up @@ -234,11 +264,12 @@ def _print_banner(host, port):
print(banner, flush=True)


def main():
def run_web(bind=None, port=None):
"""Launch the local web UI (default mode)."""
# Bind to localhost by default for security.
# Set FC_INVENTORY_BIND=0.0.0.0 to expose on the LAN (use with caution).
host = os.environ.get("FC_INVENTORY_BIND", "127.0.0.1")
port = int(os.environ.get("FC_INVENTORY_PORT", "5000"))
host = bind or os.environ.get("FC_INVENTORY_BIND", "127.0.0.1")
port = port or int(os.environ.get("FC_INVENTORY_PORT", "5000"))

_print_banner(host, port)

Expand All @@ -259,5 +290,108 @@ def main():
app.run(host=host, port=port, debug=False, use_reloader=False)


def run_headless(args):
"""
Run a single collection without the web UI and write an Excel file, then
exit. Intended for automation (e.g. Windows Task Scheduler). Returns a
process exit code.
"""
password = args.password or os.environ.get("FC_INVENTORY_PASSWORD")
if not password:
try:
password = getpass.getpass("FusionCompute password: ")
except Exception:
password = ""
if not password:
print(
"ERROR: password required. Pass --password, set the "
"FC_INVENTORY_PASSWORD environment variable, or run interactively.",
file=sys.stderr,
)
return 2

collector = InventoryCollector(args.host, args.username, password, port=args.port)
logging.info(
f"Headless collection from {args.host}:{args.port} as {args.username}"
)

try:
data = collector.collect_all()
except KeyboardInterrupt:
logging.warning("Interrupted by user")
return 130
except Exception as e:
logging.error(f"Collection failed: {e}")
return 1

out = args.out
if not out:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
out = os.path.join(os.getcwd(), f"FC_Inventory_{timestamp}.xlsx")
out = os.path.abspath(out)

out_dir = os.path.dirname(out)
if out_dir and not os.path.isdir(out_dir):
os.makedirs(out_dir, exist_ok=True)

build_excel(data, out)
logging.info(f"Excel saved to: {out}")
# Print the path on its own line so scripts can capture it.
print(out)
return 0


def _build_arg_parser():
parser = argparse.ArgumentParser(
prog="FCInventoryTool",
description="FusionCompute inventory collector (RVTools-style).",
)
parser.add_argument(
"--version", action="version",
version=f"FC Inventory Tool {__version__}",
)
sub = parser.add_subparsers(dest="command")

web_p = sub.add_parser("web", help="Launch the web UI (this is the default).")
web_p.add_argument(
"--host", default=None,
help="Bind address for the web UI (overrides FC_INVENTORY_BIND).",
)
web_p.add_argument(
"--port", type=int, default=None,
help="Web UI port (overrides FC_INVENTORY_PORT, default 5000).",
)

col_p = sub.add_parser(
"collect", help="Run one headless collection to an .xlsx file and exit.",
)
col_p.add_argument("--host", required=True, help="FusionCompute VRM host or IP.")
col_p.add_argument("--username", required=True, help="Login username.")
col_p.add_argument(
"--password", default=None,
help="Login password. Omit to use FC_INVENTORY_PASSWORD or be prompted.",
)
col_p.add_argument(
"--port", type=int, default=7443,
help="FusionCompute API port (default 7443).",
)
col_p.add_argument(
"--out", default=None,
help="Output .xlsx path (default: ./FC_Inventory_<timestamp>.xlsx).",
)
return parser


def main():
parser = _build_arg_parser()
args = parser.parse_args()

if args.command == "collect":
sys.exit(run_headless(args))

# Default (no subcommand) or explicit "web": launch the UI.
run_web(getattr(args, "host", None), getattr(args, "port", None))


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pyinstaller --noconfirm --onedir --name FCInventoryTool ^
--add-data "static;static" ^
--add-data "CHANGELOG.md;." ^
--add-data "LICENSE;." ^
--add-data "README.md;." ^
--hidden-import waitress ^
--collect-submodules waitress ^
app.py
Expand Down
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8,<9
Loading
Loading