diff --git a/CHANGELOG.md b/CHANGELOG.md index bff76cd..b9556b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2.0.1] - 2026-03-05 + +### Added +- `hammerdb-scale init --interactive` (`-i`) guided configuration wizard with Rich UI + - 6-step flow: Deployment, Database & Benchmark, Targets, Credentials, Benchmark Parameters, Infrastructure + - Numbered selection menus for database type and benchmark + - Optional advanced options (VUs, rampup, duration, pod resources) + - Configuration summary table with confirmation before writing + - Input validation and Ctrl+C handling + +### Fixed +- README.md documentation links broken on PyPI — relative paths like `docs/CONFIGURATION.md` resolved against `pypi.org` instead of GitHub. Converted all links to absolute GitHub URLs. + ## [2.0.0] - 2026-03-01 ### Added diff --git a/README.md b/README.md index 4784061..a5fe172 100644 --- a/README.md +++ b/README.md @@ -19,20 +19,22 @@ HammerDB-Scale runs **synchronized database performance tests across multiple da HammerDB-Scale is a CLI orchestrator that sits on your workstation and drives benchmarks through Kubernetes: ``` - ┌──────────────────────────────────────┐ -hammerdb-scale CLI │ Kubernetes Cluster │ - (your machine) │ │ - │ │ ┌─────────┐ ┌──────────────┐ │ - │ helm install │ │ HammerDB│────▶│ Database 1 │ │ - ├────────────────▶│ │ Job 1 │ └──────────────┘ │ - │ │ ├─────────┤ ┌──────────────┐ │ - │ kubectl logs │ │ HammerDB│────▶│ Database 2 │ │ - ├────────────────▶│ │ Job 2 │ └──────────────┘ │ - │ │ ├─────────┤ ┌──────────────┐ │ - │ results/report │ │ HammerDB│────▶│ Database N │ │ - │ │ │ Job N │ └──────────────┘ │ - │ │ └─────────┘ │ - │ └──────────────────────────────────────┘ + +--------------------------------------+ + | Kubernetes Cluster | +hammerdb-scale CLI | | + (your machine) | +-----------+ +--------------+ | + | | | HammerDB |--->| Database 1 | | + | helm install | | Job 1 | +--------------+ | + |---------------->| +-----------+ | + | | +-----------+ +--------------+ | + | kubectl logs | | HammerDB |--->| Database 2 | | + |---------------->| | Job 2 | +--------------+ | + | | +-----------+ | + | results/report | +-----------+ +--------------+ | + | | | HammerDB |--->| Database N | | + | | | Job N | +--------------+ | + | | +-----------+ | + | +--------------------------------------+ ``` 1. You define your database targets and benchmark parameters in a YAML config file @@ -95,12 +97,12 @@ init → validate → run --build → results → report ## Documentation -- [Configuration Reference](docs/CONFIGURATION.md) — YAML schema, target defaults, examples -- [Usage Guide](docs/USAGE-GUIDE.md) — Command reference, results interpretation, troubleshooting -- [Container Images](docs/CONTAINER-IMAGES.md) — Pre-built images, building your own, architecture -- [Migration Guide (v1 to v2)](docs/MIGRATION.md) — Upgrading from shell-script version -- [Security](docs/SECURITY.md) — Credential handling and network considerations -- [Changelog](CHANGELOG.md) +- [Configuration Reference](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md) — YAML schema, target defaults, examples +- [Usage Guide](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md) — Command reference, results interpretation, troubleshooting +- [Container Images](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONTAINER-IMAGES.md) — Pre-built images, building your own, architecture +- [Migration Guide (v1 to v2)](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/MIGRATION.md) — Upgrading from shell-script version +- [Security](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/SECURITY.md) — Credential handling and network considerations +- [Changelog](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/CHANGELOG.md) ## Requirements @@ -116,7 +118,7 @@ init → validate → run --build → results → report ## Configuration -See the [Configuration Reference](docs/CONFIGURATION.md) for the full schema. Minimal example: +See the [Configuration Reference](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md) for the full schema. Minimal example: ```yaml name: my-benchmark @@ -143,7 +145,7 @@ hammerdb: duration: 5 ``` -Complete examples for all database/benchmark combinations are in the [examples/](examples/) directory. +Complete examples for all database/benchmark combinations are in the [examples/](https://github.com/PureStorage-OpenConnect/hammerdb-scale/tree/main/examples) directory. ## Contributing @@ -151,4 +153,4 @@ Contributions are welcome! Please [open an issue](https://github.com/PureStorage ## License -[Apache 2.0](LICENSE) +[Apache 2.0](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/LICENSE) diff --git a/docs/USAGE-GUIDE.md b/docs/USAGE-GUIDE.md index 4e67f4b..2981cba 100644 --- a/docs/USAGE-GUIDE.md +++ b/docs/USAGE-GUIDE.md @@ -30,7 +30,7 @@ pip install -e ".[dev]" ## Quick Start ```bash -# 1. Generate a config file +# 1. Generate a config file (or use -i for a guided wizard) hammerdb-scale init # 2. Validate configuration and connectivity @@ -60,8 +60,32 @@ Interactive config generator. Prompts for deployment name, database type, benchm hammerdb-scale init # Default: hammerdb-scale.yaml hammerdb-scale init -o my-config.yaml # Custom output path hammerdb-scale init --force # Overwrite existing file +hammerdb-scale init -i # Guided wizard mode ``` +#### Wizard Mode (`--interactive` / `-i`) + +The `--interactive` flag launches a step-by-step guided wizard with a polished terminal UI: + +```bash +hammerdb-scale init -i +``` + +The wizard walks through 6 steps: + +1. **Deployment** — Name your benchmark +2. **Database & Benchmark** — Select Oracle or SQL Server, TPC-C or TPC-H +3. **Database Targets** — Enter hostnames/IPs (with auto-generated names like `db-01`) +4. **Credentials** — Database username and password (Oracle: service name, schema password) +5. **Benchmark Parameters** — Warehouses (TPC-C) or scale factor (TPC-H) +6. **Infrastructure** — Kubernetes namespace, Pure Storage metrics + +After the core steps, an optional **Advanced Options** prompt lets you configure virtual users, rampup/duration, and pod resources. If declined, sensible defaults are used. + +A **Configuration Summary** table is shown before writing, giving you a chance to review all values (passwords masked) and confirm or cancel. + +Both `init` and `init -i` produce identical YAML output — the wizard is purely a UX enhancement for the input experience. + ### `hammerdb-scale validate` Validates configuration through 6 layers: diff --git a/pyproject.toml b/pyproject.toml index 58c0142..6921073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hammerdb-scale" -version = "2.0.0" +version = "2.0.1" description = "CLI for orchestrating parallel HammerDB database benchmarks at scale on Kubernetes" readme = {file = "README.md", content-type = "text/markdown"} license = "Apache-2.0" diff --git a/src/hammerdb_scale/__init__.py b/src/hammerdb_scale/__init__.py index 8c0d5d5..159d48b 100644 --- a/src/hammerdb_scale/__init__.py +++ b/src/hammerdb_scale/__init__.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "2.0.1" diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index 13a93d3..4d24764 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -100,94 +100,32 @@ def version() -> None: ) -@app.command() -def init( - output: Path = typer.Option( - Path("./hammerdb-scale.yaml"), "-o", "--output", help="Output file path." - ), - force: bool = typer.Option(False, "--force", help="Overwrite if file exists."), -) -> None: - """Generate a starter config file through interactive prompts.""" - if output.exists() and not force: - overwrite = typer.confirm(f"File {output} exists. Overwrite?") - if not overwrite: - raise typer.Abort() - - # Deployment name - name = typer.prompt("Deployment name") - - # Database type - db_type_str = typer.prompt("Database type (oracle/mssql)") - while db_type_str not in ("oracle", "mssql"): - console.print("[red]Must be 'oracle' or 'mssql'[/red]") - db_type_str = typer.prompt("Database type (oracle/mssql)") - - # Benchmark - benchmark_str = typer.prompt("Benchmark (tprocc/tproch)") - while benchmark_str not in ("tprocc", "tproch"): - console.print("[red]Must be 'tprocc' or 'tproch'[/red]") - benchmark_str = typer.prompt("Benchmark (tprocc/tproch)") - - # Targets - num_targets = typer.prompt("Number of database targets", type=int) - hosts: list[dict] = [] - for i in range(num_targets): - console.print(f"\nTarget {i + 1}:") - t_name = typer.prompt(" Name (short identifier)") - t_host = typer.prompt(" Hostname or IP") - hosts.append({"name": t_name, "host": t_host}) - - # Credentials - default_user = "system" if db_type_str == "oracle" else "sa" - username = typer.prompt("\nDatabase username", default=default_user) - password = typer.prompt("Database password", hide_input=True) - - # Oracle-specific prompts - oracle_config = None - if db_type_str == "oracle": - service = typer.prompt("\nOracle service name", default="ORCLPDB") - oracle_schema_password = typer.prompt( - "TPC-C schema password", - default=password, - hide_input=True, - ) - oracle_config = { - "service": service, - "port": 1521, - "tablespace": "TPCC", - "temp_tablespace": "TEMP", - "tprocc": {"user": "TPCC", "password": oracle_schema_password}, - "tproch": {"user": "tpch", "password": oracle_schema_password}, - } - - # Warehouses (for TPC-C) - warehouses = 100 - if benchmark_str == "tprocc": - warehouses = typer.prompt("\nTPC-C warehouses", default=100, type=int) - - # Namespace - namespace = typer.prompt("\nKubernetes namespace", default="hammerdb") - - # Pure Storage - storage_metrics = None - enable_pure = typer.confirm("\nEnable Pure Storage metrics?", default=False) - if enable_pure: - pure_host = typer.prompt(" FlashArray host/IP") - pure_token = typer.prompt(" API token", hide_input=True) - storage_metrics = { - "enabled": True, - "provider": "pure", - "pure": { - "host": pure_host, - "api_token": pure_token, - "volume": "", - "poll_interval": 5, - "verify_ssl": False, - "api_version": "2.4", - }, - } - - # Build commented YAML template +def _build_config_yaml( + *, + name: str, + db_type_str: str, + benchmark_str: str, + hosts: list[dict], + username: str, + password: str, + oracle_config: dict | None, + warehouses: int = 100, + namespace: str = "hammerdb", + storage_metrics: dict | None = None, + build_virtual_users: int = 4, + load_virtual_users: int = 4, + rampup: int = 5, + duration: int = 10, + scale_factor: int = 1, + build_threads: int = 4, + tproch_load_virtual_users: int = 1, + total_querysets: int = 1, + req_memory: str = "4Gi", + req_cpu: str = "4", + lim_memory: str = "8Gi", + lim_cpu: str = "8", +) -> str: + """Build the commented YAML config template string.""" image_repo = DEFAULT_IMAGES.get(db_type_str, "sillidata/hammerdb-scale") # Host entries @@ -231,11 +169,11 @@ def init( tprocc_comment = "" if benchmark_str == "tprocc" else "# " tprocc_section = f"""{tprocc_comment} tprocc: {tprocc_comment} warehouses: {warehouses} # data size: 100 = ~10GB, 1000 = ~100GB, 10000 = ~1TB per target -{tprocc_comment} build_virtual_users: 4 # parallel threads for schema creation -{tprocc_comment} load_virtual_users: 4 # concurrent users during benchmark (tune to match CPU cores) +{tprocc_comment} build_virtual_users: {build_virtual_users} # parallel threads for schema creation +{tprocc_comment} load_virtual_users: {load_virtual_users} # concurrent users during benchmark (tune to match CPU cores) {tprocc_comment} driver: timed # "timed" = run for fixed duration, "test" = single iteration -{tprocc_comment} rampup: 5 # warm-up period before measuring (minutes) -{tprocc_comment} duration: 10 # measurement window (minutes), 5-60 typical +{tprocc_comment} rampup: {rampup} # warm-up period before measuring (minutes) +{tprocc_comment} duration: {duration} # measurement window (minutes), 5-60 typical {tprocc_comment} total_iterations: 10000000 # max iterations (effectively unlimited with timed driver) {tprocc_comment} all_warehouses: true # distribute load across all warehouses {tprocc_comment} checkpoint: true # issue checkpoint before benchmark @@ -244,11 +182,11 @@ def init( # TPC-H section (always included, commented out if not selected) tproch_comment = "" if benchmark_str == "tproch" else "# " tproch_section = f"""{tproch_comment} tproch: -{tproch_comment} scale_factor: 1 # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB -{tproch_comment} build_threads: 4 # parallel threads for data generation +{tproch_comment} scale_factor: {scale_factor} # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB +{tproch_comment} build_threads: {build_threads} # parallel threads for data generation {tproch_comment} build_virtual_users: 1 # HammerDB orchestrator (keep at 1) -{tproch_comment} load_virtual_users: 1 # query concurrency: 1 = Power run, >1 = Throughput run -{tproch_comment} total_querysets: 1 # number of full 22-query runs""" +{tproch_comment} load_virtual_users: {tproch_load_virtual_users} # query concurrency: 1 = Power run, >1 = Throughput run +{tproch_comment} total_querysets: {total_querysets} # number of full 22-query runs""" # Storage metrics section if storage_metrics: @@ -285,7 +223,7 @@ def init( # verify_ssl: false""" # Assemble full config - config_yaml = f"""# ============================================================================ + return f"""# ============================================================================ # HammerDB-Scale Configuration # ============================================================================ # Docs: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md @@ -338,11 +276,11 @@ def init( # Each target gets one pod — scale these based on virtual user count. resources: requests: - memory: "4Gi" - cpu: "4" + memory: "{req_memory}" + cpu: "{req_cpu}" limits: - memory: "8Gi" - cpu: "8" + memory: "{lim_memory}" + cpu: "{lim_cpu}" # ============================================================================ # KUBERNETES SETTINGS @@ -353,6 +291,119 @@ def init( {storage_section} """ + +@app.command() +def init( + output: Path = typer.Option( + Path("./hammerdb-scale.yaml"), "-o", "--output", help="Output file path." + ), + force: bool = typer.Option(False, "--force", help="Overwrite if file exists."), + interactive: bool = typer.Option( + False, "-i", "--interactive", help="Launch guided configuration wizard." + ), +) -> None: + """Generate a starter config file through interactive prompts.""" + if output.exists() and not force: + overwrite = typer.confirm(f"File {output} exists. Overwrite?") + if not overwrite: + raise typer.Abort() + + if interactive: + from hammerdb_scale.wizard import run_wizard + + values = run_wizard() + if values is None: + raise typer.Abort() + + config_yaml = _build_config_yaml(**values) + else: + # Deployment name + name = typer.prompt("Deployment name") + + # Database type + db_type_str = typer.prompt("Database type (oracle/mssql)") + while db_type_str not in ("oracle", "mssql"): + console.print("[red]Must be 'oracle' or 'mssql'[/red]") + db_type_str = typer.prompt("Database type (oracle/mssql)") + + # Benchmark + benchmark_str = typer.prompt("Benchmark (tprocc/tproch)") + while benchmark_str not in ("tprocc", "tproch"): + console.print("[red]Must be 'tprocc' or 'tproch'[/red]") + benchmark_str = typer.prompt("Benchmark (tprocc/tproch)") + + # Targets + num_targets = typer.prompt("Number of database targets", type=int) + hosts: list[dict] = [] + for i in range(num_targets): + console.print(f"\nTarget {i + 1}:") + t_name = typer.prompt(" Name (short identifier)") + t_host = typer.prompt(" Hostname or IP") + hosts.append({"name": t_name, "host": t_host}) + + # Credentials + default_user = "system" if db_type_str == "oracle" else "sa" + username = typer.prompt("\nDatabase username", default=default_user) + password = typer.prompt("Database password", hide_input=True) + + # Oracle-specific prompts + oracle_config = None + if db_type_str == "oracle": + service = typer.prompt("\nOracle service name", default="ORCLPDB") + oracle_schema_password = typer.prompt( + "TPC-C schema password", + default=password, + hide_input=True, + ) + oracle_config = { + "service": service, + "port": 1521, + "tablespace": "TPCC", + "temp_tablespace": "TEMP", + "tprocc": {"user": "TPCC", "password": oracle_schema_password}, + "tproch": {"user": "tpch", "password": oracle_schema_password}, + } + + # Warehouses (for TPC-C) + warehouses = 100 + if benchmark_str == "tprocc": + warehouses = typer.prompt("\nTPC-C warehouses", default=100, type=int) + + # Namespace + namespace = typer.prompt("\nKubernetes namespace", default="hammerdb") + + # Pure Storage + storage_metrics = None + enable_pure = typer.confirm("\nEnable Pure Storage metrics?", default=False) + if enable_pure: + pure_host = typer.prompt(" FlashArray host/IP") + pure_token = typer.prompt(" API token", hide_input=True) + storage_metrics = { + "enabled": True, + "provider": "pure", + "pure": { + "host": pure_host, + "api_token": pure_token, + "volume": "", + "poll_interval": 5, + "verify_ssl": False, + "api_version": "2.4", + }, + } + + config_yaml = _build_config_yaml( + name=name, + db_type_str=db_type_str, + benchmark_str=benchmark_str, + hosts=hosts, + username=username, + password=password, + oracle_config=oracle_config, + warehouses=warehouses, + namespace=namespace, + storage_metrics=storage_metrics, + ) + # Write YAML with open(output, "w") as f: f.write(config_yaml) diff --git a/src/hammerdb_scale/constants.py b/src/hammerdb_scale/constants.py index bfb0f77..7849a33 100644 --- a/src/hammerdb_scale/constants.py +++ b/src/hammerdb_scale/constants.py @@ -2,7 +2,7 @@ from pathlib import Path -VERSION = "2.0.0" +VERSION = "2.0.1" DEFAULT_CONFIG_FILENAMES = ["hammerdb-scale.yaml", "hammerdb-scale.yml"] CONFIG_ENV_VAR = "HAMMERDB_SCALE_CONFIG" diff --git a/src/hammerdb_scale/output.py b/src/hammerdb_scale/output.py index d80083f..ac6414f 100644 --- a/src/hammerdb_scale/output.py +++ b/src/hammerdb_scale/output.py @@ -24,4 +24,4 @@ def print_info(msg: str) -> None: def print_banner(name: str, benchmark: str, target_count: int, detail: str) -> None: text = f"{name} | {benchmark} | {target_count} targets | {detail}" - console.print(Panel(text, title="HammerDB-Scale 2.0.0")) + console.print(Panel(text, title="HammerDB-Scale 2.0.1")) diff --git a/src/hammerdb_scale/reports/generator.py b/src/hammerdb_scale/reports/generator.py index 858c694..1f7205b 100644 --- a/src/hammerdb_scale/reports/generator.py +++ b/src/hammerdb_scale/reports/generator.py @@ -217,7 +217,7 @@ def _config_snapshot(summary: dict) -> str: def _footer_html(summary: dict) -> str: - version = summary.get("version", "2.0.0") + version = summary.get("version", "2.0.1") test_id = summary.get("test_id", "") ts = summary.get("timestamp", "") try: diff --git a/src/hammerdb_scale/wizard.py b/src/hammerdb_scale/wizard.py new file mode 100644 index 0000000..0f1bb23 --- /dev/null +++ b/src/hammerdb_scale/wizard.py @@ -0,0 +1,352 @@ +"""Interactive configuration wizard for hammerdb-scale init --interactive.""" + +from __future__ import annotations + +from rich.panel import Panel +from rich.prompt import Confirm, IntPrompt, Prompt +from rich.table import Table + +from hammerdb_scale.constants import VERSION +from hammerdb_scale.output import console + + +def _step_header(step: int, total: int, title: str, subtitle: str) -> None: + """Print a styled step header panel.""" + console.print() + console.print( + Panel( + f"[dim]{subtitle}[/dim]", + title=f"[bold]Step {step} of {total} — {title}[/bold]", + border_style="cyan", + padding=(0, 2), + ) + ) + + +def _prompt_required(prompt_text: str, **kwargs: object) -> str: + """Prompt for a non-empty string, re-asking if blank.""" + while True: + value = Prompt.ask(prompt_text, **kwargs) + if value and value.strip(): + return value.strip() + console.print("[red]This field is required.[/red]") + + +def _select_option(prompt_text: str, options: list[tuple[str, str]]) -> str: + """Display a numbered menu and return the selected value. + + Args: + prompt_text: The prompt to show after the menu. + options: List of (value, display_label) tuples. + + Returns: + The value string of the selected option. + """ + for i, (_, label) in enumerate(options, 1): + console.print(f" [bold]{i}.[/bold] {label}") + console.print() + choices = [str(i) for i in range(1, len(options) + 1)] + choice = Prompt.ask(prompt_text, choices=choices, show_choices=True) + return options[int(choice) - 1][0] + + +def _build_summary_table(values: dict) -> Table: + """Build a Rich Table summarising the collected wizard values.""" + table = Table(show_header=True, header_style="bold", expand=True) + table.add_column("Setting", style="cyan", min_width=22) + table.add_column("Value") + + db_label = "Oracle" if values["db_type_str"] == "oracle" else "SQL Server" + bench_label = ( + "TPC-C (OLTP)" if values["benchmark_str"] == "tprocc" else "TPC-H (OLAP)" + ) + + table.add_row("Deployment name", values["name"]) + table.add_row("Database type", db_label) + table.add_row("Benchmark", bench_label) + table.add_row("Targets", str(len(values["hosts"]))) + for h in values["hosts"]: + table.add_row(f" {h['name']}", h["host"], style="dim") + table.add_row("Username", values["username"]) + table.add_row("Password", "********") + + if values["db_type_str"] == "oracle" and values.get("oracle_config"): + table.add_row("Oracle service", values["oracle_config"]["service"]) + table.add_row("Schema password", "********") + + if values["benchmark_str"] == "tprocc": + table.add_row("Warehouses", str(values.get("warehouses", 100))) + else: + table.add_row("Scale factor", str(values.get("scale_factor", 1))) + + table.add_row("Namespace", values.get("namespace", "hammerdb")) + + storage = values.get("storage_metrics") + if storage and storage.get("enabled"): + table.add_row("Pure Storage", f"Enabled ({storage['pure']['host']})") + else: + table.add_row("Pure Storage", "Disabled") + + # Show advanced values if they differ from defaults + adv_fields = [ + ("build_virtual_users", "Build VUs", 4), + ("load_virtual_users", "Load VUs", 4), + ("rampup", "Rampup (min)", 5), + ("duration", "Duration (min)", 10), + ("req_memory", "Pod request memory", "4Gi"), + ("req_cpu", "Pod request CPU", "4"), + ("lim_memory", "Pod limit memory", "8Gi"), + ("lim_cpu", "Pod limit CPU", "8"), + ] + has_advanced = False + for key, label, default in adv_fields: + val = values.get(key) + if val is not None and val != default: + if not has_advanced: + table.add_section() + has_advanced = True + table.add_row(label, str(val)) + + return table + + +def run_wizard() -> dict | None: + """Run the interactive configuration wizard. + + Returns: + A dict of keyword arguments for ``_build_config_yaml()``, + or ``None`` if the user cancels. + """ + total_steps = 6 + + try: + # Welcome banner + console.print() + console.print( + Panel( + "[bold]HammerDB-Scale Configuration Wizard[/bold]\n\n" + "This wizard will guide you through creating a benchmark\n" + "configuration file step by step.\n\n" + "[dim]Press Ctrl+C at any time to cancel.[/dim]", + title=f"hammerdb-scale {VERSION}", + border_style="blue", + padding=(1, 2), + ) + ) + + # ── Step 1: Deployment ────────────────────────────────────── + _step_header(1, total_steps, "Deployment", "Give this benchmark a name.") + console.print() + name = _prompt_required("Deployment name") + + # ── Step 2: Database & Benchmark ──────────────────────────── + _step_header( + 2, + total_steps, + "Database & Benchmark", + "Select the database engine and TPC benchmark type.", + ) + console.print() + + db_type_str = _select_option( + "Database type", + [("oracle", "Oracle"), ("mssql", "Microsoft SQL Server")], + ) + + console.print() + benchmark_str = _select_option( + "Benchmark", + [ + ("tprocc", "TPC-C (OLTP transactional)"), + ("tproch", "TPC-H (OLAP analytical)"), + ], + ) + + # ── Step 3: Database Targets ──────────────────────────────── + _step_header( + 3, + total_steps, + "Database Targets", + "Define the database hosts to benchmark against.\n" + "[dim]Each target gets its own HammerDB Job pod in Kubernetes.[/dim]", + ) + + console.print() + num_targets = IntPrompt.ask("Number of database targets", default=1) + while num_targets < 1: + console.print("[red]Must be at least 1.[/red]") + num_targets = IntPrompt.ask("Number of database targets", default=1) + + hosts: list[dict] = [] + for i in range(num_targets): + console.print(f"\n [bold cyan]Target {i + 1} of {num_targets}[/bold cyan]") + t_name = Prompt.ask(" Name", default=f"db-{i + 1:02d}") + t_host = _prompt_required(" Hostname or IP") + hosts.append({"name": t_name, "host": t_host}) + + # ── Step 4: Credentials ───────────────────────────────────── + _step_header( + 4, + total_steps, + "Credentials", + "Database authentication shared by all targets.\n" + "[dim]You can override per-host in the YAML later.[/dim]", + ) + + console.print() + default_user = "system" if db_type_str == "oracle" else "sa" + username = Prompt.ask("Database username", default=default_user) + password = _prompt_required("Database password", password=True) + + oracle_config = None + if db_type_str == "oracle": + console.print("\n [dim]Oracle-specific settings:[/dim]") + service = Prompt.ask(" Oracle service name", default="ORCLPDB") + console.print( + " [dim]Schema password for TPC-C/H user " + "(Enter to reuse database password).[/dim]" + ) + schema_password = Prompt.ask(" Schema password", password=True) + if not schema_password: + schema_password = password + oracle_config = { + "service": service, + "port": 1521, + "tablespace": "TPCC", + "temp_tablespace": "TEMP", + "tprocc": {"user": "TPCC", "password": schema_password}, + "tproch": {"user": "tpch", "password": schema_password}, + } + + # ── Step 5: Benchmark Parameters ──────────────────────────── + _step_header( + 5, + total_steps, + "Benchmark Parameters", + "Configure the benchmark workload.", + ) + + warehouses = 100 + scale_factor = 1 + if benchmark_str == "tprocc": + console.print() + console.print( + " [dim]Rule of thumb: 100 warehouses ~ 10 GB per target.[/dim]\n" + ) + warehouses = IntPrompt.ask("Warehouses per target", default=100) + while warehouses < 1: + console.print("[red]Must be at least 1.[/red]") + warehouses = IntPrompt.ask("Warehouses per target", default=100) + else: + console.print() + console.print( + " [dim]Scale factor controls data size: 1 ~ 1 GB, 10 ~ 10 GB.[/dim]\n" + ) + scale_factor = IntPrompt.ask("Scale factor", default=1) + while scale_factor < 1: + console.print("[red]Must be at least 1.[/red]") + scale_factor = IntPrompt.ask("Scale factor", default=1) + + # ── Step 6: Infrastructure ────────────────────────────────── + _step_header( + 6, + total_steps, + "Infrastructure", + "Kubernetes and storage settings.", + ) + + console.print() + namespace = Prompt.ask("Kubernetes namespace", default="hammerdb") + + storage_metrics = None + console.print() + enable_pure = Confirm.ask( + "Enable Pure Storage metrics collection?", default=False + ) + if enable_pure: + pure_host = Prompt.ask(" FlashArray host/IP") + pure_token = Prompt.ask(" API token", password=True) + storage_metrics = { + "enabled": True, + "provider": "pure", + "pure": { + "host": pure_host, + "api_token": pure_token, + "volume": "", + "poll_interval": 5, + "verify_ssl": False, + "api_version": "2.4", + }, + } + + # ── Advanced Options (optional) ───────────────────────────── + values: dict = { + "name": name, + "db_type_str": db_type_str, + "benchmark_str": benchmark_str, + "hosts": hosts, + "username": username, + "password": password, + "oracle_config": oracle_config, + "warehouses": warehouses, + "scale_factor": scale_factor, + "namespace": namespace, + "storage_metrics": storage_metrics, + } + + console.print() + want_advanced = Confirm.ask( + "Configure advanced options (VUs, rampup, duration, resources)?", + default=False, + ) + if want_advanced: + console.print( + Panel( + "[dim]Tune concurrency, timing, and pod resources.[/dim]", + title="[bold]Advanced Options[/bold]", + border_style="yellow", + padding=(0, 2), + ) + ) + console.print() + + if benchmark_str == "tprocc": + values["build_virtual_users"] = IntPrompt.ask( + "Build virtual users", default=4 + ) + values["load_virtual_users"] = IntPrompt.ask( + "Load virtual users", default=4 + ) + values["rampup"] = IntPrompt.ask("Rampup (minutes)", default=5) + values["duration"] = IntPrompt.ask("Duration (minutes)", default=10) + else: + values["build_threads"] = IntPrompt.ask("Build threads", default=4) + values["tproch_load_virtual_users"] = IntPrompt.ask( + "Load virtual users (query concurrency)", default=1 + ) + values["total_querysets"] = IntPrompt.ask("Total query sets", default=1) + + console.print("\n [dim]Kubernetes pod resources:[/dim]\n") + values["req_memory"] = Prompt.ask("Request memory", default="4Gi") + values["req_cpu"] = Prompt.ask("Request CPU", default="4") + values["lim_memory"] = Prompt.ask("Limit memory", default="8Gi") + values["lim_cpu"] = Prompt.ask("Limit CPU", default="8") + + # ── Summary & Confirmation ────────────────────────────────── + console.print() + table = _build_summary_table(values) + console.print( + Panel( + table, title="[bold]Configuration Summary[/bold]", border_style="green" + ) + ) + console.print() + + if not Confirm.ask("Write configuration?", default=True): + return None + + return values + + except KeyboardInterrupt: + console.print("\n[yellow]Wizard cancelled.[/yellow]") + return None diff --git a/tests/test_wizard.py b/tests/test_wizard.py new file mode 100644 index 0000000..b7102cb --- /dev/null +++ b/tests/test_wizard.py @@ -0,0 +1,347 @@ +"""Tests for the interactive configuration wizard.""" + +from __future__ import annotations + +from io import StringIO +from unittest.mock import patch + +from rich.console import Console + +from hammerdb_scale.cli import _build_config_yaml +from hammerdb_scale.wizard import _build_summary_table, run_wizard + + +# ── Fixtures ──────────────────────────────────────────────────────────────── + + +def _oracle_values() -> dict: + """Minimal Oracle TPC-C wizard values.""" + return { + "name": "oracle-bench", + "db_type_str": "oracle", + "benchmark_str": "tprocc", + "hosts": [ + {"name": "ora-01", "host": "10.0.0.1"}, + {"name": "ora-02", "host": "10.0.0.2"}, + ], + "username": "system", + "password": "secret123", + "oracle_config": { + "service": "ORCLPDB", + "port": 1521, + "tablespace": "TPCC", + "temp_tablespace": "TEMP", + "tprocc": {"user": "TPCC", "password": "secret123"}, + "tproch": {"user": "tpch", "password": "secret123"}, + }, + "warehouses": 500, + "scale_factor": 1, + "namespace": "hammerdb", + "storage_metrics": None, + } + + +def _mssql_values() -> dict: + """Minimal MSSQL TPC-H wizard values.""" + return { + "name": "mssql-bench", + "db_type_str": "mssql", + "benchmark_str": "tproch", + "hosts": [{"name": "sql-01", "host": "10.0.0.10"}], + "username": "sa", + "password": "P@ssw0rd", + "oracle_config": None, + "warehouses": 100, + "scale_factor": 10, + "namespace": "benchmarks", + "storage_metrics": { + "enabled": True, + "provider": "pure", + "pure": { + "host": "flasharray.local", + "api_token": "token123", + "volume": "", + "poll_interval": 5, + "verify_ssl": False, + "api_version": "2.4", + }, + }, + } + + +# ── Summary Table Tests ──────────────────────────────────────────────────── + + +class TestBuildSummaryTable: + def test_oracle_tprocc_table(self) -> None: + values = _oracle_values() + table = _build_summary_table(values) + assert table.row_count > 0 + # Render to string to verify content + buf = StringIO() + c = Console(file=buf, width=100) + c.print(table) + output = buf.getvalue() + assert "oracle-bench" in output + assert "Oracle" in output + assert "TPC-C" in output + assert "ora-01" in output + assert "ora-02" in output + assert "500" in output + assert "ORCLPDB" in output + assert "********" in output + assert "secret123" not in output + + def test_mssql_tproch_table(self) -> None: + values = _mssql_values() + table = _build_summary_table(values) + buf = StringIO() + c = Console(file=buf, width=100) + c.print(table) + output = buf.getvalue() + assert "mssql-bench" in output + assert "SQL Server" in output + assert "TPC-H" in output + assert "sql-01" in output + assert "10" in output # scale factor + assert "benchmarks" in output # namespace + assert "flasharray.local" in output + assert "P@ssw0rd" not in output + + def test_advanced_values_shown(self) -> None: + values = _oracle_values() + values["build_virtual_users"] = 16 + values["load_virtual_users"] = 32 + table = _build_summary_table(values) + buf = StringIO() + c = Console(file=buf, width=100) + c.print(table) + output = buf.getvalue() + assert "16" in output + assert "32" in output + + def test_default_advanced_not_shown(self) -> None: + """Advanced values at defaults should not add extra rows.""" + values = _oracle_values() + values["build_virtual_users"] = 4 + values["load_virtual_users"] = 4 + table = _build_summary_table(values) + buf = StringIO() + c = Console(file=buf, width=100) + c.print(table) + output = buf.getvalue() + assert "Build VUs" not in output + + +# ── Wizard Flow Tests (mocked prompts) ────────────────────────────────────── + + +class TestRunWizard: + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_oracle_tprocc_basic(self, mock_prompt, mock_int, mock_confirm) -> None: + """Full wizard flow for Oracle TPC-C without advanced options.""" + mock_prompt.ask.side_effect = [ + "my-bench", # deployment name + "1", # oracle + "1", # tprocc + "ora-01", # target name + "10.0.0.1", # target host + "system", # username + "pass", # password + "ORCLPDB", # oracle service + "pass", # schema password (reuse) + "hammerdb", # namespace + ] + mock_int.ask.side_effect = [ + 1, # num targets + 100, # warehouses + ] + mock_confirm.ask.side_effect = [ + False, # pure storage + False, # advanced + True, # write confirmation + ] + + result = run_wizard() + + assert result is not None + assert result["name"] == "my-bench" + assert result["db_type_str"] == "oracle" + assert result["benchmark_str"] == "tprocc" + assert len(result["hosts"]) == 1 + assert result["hosts"][0]["name"] == "ora-01" + assert result["oracle_config"] is not None + assert result["oracle_config"]["service"] == "ORCLPDB" + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_mssql_tproch_basic(self, mock_prompt, mock_int, mock_confirm) -> None: + """Full wizard flow for MSSQL TPC-H.""" + mock_prompt.ask.side_effect = [ + "sql-test", # deployment name + "2", # mssql + "2", # tproch + "sql-01", # target name + "10.0.0.5", # target host + "sa", # username + "pass", # password + "hammerdb", # namespace + ] + mock_int.ask.side_effect = [ + 1, # num targets + 10, # scale factor + ] + mock_confirm.ask.side_effect = [ + False, # pure storage + False, # advanced + True, # write confirmation + ] + + result = run_wizard() + + assert result is not None + assert result["db_type_str"] == "mssql" + assert result["benchmark_str"] == "tproch" + assert result["oracle_config"] is None + assert result["scale_factor"] == 10 + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_user_cancels_at_confirmation( + self, mock_prompt, mock_int, mock_confirm + ) -> None: + """Wizard returns None when user declines at the write confirmation.""" + mock_prompt.ask.side_effect = [ + "test", + "2", + "1", + "db-01", + "10.0.0.1", + "sa", + "pass", + "hammerdb", + ] + mock_int.ask.side_effect = [1, 100] + mock_confirm.ask.side_effect = [ + False, # pure storage + False, # advanced + False, # decline write + ] + + result = run_wizard() + assert result is None + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_with_advanced_options(self, mock_prompt, mock_int, mock_confirm) -> None: + """Wizard with advanced options for TPC-C.""" + mock_prompt.ask.side_effect = [ + "adv-bench", + "2", + "1", # name, mssql, tprocc + "db-01", + "10.0.0.1", # target + "sa", + "pass", # credentials + "hammerdb", # namespace + "8Gi", + "8", + "16Gi", + "16", # resources + ] + mock_int.ask.side_effect = [ + 1, # num targets + 200, # warehouses + 8, # build VUs + 32, # load VUs + 10, # rampup + 30, # duration + ] + mock_confirm.ask.side_effect = [ + False, # pure storage + True, # want advanced + True, # write confirmation + ] + + result = run_wizard() + + assert result is not None + assert result["build_virtual_users"] == 8 + assert result["load_virtual_users"] == 32 + assert result["rampup"] == 10 + assert result["duration"] == 30 + assert result["req_memory"] == "8Gi" + assert result["lim_cpu"] == "16" + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_ctrl_c_returns_none(self, mock_prompt, mock_int, mock_confirm) -> None: + """Ctrl+C during wizard returns None gracefully.""" + mock_prompt.ask.side_effect = KeyboardInterrupt + + result = run_wizard() + assert result is None + + +# ── YAML Output Tests ─────────────────────────────────────────────────────── + + +class TestYamlOutput: + def test_wizard_values_produce_valid_yaml(self) -> None: + """Wizard values dict can be passed to _build_config_yaml.""" + values = _oracle_values() + yaml_str = _build_config_yaml(**values) + assert "name: oracle-bench" in yaml_str + assert "type: oracle" in yaml_str + assert "warehouses: 500" in yaml_str + assert 'host: "10.0.0.1"' in yaml_str + assert 'host: "10.0.0.2"' in yaml_str + + def test_mssql_yaml_output(self) -> None: + values = _mssql_values() + yaml_str = _build_config_yaml(**values) + assert "name: mssql-bench" in yaml_str + assert "type: mssql" in yaml_str + assert "namespace: benchmarks" in yaml_str + assert "enabled: true" in yaml_str + assert "flasharray.local" in yaml_str + + def test_advanced_overrides_in_yaml(self) -> None: + values = _oracle_values() + values["build_virtual_users"] = 16 + values["load_virtual_users"] = 64 + values["rampup"] = 15 + values["duration"] = 30 + values["req_memory"] = "8Gi" + values["lim_cpu"] = "16" + yaml_str = _build_config_yaml(**values) + assert "build_virtual_users: 16" in yaml_str + assert "load_virtual_users: 64" in yaml_str + assert "rampup: 15" in yaml_str + assert "duration: 30" in yaml_str + assert 'memory: "8Gi"' in yaml_str + + def test_default_values_match_original(self) -> None: + """Calling with defaults should produce the same output as the old hardcoded template.""" + yaml_str = _build_config_yaml( + name="test", + db_type_str="mssql", + benchmark_str="tprocc", + hosts=[{"name": "db-01", "host": "10.0.0.1"}], + username="sa", + password="pass", + oracle_config=None, + ) + # Verify the default values appear + assert "build_virtual_users: 4" in yaml_str + assert "load_virtual_users: 4" in yaml_str + assert "rampup: 5" in yaml_str + assert "duration: 10" in yaml_str + assert 'memory: "4Gi"' in yaml_str + assert 'cpu: "4"' in yaml_str