From b08a2a1c5b98e3b36c0fbde0b6998ed0573d79d5 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Fri, 24 Jul 2026 23:40:01 -0600 Subject: [PATCH 01/14] Add container backend for podman and docker Introduce a backend abstraction so benchmarks can run without Kubernetes. Selected via `backend: kubernetes | container | podman | docker` in config; kubernetes remains the default so existing configs are unaffected. The enabling change is runtime/environment.py, which lifts the container env-var contract out of the Helm template into Python. Previously that contract existed only in Go template syntax, so a non-Kubernetes backend had no way to reuse it and it could not be unit tested. Both backends now derive their environment from the same code. Verified parity by diffing helm-rendered Job env against the Python builder across Oracle and SQL Server, TPC-C and TPC-H, with and without Pure Storage: identical keys and values in every combination. That diff caught a real bug. Helm rendered total_iterations 10000000 as "1e+07", which reaches `diset tpcc total_iterations` in the TCL scripts and is not a valid Tcl integer. Fixed in both chart copies with printf %.0f. This is the same class of YAML scientific-notation issue as 36c9a67. Validated against live hardware: - 8 Oracle 26ai targets in parallel, all completed and parsed, aggregating 173,444 TPM / 84,303 NOPM - 2 SQL Server 2025 targets, both completed and parsed - status, log retrieval, duration and cleanup all correct on both Tests: 138 -> 178. Co-Authored-By: Claude Opus 5 (1M context) --- .../chart/templates/job-hammerdb-worker.yaml | 4 +- src/hammerdb_scale/config/schema.py | 24 + src/hammerdb_scale/runtime/__init__.py | 57 +++ src/hammerdb_scale/runtime/base.py | 77 +++ src/hammerdb_scale/runtime/container.py | 439 ++++++++++++++++++ src/hammerdb_scale/runtime/environment.py | 221 +++++++++ src/hammerdb_scale/runtime/kubernetes.py | 128 +++++ templates/job-hammerdb-worker.yaml | 4 +- tests/test_container_backend.py | 173 +++++++ tests/test_environment.py | 215 +++++++++ 10 files changed, 1340 insertions(+), 2 deletions(-) create mode 100644 src/hammerdb_scale/runtime/__init__.py create mode 100644 src/hammerdb_scale/runtime/base.py create mode 100644 src/hammerdb_scale/runtime/container.py create mode 100644 src/hammerdb_scale/runtime/environment.py create mode 100644 src/hammerdb_scale/runtime/kubernetes.py create mode 100644 tests/test_container_backend.py create mode 100644 tests/test_environment.py diff --git a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml index 60bcbe0..f7a026f 100644 --- a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml +++ b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml @@ -157,7 +157,9 @@ spec: - name: DURATION value: {{ $tproccConfig.duration | quote }} - name: TOTAL_ITERATIONS - value: {{ $tproccConfig.total_iterations | quote }} + # printf %.0f avoids Go rendering large ints in scientific notation + # (10000000 -> "1e+07"), which the TCL scripts cannot parse. + value: {{ printf "%.0f" (float64 $tproccConfig.total_iterations) | quote }} - name: TPROCC_LOG_TO_TEMP value: "0" - name: TPROCC_USE_TRANSACTION_COUNTER diff --git a/src/hammerdb_scale/config/schema.py b/src/hammerdb_scale/config/schema.py index c9bff17..32a900a 100644 --- a/src/hammerdb_scale/config/schema.py +++ b/src/hammerdb_scale/config/schema.py @@ -30,6 +30,21 @@ class ImagePullPolicy(str, Enum): never = "Never" +class BackendType(str, Enum): + """Where benchmark workloads execute.""" + + kubernetes = "kubernetes" + container = "container" # auto-detect podman or docker + podman = "podman" + docker = "docker" + + +class ContainerRuntimeName(str, Enum): + auto = "auto" + podman = "podman" + docker = "docker" + + # --- Oracle Config Models --- @@ -166,6 +181,13 @@ class KubernetesConfig(BaseModel): job_ttl: int = Field(default=86400, ge=0) +class ContainerConfig(BaseModel): + """Settings for the local container backend (podman or docker).""" + + runtime: ContainerRuntimeName = ContainerRuntimeName.auto + network: Optional[str] = None # None uses the runtime's default network + + # --- Storage Metrics --- @@ -194,10 +216,12 @@ class HammerDBScaleConfig(BaseModel): name: str description: str = "" default_benchmark: Optional[BenchmarkType] = None + backend: BackendType = BackendType.kubernetes targets: TargetsConfig hammerdb: HammerDBConfig = HammerDBConfig() resources: ResourcesConfig = ResourcesConfig() kubernetes: KubernetesConfig = KubernetesConfig() + container: ContainerConfig = ContainerConfig() storage_metrics: StorageMetricsConfig = StorageMetricsConfig() @model_validator(mode="after") diff --git a/src/hammerdb_scale/runtime/__init__.py b/src/hammerdb_scale/runtime/__init__.py new file mode 100644 index 0000000..0a85e5b --- /dev/null +++ b/src/hammerdb_scale/runtime/__init__.py @@ -0,0 +1,57 @@ +"""Execution backends for HammerDB workloads. + +``get_backend()`` is the single entry point the CLI uses to obtain a backend. +""" + +from __future__ import annotations + +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + STATUS_PENDING, + STATUS_RUNNING, + Backend, + WorkloadRef, +) + +__all__ = [ + "Backend", + "WorkloadRef", + "STATUS_PENDING", + "STATUS_RUNNING", + "STATUS_COMPLETED", + "STATUS_FAILED", + "get_backend", +] + + +def get_backend(config, namespace: str | None = None) -> Backend: + """Build the backend selected by the config. + + Kubernetes remains the default so that existing configs behave exactly as + they did before backends were introduced. + """ + backend_name = getattr(config, "backend", "kubernetes") + backend_name = getattr(backend_name, "value", backend_name) + + if backend_name in ("container", "docker", "podman"): + from hammerdb_scale.runtime.container import ContainerBackend + + container_cfg = getattr(config, "container", None) + runtime = getattr(container_cfg, "runtime", None) + runtime = getattr(runtime, "value", runtime) + if runtime == "auto": + runtime = None + # An explicit `backend: docker` or `backend: podman` names the runtime. + if backend_name in ("docker", "podman"): + runtime = backend_name + + return ContainerBackend( + runtime=runtime, + network=getattr(container_cfg, "network", None), + ) + + from hammerdb_scale.runtime.kubernetes import KubernetesBackend + + ns = namespace or config.kubernetes.namespace + return KubernetesBackend(namespace=ns, config=config) diff --git a/src/hammerdb_scale/runtime/base.py b/src/hammerdb_scale/runtime/base.py new file mode 100644 index 0000000..e78fafb --- /dev/null +++ b/src/hammerdb_scale/runtime/base.py @@ -0,0 +1,77 @@ +"""Backend protocol shared by the Kubernetes and container execution paths. + +A backend is responsible for turning a set of expanded targets into running +HammerDB workloads and for reporting on them afterwards. Everything above this +layer (config parsing, result parsing, aggregation, reporting) is transport +agnostic and must not import backend-specific modules. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +# Job lifecycle states, normalised across backends. The Kubernetes backend maps +# Job conditions onto these; the container backend maps exit codes. +STATUS_PENDING = "Pending" +STATUS_RUNNING = "Running" +STATUS_COMPLETED = "Completed" +STATUS_FAILED = "Failed" + + +@dataclass +class WorkloadRef: + """A single running or completed HammerDB workload. + + This is the backend-neutral view of what Kubernetes calls a Job and what + the container backend calls a container. + """ + + name: str + target_name: str + target_host: str + database_type: str + index: int + status: str + duration_seconds: int | None = None + + +@runtime_checkable +class Backend(Protocol): + """Execution backend for HammerDB workloads.""" + + name: str + + def deploy( + self, + targets: list[dict], + env_per_target: list[dict[str, str]], + *, + test_id: str, + phase: str, + benchmark: str, + image: str, + pull_policy: str, + dry_run: bool = False, + ) -> list[str]: + """Start one workload per target. Returns the workload names created.""" + ... + + def list_workloads( + self, test_id: str, phase: str | None = None + ) -> list[WorkloadRef]: + """Find all workloads belonging to a test run, newest phase first.""" + ... + + def get_logs(self, workload_name: str, tail: int | None = None) -> str: + """Fetch the full stdout/stderr of a workload.""" + ... + + def remove(self, test_id: str | None = None, everything: bool = False) -> int: + """Remove workloads for a test run. Returns the count removed.""" + ... + + def preflight(self) -> list[str]: + """Check that this backend can run. Returns a list of problems, empty if OK.""" + ... diff --git a/src/hammerdb_scale/runtime/container.py b/src/hammerdb_scale/runtime/container.py new file mode 100644 index 0000000..ff0d162 --- /dev/null +++ b/src/hammerdb_scale/runtime/container.py @@ -0,0 +1,439 @@ +"""Container execution backend for podman and docker. + +Runs one HammerDB container per database target on the local host, with no +Kubernetes involved. This is the low-friction path: a user needs a container +runtime and a config file rather than a cluster, a kubeconfig, helm, kubectl +and RBAC to create Jobs. + +podman and docker expose a compatible CLI for everything used here, so a single +implementation drives both. podman is preferred when present because it is +rootless and daemonless. + +Labels carry the same metadata the Kubernetes backend puts in Job labels, which +is what lets test-id resolution, status and log retrieval behave identically on +either backend. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +from hammerdb_scale.constants import HammerDBScaleError, get_chart_path +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + STATUS_RUNNING, + WorkloadRef, +) + +# Label keys, mirroring the Kubernetes label scheme. +LABEL_MANAGED = "hammerdb.io/managed-by" +LABEL_TEST_ID = "hammerdb.io/test-id" +LABEL_PHASE = "hammerdb.io/phase" +LABEL_TARGET = "hammerdb.io/target-name" +LABEL_TARGET_HOST = "hammerdb.io/target-host" +LABEL_DB_TYPE = "hammerdb.io/database-type" +LABEL_INDEX = "hammerdb.io/target-index" + +MANAGED_VALUE = "hammerdb-scale" + + +class ContainerRuntimeError(HammerDBScaleError): + """The container runtime is missing or a command against it failed.""" + + +def detect_runtime(preferred: str | None = None) -> str: + """Find an available container runtime. + + Prefers podman (rootless, daemonless) over docker unless told otherwise. + """ + candidates = [preferred] if preferred else ["podman", "docker"] + for name in candidates: + if name and shutil.which(name): + return name + raise ContainerRuntimeError( + "No container runtime found. Install podman " + "(https://podman.io/getting-started/installation) or docker " + "(https://docs.docker.com/get-docker/), or set backend: kubernetes." + ) + + +class ContainerBackend: + """Runs HammerDB workloads as local containers.""" + + def __init__( + self, + runtime: str | None = None, + network: str | None = None, + scripts_dir: Path | None = None, + ) -> None: + self.runtime = detect_runtime(runtime) + self.name = self.runtime + self.network = network + self._scripts_dir = scripts_dir + + # --- internals --- + + def _run( + self, args: list[str], timeout: int = 300, check: bool = True + ) -> subprocess.CompletedProcess: + """Invoke the container runtime CLI.""" + result = subprocess.run( + [self.runtime] + args, + capture_output=True, + text=True, + timeout=timeout, + encoding="utf-8", + errors="replace", + ) + if check and result.returncode != 0: + raise ContainerRuntimeError( + f"{self.runtime} {' '.join(args[:3])} failed:\n{result.stderr.strip()}" + ) + return result + + def _scripts_path(self, database_type: str) -> Path: + """Locate the TCL scripts for a database type. + + These are the same files the Helm chart packages into a ConfigMap, so + both backends execute identical benchmark logic. + """ + base = self._scripts_dir or (Path(get_chart_path()) / "scripts") + path = base / database_type + if not path.is_dir(): + raise ContainerRuntimeError( + f"No TCL scripts found for database type '{database_type}' at {path}" + ) + return path + + @staticmethod + def _container_name(phase: str, index: int, test_id: str) -> str: + return f"hdb-{phase}-{index:02d}-{test_id}" + + # --- Backend protocol --- + + def preflight(self) -> list[str]: + """Verify the runtime responds before we try to deploy against it.""" + problems: list[str] = [] + try: + result = self._run(["version", "--format", "{{.Client.Version}}"], + timeout=30, check=False) + if result.returncode != 0: + # Older podman/docker may not support that format string. + result = self._run(["--version"], timeout=30, check=False) + if result.returncode != 0: + problems.append( + f"{self.runtime} is installed but not responding: " + f"{result.stderr.strip()}" + ) + except Exception as e: + problems.append(f"Could not run {self.runtime}: {e}") + return problems + + def deploy( + self, + targets: list[dict], + env_per_target: list[dict[str, str]], + *, + test_id: str, + phase: str, + benchmark: str, + image: str, + pull_policy: str = "Always", + dry_run: bool = False, + ) -> list[str]: + """Start one detached container per target.""" + if pull_policy == "Always" and not dry_run: + self._run(["pull", image], timeout=1800, check=False) + + names: list[str] = [] + for index, (target, env) in enumerate(zip(targets, env_per_target)): + name = self._container_name(phase, index, test_id) + scripts = self._scripts_path(target["type"]) + + args = [ + "run", + "--detach", + "--name", + name, + "--label", f"{LABEL_MANAGED}={MANAGED_VALUE}", + "--label", f"{LABEL_TEST_ID}={test_id}", + "--label", f"{LABEL_PHASE}={phase}", + "--label", f"{LABEL_TARGET}={target['name']}", + "--label", f"{LABEL_TARGET_HOST}={target['host']}", + "--label", f"{LABEL_DB_TYPE}={target['type']}", + "--label", f"{LABEL_INDEX}={index}", + ] + + if self.network: + args.extend(["--network", self.network]) + + # Env vars go via a file so that passwords never appear in the + # process list or the shell history of the calling user. + env_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".env", delete=False, encoding="utf-8" + ) + try: + for key, value in env.items(): + env_file.write(f"{key}={value}\n") + env_file.close() + args.extend(["--env-file", env_file.name]) + + # Mount the TCL scripts where entrypoint.sh expects them. The + # :ro,z suffix keeps SELinux hosts working; docker ignores z. + mount_target = self._script_mount_target(image) + args.extend(["-v", f"{scripts}:{mount_target}:ro,z"]) + args.append(image) + + if dry_run: + redacted = [ + a if not a.startswith("/tmp/") else "" for a in args + ] + print(f"{self.runtime} " + " ".join(redacted)) + names.append(name) + continue + + self._run(args, timeout=120) + names.append(name) + finally: + if not dry_run: + Path(env_file.name).unlink(missing_ok=True) + + return names + + def _script_mount_target(self, image: str) -> str: + """Where inside the container the TCL scripts must appear. + + The image's HAMMERDB_HOME tells us; fall back to the historical 5.0 + path for images built before that variable existed. + """ + result = self._run( + ["image", "inspect", image, "--format", "{{range .Config.Env}}{{println .}}{{end}}"], + timeout=60, + check=False, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + if line.startswith("HAMMERDB_HOME="): + home = line.split("=", 1)[1].strip() + if home: + return f"{home}/scripts" + return "/opt/HammerDB-5.0/scripts" + + def list_workloads( + self, test_id: str, phase: str | None = None + ) -> list[WorkloadRef]: + """Find containers for a test run.""" + filters = [ + "--filter", f"label={LABEL_MANAGED}={MANAGED_VALUE}", + "--filter", f"label={LABEL_TEST_ID}={test_id}", + ] + if phase: + filters.extend(["--filter", f"label={LABEL_PHASE}={phase}"]) + + result = self._run( + ["ps", "--all", "--format", "json"] + filters, check=False + ) + if result.returncode != 0 or not result.stdout.strip(): + return [] + + entries = self._parse_ps_json(result.stdout) + workloads = [self._to_workload(e) for e in entries] + return sorted(workloads, key=lambda w: w.index) + + @staticmethod + def _parse_ps_json(stdout: str) -> list[dict]: + """Parse `ps --format json`. + + podman emits a JSON array; docker emits newline-delimited objects. + """ + text = stdout.strip() + try: + data = json.loads(text) + return data if isinstance(data, list) else [data] + except json.JSONDecodeError: + pass + + entries = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries + + def _to_workload(self, entry: dict) -> WorkloadRef: + """Normalise a runtime-specific ps entry into a WorkloadRef.""" + labels = entry.get("Labels") or {} + if isinstance(labels, str): + labels = dict( + pair.split("=", 1) for pair in labels.split(",") if "=" in pair + ) + + name = entry.get("Names") or entry.get("Name") or "" + if isinstance(name, list): + name = name[0] if name else "" + + try: + index = int(labels.get(LABEL_INDEX, 0)) + except (TypeError, ValueError): + index = 0 + + return WorkloadRef( + name=name, + target_name=labels.get(LABEL_TARGET, "unknown"), + target_host=labels.get(LABEL_TARGET_HOST, "unknown"), + database_type=labels.get(LABEL_DB_TYPE, "unknown"), + index=index, + status=self._normalise_status(entry), + duration_seconds=self._duration(name), + ) + + @staticmethod + def _normalise_status(entry: dict) -> str: + """Map runtime state onto the shared status vocabulary.""" + state = str(entry.get("State", "")).lower() + exit_code = entry.get("ExitCode") + + if state in ("running", "up"): + return STATUS_RUNNING + if state in ("created", "paused"): + return STATUS_RUNNING + if state in ("exited", "stopped", "dead"): + # docker sometimes reports status as "Exited (1) 2 minutes ago" + if exit_code is None: + status_text = str(entry.get("Status", "")) + exit_code = 0 if "(0)" in status_text else 1 + return STATUS_COMPLETED if int(exit_code) == 0 else STATUS_FAILED + return STATUS_RUNNING + + def _duration(self, container_name: str) -> int | None: + """Wall-clock runtime of a container, in seconds.""" + if not container_name: + return None + # Ask for RFC3339 explicitly. podman's default rendering of these + # fields is Go's native time format ("2026-07-24 23:32:08.69 -0600 MDT"), + # which is not parseable as ISO 8601. + result = self._run( + [ + "inspect", + container_name, + "--format", + "{{.State.StartedAt.Format " + '"2006-01-02T15:04:05.999999999Z07:00"' + "}}|{{.State.FinishedAt.Format " + '"2006-01-02T15:04:05.999999999Z07:00"' + "}}", + ], + timeout=30, + check=False, + ) + if result.returncode != 0: + # docker exposes these as pre-formatted strings, so .Format fails. + result = self._run( + [ + "inspect", + container_name, + "--format", + "{{.State.StartedAt}}|{{.State.FinishedAt}}", + ], + timeout=30, + check=False, + ) + if result.returncode != 0: + return None + + raw = result.stdout.strip() + if "|" not in raw: + return None + start_s, end_s = raw.split("|", 1) + start = _parse_ts(start_s) + end = _parse_ts(end_s) + if not start: + return None + if not end: + end = datetime.now(timezone.utc) + seconds = int((end - start).total_seconds()) + return seconds if seconds >= 0 else None + + def get_logs(self, workload_name: str, tail: int | None = None) -> str: + """Fetch container logs.""" + args = ["logs"] + if tail is not None: + args.extend(["--tail", str(tail)]) + args.append(workload_name) + result = self._run(args, timeout=120, check=False) + # HammerDB writes to both streams; callers want the combined output. + return (result.stdout or "") + (result.stderr or "") + + def remove(self, test_id: str | None = None, everything: bool = False) -> int: + """Remove containers for a test run, or every managed container.""" + filters = ["--filter", f"label={LABEL_MANAGED}={MANAGED_VALUE}"] + if test_id and not everything: + filters.extend(["--filter", f"label={LABEL_TEST_ID}={test_id}"]) + + result = self._run( + ["ps", "--all", "--quiet"] + filters, check=False + ) + ids = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if not ids: + return 0 + + self._run(["rm", "--force"] + ids, timeout=180, check=False) + return len(ids) + + def wait(self, workload_names: list[str], timeout: int) -> None: + """Block until the given containers exit, or the timeout elapses.""" + if not workload_names: + return + self._run(["wait"] + workload_names, timeout=timeout, check=False) + + +def _parse_ts(value: str) -> datetime | None: + """Parse a container runtime timestamp. + + Both runtimes emit RFC3339 with varying sub-second precision, and use a + zero value for containers that have not finished. + """ + value = value.strip() + if not value or value.startswith("0001-01-01"): + return None + + # Go's native time rendering, e.g. "2026-07-24 23:32:08.6967 -0600 MDT". + # Drop the trailing timezone abbreviation and keep the numeric offset. + parts = value.split() + if len(parts) >= 3 and ":" in parts[1] and ( + parts[2].startswith("+") or parts[2].startswith("-") + ): + value = f"{parts[0]}T{parts[1]}{parts[2]}" + + text = value.replace("Z", "+00:00") + # Trim sub-second precision beyond microseconds, which fromisoformat rejects + # on older Python versions. + if "." in text: + head, _, tail = text.partition(".") + digits = "" + rest = "" + for i, ch in enumerate(tail): + if ch.isdigit(): + digits += ch + else: + rest = tail[i:] + break + text = f"{head}.{digits[:6]}{rest}" + + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed diff --git a/src/hammerdb_scale/runtime/environment.py b/src/hammerdb_scale/runtime/environment.py new file mode 100644 index 0000000..43cfcaa --- /dev/null +++ b/src/hammerdb_scale/runtime/environment.py @@ -0,0 +1,221 @@ +"""Build the HammerDB container environment from a v2 config. + +This module is the single source of truth for the container's environment +variable contract. Both the Kubernetes backend (via the Helm chart) and the +container backend (via ``podman``/``docker run -e``) must produce the same +variables for the same config, otherwise the two backends silently diverge. + +The contract itself is defined by ``entrypoint.sh`` and the TCL scripts it +dispatches to. Previously it was expressed only in Go template syntax inside +``templates/job-hammerdb-worker.yaml``; lifting it here lets a non-Kubernetes +backend reuse it and lets it be unit tested. +""" + +from __future__ import annotations + +from hammerdb_scale.config.schema import HammerDBScaleConfig, MssqlConfig +from hammerdb_scale.constants import PHASE_MAP + +# HammerDB driver name per database type, mirroring the +# "hammerdb-scale-test.dbDriver" helper in _helpers.tpl. +DB_DRIVERS = { + "mssql": "mssqls", + "oracle": "oracle", + "postgres": "pg", + "mysql": "mysql", +} + + +def get_db_driver(database_type: str) -> str: + """Return the HammerDB driver name for a database type.""" + driver = DB_DRIVERS.get(database_type) + if not driver: + raise ValueError(f"Unsupported database type: {database_type}") + return driver + + +def _bool_str(value: object) -> str: + """Render a value the way the Helm template's `quote` does for booleans.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def build_target_env( + config: HammerDBScaleConfig, + target: dict, + *, + phase: str, + benchmark: str, + test_id: str, + target_index: int, +) -> dict[str, str]: + """Build the full environment for one target's HammerDB container. + + Args: + config: The validated v2 configuration. + target: One expanded target dict from ``expand_targets()``. + phase: CLI phase, ``"build"`` or ``"run"``. + benchmark: ``"tprocc"`` or ``"tproch"``. + test_id: The test run identifier. + target_index: Zero-based index of this target within the run. + + Returns: + A flat ``{name: value}`` mapping with every value already a string, + matching what the Kubernetes Job spec would set. + """ + db_type = target["type"] + run_mode = PHASE_MAP.get(phase, phase) + + env: dict[str, str] = { + # Test identification + "TEST_RUN_ID": test_id, + "TARGET_NAME": target["name"], + "TARGET_INDEX": str(target_index), + "RUN_MODE": run_mode, + "BENCHMARK": benchmark, + "DATABASE_TYPE": db_type, + # Database connection + "USERNAME": target["username"], + "PASSWORD": target["password"], + "HOST": target["host"], + # Global settings + "TMP": "/tmp", + "TMPDIR": "/tmp", + } + + mssql_cfg = config.targets.defaults.mssql or MssqlConfig() + env["USE_BCP"] = _bool_str(mssql_cfg.tprocc.use_bcp) + + if db_type == "mssql": + env.update(_mssql_connection_env(target, mssql_cfg)) + elif db_type == "oracle": + env.update(_oracle_env(target)) + + if benchmark == "tprocc": + env.update(_tprocc_env(config, target, db_type)) + elif benchmark == "tproch": + env.update(_tproch_env(config, target, db_type, mssql_cfg)) + + if config.storage_metrics.enabled: + env.update(_pure_storage_env(config, target_index)) + + return env + + +def _mssql_connection_env(target: dict, mssql_cfg: MssqlConfig) -> dict[str, str]: + """SQL Server connection variables.""" + conn = mssql_cfg.connection + return { + "SQL_SERVER_HOST": target["host"], + "MSSQLS_TCP": _bool_str(conn.tcp), + "MSSQLS_PORT": str(mssql_cfg.port), + "MSSQLS_AUTHENTICATION": conn.authentication, + "MSSQLS_AZURE": "false", + "MSSQLS_LINUX_ODBC": conn.odbc_driver, + "MSSQLS_ODBC_DRIVER": conn.odbc_driver, + "MSSQLS_ENCRYPT_CONNECTION": _bool_str(conn.encrypt_connection), + "MSSQLS_TRUST_SERVER_CERT": _bool_str(conn.trust_server_cert), + } + + +def _oracle_env(target: dict) -> dict[str, str]: + """Oracle connection and schema variables.""" + oracle = target.get("oracle", {}) + tprocc = oracle.get("tprocc", {}) + tproch = oracle.get("tproch", {}) + password = target["password"] + + env = { + "ORACLE_SERVICE": oracle.get("service", "ORCL"), + "ORACLE_PORT": str(oracle.get("port", 1521)), + "ORACLE_TABLESPACE": oracle.get("tablespace", "USERS"), + "ORACLE_TEMP_TABLESPACE": oracle.get("temp_tablespace", "TEMP"), + "TPROCC_USER": tprocc.get("user") or "tpcc", + "TPROCC_PASSWORD": tprocc.get("password") or password, + "TPROCH_USER": tproch.get("user") or "tpch", + "TPROCH_PASSWORD": tproch.get("password") or password, + "TPROCH_DEGREE_OF_PARALLEL": str(tproch.get("degree_of_parallel", 8)), + } + if oracle.get("sid"): + env["ORACLE_SID"] = oracle["sid"] + return env + + +def _tprocc_env( + config: HammerDBScaleConfig, target: dict, db_type: str +) -> dict[str, str]: + """TPC-C benchmark variables.""" + tc = config.hammerdb.tprocc + env = { + "TPROCC_DRIVER": get_db_driver(db_type), + "TPROCC_BUILD_VIRTUAL_USERS": str(tc.build_virtual_users), + "WAREHOUSES": str(tc.warehouses), + "TPROCC_DRIVER_TYPE": tc.driver, + "TPROCC_ALLWAREHOUSE": _bool_str(tc.all_warehouses), + "VIRTUAL_USERS": str(tc.load_virtual_users), + "RAMPUP": str(tc.rampup), + "DURATION": str(tc.duration), + "TOTAL_ITERATIONS": str(tc.total_iterations), + "TPROCC_LOG_TO_TEMP": "0", + "TPROCC_USE_TRANSACTION_COUNTER": "true", + "TPROCC_CHECKPOINT": _bool_str(tc.checkpoint), + "TPROCC_TIMEPROFILE": _bool_str(tc.time_profile), + } + if db_type == "mssql": + env["TPROCC_DATABASE_NAME"] = target.get("tprocc", {}).get( + "databaseName", "tpcc" + ) + return env + + +def _tproch_env( + config: HammerDBScaleConfig, + target: dict, + db_type: str, + mssql_cfg: MssqlConfig, +) -> dict[str, str]: + """TPC-H benchmark variables.""" + th = config.hammerdb.tproch + env = { + "TPROCH_DRIVER": get_db_driver(db_type), + "TPROCH_SCALE_FACTOR": str(th.scale_factor), + "TPROCH_BUILD_THREADS": str(th.build_threads), + "TPROCH_BUILD_VIRTUAL_USERS": str(th.build_virtual_users), + "TPROCH_VIRTUAL_USERS": str(th.load_virtual_users), + "TPROCH_TOTAL_QUERYSETS": str(th.total_querysets), + "TPROCH_LOG_TO_TEMP": "1", + } + if db_type == "mssql": + env["TPROCH_DATABASE_NAME"] = target.get("tproch", {}).get( + "databaseName", "tpch" + ) + env["TPROCH_USE_CLUSTERED_COLUMNSTORE"] = _bool_str( + mssql_cfg.tproch.use_clustered_columnstore + ) + env["TPROCH_MAXDOP"] = str(mssql_cfg.tproch.maxdop) + return env + + +def _pure_storage_env(config: HammerDBScaleConfig, target_index: int) -> dict[str, str]: + """Pure Storage metrics collection variables. + + Only the first target collects metrics, matching the Helm template, so that + N targets do not each hammer the array's REST API with identical queries. + """ + pure = config.storage_metrics.pure + env = { + "PURE_ENABLED": "true", + "PURE_COLLECT_METRICS": "true" if target_index == 0 else "false", + "PURE_HOST": pure.host, + "PURE_API_TOKEN": pure.api_token, + "PURE_INTERVAL": str(pure.poll_interval), + "PURE_NO_VERIFY_SSL": "false" if pure.verify_ssl else "true", + "PURE_API_VERSION": pure.api_version, + "PURE_OUTPUT": "/tmp/pure_metrics.json", + } + if pure.volume: + env["PURE_VOLUME"] = pure.volume + if pure.duration is not None: + env["PURE_DURATION"] = str(pure.duration) + return env diff --git a/src/hammerdb_scale/runtime/kubernetes.py b/src/hammerdb_scale/runtime/kubernetes.py new file mode 100644 index 0000000..bf00120 --- /dev/null +++ b/src/hammerdb_scale/runtime/kubernetes.py @@ -0,0 +1,128 @@ +"""Kubernetes execution backend. + +A thin adapter over the existing helm and kubectl code paths. It exists so the +CLI can treat Kubernetes and local containers uniformly; the underlying +behaviour is unchanged from before the backend abstraction was introduced. +""" + +from __future__ import annotations + +import shutil + +from hammerdb_scale.constants import get_chart_path +from hammerdb_scale.helm.deployer import helm_install, helm_list, helm_uninstall +from hammerdb_scale.k8s.jobs import ( + discover_jobs, + get_job_database_type, + get_job_duration, + get_job_logs, + get_job_status, + get_job_target_host, + get_job_target_name, +) +from hammerdb_scale.k8s.naming import generate_release_name, generate_run_hash +from hammerdb_scale.runtime.base import WorkloadRef + + +class KubernetesBackend: + """Runs HammerDB workloads as Kubernetes Jobs via Helm.""" + + name = "kubernetes" + + def __init__(self, namespace: str, config=None) -> None: + self.namespace = namespace + self.config = config + + def preflight(self) -> list[str]: + """Check that helm and kubectl are present and a cluster is reachable.""" + problems: list[str] = [] + for tool, url in ( + ("helm", "https://helm.sh/docs/intro/install/"), + ("kubectl", "https://kubernetes.io/docs/tasks/tools/"), + ): + if not shutil.which(tool): + problems.append(f"{tool} not found. Install from {url}") + return problems + + def deploy( + self, + targets: list[dict], + env_per_target: list[dict[str, str]], + *, + test_id: str, + phase: str, + benchmark: str, + image: str, + pull_policy: str = "Always", + dry_run: bool = False, + ) -> list[str]: + """Install a Helm release that creates one Job per target. + + The Helm chart derives the container environment from values, so + ``env_per_target`` is unused here. It stays in the signature because the + container backend needs it and both must satisfy one protocol. + """ + from hammerdb_scale.helm.values import generate_helm_values + + if self.config is None: + raise ValueError("KubernetesBackend requires a config to render values") + + run_hash = generate_run_hash(self.config.name, test_id) + release = generate_release_name(phase, run_hash) + values = generate_helm_values(self.config, phase, benchmark, test_id) + + helm_install(release, get_chart_path(), self.namespace, values, dry_run=dry_run) + + helm_phase = "load" if phase == "run" else phase + return [ + f"hdb-{helm_phase}-{i:02d}-{run_hash}" for i in range(len(targets)) + ] + + def list_workloads( + self, test_id: str, phase: str | None = None + ) -> list[WorkloadRef]: + """Find Jobs belonging to a test run.""" + jobs = discover_jobs(self.namespace, test_id, phase=phase) + workloads = [] + for job in jobs: + annotations = job.get("metadata", {}).get("annotations", {}) + try: + index = int(annotations.get("hammerdb.io/target-index", 0)) + except (TypeError, ValueError): + index = 0 + workloads.append( + WorkloadRef( + name=job.get("metadata", {}).get("name", ""), + target_name=get_job_target_name(job), + target_host=get_job_target_host(job), + database_type=get_job_database_type(job), + index=index, + status=get_job_status(job), + duration_seconds=get_job_duration(job), + ) + ) + return sorted(workloads, key=lambda w: w.index) + + def get_logs(self, workload_name: str, tail: int | None = None) -> str: + """Fetch logs from a Job's pod.""" + return get_job_logs(self.namespace, workload_name, tail=tail) + + def remove(self, test_id: str | None = None, everything: bool = False) -> int: + """Uninstall Helm releases for a test run, or all managed releases.""" + releases = helm_list(self.namespace) + removed = 0 + for release in releases: + name = release.get("name", "") + if not name.startswith("hdb-"): + continue + if not everything and test_id: + jobs = discover_jobs(self.namespace, test_id) + job_names = {j.get("metadata", {}).get("name", "") for j in jobs} + if not any(name in jn for jn in job_names): + continue + try: + helm_uninstall(name, self.namespace) + removed += 1 + except Exception: + continue + return removed diff --git a/templates/job-hammerdb-worker.yaml b/templates/job-hammerdb-worker.yaml index 60bcbe0..f7a026f 100644 --- a/templates/job-hammerdb-worker.yaml +++ b/templates/job-hammerdb-worker.yaml @@ -157,7 +157,9 @@ spec: - name: DURATION value: {{ $tproccConfig.duration | quote }} - name: TOTAL_ITERATIONS - value: {{ $tproccConfig.total_iterations | quote }} + # printf %.0f avoids Go rendering large ints in scientific notation + # (10000000 -> "1e+07"), which the TCL scripts cannot parse. + value: {{ printf "%.0f" (float64 $tproccConfig.total_iterations) | quote }} - name: TPROCC_LOG_TO_TEMP value: "0" - name: TPROCC_USE_TRANSACTION_COUNTER diff --git a/tests/test_container_backend.py b/tests/test_container_backend.py new file mode 100644 index 0000000..4b35968 --- /dev/null +++ b/tests/test_container_backend.py @@ -0,0 +1,173 @@ +"""Tests for the container backend's runtime-neutral parsing logic. + +podman and docker differ in how they render `ps --format json`, timestamps and +container state. These tests pin down the normalisation so a change in either +runtime's output surfaces here rather than as a wrong benchmark report. +""" + +from __future__ import annotations + +import json + +import pytest + +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + STATUS_RUNNING, +) +from hammerdb_scale.runtime.container import ( + LABEL_DB_TYPE, + LABEL_INDEX, + LABEL_TARGET, + LABEL_TARGET_HOST, + ContainerBackend, + _parse_ts, +) + + +@pytest.fixture +def backend(monkeypatch): + """A backend with runtime detection stubbed out.""" + monkeypatch.setattr( + "hammerdb_scale.runtime.container.detect_runtime", lambda preferred=None: "podman" + ) + return ContainerBackend() + + +class TestTimestampParsing: + def test_podman_native_go_format(self): + """podman renders Go's native time, not RFC3339.""" + parsed = _parse_ts("2026-07-24 23:32:08.696753997 -0600 MDT") + assert parsed is not None + assert parsed.year == 2026 + assert parsed.hour == 23 + + def test_rfc3339_with_offset(self): + parsed = _parse_ts("2026-07-24T23:32:08.696753997-06:00") + assert parsed is not None + assert parsed.minute == 32 + + def test_rfc3339_zulu(self): + parsed = _parse_ts("2026-07-24T05:21:54.123456789Z") + assert parsed is not None + assert parsed.hour == 5 + + def test_zero_value_means_not_finished(self): + """Both runtimes use year 1 for a container that has not exited.""" + assert _parse_ts("0001-01-01T00:00:00Z") is None + + def test_empty_and_garbage(self): + assert _parse_ts("") is None + assert _parse_ts("not a timestamp") is None + + +class TestPsJsonParsing: + def test_podman_emits_json_array(self): + stdout = json.dumps([{"Names": ["a"]}, {"Names": ["b"]}]) + assert len(ContainerBackend._parse_ps_json(stdout)) == 2 + + def test_docker_emits_newline_delimited_objects(self): + stdout = '{"Names":"a"}\n{"Names":"b"}\n' + assert len(ContainerBackend._parse_ps_json(stdout)) == 2 + + def test_single_object(self): + assert len(ContainerBackend._parse_ps_json('{"Names":"a"}')) == 1 + + def test_empty_and_malformed(self): + assert ContainerBackend._parse_ps_json("") == [] + assert ContainerBackend._parse_ps_json("not json") == [] + + def test_skips_malformed_lines_but_keeps_valid_ones(self): + stdout = '{"Names":"a"}\ngarbage\n{"Names":"b"}' + assert len(ContainerBackend._parse_ps_json(stdout)) == 2 + + +class TestStatusNormalisation: + def test_running(self): + assert ContainerBackend._normalise_status({"State": "running"}) == STATUS_RUNNING + + def test_exited_zero_is_completed(self): + entry = {"State": "exited", "ExitCode": 0} + assert ContainerBackend._normalise_status(entry) == STATUS_COMPLETED + + def test_exited_nonzero_is_failed(self): + entry = {"State": "exited", "ExitCode": 1} + assert ContainerBackend._normalise_status(entry) == STATUS_FAILED + + def test_docker_status_string_fallback(self): + """docker may omit ExitCode and encode it in the status text.""" + assert ( + ContainerBackend._normalise_status( + {"State": "exited", "Status": "Exited (0) 2 minutes ago"} + ) + == STATUS_COMPLETED + ) + assert ( + ContainerBackend._normalise_status( + {"State": "exited", "Status": "Exited (137) 1 minute ago"} + ) + == STATUS_FAILED + ) + + def test_created_counts_as_running(self): + """A created-but-not-started container has not failed.""" + assert ContainerBackend._normalise_status({"State": "created"}) == STATUS_RUNNING + + +class TestWorkloadMapping: + def _entry(self, **labels): + base = { + LABEL_TARGET: "ora-01", + LABEL_TARGET_HOST: "10.0.0.1", + LABEL_DB_TYPE: "oracle", + LABEL_INDEX: "3", + } + base.update(labels) + return { + "Names": ["hdb-run-03-t1"], + "State": "exited", + "ExitCode": 0, + "Labels": base, + } + + def test_maps_labels_onto_workload(self, backend, monkeypatch): + monkeypatch.setattr(backend, "_duration", lambda name: 42) + workload = backend._to_workload(self._entry()) + assert workload.name == "hdb-run-03-t1" + assert workload.target_name == "ora-01" + assert workload.target_host == "10.0.0.1" + assert workload.database_type == "oracle" + assert workload.index == 3 + assert workload.status == STATUS_COMPLETED + assert workload.duration_seconds == 42 + + def test_docker_string_labels(self, backend, monkeypatch): + """docker may return Labels as a comma-separated string.""" + monkeypatch.setattr(backend, "_duration", lambda name: None) + entry = { + "Names": "hdb-run-01-t1", + "State": "running", + "Labels": f"{LABEL_TARGET}=sql-02,{LABEL_INDEX}=1", + } + workload = backend._to_workload(entry) + assert workload.target_name == "sql-02" + assert workload.index == 1 + + def test_missing_labels_do_not_crash(self, backend, monkeypatch): + monkeypatch.setattr(backend, "_duration", lambda name: None) + workload = backend._to_workload({"Names": ["x"], "State": "running"}) + assert workload.target_name == "unknown" + assert workload.index == 0 + + def test_non_numeric_index_defaults_to_zero(self, backend, monkeypatch): + monkeypatch.setattr(backend, "_duration", lambda name: None) + workload = backend._to_workload(self._entry(**{LABEL_INDEX: "abc"})) + assert workload.index == 0 + + +class TestNaming: + def test_matches_kubernetes_job_naming(self): + """Names must line up with the Helm chart's job names.""" + assert ContainerBackend._container_name("run", 0, "abc") == "hdb-run-00-abc" + assert ContainerBackend._container_name("build", 11, "abc") == "hdb-build-11-abc" diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..51d809a --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,215 @@ +"""Tests for the shared container environment builder. + +These lock down the env-var contract that both backends depend on. If the +Kubernetes backend and the container backend disagree about a variable, the two +execution paths silently produce different benchmarks. +""" + +from __future__ import annotations + +import pytest + +from hammerdb_scale.config.defaults import expand_targets +from hammerdb_scale.config.schema import ( + HammerDBScaleConfig, + HammerDBConfig, + MssqlConfig, + MssqlTproccConfig, + OracleConfig, + OracleTproccConfig, + PureStorageConfig, + StorageMetricsConfig, + TargetDefaults, + TargetHost, + TargetsConfig, + TproccConfig, + TprochConfig, +) +from hammerdb_scale.runtime.environment import build_target_env, get_db_driver + + +def _oracle_config(**overrides) -> HammerDBScaleConfig: + base = dict( + name="ora", + targets=TargetsConfig( + defaults=TargetDefaults( + type="oracle", + username="system", + password="secret", + oracle=OracleConfig( + service="TPCC", + port=1521, + tprocc=OracleTproccConfig(user="TPCC", password="schemapw"), + ), + ), + hosts=[TargetHost(name="ora-01", host="10.0.0.1")], + ), + ) + base.update(overrides) + return HammerDBScaleConfig(**base) + + +def _mssql_config(**overrides) -> HammerDBScaleConfig: + base = dict( + name="sql", + targets=TargetsConfig( + defaults=TargetDefaults( + type="mssql", + username="sa", + password="secret", + mssql=MssqlConfig( + port=1433, + tprocc=MssqlTproccConfig(database_name="TPCC", use_bcp=True), + ), + ), + hosts=[TargetHost(name="sql-01", host="10.0.0.2")], + ), + ) + base.update(overrides) + return HammerDBScaleConfig(**base) + + +def _env_for(config, benchmark="tprocc", phase="run", index=0): + target = expand_targets(config)[index] + return build_target_env( + config, + target, + phase=phase, + benchmark=benchmark, + test_id="t1", + target_index=index, + ) + + +class TestDriverMapping: + def test_known_drivers(self): + assert get_db_driver("mssql") == "mssqls" + assert get_db_driver("oracle") == "oracle" + assert get_db_driver("postgres") == "pg" + + def test_unknown_driver_raises(self): + with pytest.raises(ValueError, match="Unsupported database type"): + get_db_driver("cassandra") + + +class TestPhaseMapping: + def test_run_phase_becomes_load(self): + """The CLI says "run"; entrypoint.sh expects "load".""" + assert _env_for(_oracle_config(), phase="run")["RUN_MODE"] == "load" + + def test_build_phase_unchanged(self): + assert _env_for(_oracle_config(), phase="build")["RUN_MODE"] == "build" + + +class TestOracleEnv: + def test_connection_vars(self): + env = _env_for(_oracle_config()) + assert env["HOST"] == "10.0.0.1" + assert env["ORACLE_SERVICE"] == "TPCC" + assert env["ORACLE_PORT"] == "1521" + assert env["DATABASE_TYPE"] == "oracle" + assert env["TPROCC_DRIVER"] == "oracle" + + def test_schema_credentials(self): + env = _env_for(_oracle_config()) + assert env["TPROCC_USER"] == "TPCC" + assert env["TPROCC_PASSWORD"] == "schemapw" + + def test_schema_password_falls_back_to_target_password(self): + """An unset schema password inherits the target's password.""" + config = _oracle_config() + config.targets.defaults.oracle.tprocc.password = "" + assert _env_for(config)["TPROCC_PASSWORD"] == "secret" + + def test_no_mssql_vars_leak_in(self): + env = _env_for(_oracle_config()) + assert "MSSQLS_PORT" not in env + assert "SQL_SERVER_HOST" not in env + + +class TestMssqlEnv: + def test_connection_vars(self): + env = _env_for(_mssql_config()) + assert env["SQL_SERVER_HOST"] == "10.0.0.2" + assert env["MSSQLS_PORT"] == "1433" + assert env["TPROCC_DRIVER"] == "mssqls" + assert env["TPROCC_DATABASE_NAME"] == "TPCC" + + def test_booleans_render_lowercase(self): + """TCL compares against the literal strings "true"/"false".""" + env = _env_for(_mssql_config()) + assert env["MSSQLS_ENCRYPT_CONNECTION"] == "true" + assert env["USE_BCP"] == "true" + + def test_no_oracle_vars_leak_in(self): + env = _env_for(_mssql_config()) + assert "ORACLE_SERVICE" not in env + assert "TPROCC_USER" not in env + + +class TestBenchmarkSelection: + def test_tprocc_excludes_tproch_vars(self): + env = _env_for(_oracle_config(), benchmark="tprocc") + assert "WAREHOUSES" in env + assert "TPROCH_SCALE_FACTOR" not in env + + def test_tproch_excludes_tprocc_vars(self): + env = _env_for(_oracle_config(), benchmark="tproch") + assert "TPROCH_SCALE_FACTOR" in env + assert "WAREHOUSES" not in env + + def test_large_integers_never_use_scientific_notation(self): + """Regression: Helm rendered 10000000 as "1e+07", which TCL rejects.""" + config = _oracle_config( + hammerdb=HammerDBConfig(tprocc=TproccConfig(total_iterations=10_000_000)) + ) + value = _env_for(config)["TOTAL_ITERATIONS"] + assert value == "10000000" + assert "e" not in value.lower() + + def test_all_values_are_strings(self): + """Container runtimes and K8s env both require string values.""" + for benchmark in ("tprocc", "tproch"): + env = _env_for(_oracle_config(), benchmark=benchmark) + assert all(isinstance(v, str) for v in env.values()) + + +class TestPureStorageEnv: + def _config(self): + return _oracle_config( + storage_metrics=StorageMetricsConfig( + enabled=True, + pure=PureStorageConfig(host="10.1.1.1", api_token="tok"), + ) + ) + + def test_disabled_by_default(self): + assert "PURE_ENABLED" not in _env_for(_oracle_config()) + + def test_only_first_target_collects(self): + """N targets must not each poll the array's REST API.""" + config = self._config() + config.targets.hosts.append(TargetHost(name="ora-02", host="10.0.0.9")) + assert _env_for(config, index=0)["PURE_COLLECT_METRICS"] == "true" + assert _env_for(config, index=1)["PURE_COLLECT_METRICS"] == "false" + + def test_verify_ssl_is_inverted_for_the_collector(self): + """The collector takes PURE_NO_VERIFY_SSL, the inverse of verify_ssl.""" + config = self._config() + config.storage_metrics.pure.verify_ssl = False + assert _env_for(config)["PURE_NO_VERIFY_SSL"] == "true" + + +class TestTprochEnv: + def test_mssql_only_vars_are_gated(self): + oracle_env = _env_for(_oracle_config(), benchmark="tproch") + assert "TPROCH_MAXDOP" not in oracle_env + + mssql_env = _env_for(_mssql_config(), benchmark="tproch") + assert mssql_env["TPROCH_MAXDOP"] == "2" + + def test_scale_factor_propagates(self): + config = _oracle_config( + hammerdb=HammerDBConfig(tproch=TprochConfig(scale_factor=100)) + ) + assert _env_for(config, benchmark="tproch")["TPROCH_SCALE_FACTOR"] == "100" From d48229835929f32adf66a04dacf2e5bb0b71bfc5 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Fri, 24 Jul 2026 23:54:43 -0600 Subject: [PATCH 02/14] Wire the backend abstraction through the CLI Route build, run, status, logs, results, report and clean through the selected backend rather than calling helm and kubectl directly. Kubernetes stays the default, and its rendered manifests are byte-identical to before. Adds runtime/resolve.py with backend-agnostic test-id resolution and completion waiting, replacing the Kubernetes-only _wait_for_jobs. The new waiter also prints the database error extracted from a failing workload's log instead of leaving the user to go and read logs themselves. aggregate_results now takes a backend instead of a namespace, which is what lets the results and report commands work on either path. Fixes a phase-label bug found by running the wired CLI end to end: containers were labelled phase=run while every caller filters on phase=load, the name Helm uses. --wait therefore never observed completion and timed out at 400s against containers that had exited successfully in 65s. The container backend now normalises the phase for both labelling and querying, so container names also match the Helm scheme (hdb-load-00-). Verified the full workflow on live Oracle via podman: run --wait, status, results, report (210KB scorecard with correct figures) and clean. Tests: 178 -> 181. Co-Authored-By: Claude Opus 5 (1M context) --- src/hammerdb_scale/clean/resources.py | 42 +++ src/hammerdb_scale/cli.py | 357 +++++++++++------------ src/hammerdb_scale/results/aggregator.py | 62 ++-- src/hammerdb_scale/runtime/container.py | 51 +++- src/hammerdb_scale/runtime/resolve.py | 149 ++++++++++ tests/test_container_backend.py | 25 +- 6 files changed, 455 insertions(+), 231 deletions(-) create mode 100644 src/hammerdb_scale/runtime/resolve.py diff --git a/src/hammerdb_scale/clean/resources.py b/src/hammerdb_scale/clean/resources.py index 27cd65c..d376aa1 100644 --- a/src/hammerdb_scale/clean/resources.py +++ b/src/hammerdb_scale/clean/resources.py @@ -9,6 +9,48 @@ from hammerdb_scale.results.storage import results_exist +def clean_container_resources( + backend, + test_id: str | None = None, + everything: bool = False, + force: bool = False, + results_dir=None, +) -> None: + """Remove containers created by the container backend.""" + from pathlib import Path + + if results_dir is None: + results_dir = Path("./results") + + workloads = backend.list_workloads(test_id) if test_id else [] + if everything: + console.print("\nRemoving all hammerdb-scale containers.") + elif not workloads: + console.print("No containers found to clean.") + return + else: + console.print(f"\nFound {len(workloads)} container(s):") + for w in workloads: + console.print(f" {w.name} ({w.status})") + + if test_id and not results_exist(test_id, results_dir) and not force: + print_warning( + f"Results for test '{test_id}' have not been aggregated.\n" + f" Run 'hammerdb-scale results --id {test_id}' first,\n" + f" or use --force to proceed without aggregating." + ) + if not typer.confirm("\nProceed anyway?"): + raise typer.Abort() + + if not force: + if not typer.confirm("\nRemove these containers?"): + raise typer.Abort() + + removed = backend.remove(test_id=test_id, everything=everything) + print_success(f"Removed {removed} container(s)") + console.print("\nContainers cleaned. Local results preserved in ./results/") + + def clean_resources( namespace: str, test_id: str | None = None, diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index 4d24764..e6026b5 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -19,7 +19,6 @@ DEFAULT_NAMESPACE, VERSION, ConfigError, - get_chart_path, ) from hammerdb_scale.output import console, print_error, print_success, print_warning from rich.table import Table @@ -710,15 +709,9 @@ def build( wait: bool = typer.Option(False, "--wait", help="Wait for all jobs to complete."), timeout: int = typer.Option(7200, "--timeout", help="Wait timeout in seconds."), ) -> None: - """Create database schemas. Wraps helm install with phase=build.""" - from hammerdb_scale.helm.deployer import helm_install - from hammerdb_scale.helm.values import generate_helm_values + """Create database schemas on every target.""" from hammerdb_scale.k8s.jobs import resolve_benchmark - from hammerdb_scale.k8s.naming import ( - generate_release_name, - generate_run_hash, - generate_test_id, - ) + from hammerdb_scale.k8s.naming import generate_test_id config_path = file or _state.get("file") config = load_config(discover_config_file(config_path)) @@ -730,8 +723,6 @@ def build( print_error(str(e)) raise typer.Exit(1) test_id = id or generate_test_id(config.name) - run_hash = generate_run_hash(config.name, test_id) - release_name = generate_release_name("build", run_hash) target_count = len(config.targets.hosts) from hammerdb_scale.output import print_banner @@ -747,27 +738,24 @@ def build( config.name, bm, target_count, f"SF {config.hammerdb.tproch.scale_factor}" ) - values = generate_helm_values(config, "build", bm, test_id) - chart_path = get_chart_path() - - console.print("Deploying jobs...") - result = helm_install(release_name, chart_path, ns, values, dry_run=dry_run) - + backend, names = _deploy( + config, ns, phase="build", benchmark=bm, test_id=test_id, dry_run=dry_run + ) if dry_run: - console.print(result.stdout) return - # Print deployed jobs - for i, host in enumerate(config.targets.hosts): - job_name = f"hdb-build-{i:02d}-{run_hash}" - print_success(f"{job_name} ({host.name})") + for name, host in zip(names, config.targets.hosts): + print_success(f"{name} ({host.name})") - console.print(f"\n{target_count} build jobs deployed to namespace '{ns}'.\n") + console.print(f"\n{target_count} build jobs deployed via {backend.name}.\n") console.print(f"Monitor progress: hammerdb-scale status --id {test_id}") console.print(f"View logs: hammerdb-scale logs --id {test_id}") if wait: - _wait_for_jobs(ns, test_id, "build", timeout) + from hammerdb_scale.runtime.resolve import wait_for_completion + + if not wait_for_completion(backend, test_id, "build", timeout): + raise typer.Exit(1) @app.command() @@ -789,17 +777,12 @@ def run( 3600, "--timeout", help="Wait timeout seconds for run phase." ), ) -> None: - """Execute the benchmark. Wraps helm install with phase=load.""" + """Execute the benchmark workload against every target.""" from hammerdb_scale.constants import BUILD_TIMEOUT_DEFAULT - from hammerdb_scale.helm.deployer import helm_install, helm_uninstall - from hammerdb_scale.helm.values import generate_helm_values from hammerdb_scale.k8s.jobs import resolve_benchmark - from hammerdb_scale.k8s.naming import ( - generate_release_name, - generate_run_hash, - generate_test_id, - ) + from hammerdb_scale.k8s.naming import generate_test_id from hammerdb_scale.output import print_banner + from hammerdb_scale.runtime.resolve import wait_for_completion config_path = file or _state.get("file") config = load_config(discover_config_file(config_path)) @@ -811,22 +794,21 @@ def run( print_error(str(e)) raise typer.Exit(1) test_id = id or generate_test_id(config.name) - run_hash = generate_run_hash(config.name, test_id) target_count = len(config.targets.hosts) # --build: build schemas first if build_first: console.print("Building schemas first...\n") - build_release = generate_release_name("build", run_hash) - build_values = generate_helm_values(config, "build", bm, test_id) - chart_path = get_chart_path() - - helm_install(build_release, chart_path, ns, build_values, dry_run=dry_run) + build_backend, _ = _deploy( + config, ns, phase="build", benchmark=bm, test_id=test_id, dry_run=dry_run + ) if dry_run: return console.print("Build jobs deployed. Waiting for completion...\n") - success = _wait_for_jobs(ns, test_id, "build", BUILD_TIMEOUT_DEFAULT) + success = wait_for_completion( + build_backend, test_id, "build", BUILD_TIMEOUT_DEFAULT + ) if not success: console.print( @@ -837,16 +819,12 @@ def run( raise typer.Exit(1) console.print("\n[green]All builds completed successfully.[/green]\n") - # Clean build release before proceeding + # Remove the finished build workloads before starting the run phase. try: - helm_uninstall(build_release, ns) + build_backend.remove(test_id=test_id) except Exception: pass # Non-fatal - run_release = generate_release_name("run", run_hash) - run_values = generate_helm_values(config, "run", bm, test_id) - chart_path = get_chart_path() - detail = "" if bm == "tprocc": vu = config.hammerdb.tprocc.load_virtual_users @@ -863,28 +841,27 @@ def run( detail = f"SF {config.hammerdb.tproch.scale_factor}" print_banner(config.name, bm, target_count, detail) - console.print("Deploying jobs...") - result = helm_install(run_release, chart_path, ns, run_values, dry_run=dry_run) - + backend, names = _deploy( + config, ns, phase="run", benchmark=bm, test_id=test_id, dry_run=dry_run + ) if dry_run: - console.print(result.stdout) return - for i, host in enumerate(config.targets.hosts): - job_name = f"hdb-run-{i:02d}-{run_hash}" + for i, (name, host) in enumerate(zip(names, config.targets.hosts)): extra = "" if i == 0 and config.storage_metrics.enabled: extra = " [Pure Storage collector active]" - print_success(f"{job_name} ({host.name}){extra}") + print_success(f"{name} ({host.name}){extra}") - console.print(f"\n{target_count} benchmark jobs deployed to namespace '{ns}'.\n") + console.print(f"\n{target_count} benchmark jobs deployed via {backend.name}.\n") console.print( f"When complete: hammerdb-scale results --benchmark {bm} --id {test_id}" ) console.print(f"Generate report: hammerdb-scale report --id {test_id}") if wait: - _wait_for_jobs(ns, test_id, "load", timeout) + if not wait_for_completion(backend, test_id, "load", timeout): + raise typer.Exit(1) @app.command() @@ -900,18 +877,12 @@ def status( import json as json_mod import time - from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_duration, - get_job_status, - get_job_target_host, - get_job_target_name, - resolve_test_id, - ) from hammerdb_scale.results.parsers import get_parser + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = _state.get("file") deploy_name = None + config = None try: config = load_config(discover_config_file(config_path)) ns = namespace or config.kubernetes.namespace @@ -919,34 +890,35 @@ def status( except ConfigError: ns = namespace or DEFAULT_NAMESPACE - test_id = resolve_test_id(id, ns, deployment_name=deploy_name) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=deploy_name) + bm = ( + config.default_benchmark.value + if config is not None and config.default_benchmark + else "tprocc" + ) while True: - jobs = discover_jobs(ns, test_id) - if not jobs: - console.print(f"No jobs found for test '{test_id}' in namespace '{ns}'.") + workloads = backend.list_workloads(test_id) + if not workloads: + console.print(f"No jobs found for test '{test_id}'.") raise typer.Exit(1) - first_labels = jobs[0].get("metadata", {}).get("labels", {}) - phase = first_labels.get("hammerdb.io/phase", "unknown") - bm = first_labels.get("hammerdb.io/benchmark", "unknown") - if json_output: - data = [] - for job in jobs: - data.append( - { - "target": get_job_target_name(job), - "host": get_job_target_host(job), - "status": get_job_status(job), - "duration": get_job_duration(job), - } - ) + data = [ + { + "target": w.target_name, + "host": w.target_host, + "status": w.status, + "duration": w.duration_seconds, + } + for w in workloads + ] console.print(json_mod.dumps(data, indent=2)) return console.print(f"\nTest: {test_id}") - console.print(f"Phase: {phase} | Benchmark: {bm} | Namespace: {ns}\n") + console.print(f"Benchmark: {bm} | Backend: {backend.name}\n") table = Table() table.add_column("#", style="dim") @@ -964,12 +936,11 @@ def status( failed = 0 running = 0 - for i, job in enumerate(jobs): - job_status = get_job_status(job) - duration = get_job_duration(job) - dur_str = _format_duration(duration) if duration else "-" - target_name = get_job_target_name(job) - target_host = get_job_target_host(job) + for i, w in enumerate(workloads): + job_status = w.status + dur_str = ( + _format_duration(w.duration_seconds) if w.duration_seconds else "-" + ) status_style = ( "green" @@ -985,18 +956,9 @@ def status( if job_status == "Completed": completed += 1 - db_type = ( - job.get("metadata", {}) - .get("labels", {}) - .get("hammerdb.io/database-type", "oracle") - ) try: - parser = get_parser(db_type) - from hammerdb_scale.k8s.jobs import get_job_logs - - log_text = get_job_logs( - ns, job.get("metadata", {}).get("name", ""), tail=200 - ) + parser = get_parser(w.database_type) + log_text = backend.get_logs(w.name, tail=200) if bm == "tprocc": result = parser.parse_tprocc(log_text) if result: @@ -1015,8 +977,8 @@ def status( row = [ str(i), - target_name, - target_host, + w.target_name, + w.target_host, f"[{status_style}]{job_status}[/{status_style}]", dur_str, ] @@ -1050,15 +1012,11 @@ def logs( tail: int = typer.Option(100, "--tail", help="Lines from end."), ) -> None: """Stream or fetch logs from benchmark jobs.""" - from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_logs, - get_job_target_name, - resolve_test_id, - ) + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = _state.get("file") deploy_name = None + config = None try: config = load_config(discover_config_file(config_path)) ns = namespace or config.kubernetes.namespace @@ -1066,33 +1024,27 @@ def logs( except ConfigError: ns = namespace or DEFAULT_NAMESPACE - test_id = resolve_test_id(id, ns, deployment_name=deploy_name) - jobs = discover_jobs(ns, test_id) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=deploy_name) + workloads = backend.list_workloads(test_id) - if not jobs: + if not workloads: console.print(f"No jobs found for test '{test_id}'.") raise typer.Exit(1) if target: - # Find job for specific target - matching = [j for j in jobs if get_job_target_name(j) == target] + matching = [w for w in workloads if w.target_name == target] if not matching: console.print(f"[red]No job found for target '{target}'.[/red]") raise typer.Exit(1) - job = matching[0] - job_name = job.get("metadata", {}).get("name", "") - log_text = get_job_logs(ns, job_name, tail=tail, follow=follow) - console.print(log_text) + console.print(backend.get_logs(matching[0].name, tail=tail)) else: # Show all logs, prefixed with target name colors = ["cyan", "green", "yellow", "magenta", "blue", "red"] - for i, job in enumerate(jobs): - target_name = get_job_target_name(job) - job_name = job.get("metadata", {}).get("name", "") + for i, w in enumerate(workloads): color = colors[i % len(colors)] - log_text = get_job_logs(ns, job_name, tail=tail) - for line in log_text.splitlines(): - console.print(f"[{color}]{target_name}[/{color}] {line}") + for line in backend.get_logs(w.name, tail=tail).splitlines(): + console.print(f"[{color}]{w.target_name}[/{color}] {line}") @app.command() @@ -1113,23 +1065,25 @@ def results( """Aggregate results from completed jobs.""" import json as json_mod - from hammerdb_scale.k8s.jobs import resolve_benchmark, resolve_test_id + from hammerdb_scale.k8s.jobs import resolve_benchmark from hammerdb_scale.results.aggregator import aggregate_results from hammerdb_scale.results.storage import load_results, save_results + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = file or _state.get("file") config = load_config(discover_config_file(config_path)) ns = namespace or config.kubernetes.namespace bm = resolve_benchmark(benchmark, config, "results") - test_id = resolve_test_id(id, ns, deployment_name=config.name) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=config.name) results_dir = output or Path("./results") - # Try aggregating from K8s first, fall back to local + # Try aggregating from the backend first, fall back to stored results try: summary, logs_dict, pure_metrics = aggregate_results( - config, ns, test_id, bm, results_dir + config, backend, test_id, bm, results_dir ) # Only save if K8s returned actual target data if summary.get("targets"): @@ -1181,12 +1135,12 @@ def report( """Generate a self-contained HTML scorecard.""" import webbrowser - from hammerdb_scale.k8s.jobs import resolve_test_id from hammerdb_scale.results.storage import ( load_results, load_pure_metrics, results_exist, ) + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = file or _state.get("file") config = None @@ -1198,7 +1152,8 @@ def report( except ConfigError: ns = DEFAULT_NAMESPACE - test_id = resolve_test_id(id, ns, deployment_name=deploy_name) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=deploy_name) results_dir = Path("./results") # Auto-run results if not aggregated yet @@ -1217,7 +1172,7 @@ def report( config.default_benchmark.value if config.default_benchmark else "tprocc" ) summary, logs_dict, pure_metrics = aggregate_results( - config, ns, test_id, bm, results_dir + config, backend, test_id, bm, results_dir ) if summary.get("targets"): save_results(test_id, summary, logs_dict, pure_metrics, results_dir) @@ -1320,20 +1275,32 @@ def clean( config_path = file or _state.get("file") if resources: + clean_config = None try: - config = load_config(discover_config_file(config_path)) - ns = namespace or config.kubernetes.namespace + clean_config = load_config(discover_config_file(config_path)) + ns = namespace or clean_config.kubernetes.namespace except ConfigError: ns = namespace or DEFAULT_NAMESPACE - from hammerdb_scale.clean.resources import clean_resources + backend = _backend_for(clean_config, ns) + if backend.name == "kubernetes": + from hammerdb_scale.clean.resources import clean_resources - clean_resources( - namespace=ns, - test_id=id, - everything=everything, - force=force, - ) + clean_resources( + namespace=ns, + test_id=id, + everything=everything, + force=force, + ) + else: + from hammerdb_scale.clean.resources import clean_container_resources + + clean_container_resources( + backend, + test_id=id, + everything=everything, + force=force, + ) if database: config = load_config(discover_config_file(config_path)) @@ -1349,64 +1316,82 @@ def clean( ) -def _format_duration(seconds: int | None) -> str: - """Format seconds as 'Xm Ys'.""" - if seconds is None: - return "-" - minutes = seconds // 60 - secs = seconds % 60 - if minutes > 0: - return f"{minutes}m {secs:02d}s" - return f"{secs}s" +def _backend_for(config: Optional[HammerDBScaleConfig], namespace: str): + """Get a backend, falling back to Kubernetes when no config was found. + Commands like `status` and `logs` can run without a config file, in which + case there is nothing to select a backend from. + """ + from hammerdb_scale.runtime import get_backend + from hammerdb_scale.runtime.kubernetes import KubernetesBackend -def _wait_for_jobs(namespace: str, test_id: str, phase: str, timeout: int) -> bool: - """Poll job status until all complete or timeout. Returns True if all succeeded.""" - import time + if config is None: + return KubernetesBackend(namespace=namespace) + return get_backend(config, namespace) - from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_status, - get_job_target_name, - ) - from hammerdb_scale.constants import POLL_INTERVAL - start = time.time() - while time.time() - start < timeout: - jobs = discover_jobs(namespace, test_id, phase=phase) - if not jobs: - time.sleep(POLL_INTERVAL) - continue +def _deploy( + config: HammerDBScaleConfig, + namespace: str, + *, + phase: str, + benchmark: str, + test_id: str, + dry_run: bool = False, +): + """Deploy one workload per target using the configured backend. + + Returns the backend and the workload names it created. + """ + from hammerdb_scale.config.defaults import expand_targets + from hammerdb_scale.runtime import get_backend + from hammerdb_scale.runtime.environment import build_target_env - statuses = [(get_job_target_name(j), get_job_status(j)) for j in jobs] - completed = sum(1 for _, s in statuses if s == "Completed") - failed = sum(1 for _, s in statuses if s == "Failed") - total = len(statuses) + backend = get_backend(config, namespace) - # Show progress - console.print( - f" [{completed}/{total}] completed, {failed} failed", - end="\r", - ) + problems = backend.preflight() + if problems: + for problem in problems: + print_error(problem) + raise typer.Exit(1) - if completed + failed >= total: - console.print() # New line after progress - if failed > 0: - for name, s in statuses: - if s == "Failed": - print_error(f"{name} Failed") - else: - print_success(f"{name} {s}") - return False - else: - for name, s in statuses: - print_success(f"{name} {s}") - return True + targets = expand_targets(config) + envs = [ + build_target_env( + config, + target, + phase=phase, + benchmark=benchmark, + test_id=test_id, + target_index=i, + ) + for i, target in enumerate(targets) + ] + + image = config.targets.defaults.image + console.print(f"Deploying jobs via {backend.name}...") + names = backend.deploy( + targets, + envs, + test_id=test_id, + phase=phase, + benchmark=benchmark, + image=f"{image.repository}:{image.tag}", + pull_policy=image.pull_policy.value, + dry_run=dry_run, + ) + return backend, names - time.sleep(POLL_INTERVAL) - console.print(f"\n[yellow]Timeout ({timeout}s) reached.[/yellow]") - return False +def _format_duration(seconds: int | None) -> str: + """Format seconds as 'Xm Ys'.""" + if seconds is None: + return "-" + minutes = seconds // 60 + secs = seconds % 60 + if minutes > 0: + return f"{minutes}m {secs:02d}s" + return f"{secs}s" def _display_results_table(summary: dict, benchmark: str) -> None: diff --git a/src/hammerdb_scale/results/aggregator.py b/src/hammerdb_scale/results/aggregator.py index 65e68a7..28474e0 100644 --- a/src/hammerdb_scale/results/aggregator.py +++ b/src/hammerdb_scale/results/aggregator.py @@ -9,15 +9,6 @@ from hammerdb_scale.config.schema import HammerDBScaleConfig from hammerdb_scale.constants import VERSION -from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_database_type, - get_job_duration, - get_job_logs, - get_job_status, - get_job_target_host, - get_job_target_name, -) from hammerdb_scale.results.parsers import get_parser @@ -137,28 +128,25 @@ def _extract(pattern: str, *keys: str) -> None: def aggregate_results( config: HammerDBScaleConfig, - namespace: str, + backend, test_id: str, benchmark: str, results_dir: Path = Path("./results"), ) -> dict: - """Aggregate results from all jobs for a test run.""" - # Determine phase to look for (run jobs have phase=load in Helm) - jobs = discover_jobs(namespace, test_id, phase="load") - if not jobs: - jobs = discover_jobs(namespace, test_id, phase="run") - if not jobs: - jobs = discover_jobs(namespace, test_id) - - # Sort jobs by target index - def _sort_key(j): - labels = j.get("metadata", {}).get("labels", {}) - try: - return int(labels.get("hammerdb.io/target-index", "99")) - except ValueError: - return 99 - - jobs.sort(key=_sort_key) + """Aggregate results from all workloads for a test run. + + ``backend`` is any object satisfying the Backend protocol, so this works + identically for Kubernetes Jobs and local containers. + """ + # The run phase is recorded as "load" by the entrypoint; fall back through + # the other spellings for older runs. + workloads = backend.list_workloads(test_id, phase="load") + if not workloads: + workloads = backend.list_workloads(test_id, phase="run") + if not workloads: + workloads = backend.list_workloads(test_id) + + workloads = sorted(workloads, key=lambda w: w.index) # Determine db type db_type = "oracle" @@ -170,17 +158,15 @@ def _sort_key(j): logs_dict: dict[str, str] = {} pure_metrics = None - for i, job in enumerate(jobs): - job_name = job.get("metadata", {}).get("name", "") - target_name = get_job_target_name(job) - target_host = get_job_target_host(job) - job_db_type = get_job_database_type(job) - if job_db_type != "unknown": - db_type = job_db_type - - status = get_job_status(job) - duration = get_job_duration(job) - log_text = get_job_logs(namespace, job_name) + for i, w in enumerate(workloads): + target_name = w.target_name + target_host = w.target_host + if w.database_type != "unknown": + db_type = w.database_type + + status = w.status + duration = w.duration_seconds + log_text = backend.get_logs(w.name) logs_dict[target_name] = log_text diff --git a/src/hammerdb_scale/runtime/container.py b/src/hammerdb_scale/runtime/container.py index ff0d162..06c0f71 100644 --- a/src/hammerdb_scale/runtime/container.py +++ b/src/hammerdb_scale/runtime/container.py @@ -23,7 +23,7 @@ from datetime import datetime, timezone from pathlib import Path -from hammerdb_scale.constants import HammerDBScaleError, get_chart_path +from hammerdb_scale.constants import PHASE_MAP, HammerDBScaleError, get_chart_path from hammerdb_scale.runtime.base import ( STATUS_COMPLETED, STATUS_FAILED, @@ -112,8 +112,18 @@ def _scripts_path(self, database_type: str) -> Path: return path @staticmethod - def _container_name(phase: str, index: int, test_id: str) -> str: - return f"hdb-{phase}-{index:02d}-{test_id}" + def _normalise_phase(phase: str) -> str: + """Map the CLI phase onto the label the Kubernetes path records. + + The CLI says "run" but Helm and the entrypoint both record "load". + Containers must use the same vocabulary or callers that filter by phase + silently find nothing. + """ + return PHASE_MAP.get(phase, phase) + + @classmethod + def _container_name(cls, phase: str, index: int, test_id: str) -> str: + return f"hdb-{cls._normalise_phase(phase)}-{index:02d}-{test_id}" # --- Backend protocol --- @@ -151,6 +161,7 @@ def deploy( if pull_policy == "Always" and not dry_run: self._run(["pull", image], timeout=1800, check=False) + label_phase = self._normalise_phase(phase) names: list[str] = [] for index, (target, env) in enumerate(zip(targets, env_per_target)): name = self._container_name(phase, index, test_id) @@ -163,7 +174,7 @@ def deploy( name, "--label", f"{LABEL_MANAGED}={MANAGED_VALUE}", "--label", f"{LABEL_TEST_ID}={test_id}", - "--label", f"{LABEL_PHASE}={phase}", + "--label", f"{LABEL_PHASE}={label_phase}", "--label", f"{LABEL_TARGET}={target['name']}", "--label", f"{LABEL_TARGET_HOST}={target['host']}", "--label", f"{LABEL_DB_TYPE}={target['type']}", @@ -234,7 +245,9 @@ def list_workloads( "--filter", f"label={LABEL_TEST_ID}={test_id}", ] if phase: - filters.extend(["--filter", f"label={LABEL_PHASE}={phase}"]) + filters.extend( + ["--filter", f"label={LABEL_PHASE}={self._normalise_phase(phase)}"] + ) result = self._run( ["ps", "--all", "--format", "json"] + filters, check=False @@ -396,6 +409,34 @@ def wait(self, workload_names: list[str], timeout: int) -> None: return self._run(["wait"] + workload_names, timeout=timeout, check=False) + def find_test_ids(self) -> list[str]: + """List test IDs that have managed containers, most recent first.""" + result = self._run( + [ + "ps", + "--all", + "--filter", + f"label={LABEL_MANAGED}={MANAGED_VALUE}", + "--format", + "json", + ], + check=False, + ) + if result.returncode != 0 or not result.stdout.strip(): + return [] + + seen: list[str] = [] + for entry in self._parse_ps_json(result.stdout): + labels = entry.get("Labels") or {} + if isinstance(labels, str): + labels = dict( + pair.split("=", 1) for pair in labels.split(",") if "=" in pair + ) + test_id = labels.get(LABEL_TEST_ID) + if test_id and test_id not in seen: + seen.append(test_id) + return seen + def _parse_ts(value: str) -> datetime | None: """Parse a container runtime timestamp. diff --git a/src/hammerdb_scale/runtime/resolve.py b/src/hammerdb_scale/runtime/resolve.py new file mode 100644 index 0000000..9e01af6 --- /dev/null +++ b/src/hammerdb_scale/runtime/resolve.py @@ -0,0 +1,149 @@ +"""Backend-agnostic test-run resolution and completion waiting. + +The Kubernetes path resolves a test ID by inspecting Helm releases; the +container path inspects container labels. Both then fall back to the local +results directory. Keeping that logic here means CLI commands do not branch on +backend type. +""" + +from __future__ import annotations + +import time +from pathlib import Path + +from hammerdb_scale.constants import POLL_INTERVAL, NoResultsError +from hammerdb_scale.output import console, print_error, print_success +from hammerdb_scale.runtime.base import STATUS_COMPLETED, STATUS_FAILED + + +def resolve_test_id( + backend, + cli_id: str | None, + *, + results_dir: Path = Path("./results"), + deployment_name: str | None = None, +) -> str: + """Decide which test run a command should act on. + + Order of precedence: an explicit --id, then the most recent run the backend + knows about, then the most recent locally stored results. + """ + if cli_id: + return cli_id + + backend_id = _find_backend_test_id(backend, deployment_name) + if backend_id: + return backend_id + + local_ids = _find_local_test_ids(results_dir) + if deployment_name and len(local_ids) > 1: + filtered = [t for t in local_ids if t.startswith(deployment_name + "-")] + if filtered: + local_ids = filtered + + if local_ids: + return sorted(local_ids)[-1] + + raise NoResultsError( + "No test runs found. Run a benchmark first, or pass --id explicitly." + ) + + +def _find_backend_test_id(backend, deployment_name: str | None) -> str | None: + """Ask the backend for its most recent test ID.""" + finder = getattr(backend, "find_test_ids", None) + if finder is not None: + try: + for test_id in finder(): + if deployment_name and not test_id.startswith(deployment_name + "-"): + continue + return test_id + except Exception: + return None + return None + + # Kubernetes backend: reuse the existing Helm-release-based lookup. + try: + from hammerdb_scale.k8s.jobs import _find_most_recent_k8s_test_id + + return _find_most_recent_k8s_test_id(backend.namespace, deployment_name) + except Exception: + return None + + +def _find_local_test_ids(results_dir: Path) -> list[str]: + """Scan the results directory for previously stored runs.""" + if not results_dir.exists(): + return [] + return [ + d.name + for d in results_dir.iterdir() + if d.is_dir() and (d / "summary.json").exists() + ] + + +def wait_for_completion( + backend, + test_id: str, + phase: str, + timeout: int, + *, + show_errors: bool = True, +) -> bool: + """Poll until every workload finishes or the timeout elapses. + + Returns True only if all workloads completed successfully. On failure the + error extracted from each failing workload's log is printed, so the user + does not have to go and read logs to find out what went wrong. + """ + start = time.time() + while time.time() - start < timeout: + workloads = backend.list_workloads(test_id, phase=phase) + if not workloads: + time.sleep(POLL_INTERVAL) + continue + + completed = sum(1 for w in workloads if w.status == STATUS_COMPLETED) + failed = sum(1 for w in workloads if w.status == STATUS_FAILED) + total = len(workloads) + + console.print( + f" [{completed}/{total}] completed, {failed} failed", end="\r" + ) + + if completed + failed >= total: + console.print() + for w in workloads: + if w.status == STATUS_FAILED: + print_error(f"{w.target_name} Failed") + if show_errors: + _print_failure_reason(backend, w) + else: + print_success(f"{w.target_name} {w.status}") + return failed == 0 + + time.sleep(POLL_INTERVAL) + + console.print(f"\n[yellow]Timeout ({timeout}s) reached.[/yellow]") + return False + + +def _print_failure_reason(backend, workload) -> None: + """Surface the database error inline instead of making the user dig.""" + try: + from hammerdb_scale.results.parsers import get_parser + + log_text = backend.get_logs(workload.name, tail=200) + if not log_text: + return + try: + error = get_parser(workload.database_type).detect_error(log_text) + except ValueError: + error = None + if error: + console.print(f" [red]{error.strip()}[/red]") + console.print( + f" [dim]Full log: hammerdb-scale logs --target {workload.target_name}[/dim]" + ) + except Exception: + return diff --git a/tests/test_container_backend.py b/tests/test_container_backend.py index 4b35968..22e85bd 100644 --- a/tests/test_container_backend.py +++ b/tests/test_container_backend.py @@ -166,8 +166,29 @@ def test_non_numeric_index_defaults_to_zero(self, backend, monkeypatch): assert workload.index == 0 +class TestPhaseNormalisation: + """The CLI says "run"; Helm, the entrypoint and the labels all say "load". + + Regression: containers were labelled phase=run while callers filtered on + phase=load, so --wait never saw them finish and timed out. + """ + + def test_run_becomes_load(self): + assert ContainerBackend._normalise_phase("run") == "load" + + def test_build_is_unchanged(self): + assert ContainerBackend._normalise_phase("build") == "build" + + def test_load_is_idempotent(self): + assert ContainerBackend._normalise_phase("load") == "load" + + class TestNaming: def test_matches_kubernetes_job_naming(self): - """Names must line up with the Helm chart's job names.""" - assert ContainerBackend._container_name("run", 0, "abc") == "hdb-run-00-abc" + """Names must line up with the Helm chart's job names. + + Helm renders hdb--- with phase already mapped to + "load", so the container names must map it too. + """ + assert ContainerBackend._container_name("run", 0, "abc") == "hdb-load-00-abc" assert ContainerBackend._container_name("build", 11, "abc") == "hdb-build-11-abc" From a8bc9979229f53b52210067a972129f453423863 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 09:21:04 -0600 Subject: [PATCH 03/14] Upgrade to HammerDB 6.0 and remove hardcoded version paths The version was hardcoded in 22 places across entrypoint.sh, both Dockerfiles and both chart copies, split across the image (where HammerDB installs) and the chart (where scripts get mounted). A mismatched pair produces a container that starts and then cannot find its TCL scripts. Now defined once as ARG HAMMERDB_VERSION in the Dockerfile, exported as HAMMERDB_HOME, and mirrored by DEFAULT_HAMMERDB_VERSION for chart rendering. entrypoint.sh reads HAMMERDB_HOME and falls back to a glob probe, so it still works in images built before that variable existed. A future bump is one build arg and one constant. Also fixes two latent coupling issues found on the way: - entrypoint.sh lived inside the scripts directory that gets overmounted at runtime; moved to /usr/local/bin - the Oracle Instant Client check was pinned to instantclient_21_11, so a client upgrade would have silently failed the check; now version-agnostic Validated against live hardware with locally-built 6.0 images: - Oracle 26ai read its existing 5.0-built 10,000-warehouse schema with no rebuild required, which was the main upgrade risk - log formats are unchanged, so the existing parsers work untouched - same-host comparison: 5.0 19,398 TPM vs 6.0 18,882 TPM, a 2.7% delta consistent with run-to-run variance on a 1-minute test - SQL Server 2025 also verified end to end Images are built and tested locally only; publishing to a registry is a separate step. Chart appVersion moved to 6.0. Adds test_hammerdb_version.py, including a chart-sync test that fails if the repo-root chart and the packaged chart drift apart. get_chart_path() prefers the packaged copy, so that drift would otherwise mean fixes never reach anyone installing from PyPI. Tests: 181 -> 193. Co-Authored-By: Claude Opus 5 (1M context) --- Chart.yaml | 2 +- Dockerfile.oracle | 9 +- dockerfile | 40 +++++--- entrypoint.sh | 46 ++++++--- src/hammerdb_scale/chart/Chart.yaml | 2 +- .../chart/templates/job-hammerdb-worker.yaml | 5 +- src/hammerdb_scale/config/schema.py | 4 + src/hammerdb_scale/constants.py | 6 ++ src/hammerdb_scale/helm/values.py | 5 +- src/hammerdb_scale/runtime/__init__.py | 1 + src/hammerdb_scale/runtime/container.py | 48 ++++++++- templates/job-hammerdb-worker.yaml | 5 +- tests/test_hammerdb_version.py | 99 +++++++++++++++++++ 13 files changed, 233 insertions(+), 39 deletions(-) create mode 100644 tests/test_hammerdb_version.py diff --git a/Chart.yaml b/Chart.yaml index ca9252b..556f3e7 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -3,7 +3,7 @@ name: hammerdb-scale description: Modular Helm chart for parallel HammerDB scale testing across multiple databases (SQL Server, PostgreSQL, Oracle, MySQL) type: application version: 2.0.2 -appVersion: "5.0" +appVersion: "6.0" keywords: - hammerdb - benchmark diff --git a/Dockerfile.oracle b/Dockerfile.oracle index 3f30b65..e5f3e36 100644 --- a/Dockerfile.oracle +++ b/Dockerfile.oracle @@ -9,10 +9,15 @@ # docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:latest . # docker push myregistry/hammerdb-scale-oracle:latest # -# The base image (sillidata/hammerdb-scale) includes HammerDB 5.0 and drivers for +# The base image (sillidata/hammerdb-scale) includes HammerDB and drivers for # SQL Server, PostgreSQL, and MySQL. This extension adds Oracle support. +# +# To build against a locally-built base image: +# docker build -f Dockerfile.oracle --build-arg BASE_IMAGE=hammerdb-scale:local \ +# -t hammerdb-scale-oracle:local . -FROM sillidata/hammerdb-scale:latest +ARG BASE_IMAGE=sillidata/hammerdb-scale:latest +FROM ${BASE_IMAGE} LABEL maintainer="hammerdb-scale" LABEL description="HammerDB Scale - Oracle Extension" diff --git a/dockerfile b/dockerfile index 8266e82..b480cce 100644 --- a/dockerfile +++ b/dockerfile @@ -1,24 +1,33 @@ # HammerDB Scale - Base Image # Supports: SQL Server, PostgreSQL, MySQL (Oracle requires extension image) # -# This is the public base image containing HammerDB 5.0 and open-source database drivers. -# For Oracle support, build the extension image using Dockerfile.oracle +# This is the public base image containing HammerDB and open-source database +# drivers. For Oracle support, build the extension image using Dockerfile.oracle # # BUILD: # docker build -t sillidata/hammerdb-scale:latest . # +# To build a different HammerDB version: +# docker build --build-arg HAMMERDB_VERSION=5.0 -t sillidata/hammerdb-scale:5.0 . +# # ORACLE USERS: # docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:latest . FROM ubuntu:24.04 +# The HammerDB version is defined once here and flows into the install path, +# HAMMERDB_HOME and the image label. entrypoint.sh reads HAMMERDB_HOME rather +# than hardcoding a path, so a version bump is this one argument. +ARG HAMMERDB_VERSION=6.0 + LABEL maintainer="hammerdb-scale" LABEL description="HammerDB Scale Test Runner - Multi-Database Performance Testing" -LABEL hammerdb.version="5.0" +LABEL hammerdb.version="${HAMMERDB_VERSION}" LABEL database.support="mssql,postgresql,mysql (oracle via extension)" # Set environment variables ENV DEBIAN_FRONTEND=noninteractive +ENV HAMMERDB_HOME=/opt/HammerDB-${HAMMERDB_VERSION} # Install base packages and SQL Server drivers (Microsoft - permissive license) RUN apt-get update && \ @@ -43,27 +52,26 @@ RUN apt-get update && \ # Install Python dependencies for Pure Storage metrics collection RUN python3 -m pip install --no-cache-dir --break-system-packages requests urllib3 -# Install HammerDB 5.0 +# Install HammerDB WORKDIR /opt -RUN wget https://github.com/TPC-Council/HammerDB/releases/download/v5.0/HammerDB-5.0-Prod-Lin-UBU24.tar.gz && \ - tar -xzf HammerDB-5.0-Prod-Lin-UBU24.tar.gz && \ - rm HammerDB-5.0-Prod-Lin-UBU24.tar.gz && \ +RUN wget "https://github.com/TPC-Council/HammerDB/releases/download/v${HAMMERDB_VERSION}/HammerDB-${HAMMERDB_VERSION}-Prod-Lin-UBU24.tar.gz" && \ + tar -xzf "HammerDB-${HAMMERDB_VERSION}-Prod-Lin-UBU24.tar.gz" && \ + rm "HammerDB-${HAMMERDB_VERSION}-Prod-Lin-UBU24.tar.gz" && \ echo 'export LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/:$LD_LIBRARY_PATH' >> ~/.bashrc # Configure HammerDB -WORKDIR /opt/HammerDB-5.0 -RUN chmod +x ./hammerdbcli && \ - ln -sf /opt/mssql-tools18/bin/bcp /opt/HammerDB-5.0/bcp && \ +RUN chmod +x "${HAMMERDB_HOME}/hammerdbcli" && \ + ln -sf /opt/mssql-tools18/bin/bcp "${HAMMERDB_HOME}/bcp" && \ ln -sf /opt/mssql-tools18/bin/bcp /usr/local/bin/bcp # Add entrypoint script -COPY entrypoint.sh /opt/HammerDB-5.0/entrypoint.sh -RUN chmod +x /opt/HammerDB-5.0/entrypoint.sh +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh # Add Pure Storage metrics collection script -COPY scripts/collect_pure_metrics.py /opt/HammerDB-5.0/scripts/collect_pure_metrics.py -RUN chmod +x /opt/HammerDB-5.0/scripts/collect_pure_metrics.py +COPY scripts/collect_pure_metrics.py ${HAMMERDB_HOME}/scripts/collect_pure_metrics.py +RUN chmod +x "${HAMMERDB_HOME}/scripts/collect_pure_metrics.py" -WORKDIR /opt/HammerDB-5.0 +WORKDIR ${HAMMERDB_HOME} -ENTRYPOINT ["/opt/HammerDB-5.0/entrypoint.sh"] +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index f7f0808..ea68b9e 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -14,6 +14,29 @@ log() { # Set TMPDIR if not already set export TMPDIR="${TMPDIR:-/tmp}" +# Resolve the HammerDB installation directory. +# +# HAMMERDB_HOME is set by the Dockerfile so the version lives in exactly one +# place. The glob fallback keeps this script working in images built before +# that variable existed, and in any image where HammerDB was installed to a +# differently-versioned path. +if [ -z "$HAMMERDB_HOME" ]; then + for candidate in /opt/HammerDB-*; do + if [ -x "$candidate/hammerdbcli" ]; then + HAMMERDB_HOME="$candidate" + break + fi + done +fi + +if [ -z "$HAMMERDB_HOME" ] || [ ! -x "$HAMMERDB_HOME/hammerdbcli" ]; then + log "ERROR: Could not locate a HammerDB installation." + log " Set HAMMERDB_HOME, or install HammerDB under /opt/HammerDB-." + exit 1 +fi +export HAMMERDB_HOME +SCRIPT_DIR="$HAMMERDB_HOME/scripts" + # Ensure all required environment variables are set if [ -z "$USERNAME" ] || [ -z "$PASSWORD" ] || [ -z "$HOST" ] || [ -z "$BENCHMARK" ]; then log "ERROR: Environment variables USERNAME, PASSWORD, HOST and BENCHMARK must be set." @@ -55,8 +78,9 @@ case "$DATABASE_TYPE" in ;; oracle) log "Database: Oracle" - # Check if Oracle Instant Client is installed - if [ ! -d "/opt/oracle/instantclient_21_11" ]; then + # Check if Oracle Instant Client is installed. Match any version so a + # client upgrade does not require editing this script. + if ! compgen -G "/opt/oracle/instantclient_*" > /dev/null; then log "" log "============================================================" log "ERROR: Oracle Instant Client not found!" @@ -207,8 +231,8 @@ else fi # Check if the script exists -if [ ! -f "/opt/HammerDB-5.0/scripts/$SCRIPT_NAME" ]; then - log "ERROR: Script '/opt/HammerDB-5.0/scripts/$SCRIPT_NAME' not found." +if [ ! -f "$SCRIPT_DIR/$SCRIPT_NAME" ]; then + log "ERROR: Script '$SCRIPT_DIR/$SCRIPT_NAME' not found." exit 1 fi @@ -260,7 +284,7 @@ if [[ "$RUN_MODE" == "load" ]] && [[ "${PURE_ENABLED:-false}" == "true" ]]; then fi # Check if Python script exists - if [ -f "/opt/HammerDB-5.0/scripts/collect_pure_metrics.py" ]; then + if [ -f "$SCRIPT_DIR/collect_pure_metrics.py" ]; then log "Starting Pure Storage metrics collector" log " Array: ${PURE_HOST}" log " Duration: ${COLLECTION_DURATION}s" @@ -283,7 +307,7 @@ if [[ "$RUN_MODE" == "load" ]] && [[ "${PURE_ENABLED:-false}" == "true" ]]; then log "Using Python interpreter: $PYTHON_CMD" # Start collector in background with all parameters from environment - $PYTHON_CMD /opt/HammerDB-5.0/scripts/collect_pure_metrics.py \ + $PYTHON_CMD $SCRIPT_DIR/collect_pure_metrics.py \ --host "${PURE_HOST}" \ --token "${PURE_API_TOKEN}" \ --duration "$COLLECTION_DURATION" \ @@ -297,7 +321,7 @@ if [[ "$RUN_MODE" == "load" ]] && [[ "${PURE_ENABLED:-false}" == "true" ]]; then log "Pure Storage collector started with PID: $PURE_PID" fi else - log "WARNING: Pure Storage metrics script not found at /opt/HammerDB-5.0/scripts/collect_pure_metrics.py" + log "WARNING: Pure Storage metrics script not found at $SCRIPT_DIR/collect_pure_metrics.py" fi else log "Pure Storage monitoring enabled but this pod is NOT the designated collector" @@ -309,7 +333,7 @@ fi # Run the specified HammerDB script with unbuffered output log "Test started at: $START_TIME" EXIT_CODE=0 -/opt/HammerDB-5.0/hammerdbcli auto /opt/HammerDB-5.0/scripts/$SCRIPT_NAME 2>&1 || EXIT_CODE=$? +"$HAMMERDB_HOME/hammerdbcli" auto "$SCRIPT_DIR/$SCRIPT_NAME" 2>&1 || EXIT_CODE=$? # Auto-invoke parse phase after load tests complete successfully if [[ "$RUN_MODE" == "load" ]] && [ $EXIT_CODE -eq 0 ]; then @@ -322,13 +346,13 @@ if [[ "$RUN_MODE" == "load" ]] && [ $EXIT_CODE -eq 0 ]; then fi if [ -n "$PARSE_SCRIPT" ]; then - if [ -f "/opt/HammerDB-5.0/scripts/$PARSE_SCRIPT" ]; then + if [ -f "$SCRIPT_DIR/$PARSE_SCRIPT" ]; then log "Executing parse script: $PARSE_SCRIPT" - /opt/HammerDB-5.0/hammerdbcli auto /opt/HammerDB-5.0/scripts/$PARSE_SCRIPT 2>&1 || { + "$HAMMERDB_HOME/hammerdbcli" auto "$SCRIPT_DIR/$PARSE_SCRIPT" 2>&1 || { log "WARNING: Parse script failed but continuing (exit code: $?)" } else - log "WARNING: Parse script not found at /opt/HammerDB-5.0/scripts/$PARSE_SCRIPT" + log "WARNING: Parse script not found at $SCRIPT_DIR/$PARSE_SCRIPT" fi fi fi diff --git a/src/hammerdb_scale/chart/Chart.yaml b/src/hammerdb_scale/chart/Chart.yaml index ca9252b..556f3e7 100644 --- a/src/hammerdb_scale/chart/Chart.yaml +++ b/src/hammerdb_scale/chart/Chart.yaml @@ -3,7 +3,7 @@ name: hammerdb-scale description: Modular Helm chart for parallel HammerDB scale testing across multiple databases (SQL Server, PostgreSQL, Oracle, MySQL) type: application version: 2.0.2 -appVersion: "5.0" +appVersion: "6.0" keywords: - hammerdb - benchmark diff --git a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml index f7a026f..d482a28 100644 --- a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml +++ b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml @@ -232,7 +232,10 @@ spec: volumeMounts: - name: scripts - mountPath: /opt/HammerDB-5.0/scripts + # Must match HAMMERDB_HOME in the image, since entrypoint.sh looks + # for its TCL scripts there. Override global.hammerdbHome when using + # an image built with a different HAMMERDB_VERSION. + mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-6.0") }} volumes: - name: scripts diff --git a/src/hammerdb_scale/config/schema.py b/src/hammerdb_scale/config/schema.py index 32a900a..8335c03 100644 --- a/src/hammerdb_scale/config/schema.py +++ b/src/hammerdb_scale/config/schema.py @@ -104,6 +104,10 @@ class ImageConfig(BaseModel): repository: str = "sillidata/hammerdb-scale" tag: str = "latest" pull_policy: ImagePullPolicy = ImagePullPolicy.always + # Where HammerDB lives inside the image. Only needs setting for a custom + # image built with a non-default HAMMERDB_VERSION; the container backend + # otherwise reads HAMMERDB_HOME from the image itself. + hammerdb_home: Optional[str] = None # --- Target Models --- diff --git a/src/hammerdb_scale/constants.py b/src/hammerdb_scale/constants.py index b10922a..3d8c0c9 100644 --- a/src/hammerdb_scale/constants.py +++ b/src/hammerdb_scale/constants.py @@ -11,6 +11,12 @@ # CLI phase -> Helm/entrypoint phase PHASE_MAP = {"build": "build", "run": "load"} +# HammerDB version shipped in the default images, and where it installs to. +# entrypoint.sh reads HAMMERDB_HOME from the image; this is the fallback used +# when rendering the chart, and must match the image's ARG HAMMERDB_VERSION. +DEFAULT_HAMMERDB_VERSION = "6.0" +DEFAULT_HAMMERDB_HOME = f"/opt/HammerDB-{DEFAULT_HAMMERDB_VERSION}" + DEFAULT_RESULTS_DIR = "results" DEFAULT_JOB_TTL = 86400 NAMING_PREFIX = "hdb" diff --git a/src/hammerdb_scale/helm/values.py b/src/hammerdb_scale/helm/values.py index 92c22a8..c01a094 100644 --- a/src/hammerdb_scale/helm/values.py +++ b/src/hammerdb_scale/helm/values.py @@ -4,7 +4,7 @@ from hammerdb_scale.config.defaults import expand_targets from hammerdb_scale.config.schema import HammerDBScaleConfig, MssqlConfig -from hammerdb_scale.constants import PHASE_MAP, VERSION +from hammerdb_scale.constants import DEFAULT_HAMMERDB_HOME, PHASE_MAP, VERSION from hammerdb_scale.k8s.naming import generate_run_hash @@ -42,6 +42,9 @@ def generate_helm_values( "tag": default_image.tag, "pullPolicy": default_image.pull_policy.value, }, + # Where the chart mounts the TCL scripts. Must match HAMMERDB_HOME + # in the image or entrypoint.sh will not find its scripts. + "hammerdbHome": default_image.hammerdb_home or DEFAULT_HAMMERDB_HOME, "resources": { "requests": config.resources.requests.model_dump(), "limits": config.resources.limits.model_dump(), diff --git a/src/hammerdb_scale/runtime/__init__.py b/src/hammerdb_scale/runtime/__init__.py index 0a85e5b..20922e7 100644 --- a/src/hammerdb_scale/runtime/__init__.py +++ b/src/hammerdb_scale/runtime/__init__.py @@ -49,6 +49,7 @@ def get_backend(config, namespace: str | None = None) -> Backend: return ContainerBackend( runtime=runtime, network=getattr(container_cfg, "network", None), + hammerdb_home=config.targets.defaults.image.hammerdb_home, ) from hammerdb_scale.runtime.kubernetes import KubernetesBackend diff --git a/src/hammerdb_scale/runtime/container.py b/src/hammerdb_scale/runtime/container.py index 06c0f71..189d038 100644 --- a/src/hammerdb_scale/runtime/container.py +++ b/src/hammerdb_scale/runtime/container.py @@ -23,7 +23,12 @@ from datetime import datetime, timezone from pathlib import Path -from hammerdb_scale.constants import PHASE_MAP, HammerDBScaleError, get_chart_path +from hammerdb_scale.constants import ( + DEFAULT_HAMMERDB_HOME, + PHASE_MAP, + HammerDBScaleError, + get_chart_path, +) from hammerdb_scale.runtime.base import ( STATUS_COMPLETED, STATUS_FAILED, @@ -71,11 +76,13 @@ def __init__( runtime: str | None = None, network: str | None = None, scripts_dir: Path | None = None, + hammerdb_home: str | None = None, ) -> None: self.runtime = detect_runtime(runtime) self.name = self.runtime self.network = network self._scripts_dir = scripts_dir + self.hammerdb_home = hammerdb_home # --- internals --- @@ -220,11 +227,22 @@ def deploy( def _script_mount_target(self, image: str) -> str: """Where inside the container the TCL scripts must appear. - The image's HAMMERDB_HOME tells us; fall back to the historical 5.0 - path for images built before that variable existed. + Preference order: an explicit config override, then the image's own + HAMMERDB_HOME, then a probe for an installed HammerDB. Getting this + wrong means entrypoint.sh cannot find its scripts, so the probe is + worth the extra call. """ + if self.hammerdb_home: + return f"{self.hammerdb_home}/scripts" + result = self._run( - ["image", "inspect", image, "--format", "{{range .Config.Env}}{{println .}}{{end}}"], + [ + "image", + "inspect", + image, + "--format", + "{{range .Config.Env}}{{println .}}{{end}}", + ], timeout=60, check=False, ) @@ -234,7 +252,27 @@ def _script_mount_target(self, image: str) -> str: home = line.split("=", 1)[1].strip() if home: return f"{home}/scripts" - return "/opt/HammerDB-5.0/scripts" + + # Older images predate HAMMERDB_HOME. Ask the image where HammerDB is + # rather than guessing a version that may not match. + probe = self._run( + [ + "run", + "--rm", + "--entrypoint", + "/bin/sh", + image, + "-c", + "ls -d /opt/HammerDB-* 2>/dev/null | head -1", + ], + timeout=120, + check=False, + ) + discovered = probe.stdout.strip().splitlines() + if probe.returncode == 0 and discovered and discovered[0].startswith("/opt/"): + return f"{discovered[0].strip()}/scripts" + + return f"{DEFAULT_HAMMERDB_HOME}/scripts" def list_workloads( self, test_id: str, phase: str | None = None diff --git a/templates/job-hammerdb-worker.yaml b/templates/job-hammerdb-worker.yaml index f7a026f..d482a28 100644 --- a/templates/job-hammerdb-worker.yaml +++ b/templates/job-hammerdb-worker.yaml @@ -232,7 +232,10 @@ spec: volumeMounts: - name: scripts - mountPath: /opt/HammerDB-5.0/scripts + # Must match HAMMERDB_HOME in the image, since entrypoint.sh looks + # for its TCL scripts there. Override global.hammerdbHome when using + # an image built with a different HAMMERDB_VERSION. + mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-6.0") }} volumes: - name: scripts diff --git a/tests/test_hammerdb_version.py b/tests/test_hammerdb_version.py new file mode 100644 index 0000000..2d7a8ce --- /dev/null +++ b/tests/test_hammerdb_version.py @@ -0,0 +1,99 @@ +"""Tests that the HammerDB version stays consistent across image and chart. + +The version appears in three coupled places: ARG HAMMERDB_VERSION in the +Dockerfile, the chart's script mountPath, and DEFAULT_HAMMERDB_HOME. If the +image installs to one path and the chart mounts scripts at another, containers +start and then fail to find their TCL scripts, which is a confusing runtime +failure rather than a build error. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from hammerdb_scale.constants import DEFAULT_HAMMERDB_HOME, DEFAULT_HAMMERDB_VERSION + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHART_TEMPLATE = "templates/job-hammerdb-worker.yaml" + + +def _dockerfile_version() -> str: + text = (REPO_ROOT / "dockerfile").read_text() + match = re.search(r"^ARG HAMMERDB_VERSION=(\S+)", text, re.MULTILINE) + assert match, "dockerfile must declare ARG HAMMERDB_VERSION" + return match.group(1) + + +class TestVersionConsistency: + def test_dockerfile_matches_constants(self): + assert _dockerfile_version() == DEFAULT_HAMMERDB_VERSION + + def test_home_derives_from_version(self): + assert DEFAULT_HAMMERDB_HOME == f"/opt/HammerDB-{DEFAULT_HAMMERDB_VERSION}" + + def test_chart_fallback_matches_constants(self): + """The chart's default mountPath must match where the image installs.""" + for chart_dir in ("", "src/hammerdb_scale/chart/"): + template = (REPO_ROOT / chart_dir / CHART_TEMPLATE).read_text() + match = re.search(r'default "(/opt/HammerDB-[^"]+)"', template) + assert match, f"{chart_dir}{CHART_TEMPLATE} must have a mountPath default" + assert match.group(1) == DEFAULT_HAMMERDB_HOME + + +class TestNoHardcodedPaths: + """A hardcoded version defeats the point of the indirection.""" + + @pytest.mark.parametrize( + "relative_path", + ["entrypoint.sh", "dockerfile", "Dockerfile.oracle"], + ) + def test_no_versioned_hammerdb_paths(self, relative_path): + text = (REPO_ROOT / relative_path).read_text() + offenders = [ + line.strip() + for line in text.splitlines() + # Comments may legitimately mention a version by way of example. + if "/opt/HammerDB-5" in line and not line.strip().startswith("#") + ] + assert not offenders, f"{relative_path} has hardcoded paths: {offenders}" + + def test_entrypoint_uses_hammerdb_home(self): + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert "HAMMERDB_HOME" in text + assert 'SCRIPT_DIR="$HAMMERDB_HOME/scripts"' in text + + def test_entrypoint_falls_back_when_home_unset(self): + """Older images have no HAMMERDB_HOME, so a glob probe is required.""" + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert "for candidate in /opt/HammerDB-*" in text + + def test_oracle_client_check_is_version_agnostic(self): + """A client upgrade must not require editing the entrypoint.""" + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert "instantclient_21_11" not in text + assert "/opt/oracle/instantclient_*" in text + + +class TestChartsAreInSync: + """The repo-root chart and the packaged chart must not drift. + + get_chart_path() prefers the packaged copy, so a fix applied only at the + repo root never reaches anyone who installed from PyPI. + """ + + @pytest.mark.parametrize( + "relative_path", + [CHART_TEMPLATE, "templates/_helpers.tpl", "Chart.yaml"], + ) + def test_chart_files_match(self, relative_path): + root_file = REPO_ROOT / relative_path + packaged = REPO_ROOT / "src/hammerdb_scale/chart" / relative_path + if not root_file.exists() or not packaged.exists(): + pytest.skip(f"{relative_path} not present in both charts") + assert root_file.read_text() == packaged.read_text(), ( + f"{relative_path} differs between the repo-root chart and the " + f"packaged chart; apply changes to both" + ) From 78acf6d94656185272a0dc559da0cde5e1326b3f Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 11:34:40 -0600 Subject: [PATCH 04/14] Decouple the script mount path from the image's HammerDB version Found by deploying to a real OpenShift 4.19 cluster: every Kubernetes run failed with "Script '/opt/HammerDB-5.0/scripts/load_test_tprocc.tcl' not found". The 6.0 work moved the chart's mountPath to /opt/HammerDB-6.0/scripts while the published image is still 5.0, so the ConfigMap landed the TCL scripts somewhere the entrypoint never looked. This is the exact image/chart skew the HAMMERDB_HOME indirection was meant to prevent, and it slipped through because the container backend reads HAMMERDB_HOME from the image while the chart hardcodes its own path. They are set by different artifacts and can always disagree. Two changes: - entrypoint.sh now searches for the mounted scripts ($HAMMERDB_HOME/scripts, then any /opt/HammerDB-*/scripts, then /scripts) instead of requiring an exact match. HAMMERDB_SCRIPT_DIR overrides. Any chart version now works against any image version. - the chart default tracks the published image (5.0), not the version this repo builds (6.0). Pointing it at a path the published image lacks broke every out-of-the-box run. PUBLISHED_HAMMERDB_VERSION moves to 6.0 when the 6.0 images are pushed. Verified on OpenShift 4.19.7 in namespace hammerdb-scale-dev: 2 Oracle targets, both jobs Complete, results collected through the CLI (27,332 TPM total). Tests: 193 -> 194. Co-Authored-By: Claude Opus 5 (1M context) --- entrypoint.sh | 28 ++++++++++++++++- .../chart/templates/job-hammerdb-worker.yaml | 8 ++--- src/hammerdb_scale/constants.py | 15 ++++++--- templates/job-hammerdb-worker.yaml | 8 ++--- tests/test_hammerdb_version.py | 31 ++++++++++++++++--- 5 files changed, 73 insertions(+), 17 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index ea68b9e..b3b791b 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -35,7 +35,33 @@ if [ -z "$HAMMERDB_HOME" ] || [ ! -x "$HAMMERDB_HOME/hammerdbcli" ]; then exit 1 fi export HAMMERDB_HOME -SCRIPT_DIR="$HAMMERDB_HOME/scripts" + +# Resolve the TCL script directory independently of HAMMERDB_HOME. +# +# The scripts are mounted in by the orchestrator (a ConfigMap on Kubernetes, a +# bind mount for containers), and the mount path is chosen by the chart while +# HAMMERDB_HOME is baked into the image. Those two can disagree: a 6.0-aware +# chart mounting into a 5.0 image lands the scripts somewhere this script would +# never look, and the pod fails with a confusing "script not found". +# +# Rather than requiring exact agreement, search the candidate locations. This +# keeps any chart version working against any image version. +if [ -n "$HAMMERDB_SCRIPT_DIR" ]; then + SCRIPT_DIR="$HAMMERDB_SCRIPT_DIR" +else + SCRIPT_DIR="" + for candidate in "$HAMMERDB_HOME/scripts" /opt/HammerDB-*/scripts /scripts; do + # A directory only counts if it holds mounted TCL scripts. + if compgen -G "$candidate/*.tcl" > /dev/null 2>&1; then + SCRIPT_DIR="$candidate" + break + fi + done + # Fall back to the conventional path so the error message below is sensible. + SCRIPT_DIR="${SCRIPT_DIR:-$HAMMERDB_HOME/scripts}" +fi +log "Using HammerDB at: $HAMMERDB_HOME" +log "Using scripts from: $SCRIPT_DIR" # Ensure all required environment variables are set if [ -z "$USERNAME" ] || [ -z "$PASSWORD" ] || [ -z "$HOST" ] || [ -z "$BENCHMARK" ]; then diff --git a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml index d482a28..8ca35fd 100644 --- a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml +++ b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml @@ -232,10 +232,10 @@ spec: volumeMounts: - name: scripts - # Must match HAMMERDB_HOME in the image, since entrypoint.sh looks - # for its TCL scripts there. Override global.hammerdbHome when using - # an image built with a different HAMMERDB_VERSION. - mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-6.0") }} + # entrypoint.sh searches for its scripts, so this need not match + # the image's HAMMERDB_HOME exactly. Set global.hammerdbHome to pin + # it for an image built with a different HAMMERDB_VERSION. + mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-5.0") }} volumes: - name: scripts diff --git a/src/hammerdb_scale/constants.py b/src/hammerdb_scale/constants.py index 3d8c0c9..e4297cd 100644 --- a/src/hammerdb_scale/constants.py +++ b/src/hammerdb_scale/constants.py @@ -11,11 +11,18 @@ # CLI phase -> Helm/entrypoint phase PHASE_MAP = {"build": "build", "run": "load"} -# HammerDB version shipped in the default images, and where it installs to. -# entrypoint.sh reads HAMMERDB_HOME from the image; this is the fallback used -# when rendering the chart, and must match the image's ARG HAMMERDB_VERSION. +# The HammerDB version this repo builds images with. DEFAULT_HAMMERDB_VERSION = "6.0" -DEFAULT_HAMMERDB_HOME = f"/opt/HammerDB-{DEFAULT_HAMMERDB_VERSION}" + +# Where the chart mounts TCL scripts by default. +# +# This deliberately tracks the *published* image, which is still 5.0, not the +# version we build locally. entrypoint.sh searches for its scripts rather than +# requiring an exact match, so a mismatch is tolerated either way; but pointing +# the default at a path the published image does not have would break every +# out-of-the-box Kubernetes run. Move this to 6.0 when 6.0 images are published. +PUBLISHED_HAMMERDB_VERSION = "5.0" +DEFAULT_HAMMERDB_HOME = f"/opt/HammerDB-{PUBLISHED_HAMMERDB_VERSION}" DEFAULT_RESULTS_DIR = "results" DEFAULT_JOB_TTL = 86400 diff --git a/templates/job-hammerdb-worker.yaml b/templates/job-hammerdb-worker.yaml index d482a28..8ca35fd 100644 --- a/templates/job-hammerdb-worker.yaml +++ b/templates/job-hammerdb-worker.yaml @@ -232,10 +232,10 @@ spec: volumeMounts: - name: scripts - # Must match HAMMERDB_HOME in the image, since entrypoint.sh looks - # for its TCL scripts there. Override global.hammerdbHome when using - # an image built with a different HAMMERDB_VERSION. - mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-6.0") }} + # entrypoint.sh searches for its scripts, so this need not match + # the image's HAMMERDB_HOME exactly. Set global.hammerdbHome to pin + # it for an image built with a different HAMMERDB_VERSION. + mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-5.0") }} volumes: - name: scripts diff --git a/tests/test_hammerdb_version.py b/tests/test_hammerdb_version.py index 2d7a8ce..2386dc8 100644 --- a/tests/test_hammerdb_version.py +++ b/tests/test_hammerdb_version.py @@ -14,7 +14,11 @@ import pytest -from hammerdb_scale.constants import DEFAULT_HAMMERDB_HOME, DEFAULT_HAMMERDB_VERSION +from hammerdb_scale.constants import ( + DEFAULT_HAMMERDB_HOME, + DEFAULT_HAMMERDB_VERSION, + PUBLISHED_HAMMERDB_VERSION, +) REPO_ROOT = Path(__file__).resolve().parent.parent CHART_TEMPLATE = "templates/job-hammerdb-worker.yaml" @@ -31,8 +35,15 @@ class TestVersionConsistency: def test_dockerfile_matches_constants(self): assert _dockerfile_version() == DEFAULT_HAMMERDB_VERSION - def test_home_derives_from_version(self): - assert DEFAULT_HAMMERDB_HOME == f"/opt/HammerDB-{DEFAULT_HAMMERDB_VERSION}" + def test_home_tracks_the_published_image(self): + """The chart default must point at a path the published image has. + + Regression: the default moved to 6.0 while the published image was + still 5.0, so every out-of-the-box Kubernetes run mounted scripts into + a directory the entrypoint never looked in and failed with + "script not found". + """ + assert DEFAULT_HAMMERDB_HOME == f"/opt/HammerDB-{PUBLISHED_HAMMERDB_VERSION}" def test_chart_fallback_matches_constants(self): """The chart's default mountPath must match where the image installs.""" @@ -63,13 +74,25 @@ def test_no_versioned_hammerdb_paths(self, relative_path): def test_entrypoint_uses_hammerdb_home(self): text = (REPO_ROOT / "entrypoint.sh").read_text() assert "HAMMERDB_HOME" in text - assert 'SCRIPT_DIR="$HAMMERDB_HOME/scripts"' in text + assert '"$HAMMERDB_HOME/hammerdbcli"' in text + assert "$SCRIPT_DIR/" in text def test_entrypoint_falls_back_when_home_unset(self): """Older images have no HAMMERDB_HOME, so a glob probe is required.""" text = (REPO_ROOT / "entrypoint.sh").read_text() assert "for candidate in /opt/HammerDB-*" in text + def test_entrypoint_searches_for_mounted_scripts(self): + """The script mount path and HAMMERDB_HOME are set independently. + + The chart chooses the mount path; the image bakes in HAMMERDB_HOME. + Requiring them to agree means a chart/image version skew produces a + confusing "script not found" at runtime, so the entrypoint searches. + """ + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert 'for candidate in "$HAMMERDB_HOME/scripts" /opt/HammerDB-*/scripts' in text + assert "HAMMERDB_SCRIPT_DIR" in text + def test_oracle_client_check_is_version_agnostic(self): """A client upgrade must not require editing the entrypoint.""" text = (REPO_ROOT / "entrypoint.sh").read_text() From 6c2db3f9a8fcf0fcec625ecc356f7fd41e1e0a00 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 12:07:37 -0600 Subject: [PATCH 05/14] Add pod security, credential secrets, in-cluster preflight, minimal configs Four usability and security changes, all verified on the OpenShift cluster. Credentials no longer appear in the Job spec. They were plain `value:` entries, so anyone with namespace read access could recover them with `kubectl describe job`. A per-release Secret now holds them and the Job uses secretKeyRef. Confirmed on the live cluster: zero occurrences of the password in the rendered Job. Set kubernetes.use_secrets: false to opt out. The chart now emits a restricted-compliant securityContext. OpenShift's SCC injects an equivalent one, so this changes nothing there, but on plain Kubernetes enforcing the restricted Pod Security Standard the chart would otherwise be rejected at admission. runAsUser is deliberately left unset, because OpenShift assigns a UID from the namespace range and pinning one conflicts with that. validate --from-cluster runs the reachability probe from where the benchmark actually executes, as a pod on Kubernetes or a container locally. The existing check runs from the workstation, which is a different network position: a config could pass validation and then have every job fail on connectivity, failing late and pointing nowhere useful. Minimal configs now work. The oracle:/mssql: block was mandatory whenever a type was set, but every field in it has a working default, so it was ~15 lines restating them. Omitting it now means "use the defaults". A working config is 26 lines rather than ~90; verified by running a real benchmark from one. Writing the tests caught a defect in my own templates: `X | default true` treats an explicit `false` as absent, so both opt-out flags were silently ignored. Fixed by comparing against "false" directly. Verified on OpenShift 4.19.7 with enforce=restricted: jobs deploy, run and complete, results collected through the CLI (29,671 TPM). --from-cluster verified on both backends. Tests: 194 -> 202. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 51 ++ demos/benchmark-payoff.cast | 316 +++++++++ demos/getting-started-synthetic.cast | 403 +++++++++++ demos/getting-started.cast | 668 ++++++++++++++++++ demos/record_benchmark.py | 107 +++ demos/record_getting_started.py | 227 ++++++ docs/oracle-scale-environment.md | 167 +++++ mssql-report-scale-1.html | 267 +++++++ mssql-report-scale-2.html | 267 +++++++ mssql-report-scale-4.html | 267 +++++++ mssql-report-scale-6.html | 267 +++++++ mssql-report-scale-8.html | 267 +++++++ mssql-scale-1.yaml | 64 ++ mssql-scale-2.yaml | 66 ++ mssql-scale-4.yaml | 70 ++ mssql-scale-6.yaml | 74 ++ mssql-scale-8.yaml | 78 ++ ora-rac-hdb-scale.yaml | 108 +++ run-mssql-scale-tests.sh | 33 + run-scale-tests.sh | 33 + scripts/oracle/rman_backup.sh | 267 +++++++ scripts/oracle/rman_restore.sh | 391 ++++++++++ scripts/oracle/setup_redo.sql | 117 +++ .../chart/templates/job-hammerdb-worker.yaml | 58 +- .../chart/templates/secret-credentials.yaml | 37 + src/hammerdb_scale/cli.py | 52 ++ src/hammerdb_scale/config/schema.py | 35 +- src/hammerdb_scale/helm/values.py | 6 +- src/hammerdb_scale/runtime/preflight.py | 150 ++++ templates/job-hammerdb-worker.yaml | 58 +- templates/secret-credentials.yaml | 37 + tests/test_chart_security.py | 133 ++++ tests/test_schema.py | 29 +- 33 files changed, 5147 insertions(+), 23 deletions(-) create mode 100644 CLAUDE.md create mode 100644 demos/benchmark-payoff.cast create mode 100644 demos/getting-started-synthetic.cast create mode 100644 demos/getting-started.cast create mode 100644 demos/record_benchmark.py create mode 100644 demos/record_getting_started.py create mode 100644 docs/oracle-scale-environment.md create mode 100644 mssql-report-scale-1.html create mode 100644 mssql-report-scale-2.html create mode 100644 mssql-report-scale-4.html create mode 100644 mssql-report-scale-6.html create mode 100644 mssql-report-scale-8.html create mode 100644 mssql-scale-1.yaml create mode 100644 mssql-scale-2.yaml create mode 100644 mssql-scale-4.yaml create mode 100644 mssql-scale-6.yaml create mode 100644 mssql-scale-8.yaml create mode 100644 ora-rac-hdb-scale.yaml create mode 100755 run-mssql-scale-tests.sh create mode 100755 run-scale-tests.sh create mode 100755 scripts/oracle/rman_backup.sh create mode 100755 scripts/oracle/rman_restore.sh create mode 100644 scripts/oracle/setup_redo.sql create mode 100644 src/hammerdb_scale/chart/templates/secret-credentials.yaml create mode 100644 src/hammerdb_scale/runtime/preflight.py create mode 100644 templates/secret-credentials.yaml create mode 100644 tests/test_chart_security.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6afd1d6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,51 @@ +# CLAUDE.md — HammerDB-Scale + +## Communication style + +Andrew is the director on this project. Communicate at that level. + +- Write in proper prose with correct punctuation. Full sentences, not fragments. +- Do not use em dashes. Use commas, colons, semicolons, or separate sentences instead. +- Lead with the answer or the recommendation, then supply the reasoning. Do not build up to a conclusion. +- Do not dump raw information. Synthesise it. If there are five findings, say which two matter and why. +- Give a clear recommendation when asked a decision question. Do not present a neutral menu of options and leave the choice hanging. +- Quantify effort and risk in concrete terms (days of work, files touched, what breaks) rather than vague labels. +- Flag genuine trade-offs and things that will bite later, but keep it brief and do not editorialise. +- Skip filler openings and closings. No "Great question", no "Let me know if you need anything else". +- Avoid heavy bold-and-bullet formatting where a short paragraph reads better. + +## Project overview + +HammerDB-Scale orchestrates parallel HammerDB database benchmarks across multiple +database instances at once. It is used mainly for storage platform validation: +proving how a Pure Storage FlashArray behaves when serving N database workloads +concurrently. + +Current shape: a Python CLI (`hammerdb_scale`) that generates Helm values, deploys +one Kubernetes Job per database target, then collects and parses the job logs into +aggregated results and a self-contained HTML scorecard. + +- `src/hammerdb_scale/` — the CLI package (see MEMORY.md for the module map) +- `src/hammerdb_scale/chart/` — the bundled Helm chart shipped inside the wheel +- `templates/`, `scripts/`, `Chart.yaml`, `values.yaml` — repo-root copies of the same chart +- `entrypoint.sh` — the in-container dispatcher that selects and runs a TCL script +- `dockerfile`, `Dockerfile.oracle` — base image and Oracle Instant Client extension + +### Things worth knowing + +- The repo-root chart files and `src/hammerdb_scale/chart/` are duplicates. Changes to + TCL scripts or templates must be applied to both, or the packaged wheel drifts from + the repo. +- HammerDB version is hardcoded as the path `/opt/HammerDB-5.0` in `entrypoint.sh`, + the Dockerfiles, and the Helm job template `volumeMounts.mountPath`. All three must + change together for a version bump. +- Database support is gated in four places: the `DatabaseType` enum in + `config/schema.py`, the `case` blocks in `entrypoint.sh`, the parser registry in + `results/parsers.py`, and the per-database TCL script directories. +- Credentials are currently passed as plain environment variables in the Job spec and + are visible via `kubectl describe job`. + +## Working preferences + +- Do not use `sed` or shell scripts to edit config files. Use the Edit tool per file. +- Run `pytest tests/ -q` before declaring work done. The suite is fast (under a second). diff --git a/demos/benchmark-payoff.cast b/demos/benchmark-payoff.cast new file mode 100644 index 0000000..ccbd2df --- /dev/null +++ b/demos/benchmark-payoff.cast @@ -0,0 +1,316 @@ +{"version": 2, "width": 120, "height": 35, "timestamp": 1772818908, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}, "title": "HammerDB-Scale: Benchmark Run"} +[0.0, "o", "[user@host hammerdb-scale]$ "] +[0.773444, "o", "h"] +[1.205368, "o", "a"] +[1.4664570000000001, "o", "m"] +[1.6420345, "o", "m"] +[1.77495725, "o", "e"] +[1.886626625, "o", "r"] +[1.9875018125, "o", "d"] +[2.0830429062499998, "o", "b"] +[2.176038953125, "o", "-"] +[2.2676149765625, "o", "s"] +[2.35853448828125, "o", "c"] +[2.449124244140625, "o", "a"] +[2.5396521220703123, "o", "l"] +[2.6299835610351563, "o", "e"] +[2.720262780517578, "o", " "] +[2.8105843902587893, "o", "r"] +[2.900842695129395, "o", "u"] +[2.991092347564697, "o", "n"] +[3.0813436737823485, "o", " "] +[3.171569836891174, "o", "-"] +[3.2618174184455873, "o", "f"] +[3.3519842092227936, "o", " "] +[3.4421751046113966, "o", "/"] +[3.5324275523056983, "o", "t"] +[3.622720276152849, "o", "m"] +[3.7130236380764243, "o", "p"] +[3.803228819038212, "o", "/"] +[3.893461409519106, "o", "m"] +[3.983700704759553, "o", "s"] +[4.073934352379776, "o", "s"] +[4.164072676189888, "o", "q"] +[4.254265338094944, "o", "l"] +[4.344459169047472, "o", "-"] +[4.434726084523736, "o", "2"] +[4.525014042261867, "o", "-"] +[4.615171521130934, "o", "d"] +[4.705403760565467, "o", "e"] +[4.795704380282734, "o", "m"] +[4.885933690141367, "o", "o"] +[4.976157845070683, "o", "."] +[5.066414922535342, "o", "y"] +[5.156721461267671, "o", "a"] +[5.247025230633836, "o", "m"] +[5.3372611153169185, "o", "l"] +[5.4274930576584595, "o", " "] +[5.517683528829229, "o", "-"] +[5.607902264414615, "o", "-"] +[5.698161132207307, "o", "w"] +[5.788389066103654, "o", "a"] +[5.878655533051827, "o", "i"] +[5.968905766525913, "o", "t"] +[6.134169883262956, "o", "\r\n"] +[6.551858, "o", "Benchmark: tprocc \u001b[1m(\u001b[0mfrom config default\u001b[1m)\u001b[0m\r\n"] +[6.552388, "o", "\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 HammerDB-Scale 2.0.1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\r\n\u2502 mssql-2-tprocc-demo | tprocc | 2 targets | 10 wh \u2502\r\n\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\r\n"] +[6.552877, "o", "\r\nRunning tprocc benchmark \u001b[1m(\u001b[0m\u001b[1;36m4\u001b[0m virtual users, \u001b[1;36m1\u001b[0m min rampup + \u001b[1;36m1\u001b[0m min test\u001b[1m)\u001b[0m\r\n"] +[6.553148, "o", "Expected completion: ~\u001b[1;36m2\u001b[0m minutes\r\n\r\n"] +[6.553352, "o", "Deploying jobs\u001b[33m...\u001b[0m\r\n"] +[6.994537, "o", " \u001b[32m+\u001b[0m hdb-run-\u001b[1;36m00\u001b[0m-a4226c51 \u001b[1m(\u001b[0msql-bench-\u001b[1;36m01\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[6.995501, "o", " \u001b[32m+\u001b[0m hdb-run-\u001b[1;36m01\u001b[0m-a4226c51 \u001b[1m(\u001b[0msql-bench-\u001b[1;36m02\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[6.996495, "o", "\r\n\u001b[1;36m2\u001b[0m benchmark jobs deployed to namespace \u001b[32m'hammerdb'\u001b[0m.\r\n\r\n"] +[7.096495, "o", "When complete: hammerdb-scale results --benchmark tprocc --id mssql-\u001b[1;36m2\u001b[0m-tprocc-demo-\u001b[1;36m20260306\u001b[0m-\u001b[1;36m1041\u001b[0m\r\n"] +[7.146495, "o", "Generate report: hammerdb-scale report --id mssql-\u001b[1;36m2\u001b[0m-tprocc-demo-\u001b[1;36m20260306\u001b[0m-\u001b[1;36m1041\u001b[0m\r\n"] +[8.196495, "o", " \u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m/\u001b[1;36m2\u001b[0m\u001b[1m]\u001b[0m completed, \u001b[1;36m0\u001b[0m failed\r"] +[10.196495, "o", " \u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m/\u001b[1;36m2\u001b[0m\u001b[1m]\u001b[0m completed, \u001b[1;36m0\u001b[0m failed\r"] +[12.196495, "o", "\r\n"] +[14.196495, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m Completed\r\n"] +[14.197242, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m Completed\r\n"] +[14.241763, "o", "[user@host hammerdb-scale]$ "] +[17.29297, "o", "h"] +[17.383206, "o", "a"] +[17.473288, "o", "m"] +[17.563645, "o", "m"] +[17.653799, "o", "e"] +[17.743942, "o", "r"] +[17.83412, "o", "d"] +[17.924416, "o", "b"] +[18.014587, "o", "-"] +[18.104788, "o", "s"] +[18.194969, "o", "c"] +[18.28521, "o", "a"] +[18.375411, "o", "l"] +[18.465865, "o", "e"] +[18.556117, "o", " "] +[18.64648, "o", "r"] +[18.736661, "o", "e"] +[18.826672, "o", "s"] +[18.917325, "o", "u"] +[19.007542, "o", "l"] +[19.097723, "o", "t"] +[19.187947, "o", "s"] +[19.278225, "o", " "] +[19.368364, "o", "-"] +[19.458624, "o", "f"] +[19.548881, "o", " "] +[19.639111, "o", "/"] +[19.729383, "o", "t"] +[19.819598, "o", "m"] +[19.909955, "o", "p"] +[20.000234, "o", "/"] +[20.090403, "o", "m"] +[20.180553, "o", "s"] +[20.270948, "o", "s"] +[20.36114, "o", "q"] +[20.451287, "o", "l"] +[20.541563, "o", "-"] +[20.631716, "o", "2"] +[20.721895, "o", "-"] +[20.812105, "o", "d"] +[20.90239, "o", "e"] +[20.992579, "o", "m"] +[21.082936, "o", "o"] +[21.173185, "o", "."] +[21.263463, "o", "y"] +[21.353775, "o", "a"] +[21.444021, "o", "m"] +[21.534297, "o", "l"] +[21.774713, "o", "\r\n"] +[22.03103, "o", "Benchmark: tprocc \u001b[1m(\u001b[0mfrom config default\u001b[1m)\u001b[0m\r\n"] +[22.74548, "o", "\u001b[3m Results: mssql-2-tprocc-demo-20260306-1041 \u001b[0m\r\n\u250f\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\r\n\u2503\u001b[1m \u001b[0m\u001b[1m#\u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mTarget \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mHost \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mStatus \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mDuration\u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1m TPM\u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1m NOPM\u001b[0m\u001b[1m \u001b[0m\u2503\r\n\u2521\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\r\n\u2502\u001b[2m \u001b[0m\u001b[2m0\u001b[0m\u001b[2m \u001b[0m\u2502 sql-bench-01 \u2502 sql-bench-01.soln.local \u2502 \u001b[32mcompleted\u001b[0m \u2502 2m 08s \u2502 185,918 \u2502 79,941 \u2502\r\n\u2502\u001b[2m \u001b[0m\u001b[2m1\u001b[0m\u001b[2m \u001b[0m\u2502 sql-bench-02 \u2502 sql-bench-02.soln.local \u2502 \u001b[32mcompleted\u001b[0m \u2502 2m 09s \u2502 173,137 \u2502 74,434 \u2502\r\n\u2514\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n"] +[22.745803, "o", "\r\nTotal TPM: \u001b[1;36m359\u001b[0m,\u001b[1;36m055\u001b[0m\r\n"] +[22.746035, "o", "Total NOPM: \u001b[1;36m154\u001b[0m,\u001b[1;36m375\u001b[0m\r\n"] +[22.746297, "o", "\r\nResults saved to .\u001b[35m/results/mssql-2-tprocc-demo-20260306-1041/\u001b[0m\r\n"] +[22.746609, "o", "Generate report: hammerdb-scale report --id mssql-\u001b[1;36m2\u001b[0m-tprocc-demo-\u001b[1;36m20260306\u001b[0m-\u001b[1;36m1041\u001b[0m\r\n"] +[22.789865, "o", "[user@host hammerdb-scale]$ "] +[25.840797, "o", "h"] +[25.931068, "o", "a"] +[26.021267, "o", "m"] +[26.111534, "o", "m"] +[26.201895, "o", "e"] +[26.292014, "o", "r"] +[26.382322, "o", "d"] +[26.472569, "o", "b"] +[26.562774, "o", "-"] +[26.653208, "o", "s"] +[26.743211, "o", "c"] +[26.833903, "o", "a"] +[26.923563, "o", "l"] +[27.014114, "o", "e"] +[27.104305, "o", " "] +[27.194448, "o", "r"] +[27.284714, "o", "e"] +[27.375037, "o", "p"] +[27.465175, "o", "o"] +[27.555363, "o", "r"] +[27.645678, "o", "t"] +[27.735816, "o", " "] +[27.826016, "o", "-"] +[27.916242, "o", "f"] +[28.006453, "o", " "] +[28.096695, "o", "/"] +[28.186982, "o", "t"] +[28.277168, "o", "m"] +[28.367351, "o", "p"] +[28.457596, "o", "/"] +[28.547748, "o", "m"] +[28.63793, "o", "s"] +[28.728211, "o", "s"] +[28.818574, "o", "q"] +[28.908664, "o", "l"] +[28.998872, "o", "-"] +[29.089067, "o", "2"] +[29.179251, "o", "-"] +[29.269372, "o", "d"] +[29.359572, "o", "e"] +[29.449723, "o", "m"] +[29.53995, "o", "o"] +[29.630197, "o", "."] +[29.720438, "o", "y"] +[29.810653, "o", "a"] +[29.900901, "o", "m"] +[29.991118, "o", "l"] +[30.08144, "o", " "] +[30.171701, "o", "-"] +[30.261925, "o", "o"] +[30.352179, "o", " "] +[30.442368, "o", "/"] +[30.532674, "o", "t"] +[30.622899, "o", "m"] +[30.71322, "o", "p"] +[30.803387, "o", "/"] +[30.893643, "o", "s"] +[30.983926, "o", "c"] +[31.074174, "o", "o"] +[31.16431, "o", "r"] +[31.254589, "o", "e"] +[31.344877, "o", "c"] +[31.435085, "o", "a"] +[31.525148, "o", "r"] +[31.615472, "o", "d"] +[31.705672, "o", "."] +[31.795795, "o", "h"] +[31.886057, "o", "t"] +[31.976266, "o", "m"] +[32.066518, "o", "l"] +[32.306889, "o", "\r\n"] +[32.795643, "o", "\u001b[32mReport generated: \u001b[0m\u001b[32m/tmp/\u001b[0m\u001b[32mscorecard.html\u001b[0m\r\n"] +[32.837703, "o", "[user@host hammerdb-scale]$ "] +[35.889047, "o", "h"] +[35.979203, "o", "a"] +[36.069495, "o", "m"] +[36.159823, "o", "m"] +[36.250123, "o", "e"] +[36.340341, "o", "r"] +[36.430594, "o", "d"] +[36.520892, "o", "b"] +[36.611188, "o", "-"] +[36.701457, "o", "s"] +[36.791688, "o", "c"] +[36.881998, "o", "a"] +[36.972259, "o", "l"] +[37.062503, "o", "e"] +[37.152758, "o", " "] +[37.242962, "o", "c"] +[37.333317, "o", "l"] +[37.423395, "o", "e"] +[37.513652, "o", "a"] +[37.603928, "o", "n"] +[37.69422, "o", " "] +[37.784378, "o", "-"] +[37.874619, "o", "f"] +[37.964858, "o", " "] +[38.055182, "o", "/"] +[38.145291, "o", "t"] +[38.235535, "o", "m"] +[38.325765, "o", "p"] +[38.416074, "o", "/"] +[38.506286, "o", "m"] +[38.596509, "o", "s"] +[38.686571, "o", "s"] +[38.776873, "o", "q"] +[38.867357, "o", "l"] +[38.957535, "o", "-"] +[39.047728, "o", "2"] +[39.138013, "o", "-"] +[39.22824, "o", "d"] +[39.318423, "o", "e"] +[39.408646, "o", "m"] +[39.498936, "o", "o"] +[39.589148, "o", "."] +[39.679406, "o", "y"] +[39.769685, "o", "a"] +[39.859948, "o", "m"] +[39.950201, "o", "l"] +[40.040495, "o", " "] +[40.130756, "o", "-"] +[40.220935, "o", "-"] +[40.311199, "o", "r"] +[40.401494, "o", "e"] +[40.491531, "o", "s"] +[40.581963, "o", "o"] +[40.672195, "o", "u"] +[40.762398, "o", "r"] +[40.852599, "o", "c"] +[40.942824, "o", "e"] +[41.033057, "o", "s"] +[41.123212, "o", " "] +[41.213511, "o", "-"] +[41.303672, "o", "-"] +[41.393921, "o", "d"] +[41.484146, "o", "a"] +[41.57436, "o", "t"] +[41.66455, "o", "a"] +[41.754786, "o", "b"] +[41.845158, "o", "a"] +[41.935362, "o", "s"] +[42.025546, "o", "e"] +[42.115678, "o", " "] +[42.205941, "o", "-"] +[42.296206, "o", "-"] +[42.386358, "o", "b"] +[42.476567, "o", "e"] +[42.566729, "o", "n"] +[42.657083, "o", "c"] +[42.747186, "o", "h"] +[42.837449, "o", "m"] +[42.927773, "o", "a"] +[43.01802, "o", "r"] +[43.108249, "o", "k"] +[43.198455, "o", " "] +[43.28873, "o", "t"] +[43.378961, "o", "p"] +[43.469192, "o", "r"] +[43.559501, "o", "o"] +[43.64972, "o", "c"] +[43.74003, "o", "c"] +[43.83011, "o", " "] +[43.920446, "o", "-"] +[44.010722, "o", "-"] +[44.100929, "o", "e \r"] +[44.191173, "o", "v"] +[44.281356, "o", "e"] +[44.37164, "o", "r"] +[44.461783, "o", "y"] +[44.552085, "o", "t"] +[44.642144, "o", "h"] +[44.732484, "o", "i"] +[44.822555, "o", "n"] +[44.912869, "o", "g"] +[45.003122, "o", " "] +[45.09313, "o", "-"] +[45.183433, "o", "-"] +[45.273544, "o", "f"] +[45.363704, "o", "o"] +[45.453895, "o", "r"] +[45.54394, "o", "c"] +[45.634269, "o", "e"] +[45.874722, "o", "\r\n"] +[46.211599, "o", "\r\nFound \u001b[1;36m1\u001b[0m \u001b[1;35mrelease\u001b[0m\u001b[1m(\u001b[0ms\u001b[1m)\u001b[0m:\r\n"] +[46.212359, "o", " hdb-run-a4226c51 \u001b[1m(\u001b[0mdeployed\u001b[1m)\u001b[0m\r\n"] +[46.372614, "o", " \u001b[32m+\u001b[0m Uninstalled hdb-run-a4226c51\r\n"] +[46.373531, "o", "\r\nK8s resources cleaned. Local results preserved in .\u001b[35m/results/\u001b[0m\r\n"] +[46.632827, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m \u001b[1;36m9\u001b[0m tables dropped\r\n"] +[46.865741, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m \u001b[1;36m9\u001b[0m tables dropped\r\n"] +[46.911674, "o", "[user@host hammerdb-scale]$ "] diff --git a/demos/getting-started-synthetic.cast b/demos/getting-started-synthetic.cast new file mode 100644 index 0000000..0e55f8b --- /dev/null +++ b/demos/getting-started-synthetic.cast @@ -0,0 +1,403 @@ +{"version": 2, "width": 120, "height": 35, "timestamp": 1741223100, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}, "title": "HammerDB-Scale 2.0: Getting Started"} +[0.03, "o", "\u001b[0m[user@host hammerdb-scale]$ "] +[0.223692, "o", "p"] +[0.263149, "o", "i"] +[0.290169, "o", "p"] +[0.369159, "o", " "] +[0.405328, "o", "i"] +[0.425753, "o", "n"] +[0.451866, "o", "s"] +[0.493443, "o", "t"] +[0.513523, "o", "a"] +[0.551191, "o", "l"] +[0.595659, "o", "l"] +[0.670898, "o", " "] +[0.695462, "o", "h"] +[0.723339, "o", "a"] +[0.745327, "o", "m"] +[0.785212, "o", "m"] +[0.810223, "o", "e"] +[0.833264, "o", "r"] +[0.872068, "o", "d"] +[0.910923, "o", "b"] +[0.954487, "o", "-"] +[0.980996, "o", "s"] +[1.016272, "o", "c"] +[1.035517, "o", "a"] +[1.06374, "o", "l"] +[1.087896, "o", "e"] +[1.160447, "o", "\r\n"] +[1.390447, "o", "Requirement already satisfied: hammerdb-scale in /usr/local/lib/python3.11/site-packages (2.0.2)\r\n"] +[1.420447, "o", "Requirement already satisfied: typer>=0.12.0 in /usr/local/lib/python3.11/site-packages (0.15.2)\r\n"] +[1.450447, "o", "Requirement already satisfied: pydantic>=2.5.0 in /usr/local/lib/python3.11/site-packages (2.10.6)\r\n"] +[1.480447, "o", "Requirement already satisfied: pyyaml>=6.0 in /usr/local/lib/python3.11/site-packages (6.0.2)\r\n"] +[1.510447, "o", "Requirement already satisfied: rich>=13.7.0 in /usr/local/lib/python3.11/site-packages (13.9.4)\r\n"] +[1.540447, "o", "Requirement already satisfied: oracledb>=2.0.0 in /usr/local/lib/python3.11/site-packages (2.5.1)\r\n"] +[1.570447, "o", "Requirement already satisfied: pymssql>=2.2.0 in /usr/local/lib/python3.11/site-packages (2.3.2)\r\n"] +[1.750447, "o", "\u001b[0m[user@host hammerdb-scale]$ "] +[2.095452, "o", "h"] +[2.118178, "o", "a"] +[2.154752, "o", "m"] +[2.243602, "o", "m"] +[2.266598, "o", "e"] +[2.309372, "o", "r"] +[2.333945, "o", "d"] +[2.372353, "o", "b"] +[2.407881, "o", "-"] +[2.426806, "o", "s"] +[2.453797, "o", "c"] +[2.485035, "o", "a"] +[2.519567, "o", "l"] +[2.5601, "o", "e"] +[2.646476, "o", " "] +[2.678098, "o", "i"] +[2.705412, "o", "n"] +[2.735554, "o", "i"] +[2.768682, "o", "t"] +[2.859574, "o", " "] +[2.903314, "o", "-"] +[2.943017, "o", "i"] +[3.007728, "o", " "] +[3.049018, "o", "-"] +[3.092966, "o", "o"] +[3.2072, "o", " "] +[3.238501, "o", "/"] +[3.279796, "o", "t"] +[3.340067, "o", "m"] +[3.3751, "o", "p"] +[3.401697, "o", "/"] +[3.434529, "o", "h"] +[3.465218, "o", "a"] +[3.498779, "o", "m"] +[3.532696, "o", "m"] +[3.55839, "o", "e"] +[3.577485, "o", "r"] +[3.620456, "o", "d"] +[3.653793, "o", "b"] +[3.688258, "o", "-"] +[3.712292, "o", "s"] +[3.738006, "o", "c"] +[3.760946, "o", "a"] +[3.783046, "o", "l"] +[3.824323, "o", "e"] +[3.856012, "o", "-"] +[3.893186, "o", "d"] +[3.918385, "o", "e"] +[3.93753, "o", "m"] +[3.969954, "o", "o"] +[4.013281, "o", "."] +[4.038129, "o", "y"] +[4.082951, "o", "a"] +[4.114383, "o", "m"] +[4.155436, "o", "l"] +[4.232572, "o", " "] +[4.264993, "o", "-"] +[4.295824, "o", "-"] +[4.337772, "o", "f"] +[4.369259, "o", "o"] +[4.388307, "o", "r"] +[4.408063, "o", "c"] +[4.447167, "o", "e"] +[4.507558, "o", "\r\n"] +[4.922558, "o", "\r\n"] +[4.937558, "o", "\u001b[34m\u256d\u2500\u001b[0m hammerdb-scale 2.0.2 \u001b[34m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[4.952558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[4.967558, "o", "\u001b[34m\u2502\u001b[0m \u001b[1mHammerDB-Scale Configuration Wizard\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[4.982558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[4.997558, "o", "\u001b[34m\u2502\u001b[0m This wizard will guide you through creating a benchmark \u001b[34m\u2502\u001b[0m\r\n"] +[5.012558, "o", "\u001b[34m\u2502\u001b[0m configuration file step by step. \u001b[34m\u2502\u001b[0m\r\n"] +[5.027558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[5.042558, "o", "\u001b[34m\u2502\u001b[0m \u001b[2mPress Ctrl+C at any time to cancel.\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[5.057558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[5.072558, "o", "\u001b[34m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[6.087558, "o", "\r\n"] +[6.102558, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 1 of 6 \u2014 Deployment\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[6.117558, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[6.132558, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mGive this benchmark a name.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[6.147558, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[6.162558, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[6.177558, "o", "\r\n"] +[6.197558, "o", "Deployment name: "] +[6.786073, "o", "m"] +[6.862991, "o", "s"] +[6.908683, "o", "s"] +[6.969074, "o", "q"] +[7.080079, "o", "l"] +[7.14452, "o", "-"] +[7.237919, "o", "2"] +[7.328671, "o", "-"] +[7.406783, "o", "t"] +[7.471374, "o", "p"] +[7.560689, "o", "r"] +[7.615932, "o", "o"] +[7.70818, "o", "c"] +[7.797355, "o", "c"] +[7.890943, "o", "-"] +[7.948239, "o", "d"] +[8.028665, "o", "e"] +[8.080257, "o", "m"] +[8.153042, "o", "o"] +[8.212542, "o", "\r\n"] +[8.477542, "o", "\r\n"] +[8.492542, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 2 of 6 \u2014 Database & Benchmark\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[8.507542, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[8.522542, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mSelect the database engine and TPC benchmark type.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[8.537542, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[8.552542, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[8.867542, "o", "\r\n"] +[8.927542, "o", " \u001b[1m1.\u001b[0m Oracle\r\n"] +[8.987542, "o", " \u001b[1m2.\u001b[0m Microsoft SQL Server\r\n"] +[9.002542, "o", "\r\n"] +[9.022542, "o", "Database type \u001b[1;35m[1/2]\u001b[0m: "] +[9.660315, "o", "2"] +[9.714554, "o", "\r\n"] +[10.079554, "o", "\r\n"] +[10.139554, "o", " \u001b[1m1.\u001b[0m TPC-C (OLTP transactional)\r\n"] +[10.199554, "o", " \u001b[1m2.\u001b[0m TPC-H (OLAP analytical)\r\n"] +[10.214554, "o", "\r\n"] +[10.234554, "o", "Benchmark \u001b[1;35m[1/2]\u001b[0m: "] +[10.801824, "o", "1"] +[10.848634, "o", "\r\n"] +[11.213634, "o", "\r\n"] +[11.228634, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 3 of 6 \u2014 Database Targets\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[11.243634, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[11.258634, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mDefine the database hosts to benchmark against.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[11.273634, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[11.288634, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[11.603634, "o", "\r\n"] +[11.623634, "o", "Number of database targets \u001b[1;35m(1)\u001b[0m: "] +[12.174596, "o", "2"] +[12.245777, "o", "\r\n"] +[12.560777, "o", "\n \u001b[1m\u001b[36mTarget 1 of 2\u001b[0m\r\n"] +[12.580777, "o", " Name \u001b[1;35m(db-01)\u001b[0m: "] +[13.154687, "o", "s"] +[13.211971, "o", "q"] +[13.288086, "o", "l"] +[13.379469, "o", "-"] +[13.455904, "o", "b"] +[13.524189, "o", "e"] +[13.582623, "o", "n"] +[13.672774, "o", "c"] +[13.731954, "o", "h"] +[13.81285, "o", "-"] +[13.866637, "o", "0"] +[13.925804, "o", "1"] +[13.999524, "o", "\r\n"] +[14.019524, "o", " Hostname or IP: "] +[14.611138, "o", "s"] +[14.675057, "o", "q"] +[14.712717, "o", "l"] +[14.779931, "o", "-"] +[14.836058, "o", "b"] +[14.89024, "o", "e"] +[14.941287, "o", "n"] +[14.988901, "o", "c"] +[15.053212, "o", "h"] +[15.119398, "o", "-"] +[15.187126, "o", "0"] +[15.250928, "o", "1"] +[15.318975, "o", "."] +[15.374746, "o", "s"] +[15.412954, "o", "o"] +[15.448652, "o", "l"] +[15.48845, "o", "n"] +[15.54579, "o", "."] +[15.590073, "o", "l"] +[15.656989, "o", "o"] +[15.713194, "o", "c"] +[15.755966, "o", "a"] +[15.821408, "o", "l"] +[15.861606, "o", "\r\n"] +[16.126606, "o", "\n \u001b[1m\u001b[36mTarget 2 of 2\u001b[0m\r\n"] +[16.146606, "o", " Name \u001b[1;35m(db-02)\u001b[0m: "] +[16.781571, "o", "s"] +[16.872666, "o", "q"] +[16.950926, "o", "l"] +[17.023206, "o", "-"] +[17.101768, "o", "b"] +[17.166159, "o", "e"] +[17.313978, "o", "n"] +[17.372762, "o", "c"] +[17.45197, "o", "h"] +[17.533333, "o", "-"] +[17.608461, "o", "0"] +[17.667301, "o", "2"] +[17.740063, "o", "\r\n"] +[17.760063, "o", " Hostname or IP: "] +[18.36035, "o", "s"] +[18.423445, "o", "q"] +[18.469282, "o", "l"] +[18.539229, "o", "-"] +[18.589069, "o", "b"] +[18.632966, "o", "e"] +[18.675911, "o", "n"] +[18.72383, "o", "c"] +[18.783463, "o", "h"] +[18.834984, "o", "-"] +[18.876315, "o", "0"] +[18.920136, "o", "2"] +[18.977847, "o", "."] +[19.029097, "o", "s"] +[19.079821, "o", "o"] +[19.144836, "o", "l"] +[19.180137, "o", "n"] +[19.236834, "o", "."] +[19.296139, "o", "l"] +[19.359145, "o", "o"] +[19.402386, "o", "c"] +[19.467941, "o", "a"] +[19.536977, "o", "l"] +[19.587256, "o", "\r\n"] +[19.852256, "o", "\r\n"] +[19.867256, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 4 of 6 \u2014 Credentials\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[19.882256, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[19.897256, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mDatabase authentication shared by all targets.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[19.912256, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[19.927256, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[20.242256, "o", "\r\n"] +[20.262256, "o", "Database username \u001b[1;35m(sa)\u001b[0m: "] +[20.79401, "o", "\r\n"] +[20.96401, "o", "Database password: "] +[21.483885, "o", ""] +[21.524908, "o", ""] +[21.563229, "o", ""] +[21.606008, "o", ""] +[21.660163, "o", ""] +[21.706809, "o", ""] +[21.740665, "o", ""] +[21.790951, "o", ""] +[21.845216, "o", "\r\n"] +[22.010216, "o", "\r\n"] +[22.025216, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 5 of 6 \u2014 Benchmark Parameters\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[22.040216, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[22.055216, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mConfigure the benchmark workload.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[22.070216, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[22.085216, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[22.400216, "o", "\r\n"] +[22.460216, "o", " \u001b[2mRule of thumb: 100 warehouses ~ 10 GB per target.\u001b[0m\r\n"] +[22.475216, "o", "\r\n"] +[22.495216, "o", "Warehouses per target \u001b[1;35m(100)\u001b[0m: "] +[23.059817, "o", "1"] +[23.104997, "o", "0"] +[23.245894, "o", "0"] +[23.313562, "o", "\r\n"] +[23.478562, "o", "\r\n"] +[23.493562, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 6 of 6 \u2014 Infrastructure\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[23.508562, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[23.523562, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mKubernetes and storage settings.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[23.538562, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[23.553562, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[23.868562, "o", "\r\n"] +[23.888562, "o", "Kubernetes namespace \u001b[1;35m(hammerdb)\u001b[0m: "] +[24.38804, "o", "\r\n"] +[24.55304, "o", "\r\n"] +[24.57304, "o", "Enable Pure Storage metrics collection? \u001b[1;35m[y/n]\u001b[0m (y): "] +[24.987898, "o", "\r\n"] +[25.152898, "o", "\r\n"] +[25.172898, "o", "Configure advanced options (VUs, rampup, duration, resources)? \u001b[1;35m[y/n]\u001b[0m (n): "] +[25.618553, "o", "\r\n"] +[25.933553, "o", "\r\n"] +[25.948553, "o", "\u001b[32m\u256d\u2500\u001b[0m \u001b[1mConfiguration Summary\u001b[0m \u001b[32m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[25.963553, "o", "\u001b[32m\u2502\u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[25.978553, "o", "\u001b[32m\u2502\u001b[0m \u001b[1m\u001b[36mSetting \u001b[0m \u001b[1mValue \u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[25.993553, "o", "\u001b[32m\u2502\u001b[0m \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u001b[32m\u2502\u001b[0m\r\n"] +[26.008553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mDeployment name \u001b[0m mssql-2-tprocc-demo \u001b[32m\u2502\u001b[0m\r\n"] +[26.023553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mDatabase type \u001b[0m SQL Server \u001b[32m\u2502\u001b[0m\r\n"] +[26.038553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mBenchmark \u001b[0m TPC-C (OLTP) \u001b[32m\u2502\u001b[0m\r\n"] +[26.053553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mTargets \u001b[0m 2 \u001b[32m\u2502\u001b[0m\r\n"] +[26.068553, "o", "\u001b[32m\u2502\u001b[0m \u001b[2m sql-bench-01 \u001b[0m \u001b[2msql-bench-01.soln.local \u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[26.083553, "o", "\u001b[32m\u2502\u001b[0m \u001b[2m sql-bench-02 \u001b[0m \u001b[2msql-bench-02.soln.local \u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[26.098553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mUsername \u001b[0m sa \u001b[32m\u2502\u001b[0m\r\n"] +[26.113553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mPassword \u001b[0m ******** \u001b[32m\u2502\u001b[0m\r\n"] +[26.128553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mWarehouses \u001b[0m 100 \u001b[32m\u2502\u001b[0m\r\n"] +[26.143553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mNamespace \u001b[0m hammerdb \u001b[32m\u2502\u001b[0m\r\n"] +[26.158553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mPure Storage \u001b[0m Disabled \u001b[32m\u2502\u001b[0m\r\n"] +[26.173553, "o", "\u001b[32m\u2502\u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[26.188553, "o", "\u001b[32m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[29.703553, "o", "\r\n"] +[29.723553, "o", "Write configuration? \u001b[1;35m[y/n]\u001b[0m (y): "] +[30.191687, "o", "\r\n"] +[30.406687, "o", "\n\u001b[32mConfig written to /tmp/hammerdb-scale-demo.yaml\u001b[0m\r\n"] +[30.421687, "o", "\r\n"] +[30.436687, "o", "Next steps:\r\n"] +[30.451687, "o", " hammerdb-scale validate Check config and prerequisites\r\n"] +[30.466687, "o", " hammerdb-scale run --build Build schemas and run benchmark\r\n"] +[30.481687, "o", " hammerdb-scale run Run benchmark \u001b[1m(\u001b[0mif schemas already built\u001b[1m)\u001b[0m\r\n"] +[31.311687, "o", "\u001b[0m[user@host hammerdb-scale]$ "] +[31.648907, "o", "h"] +[31.711812, "o", "a"] +[31.779217, "o", "m"] +[31.823577, "o", "m"] +[31.873947, "o", "e"] +[31.911268, "o", "r"] +[31.949216, "o", "d"] +[31.997895, "o", "b"] +[32.041726, "o", "-"] +[32.179392, "o", "s"] +[32.227152, "o", "c"] +[32.354005, "o", "a"] +[32.415179, "o", "l"] +[32.460257, "o", "e"] +[32.562903, "o", " "] +[32.617242, "o", "v"] +[32.684781, "o", "a"] +[32.753734, "o", "l"] +[32.798482, "o", "i"] +[32.848069, "o", "d"] +[32.889452, "o", "a"] +[32.936512, "o", "t"] +[32.99598, "o", "e"] +[33.103973, "o", " "] +[33.141533, "o", "-"] +[33.187803, "o", "f"] +[33.296351, "o", " "] +[33.334078, "o", "/"] +[33.382415, "o", "t"] +[33.446766, "o", "m"] +[33.509954, "o", "p"] +[33.552508, "o", "/"] +[33.588011, "o", "h"] +[33.65561, "o", "a"] +[33.700206, "o", "m"] +[33.749407, "o", "m"] +[33.797043, "o", "e"] +[33.852782, "o", "r"] +[33.897348, "o", "d"] +[33.938288, "o", "b"] +[34.005639, "o", "-"] +[34.057092, "o", "s"] +[34.123949, "o", "c"] +[34.178876, "o", "a"] +[34.219695, "o", "l"] +[34.261353, "o", "e"] +[34.296796, "o", "-"] +[34.421445, "o", "d"] +[34.569403, "o", "e"] +[34.634968, "o", "m"] +[34.699489, "o", "o"] +[34.764399, "o", "."] +[34.831788, "o", "y"] +[34.90072, "o", "a"] +[34.961273, "o", "m"] +[35.002848, "o", "l"] +[35.051201, "o", "\r\n"] +[35.431201, "o", "Benchmark: tprocc \u001b[1m(\u001b[0mfrom config default\u001b[1m)\u001b[0m\r\n"] +[35.661201, "o", "\nValidating configuration\u001b[33m...\u001b[0m\r\n"] +[35.891201, "o", " \u001b[32m+\u001b[0m YAML syntax valid\r\n"] +[35.971201, "o", " \u001b[32m+\u001b[0m Schema validates \u001b[1m(\u001b[0mall required fields present\u001b[1m)\u001b[0m\r\n"] +[36.051201, "o", " \u001b[32m+\u001b[0m \u001b[1;36m2\u001b[0m targets configured \u001b[1m(\u001b[0mtype: mssql\u001b[1m)\u001b[0m\r\n"] +[36.131201, "o", " \u001b[32m+\u001b[0m Benchmark settings valid \u001b[1m(\u001b[0mtprocc, \u001b[1;36m100\u001b[0m warehouses\u001b[1m)\u001b[0m\r\n"] +[36.211201, "o", " \u001b[32m+\u001b[0m Default benchmark: tprocc\r\n"] +[36.441201, "o", "\nChecking prerequisites\u001b[33m...\u001b[0m\r\n"] +[36.691201, "o", " \u001b[32m+\u001b[0m helm found \u001b[1m(\u001b[0mv3.\u001b[1;36m19.4\u001b[0m+g7cfb6e4\u001b[1m)\u001b[0m\r\n"] +[36.771201, "o", " \u001b[32m+\u001b[0m kubectl found \u001b[1m(\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[36.851201, "o", " \u001b[32m+\u001b[0m Current context: admin\r\n"] +[37.081201, "o", "\nChecking Kubernetes access\u001b[33m...\u001b[0m\r\n"] +[37.331201, "o", " \u001b[33m!\u001b[0m Namespace \u001b[32m'hammerdb'\u001b[0m does not exist. It will be created during deployment.\r\n"] +[37.411201, "o", " \u001b[32m+\u001b[0m Can create Jobs in namespace\r\n"] +[37.491201, "o", " \u001b[32m+\u001b[0m Can create ConfigMaps in namespace\r\n"] +[37.721201, "o", "\nChecking database connectivity\u001b[33m...\u001b[0m\r\n"] +[38.521201, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m sql-bench-\u001b[1;36m01.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[38.871201, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m sql-bench-\u001b[1;36m02.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[39.251201, "o", "\nValidation complete: \u001b[1;36m0\u001b[0m \u001b[1;35mwarning\u001b[0m\u001b[1m(\u001b[0ms\u001b[1m)\u001b[0m, \u001b[1;36m0\u001b[0m errors.\r\n"] +[41.281201, "o", "\u001b[0m[user@host hammerdb-scale]$ "] diff --git a/demos/getting-started.cast b/demos/getting-started.cast new file mode 100644 index 0000000..d7200ac --- /dev/null +++ b/demos/getting-started.cast @@ -0,0 +1,668 @@ +{"version": 2, "width": 100, "height": 30, "timestamp": 1772771692, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/bash", "TERM": null}, "title": "hammerdb-scale: Getting Started"} +[0.066034, "o", "[root@august hammerdb-scale]# "] +[1.11732, "o", "p"] +[1.207601, "o", "i"] +[1.297849, "o", "p"] +[1.387901, "o", " "] +[1.478276, "o", "i"] +[1.568629, "o", "n"] +[1.659002, "o", "s"] +[1.749211, "o", "t"] +[1.839371, "o", "a"] +[1.929528, "o", "l"] +[2.019598, "o", "l"] +[2.109889, "o", " "] +[2.200296, "o", "h"] +[2.290359, "o", "a"] +[2.380679, "o", "m"] +[2.470901, "o", "m"] +[2.561049, "o", "e"] +[2.651182, "o", "r"] +[2.741421, "o", "d"] +[2.831715, "o", "b"] +[2.921993, "o", "-"] +[3.012263, "o", "s"] +[3.102604, "o", "c"] +[3.192811, "o", "a"] +[3.283108, "o", "l"] +[3.373113, "o", "e"] +[3.61359, "o", "\r\n"] +[3.898358, "o", "Requirement already satisfied: hammerdb-scale in /usr/local/lib/python3.11/site-packages (2.0.0)\r\n"] +[3.899483, "o", "Requirement already satisfied: oracledb>=2.0.0 in /usr/local/lib64/python3.11/site-packages (from hammerdb-scale) (3.4.2)\r\n"] +[3.900004, "o", "Requirement already satisfied: pydantic>=2.5.0 in /usr/local/lib/python3.11/site-packages (from hammerdb-scale) (2.12.5)\r\n"] +[3.900524, "o", "Requirement already satisfied: pymssql>=2.2.0 in /usr/local/lib64/python3.11/site-packages (from hammerdb-scale) (2.3.13)\r\n"] +[3.90103, "o", "Requirement already satisfied: pyyaml>=6.0 in /usr/local/lib64/python3.11/site-packages (from hammerdb-scale) (6.0.3)\r\n"] +[3.901499, "o", "Requirement already satisfied: rich>=13.7.0 in /usr/local/lib/python3.11/site-packages (from hammerdb-scale) (14.3.2)\r\n"] +[3.901971, "o", "Requirement already satisfied: typer>=0.12.0 in /usr/local/lib/python3.11/site-packages (from hammerdb-scale) (0.21.2)\r\n"] +[3.903498, "o", "Requirement already satisfied: cryptography>=3.2.1 in /usr/local/lib64/python3.11/site-packages (from oracledb>=2.0.0->hammerdb-scale) (46.0.4)\r\n"] +[3.90394, "o", "Requirement already satisfied: typing_extensions>=4.14.0 in /usr/local/lib/python3.11/site-packages (from oracledb>=2.0.0->hammerdb-scale) (4.15.0)\r\n"] +[3.906208, "o", "Requirement already satisfied: cffi>=2.0.0 in /usr/local/lib64/python3.11/site-packages (from cryptography>=3.2.1->oracledb>=2.0.0->hammerdb-scale) (2.0.0)\r\n"] +[3.908759, "o", "Requirement already satisfied: pycparser in /usr/local/lib/python3.11/site-packages (from cffi>=2.0.0->cryptography>=3.2.1->oracledb>=2.0.0->hammerdb-scale) (3.0)\r\n"] +[3.910673, "o", "Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.11/site-packages (from pydantic>=2.5.0->hammerdb-scale) (0.7.0)\r\n"] +[3.911086, "o", "Requirement already satisfied: pydantic-core==2.41.5 in /usr/local/lib64/python3.11/site-packages (from pydantic>=2.5.0->hammerdb-scale) (2.41.5)\r\n"] +[3.911627, "o", "Requirement already satisfied: typing-inspection>=0.4.2 in /usr/local/lib/python3.11/site-packages (from pydantic>=2.5.0->hammerdb-scale) (0.4.2)\r\n"] +[3.917204, "o", "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/lib/python3.11/site-packages (from rich>=13.7.0->hammerdb-scale) (4.0.0)\r\n"] +[3.917713, "o", "Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /usr/local/lib/python3.11/site-packages (from rich>=13.7.0->hammerdb-scale) (2.19.2)\r\n"] +[3.91945, "o", "Requirement already satisfied: mdurl~=0.1 in /usr/local/lib/python3.11/site-packages (from markdown-it-py>=2.2.0->rich>=13.7.0->hammerdb-scale) (0.1.2)\r\n"] +[3.922536, "o", "Requirement already satisfied: click>=8.0.0 in /usr/local/lib/python3.11/site-packages (from typer>=0.12.0->hammerdb-scale) (8.3.1)\r\n"] +[3.923082, "o", "Requirement already satisfied: annotated-doc>=0.0.2 in /usr/local/lib/python3.11/site-packages (from typer>=0.12.0->hammerdb-scale) (0.0.4)\r\n"] +[3.923484, "o", "Requirement already satisfied: shellingham>=1.3.0 in /usr/local/lib/python3.11/site-packages (from typer>=0.12.0->hammerdb-scale) (1.5.4)\r\n"] +[4.089887, "o", "\u001b[33mWARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.\u001b[0m\u001b[33m\r\n\u001b[0m"] +[4.148297, "o", "[root@august hammerdb-scale]# "] +[5.199794, "o", "h"] +[5.289927, "o", "a"] +[5.380122, "o", "m"] +[5.47031, "o", "m"] +[5.560631, "o", "e"] +[5.650858, "o", "r"] +[5.741044, "o", "d"] +[5.831375, "o", "b"] +[5.921485, "o", "-"] +[6.011709, "o", "s"] +[6.102179, "o", "c"] +[6.192469, "o", "a"] +[6.282559, "o", "l"] +[6.372856, "o", "e"] +[6.462926, "o", " "] +[6.553193, "o", "i"] +[6.643521, "o", "n"] +[6.733621, "o", "i"] +[6.824075, "o", "t"] +[6.914132, "o", " "] +[7.004242, "o", "-"] +[7.094427, "o", "i"] +[7.184662, "o", " "] +[7.274849, "o", "-"] +[7.36508, "o", "o"] +[7.455257, "o", " "] +[7.545799, "o", "/"] +[7.635981, "o", "t"] +[7.726281, "o", "m"] +[7.81646, "o", "p"] +[7.90642, "o", "/"] +[7.996698, "o", "h"] +[8.086919, "o", "a"] +[8.177462, "o", "m"] +[8.267631, "o", "m"] +[8.357628, "o", "e"] +[8.447911, "o", "r"] +[8.53838, "o", "d"] +[8.628555, "o", "b"] +[8.718801, "o", "-"] +[8.8091, "o", "s"] +[8.899247, "o", "c"] +[8.989238, "o", "a"] +[9.0798, "o", "l"] +[9.17003, "o", "e"] +[9.259851, "o", "-"] +[9.350102, "o", "d"] +[9.440782, "o", "e"] +[9.531002, "o", "m"] +[9.621246, "o", "o"] +[9.711387, "o", "."] +[9.801543, "o", "y"] +[9.891355, "o", "a"] +[9.982163, "o", "m"] +[10.072015, "o", "l"] +[10.162675, "o", " "] +[10.25278, "o", "-"] +[10.343205, "o", "-"] +[10.433386, "o", "f"] +[10.523681, "o", "o"] +[10.613864, "o", "r"] +[10.704222, "o", "c"] +[10.794523, "o", "e"] +[11.034314, "o", "\r\n"] +[11.27589, "o", "\r\n"] +[11.276827, "o", "\u001b[34m╭─\u001b[0m\u001b[34m─────────────────────────────────────\u001b[0m\u001b[34m hammerdb-scale 2.0.1 \u001b[0m\u001b[34m─────────────────────────────────────\u001b[0m\u001b[34m─╮\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[1mHammerDB-Scale Configuration Wizard\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m This wizard will guide you through creating a benchmark \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m configuration file step by step. \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[2mPress Ctrl+C at any time to cancel.\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[11.277088, "o", "\r\n"] +[11.277829, "o", "\u001b[36m╭─\u001b[0m\u001b[36m───────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 1 of 6 — Deployment\u001b[0m\u001b[36m \u001b[0m\u001b[36m───────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mGive this benchmark a name.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[11.278017, "o", "\r\n"] +[11.2782, "o", "Deployment name: "] +[12.085456, "o", "m"] +[12.175532, "o", "s"] +[12.265886, "o", "s"] +[12.355915, "o", "q"] +[12.446203, "o", "l"] +[12.536438, "o", "-"] +[12.626781, "o", "8"] +[12.717211, "o", "-"] +[12.807633, "o", "t"] +[12.897657, "o", "p"] +[12.98796, "o", "r"] +[13.078182, "o", "o"] +[13.168447, "o", "c"] +[13.258656, "o", "c"] +[13.34888, "o", "-"] +[13.439145, "o", "d"] +[13.529485, "o", "e"] +[13.619728, "o", "m"] +[13.709972, "o", "o"] +[13.950315, "o", "\r\n"] +[13.950824, "o", "\r\n"] +[13.952528, "o", "\u001b[36m╭─\u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 2 of 6 — Database & Benchmark\u001b[0m\u001b[36m \u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mSelect the database engine and TPC benchmark type.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[13.95274, "o", "\r\n"] +[13.95894, "o", " \u001b[1;36m1\u001b[0m\u001b[1m.\u001b[0m Oracle\r\n"] +[13.959322, "o", " \u001b[1;36m2\u001b[0m\u001b[1m.\u001b[0m Microsoft SQL Server\r\n\r\n"] +[13.959472, "o", "Database type \u001b[1;35m[1/2]\u001b[0m: "] +[14.901705, "o", "2"] +[15.142046, "o", "\r\n"] +[15.1425, "o", "\r\n"] +[15.143572, "o", " \u001b[1;36m1\u001b[0m\u001b[1m.\u001b[0m TPC-C \u001b[1m(\u001b[0mOLTP transactional\u001b[1m)\u001b[0m\r\n"] +[15.144388, "o", " \u001b[1;36m2\u001b[0m\u001b[1m.\u001b[0m TPC-H \u001b[1m(\u001b[0mOLAP analytical\u001b[1m)\u001b[0m\r\n"] +[15.144599, "o", "\r\n"] +[15.14509, "o", "Benchmark \u001b[1;35m[1/2]\u001b[0m: "] +[16.093055, "o", "1"] +[16.333434, "o", "\r\n"] +[16.333776, "o", "\r\n"] +[16.334461, "o", "\u001b[36m╭─\u001b[0m\u001b[36m────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 3 of 6 — Database Targets\u001b[0m\u001b[36m \u001b[0m\u001b[36m────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mDefine the database hosts to benchmark against.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mEach target gets its own HammerDB Job pod in Kubernetes.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[16.334573, "o", "\r\n"] +[16.334858, "o", "Number of database targets \u001b[1;36m(1)\u001b[0m: "] +[17.284702, "o", "8"] +[17.52506, "o", "\r\n"] +[17.526426, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m1\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[17.527053, "o", " Name \u001b[1;36m(db-01)\u001b[0m: "] +[18.276022, "o", "s"] +[18.366283, "o", "q"] +[18.456255, "o", "l"] +[18.546494, "o", "-"] +[18.636702, "o", "b"] +[18.726931, "o", "e"] +[18.817116, "o", "n"] +[18.907277, "o", "c"] +[18.997431, "o", "h"] +[19.087784, "o", "-"] +[19.178076, "o", "0"] +[19.268139, "o", "1"] +[19.508613, "o", "\r\n"] +[19.509417, "o", " Hostname or IP: "] +[20.259658, "o", "s"] +[20.349945, "o", "q"] +[20.440278, "o", "l"] +[20.530542, "o", "-"] +[20.620906, "o", "b"] +[20.711147, "o", "e"] +[20.801465, "o", "n"] +[20.891642, "o", "c"] +[20.981863, "o", "h"] +[21.071993, "o", "-"] +[21.162246, "o", "0"] +[21.252483, "o", "1"] +[21.342738, "o", "."] +[21.433059, "o", "s"] +[21.523246, "o", "o"] +[21.613494, "o", "l"] +[21.703752, "o", "n"] +[21.794048, "o", "."] +[21.884294, "o", "l"] +[21.974492, "o", "o"] +[22.064669, "o", "c"] +[22.154767, "o", "a"] +[22.245055, "o", "l"] +[22.485433, "o", "\r\n"] +[22.486793, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m2\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[22.487312, "o", " Name \u001b[1;36m(db-02)\u001b[0m: "] +[23.236406, "o", "s"] +[23.326579, "o", "q"] +[23.416962, "o", "l"] +[23.507184, "o", "-"] +[23.597429, "o", "b"] +[23.687648, "o", "e"] +[23.777879, "o", "n"] +[23.868235, "o", "c"] +[23.958301, "o", "h"] +[24.048466, "o", "-"] +[24.138878, "o", "0"] +[24.229083, "o", "2"] +[24.469494, "o", "\r\n"] +[24.470304, "o", " Hostname or IP: "] +[25.220064, "o", "s"] +[25.310171, "o", "q"] +[25.400378, "o", "l"] +[25.49068, "o", "-"] +[25.580929, "o", "b"] +[25.671166, "o", "e"] +[25.761409, "o", "n"] +[25.851661, "o", "c"] +[25.941866, "o", "h"] +[26.032129, "o", "-"] +[26.122328, "o", "0"] +[26.212554, "o", "2"] +[26.302934, "o", "."] +[26.393169, "o", "s"] +[26.483395, "o", "o"] +[26.573716, "o", "l"] +[26.663923, "o", "n"] +[26.75413, "o", "."] +[26.844335, "o", "l"] +[26.934504, "o", "o"] +[27.024701, "o", "c"] +[27.11493, "o", "a"] +[27.205334, "o", "l"] +[27.445776, "o", "\r\n"] +[27.44632, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m3\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[27.446539, "o", " Name \u001b[1;36m(db-03)\u001b[0m: "] +[28.196528, "o", "s"] +[28.286662, "o", "q"] +[28.376886, "o", "l"] +[28.467092, "o", "-"] +[28.557369, "o", "b"] +[28.647589, "o", "e"] +[28.737793, "o", "n"] +[28.82804, "o", "c"] +[28.918105, "o", "h"] +[29.008445, "o", "-"] +[29.098674, "o", "0"] +[29.18891, "o", "3"] +[29.429208, "o", "\r\n"] +[29.43003, "o", " Hostname or IP: "] +[30.180222, "o", "s"] +[30.270454, "o", "q"] +[30.360673, "o", "l"] +[30.450986, "o", "-"] +[30.54116, "o", "b"] +[30.631414, "o", "e"] +[30.721635, "o", "n"] +[30.81195, "o", "c"] +[30.902068, "o", "h"] +[30.992382, "o", "-"] +[31.082552, "o", "0"] +[31.172758, "o", "3"] +[31.262999, "o", "."] +[31.353228, "o", "s"] +[31.443684, "o", "o"] +[31.53394, "o", "l"] +[31.623977, "o", "n"] +[31.714285, "o", "."] +[31.804772, "o", "l"] +[31.894998, "o", "o"] +[31.985265, "o", "c"] +[32.075428, "o", "a"] +[32.165833, "o", "l"] +[32.406124, "o", "\r\n"] +[32.407363, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m4\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[32.407928, "o", " Name \u001b[1;36m(db-04)\u001b[0m: "] +[33.157195, "o", "s"] +[33.247461, "o", "q"] +[33.338003, "o", "l"] +[33.428151, "o", "-"] +[33.518369, "o", "b"] +[33.60869, "o", "e"] +[33.698974, "o", "n"] +[33.789133, "o", "c"] +[33.87935, "o", "h"] +[33.969447, "o", "-"] +[34.05965, "o", "0"] +[34.14978, "o", "4"] +[34.390317, "o", "\r\n"] +[34.390734, "o", " Hostname or IP: "] +[35.141298, "o", "s"] +[35.231314, "o", "q"] +[35.321632, "o", "l"] +[35.411938, "o", "-"] +[35.502128, "o", "b"] +[35.592475, "o", "e"] +[35.682662, "o", "n"] +[35.772914, "o", "c"] +[35.863173, "o", "h"] +[35.953347, "o", "-"] +[36.043602, "o", "0"] +[36.133787, "o", "4"] +[36.223985, "o", "."] +[36.314211, "o", "s"] +[36.404538, "o", "o"] +[36.494829, "o", "l"] +[36.58506, "o", "n"] +[36.675243, "o", "."] +[36.765501, "o", "l"] +[36.855835, "o", "o"] +[36.946004, "o", "c"] +[37.036357, "o", "a"] +[37.126529, "o", "l"] +[37.366935, "o", "\r\n"] +[37.368194, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m5\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[37.368718, "o", " Name \u001b[1;36m(db-05)\u001b[0m: "] +[38.117738, "o", "s"] +[38.207848, "o", "q"] +[38.297939, "o", "l"] +[38.3882, "o", "-"] +[38.478462, "o", "b"] +[38.568675, "o", "e"] +[38.658922, "o", "n"] +[38.749255, "o", "c"] +[38.839607, "o", "h"] +[38.929831, "o", "-"] +[39.020095, "o", "0"] +[39.110326, "o", "5"] +[39.350762, "o", "\r\n"] +[39.351131, "o", " Hostname or IP: "] +[40.101691, "o", "s"] +[40.191811, "o", "q"] +[40.282123, "o", "l"] +[40.372136, "o", "-"] +[40.462415, "o", "b"] +[40.55259, "o", "e"] +[40.642881, "o", "n"] +[40.733053, "o", "c"] +[40.823229, "o", "h"] +[40.913538, "o", "-"] +[41.003594, "o", "0"] +[41.093857, "o", "5"] +[41.184214, "o", "."] +[41.274389, "o", "s"] +[41.364695, "o", "o"] +[41.454948, "o", "l"] +[41.545297, "o", "n"] +[41.635714, "o", "."] +[41.725794, "o", "l"] +[41.816063, "o", "o"] +[41.906194, "o", "c"] +[41.996521, "o", "a"] +[42.086772, "o", "l"] +[42.327159, "o", "\r\n"] +[42.328469, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m6\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[42.329022, "o", " Name \u001b[1;36m(db-06)\u001b[0m: "] +[43.078111, "o", "s"] +[43.168191, "o", "q"] +[43.258473, "o", "l"] +[43.348733, "o", "-"] +[43.438946, "o", "b"] +[43.529137, "o", "e"] +[43.619456, "o", "n"] +[43.709641, "o", "c"] +[43.799701, "o", "h"] +[43.889947, "o", "-"] +[43.980192, "o", "0"] +[44.070424, "o", "6"] +[44.310812, "o", "\r\n"] +[44.311636, "o", " Hostname or IP: "] +[45.061635, "o", "s"] +[45.151833, "o", "q"] +[45.24197, "o", "l"] +[45.332079, "o", "-"] +[45.422197, "o", "b"] +[45.51247, "o", "e"] +[45.602632, "o", "n"] +[45.692789, "o", "c"] +[45.782989, "o", "h"] +[45.873057, "o", "-"] +[45.963337, "o", "0"] +[46.053513, "o", "6"] +[46.143715, "o", "."] +[46.234024, "o", "s"] +[46.324225, "o", "o"] +[46.414837, "o", "l"] +[46.504957, "o", "n"] +[46.594958, "o", "."] +[46.685155, "o", "l"] +[46.775338, "o", "o"] +[46.865611, "o", "c"] +[46.955783, "o", "a"] +[47.046313, "o", "l"] +[47.286788, "o", "\r\n"] +[47.288039, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m7\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[47.288553, "o", " Name \u001b[1;36m(db-07)\u001b[0m: "] +[48.037422, "o", "s"] +[48.127557, "o", "q"] +[48.217773, "o", "l"] +[48.307921, "o", "-"] +[48.398203, "o", "b"] +[48.488338, "o", "e"] +[48.578621, "o", "n"] +[48.668854, "o", "c"] +[48.759121, "o", "h"] +[48.849334, "o", "-"] +[48.939504, "o", "0"] +[49.029794, "o", "7"] +[49.270303, "o", "\r\n"] +[49.271079, "o", " Hostname or IP: "] +[50.02112, "o", "s"] +[50.111465, "o", "q"] +[50.201791, "o", "l"] +[50.291912, "o", "-"] +[50.382125, "o", "b"] +[50.472295, "o", "e"] +[50.562507, "o", "n"] +[50.652687, "o", "c"] +[50.742814, "o", "h"] +[50.833023, "o", "-"] +[50.92332, "o", "0"] +[51.013524, "o", "7"] +[51.103754, "o", "."] +[51.194073, "o", "s"] +[51.284263, "o", "o"] +[51.374502, "o", "l"] +[51.464857, "o", "n"] +[51.554934, "o", "."] +[51.645311, "o", "l"] +[51.735578, "o", "o"] +[51.825625, "o", "c"] +[51.91597, "o", "a"] +[52.006069, "o", "l"] +[52.246774, "o", "\r\n"] +[52.2483, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m8\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[52.24886, "o", " Name \u001b[1;36m(db-08)\u001b[0m: "] +[52.997586, "o", "s"] +[53.087604, "o", "q"] +[53.17793, "o", "l"] +[53.268097, "o", "-"] +[53.358422, "o", "b"] +[53.448591, "o", "e"] +[53.538831, "o", "n"] +[53.628945, "o", "c"] +[53.719179, "o", "h"] +[53.809398, "o", "-"] +[53.899669, "o", "0"] +[53.989777, "o", "8"] +[54.230235, "o", "\r\n"] +[54.23106, "o", " Hostname or IP: "] +[54.981129, "o", "s"] +[55.071373, "o", "q"] +[55.161715, "o", "l"] +[55.252039, "o", "-"] +[55.342332, "o", "b"] +[55.432528, "o", "e"] +[55.522762, "o", "n"] +[55.613079, "o", "c"] +[55.703368, "o", "h"] +[55.793474, "o", "-"] +[55.883891, "o", "0"] +[55.974176, "o", "8"] +[56.06457, "o", "."] +[56.154824, "o", "s"] +[56.244907, "o", "o"] +[56.335099, "o", "l"] +[56.425154, "o", "n"] +[56.51561, "o", "."] +[56.605851, "o", "l"] +[56.696047, "o", "o"] +[56.786173, "o", "c"] +[56.876348, "o", "a"] +[56.966715, "o", "l"] +[57.206985, "o", "\r\n"] +[57.207231, "o", "\r\n"] +[57.208721, "o", "\u001b[36m╭─\u001b[0m\u001b[36m──────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 4 of 6 — Credentials\u001b[0m\u001b[36m \u001b[0m\u001b[36m───────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mDatabase authentication shared by all targets.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mYou can override per-host in the YAML later.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[57.208934, "o", "\r\n"] +[57.20945, "o", "Database username \u001b[1;36m(sa)\u001b[0m: "] +[58.158366, "o", "\r\n"] +[58.159112, "o", "Database password: "] +[59.881625, "o", "\r\n"] +[59.882019, "o", "\r\n"] +[59.883553, "o", "\u001b[36m╭─\u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 5 of 6 — Benchmark Parameters\u001b[0m\u001b[36m \u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mConfigure the benchmark workload.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[59.883771, "o", "\r\n"] +[59.884892, "o", " \u001b[2mRule of thumb: \u001b[0m\u001b[1;2;36m100\u001b[0m\u001b[2m warehouses ~ \u001b[0m\u001b[1;2;36m10\u001b[0m\u001b[2m GB per target.\u001b[0m\r\n\r\n"] +[59.885418, "o", "Warehouses per target \u001b[1;36m(100)\u001b[0m: "] +[60.832534, "o", "1"] +[60.922714, "o", "0"] +[61.163168, "o", "\r\n"] +[61.163685, "o", "\r\n"] +[61.165224, "o", "\u001b[36m╭─\u001b[0m\u001b[36m─────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 6 of 6 — Infrastructure\u001b[0m\u001b[36m \u001b[0m\u001b[36m─────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mKubernetes and storage settings.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[61.165442, "o", "\r\n"] +[61.165991, "o", "Kubernetes namespace \u001b[1;36m(hammerdb)\u001b[0m: "] +[62.114424, "o", "\r\n"] +[62.114901, "o", "\r\n"] +[62.115638, "o", "Enable Pure Storage metrics collection? \u001b[1;35m[y/n]\u001b[0m \u001b[1;36m(n)\u001b[0m: "] +[62.965616, "o", "\r\n"] +[62.965882, "o", "\r\n"] +[62.966162, "o", "Configure advanced options (VUs, rampup, duration, resources)? \u001b[1;35m[y/n]\u001b[0m \u001b[1;36m(n)\u001b[0m: "] +[63.816275, "o", "y"] +[64.056961, "o", "\r\n"] +[64.058707, "o", "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;33mAdvanced Options\u001b[0m\u001b[33m \u001b[0m\u001b[33m───────────────────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\r\n\u001b[33m│\u001b[0m \u001b[2mTune concurrency, timing, and pod resources.\u001b[0m \u001b[33m│\u001b[0m\r\n\u001b[33m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[64.058933, "o", "\r\n"] +[64.059479, "o", "Build virtual users \u001b[1;36m(4)\u001b[0m: "] +[64.808026, "o", "\r\n"] +[64.808901, "o", "Load virtual users \u001b[1;36m(4)\u001b[0m: "] +[65.55893, "o", "8"] +[65.799287, "o", "\r\n"] +[65.799828, "o", "Rampup (minutes) \u001b[1;36m(5)\u001b[0m: "] +[66.5505, "o", "1"] +[66.790835, "o", "\r\n"] +[66.791714, "o", "Duration (minutes) \u001b[1;36m(10)\u001b[0m: "] +[67.54177, "o", "1"] +[67.782253, "o", "\r\n"] +[67.783592, "o", "\r\n \u001b[2mKubernetes pod resources:\u001b[0m\r\n\r\n"] +[67.784143, "o", "Request memory \u001b[1;36m(4Gi)\u001b[0m: "] +[68.532993, "o", "\r\n"] +[68.533843, "o", "Request CPU \u001b[1;36m(4)\u001b[0m: "] +[69.283953, "o", "\r\n"] +[69.284803, "o", "Limit memory \u001b[1;36m(8Gi)\u001b[0m: "] +[70.035116, "o", "\r\n"] +[70.035969, "o", "Limit CPU \u001b[1;36m(8)\u001b[0m: "] +[70.786125, "o", "\r\n"] +[70.786679, "o", "\r\n"] +[70.799256, "o", "\u001b[32m╭─\u001b[0m\u001b[32m────────────────────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mConfiguration Summary\u001b[0m\u001b[32m \u001b[0m\u001b[32m─────────────────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\r\n\u001b[32m│\u001b[0m ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m ┃\u001b[1m \u001b[0m\u001b[1mSetting \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mValue \u001b[0m\u001b[1m \u001b[0m┃ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mDeployment name \u001b[0m\u001b[36m \u001b[0m│ mssql-8-tprocc-demo │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mDatabase type \u001b[0m\u001b[36m \u001b[0m│ SQL Server │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mBenchmark \u001b[0m\u001b[36m \u001b[0m│ TPC-C (OLTP) │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mTargets \u001b[0m\u001b[36m \u001b[0m│ 8 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-01 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-01.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-02 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-02.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-03 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-03.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-04 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-04.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-05 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-05.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-06 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-06.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-07 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-07.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-08 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-08.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mUsername \u001b[0m\u001b[36m \u001b[0m│ sa │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mPassword \u001b[0m\u001b[36m \u001b[0m│ ******** │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mWarehouses \u001b[0m\u001b[36m \u001b[0m│ 10 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mNamespace \u001b[0m\u001b[36m \u001b[0m│ hammerdb │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mPure Storage \u001b[0m\u001b[36m \u001b[0m│ Disabled │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m ├─────────────"] +[70.799546, "o", "─────────────────────────────────┼───────────────────────────────────────────────┤ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mLoad VUs \u001b[0m\u001b[36m \u001b[0m│ 8 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mRampup (min) \u001b[0m\u001b[36m \u001b[0m│ 1 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mDuration (min) \u001b[0m\u001b[36m \u001b[0m│ 1 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m └──────────────────────────────────────────────┴───────────────────────────────────────────────┘ \u001b[32m│\u001b[0m\r\n\u001b[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n\r\n"] +[70.799709, "o", "Write configuration? \u001b[1;35m[y/n]\u001b[0m \u001b[1;36m(y)\u001b[0m: "] +[72.838201, "o", "\r\n"] +[72.839926, "o", "\r\n\u001b[32mConfig written to \u001b[0m\u001b[32m/tmp/\u001b[0m\u001b[32mhammerdb-scale-demo.yaml\u001b[0m\r\n"] +[72.840486, "o", "\r\nNext steps:\r\n"] +[72.841117, "o", " hammerdb-scale validate Check config and prerequisites\r\n"] +[72.841764, "o", " hammerdb-scale run --build Build schemas and run benchmark\r\n"] +[72.842585, "o", " hammerdb-scale run Run benchmark \u001b[1m(\u001b[0mif schemas already built\u001b[1m)\u001b[0m\r\n"] +[72.885874, "o", "[root@august hammerdb-scale]# "] +[75.889954, "o", "c"] +[75.980126, "o", "a"] +[76.070228, "o", "t"] +[76.160514, "o", " "] +[76.250684, "o", "/"] +[76.340999, "o", "t"] +[76.431161, "o", "m"] +[76.521537, "o", "p"] +[76.611685, "o", "/"] +[76.701948, "o", "h"] +[76.79213, "o", "a"] +[76.882341, "o", "m"] +[76.972498, "o", "m"] +[77.062904, "o", "e"] +[77.153351, "o", "r"] +[77.243569, "o", "d"] +[77.333618, "o", "b"] +[77.424121, "o", "-"] +[77.514154, "o", "s"] +[77.604378, "o", "c"] +[77.694729, "o", "a"] +[77.7847, "o", "l"] +[77.8751, "o", "e"] +[77.965318, "o", "-"] +[78.055687, "o", "d"] +[78.145807, "o", "e"] +[78.236129, "o", "m"] +[78.326462, "o", "o"] +[78.416722, "o", "."] +[78.507055, "o", "y"] +[78.597236, "o", "a"] +[78.687558, "o", "m"] +[78.777764, "o", "l"] +[79.017894, "o", "\r\n"] +[79.02118, "o", "# ============================================================================\r\n# HammerDB-Scale Configuration\r\n# ============================================================================\r\n# Docs: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md\r\n# Guide: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md\r\n#\r\n# Quick start:\r\n# 1. Review and edit the values below (especially targets, warehouses, and virtual users)\r\n# 2. hammerdb-scale validate # check config, tools, and database connectivity\r\n# 3. hammerdb-scale run --build --wait # build schemas and run the benchmark\r\n# 4. hammerdb-scale results # aggregate results\r\n# 5. hammerdb-scale report --open # generate HTML scorecard\r\n# ============================================================================\r\n\r\nname: mssql-8-tprocc-demo\r\ndescription: \"MSSQL TPROCC benchmark — 8 targets\"\r\ndefault_benchmark: tprocc\r\n\r\n# ============================================================================\r\n# DATABASE TARGETS\r\n# ============================================================================\r\n"] +[79.021326, "o", "# \"defaults\" are inherited by all hosts — override any field per-host if needed.\r\n# Add or remove hosts to match your environment.\r\ntargets:\r\n defaults:\r\n type: mssql\r\n username: sa\r\n password: \"Osmium76\"\r\n image:\r\n repository: sillidata/hammerdb-scale\r\n tag: latest\r\n pull_policy: Always # Always | IfNotPresent | Never\r\n mssql:\r\n port: 1433\r\n connection:\r\n tcp: true\r\n authentication: sql\r\n odbc_driver: \"ODBC Driver 18 for SQL Server\"\r\n encrypt_connection: true # TLS encryption (recommended)\r\n trust_server_cert: true # accept self-signed certs\r\n tprocc:\r\n database_name: tpcc # database created during build phase\r\n use_bcp: false # bulk copy for faster data loading\r\n tproch:\r\n database_name: tpch # database created during build phase\r\n maxdop: 2 # max degree of parallelism for queries\r\n use_clustered_columnstore: false # columnstore indexes (analytics optimization)\r\n\r\n hosts:\r\n - name: sql-bench-01\r\n host: \"sql-bench-01.soln.local\"\r\n - name: sql-bench-02\r\n host: \"sql-bench-02.soln.local\"\r\n - name: sql-bench-03\r\n host: \"sql-bench-03.soln.local\"\r\n - name: sql-bench-04\r\n host: \"sql-bench-04.soln.local\"\r\n - name: sql-bench-05\r\n host: \"sql-bench-05.soln.local\"\r\n - name: sql-bench-06\r\n host: \"sql-bench-06.soln.local\"\r\n - name: sql-bench-07\r\n host: \"sql-bench-07.soln.local\"\r\n - name: sql-bench-08\r\n host: \"sql-bench-08.soln.local\"\r\n\r\n# ============================================================================\r\n# BENCHMARK PARAMETERS\r\n# ============================================================================\r\n# Tune these to control data size, concurrency, and test duration.\r\n# Uncomment the other benchmark section to switch between TPC-C and TPC-H.\r\nhammerdb:\r\n tprocc:\r\n warehouses: 10 # data size: 100 = ~10GB, 1000 = ~100GB, 10000 = ~1TB per target\r\n build_virtual_users: 4 # parallel threads for schema creation\r\n load_virtual_users: 8 # concurrent users during benchmark (tune to match CPU cores)\r\n driver: timed # \"timed\" = run for fixed duration, \"test\" = single iteration\r\n rampup: 1 # warm-up period before measuring (minutes)\r\n duration: 1 # measurement window (minutes), 5-60 typical\r\n total_iterations: 10000000 # max iterations (effectively unlimited with timed driver)\r\n all_warehouses: true # distribute load across all warehouses\r\n checkpoint: true # issue checkpoint before benchmark\r\n"] +[79.021478, "o", " time_profile: false # detailed per-transaction timing (adds overhead)\r\n# tproch:\r\n# scale_factor: 1 # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB\r\n# build_threads: 4 # parallel threads for data generation\r\n# build_virtual_users: 1 # HammerDB orchestrator (keep at 1)\r\n# load_virtual_users: 1 # query concurrency: 1 = Power run, >1 = Throughput run\r\n# total_querysets: 1 # number of full 22-query runs\r\n\r\n# ============================================================================\r\n# KUBERNETES RESOURCES\r\n# ============================================================================\r\n# Resource requests/limits for each HammerDB Job pod.\r\n# Each target gets one pod — scale these based on virtual user count.\r\nresources:\r\n requests:\r\n memory: \"4Gi\"\r\n cpu: \"4\"\r\n limits:\r\n memory: \"8Gi\"\r\n cpu: \"8\"\r\n\r\n# ============================================================================\r\n# KUBERNETES SETTINGS\r\n# ============================================================================\r\nkubernetes:\r\n namespace: hammerdb # namespace for benchmark jobs (must exist)\r\n job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h)\r\n\r\n# ============================================================================\r\n# PURE STORAGE METRICS (optional)\r\n# Uncomment to collect IOPS, latency, and bandwidth from a FlashArray\r\n# during benchmarks. Metrics appear in the HTML scorecard.\r\n# ============================================================================\r\nstorage_metrics:\r\n enabled: false\r\n # pure:\r\n # host: \"\" # FlashArray management IP or hostname\r\n # api_token: \"\" # REST API token (Settings > Users > API Tokens)\r\n # volume: \"\" # leave empty for array-level metrics\r\n # poll_interval: 5 # collection interval in seconds\r\n # verify_ssl: false\r\n"] +[79.022096, "o", "[root@august hammerdb-scale]# "] +[81.069097, "o", "h"] +[81.159441, "o", "a"] +[81.249637, "o", "m"] +[81.339768, "o", "m"] +[81.429788, "o", "e"] +[81.520136, "o", "r"] +[81.610934, "o", "d"] +[81.700552, "o", "b"] +[81.790644, "o", "-"] +[81.880809, "o", "s"] +[81.970872, "o", "c"] +[82.061193, "o", "a"] +[82.151391, "o", "l"] +[82.241356, "o", "e"] +[82.331503, "o", " "] +[82.4219, "o", "v"] +[82.511931, "o", "a"] +[82.602043, "o", "l"] +[82.692268, "o", "i"] +[82.782659, "o", "d"] +[82.873131, "o", "a"] +[82.963205, "o", "t"] +[83.053485, "o", "e"] +[83.143491, "o", " "] +[83.234209, "o", "-"] +[83.324607, "o", "f"] +[83.414719, "o", " "] +[83.505036, "o", "/"] +[83.595337, "o", "t"] +[83.685522, "o", "m"] +[83.77585, "o", "p"] +[83.866006, "o", "/"] +[83.956215, "o", "h"] +[84.046288, "o", "a"] +[84.136799, "o", "m"] +[84.227176, "o", "m"] +[84.317452, "o", "e"] +[84.407672, "o", "r"] +[84.497911, "o", "d"] +[84.588252, "o", "b"] +[84.678493, "o", "-"] +[84.768731, "o", "s"] +[84.858925, "o", "c"] +[84.949242, "o", "a"] +[85.039495, "o", "l"] +[85.129708, "o", "e"] +[85.219926, "o", "-"] +[85.310193, "o", "d"] +[85.400431, "o", "e"] +[85.490713, "o", "m"] +[85.580915, "o", "o"] +[85.671148, "o", "."] +[85.761365, "o", "y"] +[85.851094, "o", "a"] +[85.941374, "o", "m"] +[86.031934, "o", "l"] +[86.271588, "o", "\r\n"] +[86.513453, "o", "Validating configuration\u001b[33m...\u001b[0m\r\n"] +[86.521256, "o", " \u001b[32m+\u001b[0m YAML syntax valid\r\n"] +[86.528828, "o", " \u001b[32m+\u001b[0m Schema validates \u001b[1m(\u001b[0mall required fields present\u001b[1m)\u001b[0m\r\n"] +[86.529155, "o", " \u001b[32m+\u001b[0m \u001b[1;36m8\u001b[0m targets configured \u001b[1m(\u001b[0mtype: mssql\u001b[1m)\u001b[0m\r\n"] +[86.529471, "o", " \u001b[32m+\u001b[0m Benchmark settings valid \u001b[1m(\u001b[0mtprocc, \u001b[1;36m10\u001b[0m warehouses\u001b[1m)\u001b[0m\r\n"] +[86.52971, "o", " \u001b[32m+\u001b[0m Default benchmark: tprocc\r\n"] +[86.530015, "o", "\r\nChecking prerequisites\u001b[33m...\u001b[0m\r\n"] +[86.582595, "o", " \u001b[32m+\u001b[0m helm found \u001b[1m(\u001b[0mv3.\u001b[1;36m19.4\u001b[0m+g7cfb6e4\u001b[1m)\u001b[0m\r\n"] +[86.785664, "o", " \u001b[32m+\u001b[0m kubectl found \u001b[1m(\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[86.786302, "o", " \u001b[32m+\u001b[0m Current context: admin\r\n"] +[86.78707, "o", "\r\nChecking Kubernetes access\u001b[33m...\u001b[0m\r\n"] +[86.919517, "o", " \u001b[33m!\u001b[0m Namespace \u001b[32m'hammerdb'\u001b[0m does not exist. It will be created during deployment.\r\n"] +[87.05105, "o", " \u001b[32m+\u001b[0m Can create Jobs in namespace\r\n"] +[87.184252, "o", " \u001b[32m+\u001b[0m Can create ConfigMaps in namespace\r\n"] +[87.184514, "o", "\r\nChecking database connectivity\u001b[33m...\u001b[0m\r\n"] +[87.393312, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m05\u001b[0m sql-bench-\u001b[1;36m05.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.395281, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m03\u001b[0m sql-bench-\u001b[1;36m03.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.398472, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m07\u001b[0m sql-bench-\u001b[1;36m07.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.399767, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m08\u001b[0m sql-bench-\u001b[1;36m08.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.401599, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m04\u001b[0m sql-bench-\u001b[1;36m04.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.403038, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m06\u001b[0m sql-bench-\u001b[1;36m06.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.406043, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m sql-bench-\u001b[1;36m02.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.407358, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m sql-bench-\u001b[1;36m01.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.408567, "o", "\r\nValidation complete: \u001b[1;36m0\u001b[0m \u001b[1;35mwarning\u001b[0m\u001b[1m(\u001b[0ms\u001b[1m)\u001b[0m, \u001b[1;36m0\u001b[0m errors.\r\n"] +[87.451304, "o", "[root@august hammerdb-scale]# "] +[88.322511, "o", "e"] +[88.412699, "o", "x"] +[88.502852, "o", "i"] +[88.593027, "o", "t"] +[88.833484, "o", "\r\n"] +[88.833771, "o", "exit\r\n"] diff --git a/demos/record_benchmark.py b/demos/record_benchmark.py new file mode 100644 index 0000000..0802b21 --- /dev/null +++ b/demos/record_benchmark.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3.11 +"""Record the 'Benchmark Payoff' asciinema demo for hammerdb-scale. + +Assumes schemas are already built (pre-build before recording). +Shows: run → wait → results → report → clean +""" + +import os +import time + +import pexpect + +# ── Config ────────────────────────────────────────────────────────────── +CAST_FILE = os.path.join(os.path.dirname(__file__), "benchmark-payoff.cast") +YAML_FILE = "/tmp/hammerdb-scale-demo.yaml" +F = f"-f {YAML_FILE}" + +# Specific prompt pattern: [root@august hammerdb-scale]# +PROMPT = r"hammerdb-scale\]#" + +FAST = 0.04 +NORMAL = 0.06 +PAUSE = 1.0 +LONG_PAUSE = 2.5 + + +def typed(child, text, speed=NORMAL): + for ch in text: + child.send(ch) + time.sleep(speed) + time.sleep(0.15) + + +def enter(child): + child.send("\r") + time.sleep(0.3) + + +def type_and_enter(child, text, speed=NORMAL): + typed(child, text, speed) + enter(child) + + +def wait_for_prompt(child, timeout=600): + """Wait for the bash prompt.""" + child.expect(PROMPT, timeout=timeout) + time.sleep(0.5) + + +def main(): + if not os.path.exists(YAML_FILE): + print(f"ERROR: {YAML_FILE} not found. Run record_getting_started.py first.") + return + + os.environ["COLUMNS"] = "100" + os.environ["LINES"] = "35" + + child = pexpect.spawn( + f"asciinema rec --cols 100 --rows 35 --idle-time-limit 3 " + f"--title 'hammerdb-scale: Benchmark Run' " + f"--overwrite {CAST_FILE}", + encoding="utf-8", + timeout=600, + ) + child.setwinsize(35, 100) + + wait_for_prompt(child) + time.sleep(PAUSE) + + # ── Run benchmark ─────────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale run {F} --wait", FAST) + # rampup=1 + duration=1 = ~3 min total + wait_for_prompt(child, timeout=600) + time.sleep(LONG_PAUSE) + + # ── Results ───────────────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale results {F}", FAST) + wait_for_prompt(child, timeout=60) + time.sleep(LONG_PAUSE) + + # ── Report ────────────────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale report {F} -o /tmp/scorecard.html", FAST) + wait_for_prompt(child, timeout=60) + time.sleep(LONG_PAUSE) + + # ── Clean ─────────────────────────────────────────────────────────── + type_and_enter( + child, + f"hammerdb-scale clean {F} --resources --database " + f"--benchmark tprocc --everything --force", + FAST, + ) + wait_for_prompt(child, timeout=120) + time.sleep(LONG_PAUSE) + + # ── Exit ──────────────────────────────────────────────────────────── + type_and_enter(child, "exit", FAST) + child.expect(pexpect.EOF, timeout=10) + child.close() + + print(f"\nRecording saved to: {CAST_FILE}") + print(f"Convert to GIF: agg {CAST_FILE} benchmark-payoff.gif") + print(f"Upload: asciinema upload {CAST_FILE}") + + +if __name__ == "__main__": + main() diff --git a/demos/record_getting_started.py b/demos/record_getting_started.py new file mode 100644 index 0000000..e1cdbd4 --- /dev/null +++ b/demos/record_getting_started.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3.11 +"""Record the 'Getting Started' asciinema demo for hammerdb-scale. + +Drives the interactive wizard via pexpect with realistic typing delays, +all inside an asciinema recording session. +""" + +import os +import sys +import time + +import pexpect + +# ── Config ────────────────────────────────────────────────────────────── +CAST_FILE = os.path.join(os.path.dirname(__file__), "getting-started.cast") +YAML_OUTPUT = "/tmp/hammerdb-scale-demo.yaml" + +# Typing speed (seconds per character) +FAST = 0.04 +NORMAL = 0.06 +PAUSE = 0.8 # pause between commands +LONG_PAUSE = 1.5 + +HOSTS = [ + ("sql-bench-01", "sql-bench-01.soln.local"), + ("sql-bench-02", "sql-bench-02.soln.local"), + ("sql-bench-03", "sql-bench-03.soln.local"), + ("sql-bench-04", "sql-bench-04.soln.local"), + ("sql-bench-05", "sql-bench-05.soln.local"), + ("sql-bench-06", "sql-bench-06.soln.local"), + ("sql-bench-07", "sql-bench-07.soln.local"), + ("sql-bench-08", "sql-bench-08.soln.local"), +] + + +def typed(child, text, speed=NORMAL): + """Send text character by character with delay.""" + for ch in text: + child.send(ch) + time.sleep(speed) + time.sleep(0.15) + + +def enter(child): + """Press Enter.""" + child.send("\r") + time.sleep(0.3) + + +def type_and_enter(child, text, speed=NORMAL): + """Type text then press Enter.""" + typed(child, text, speed) + enter(child) + + +def wait_for(child, pattern, timeout=30): + """Wait for a pattern in output.""" + child.expect(pattern, timeout=timeout) + time.sleep(0.2) + + +def main(): + # Clean up any previous output + if os.path.exists(YAML_OUTPUT): + os.remove(YAML_OUTPUT) + + # Set terminal size for clean recording + os.environ["COLUMNS"] = "100" + os.environ["LINES"] = "30" + + # Start asciinema recording wrapping a bash shell + child = pexpect.spawn( + f"asciinema rec --cols 100 --rows 30 --idle-time-limit 2 " + f"--title 'hammerdb-scale: Getting Started' " + f"--overwrite {CAST_FILE}", + encoding="utf-8", + timeout=60, + ) + child.setwinsize(30, 100) + + # Wait for shell prompt + wait_for(child, r"[\$#]") + time.sleep(PAUSE) + + # ── Step 0: pip install (quick show) ──────────────────────────────── + type_and_enter(child, "pip install hammerdb-scale", FAST) + wait_for(child, r"[\$#]", timeout=60) + time.sleep(PAUSE) + + # ── Step 1: init wizard ───────────────────────────────────────────── + type_and_enter( + child, + f"hammerdb-scale init -i -o {YAML_OUTPUT} --force", + FAST, + ) + + # Step 1: Deployment name + wait_for(child, r"Deployment name") + time.sleep(0.5) + type_and_enter(child, "mssql-8-tprocc-demo", FAST) + + # Step 2: Database type — select 2 for MSSQL + wait_for(child, r"Database type") + time.sleep(0.4) + type_and_enter(child, "2", FAST) + + # Benchmark — select 1 for TPC-C + wait_for(child, r"Benchmark") + time.sleep(0.4) + type_and_enter(child, "1", FAST) + + # Step 3: Number of targets + wait_for(child, r"Number of database targets") + time.sleep(0.4) + type_and_enter(child, "8", FAST) + + # Enter all 8 hosts + for i, (name, host) in enumerate(HOSTS): + wait_for(child, r"Name") + time.sleep(0.2) + type_and_enter(child, name, FAST) + wait_for(child, r"Hostname or IP") + time.sleep(0.2) + type_and_enter(child, host, FAST) + + # Step 4: Credentials + wait_for(child, r"Database username") + time.sleep(0.4) + # Accept default "sa" + enter(child) + + wait_for(child, r"Database password") + time.sleep(0.3) + type_and_enter(child, "Osmium76", FAST) + + # Step 5: Warehouses + wait_for(child, r"Warehouses per target") + time.sleep(0.4) + type_and_enter(child, "10", FAST) + + # Step 6: Namespace + wait_for(child, r"Kubernetes namespace") + time.sleep(0.4) + # Accept default "hammerdb" + enter(child) + + # Pure Storage metrics + wait_for(child, r"Pure Storage") + time.sleep(0.3) + # Default is No + enter(child) + + # Advanced options — YES, to set fast timing for demo + wait_for(child, r"advanced options") + time.sleep(0.3) + type_and_enter(child, "y", FAST) + + # Build VUs — accept default 4 + wait_for(child, r"Build virtual users") + time.sleep(0.2) + enter(child) + + # Load VUs — set to 8 + wait_for(child, r"Load virtual users") + time.sleep(0.2) + type_and_enter(child, "8", FAST) + + # Rampup — 1 minute (fast demo) + wait_for(child, r"Rampup") + time.sleep(0.2) + type_and_enter(child, "1", FAST) + + # Duration — 1 minute (fast demo) + wait_for(child, r"Duration") + time.sleep(0.2) + type_and_enter(child, "1", FAST) + + # Pod resources — accept all defaults + wait_for(child, r"Request memory") + time.sleep(0.2) + enter(child) + + wait_for(child, r"Request CPU") + time.sleep(0.2) + enter(child) + + wait_for(child, r"Limit memory") + time.sleep(0.2) + enter(child) + + wait_for(child, r"Limit CPU") + time.sleep(0.2) + enter(child) + + # Confirm write + wait_for(child, r"Write configuration") + time.sleep(LONG_PAUSE) # Let viewer read the summary + enter(child) # Yes (default) + + # Wait for file written confirmation + wait_for(child, r"Config written") + time.sleep(LONG_PAUSE) + + # ── Step 2: Show the YAML ─────────────────────────────────────────── + wait_for(child, r"[\$#]") + time.sleep(PAUSE) + type_and_enter(child, f"cat {YAML_OUTPUT}", FAST) + wait_for(child, r"[\$#]", timeout=10) + time.sleep(LONG_PAUSE) + + # ── Step 3: Validate ──────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale validate -f {YAML_OUTPUT}", FAST) + wait_for(child, r"[\$#]", timeout=30) + time.sleep(LONG_PAUSE) + + # ── Done: exit the shell to stop recording ────────────────────────── + type_and_enter(child, "exit", FAST) + child.expect(pexpect.EOF, timeout=10) + child.close() + + print(f"\nRecording saved to: {CAST_FILE}") + print(f"Convert to GIF: agg {CAST_FILE} getting-started.gif") + print(f"Upload: asciinema upload {CAST_FILE}") + + +if __name__ == "__main__": + main() diff --git a/docs/oracle-scale-environment.md b/docs/oracle-scale-environment.md new file mode 100644 index 0000000..a592619 --- /dev/null +++ b/docs/oracle-scale-environment.md @@ -0,0 +1,167 @@ +# Oracle Scale Test Environment + +Hi Ganesh — this document describes the 8-node Oracle environment set up for HammerDB-Scale testing. Everything is pre-built and ready to go. You have 8 identical Oracle 26ai databases, each loaded with a 1TB TPC-C dataset, RMAN backups on a shared NFS mount, and YAML configs for running scale tests from 1 to 8 targets. + + +## Environment at a Glance + +- **8x Oracle 26ai** (23.26.1.0.0) single-instance databases +- **10,000 TPC-C warehouses** per database (~1TB each) +- **RMAN cold backups** on NFS (~804GB per host, ~6.4TB total) +- **Kubernetes namespace:** `hammerdb-xl190` +- **FlashArray metrics:** enabled (10.21.158.130) + + +## Connecting to the Databases + +All 8 databases are identical in layout. SSH in as the `oracle` user, or connect from HammerDB via the service name. + +| Host | IP Address | Listener Port | +|------|------------|---------------| +| oracle-01 | 10.21.227.45 | 1521 | +| oracle-02 | 10.21.227.46 | 1521 | +| oracle-03 | 10.21.227.47 | 1521 | +| oracle-04 | 10.21.227.48 | 1521 | +| oracle-05 | 10.21.227.49 | 1521 | +| oracle-06 | 10.21.227.52 | 1521 | +| oracle-07 | 10.21.227.53 | 1521 | +| oracle-08 | 10.21.227.54 | 1521 | + +**Credentials:** + +| User | Password | Where | +|------|----------|-------| +| oracle (OS) | Osmium76 | SSH to any host | +| system | Osmium76 | CDB admin (sqlplus) | +| TPCC | Osmium76 | TPC-C schema owner (TPCC PDB) | + +**Database structure on each host:** + +``` +CDB: orcl + └── PDB: TPCC (service: tpcc.puretec.purestorage.com) + └── Schema: TPCC (9 tables, 10K warehouses, ~1TB) +``` + +**Storage:** + +| Diskgroup | Size | Contents | +|-----------|------|----------| +| +DATA01 | 1.6 TB | Datafiles, controlfiles | +| +REDO01 | 400 GB | 12 redo groups x 3 members x 4GB | +| +DATA02 | 1.6 TB | Unused (available) | +| +REDO02 | 400 GB | Unused (available) | + +All databases are in **NOARCHIVELOG** mode. + + +## YAML Config Files + +There are 8 config files, one for each scale point. They progressively add targets: + +| Config File | Targets | +|-------------|---------| +| `oracle-scale-1.yaml` | oracle-01 | +| `oracle-scale-2.yaml` | oracle-01, 02 | +| `oracle-scale-3.yaml` | oracle-01, 02, 03 | +| `oracle-scale-4.yaml` | oracle-01, 02, 03, 04 | +| `oracle-scale-5.yaml` | oracle-01 through 05 | +| `oracle-scale-6.yaml` | oracle-01 through 06 | +| `oracle-scale-7.yaml` | oracle-01 through 07 | +| `oracle-scale-8.yaml` | oracle-01 through 08 | + +All configs use the same benchmark parameters: 10,000 warehouses, 200 virtual users, timed driver with 5-minute rampup and 10-minute duration. Pure FlashArray storage metrics collection is enabled. + + +## Running a Scale Test + +The schemas are already built on all 8 hosts — you do **not** need `--build` unless you want to rebuild from scratch. + +```bash +# Step 1: Validate connectivity +hammerdb-scale validate -c oracle-scale-1.yaml + +# Step 2: Run the benchmark +hammerdb-scale run -c oracle-scale-1.yaml --wait + +# Step 3: Collect results +hammerdb-scale results -c oracle-scale-1.yaml + +# Step 4: Generate HTML scorecard +hammerdb-scale report -c oracle-scale-1.yaml --open +``` + +Then move on to the next scale point (`oracle-scale-2.yaml`, etc.). + + +## Backup and Restore + +RMAN cold backups already exist for all 8 hosts on a shared NFS mount at `/oracle-backup`. Use the scripts in `scripts/oracle/` to manage them. + +### Taking a Backup + +The backup script shuts down each database, backs up in MOUNT mode, then reopens it. By default it runs all hosts in parallel and takes about 12 minutes. + +```bash +# Backup all 8 hosts in parallel +./scripts/oracle/rman_backup.sh + +# Backup only specific hosts +./scripts/oracle/rman_backup.sh 01 04 + +# Backup one at a time instead of parallel +./scripts/oracle/rman_backup.sh --sequential +``` + +### Restoring from Backup + +```bash +# Restore a host from its own backup +./scripts/oracle/rman_restore.sh 04 + +# Restore multiple hosts +./scripts/oracle/rman_restore.sh 03 04 05 + +# Restore oracle-04 using oracle-01's backup (redirected restore) +./scripts/oracle/rman_restore.sh 04 --from 01 +``` + +The restore script shuts down the database, restores from the NFS backup, recovers, and opens with RESETLOGS. It verifies the CDB and PDB are open when done. + +### When Should You Restore? + +- **Between benchmark runs** if you need the data in a clean, pre-test state +- **After a crash or issue** to get a host back to a known good state +- **To clone a host** — use `--from` to restore one host's backup onto another + +### When Should You Re-backup? + +- After rebuilding schemas with `hammerdb-scale run --build` +- The backup overwrites the previous one, so just run `./scripts/oracle/rman_backup.sh` + + +## Suggested Workflow: Full Scale Run (1 through 8) + +``` +For each scale point (1, 2, 3, ... 8): + 1. Restore the databases that will be used → ./scripts/oracle/rman_restore.sh 01 02 ... + 2. Validate → hammerdb-scale validate -c oracle-scale-N.yaml + 3. Run → hammerdb-scale run -c oracle-scale-N.yaml --wait + 4. Collect results → hammerdb-scale results -c oracle-scale-N.yaml + 5. Generate report → hammerdb-scale report -c oracle-scale-N.yaml +``` + +If you don't need a clean restore between runs, you can skip step 1. + + +## Things to Know + +**Service name must be the FQDN.** The databases have `db_domain=puretec.purestorage.com`, so the listener registers the service as `tpcc.puretec.purestorage.com`. All YAML configs already have this set correctly. If you create new configs, make sure to use the full name — bare `tpcc` will fail with ORA-12514. + +**Listeners don't auto-start after reboot.** They're not managed by CRS. If a host gets rebooted, SSH in as `oracle` and run `lsnrctl start`. + +**NFS mount for RMAN.** All hosts mount the backup NFS share at `/oracle-backup` with options `noac,nconnect=8`. Oracle 26ai has a strict NFS validation check — event 10298 level 32 is set in the SPFILE on all hosts to bypass it. Don't remove this event or RMAN writes to NFS will fail with ORA-27054. + +**Redo log layout.** Each host has 12 redo groups x 3 members x 4GB, all on +REDO01. If you ever need to rebuild this layout, use `sqlplus / as sysdba @scripts/oracle/setup_redo.sql` on the host. + +**Oracle-04 had a crash.** It was terminated by LMHB (ORA-484, hung process) during an earlier schema build. It has been rebuilt and is now healthy with a complete dataset and backup. If it acts up again, restore it: `./scripts/oracle/rman_restore.sh 04` diff --git a/mssql-report-scale-1.html b/mssql-report-scale-1.html new file mode 100644 index 0000000..5985235 --- /dev/null +++ b/mssql-report-scale-1.html @@ -0,0 +1,267 @@ + + + + + +HammerDB-Scale Scorecard + + + +
+

HammerDB-Scale Scorecard

+
mssql-scale-1
+
Test ID: mssql-scale-1-20260401-2022Benchmark: TPROCCDatabase: mssqlTargets: 1Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 02:42 UTC
+
+
+ +
+
Total TPM
1,164,226
+
Total NOPM
501,117
+
Avg TPM / Target
1,164,226
+
Avg NOPM / Target
501,117
+
+

Per-Target Results

+ + + +
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 23s1,164,226501,117
+ +
+

TPM Distribution

+ +
+
+

NOPM Distribution

+ +
+ +

Storage Performance (238 samples)

+
+
Avg Write Latency
135 µs
+
Avg Read Latency
215 µs
+
Peak Write IOPS
305,255
+
Peak Read IOPS
210,147
+
Avg Write BW
1,170.5 MB/s
+
Avg Read BW
922.6 MB/s
+
Avg Write Block Size
9.7 KB
+
Avg Read Block Size
8.2 KB
+
+ + + +
MetricAvgP95P99
Write Latency135.3 µs150 µs157 µs
Read Latency215.4 µs295 µs307 µs
+ + +
MetricAvgMax
Write IOPS126,475.1305,255
Read IOPS116,264.1210,147
Write BW1,170.5 MB/s2,603.2 MB/s
Read BW922.6 MB/s1,726.0 MB/s
Avg Write Block Size9.7 KB
Avg Read Block Size8.2 KB
+ +
+

Latency Over Time (µs)

+ +
+
+

IOPS Over Time

+ +
+
+

Bandwidth Over Time (MB/s)

+ +
+ +
+ Configuration Snapshot +
{
+  "database_type": "mssql",
+  "target_count": 1,
+  "image": "sillidata/hammerdb-scale:latest",
+  "warehouses": 10000,
+  "virtual_users": 200,
+  "rampup_minutes": 5,
+  "duration_minutes": 15
+}
+
+
+ + + \ No newline at end of file diff --git a/mssql-report-scale-2.html b/mssql-report-scale-2.html new file mode 100644 index 0000000..a310520 --- /dev/null +++ b/mssql-report-scale-2.html @@ -0,0 +1,267 @@ + + + + + +HammerDB-Scale Scorecard + + + +
+

HammerDB-Scale Scorecard

+
mssql-scale-2
+
Test ID: mssql-scale-2-20260401-2042Benchmark: TPROCCDatabase: mssqlTargets: 2Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 03:02 UTC
+
+
+ +
+
Total TPM
2,189,438
+
Total NOPM
941,875
+
Avg TPM / Target
1,094,719
+
Avg NOPM / Target
470,937
+
+

Per-Target Results

+ + + +
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 25s1,107,243476,415
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 27s1,082,195465,460
+ +
+

TPM Distribution

+ +
+
+

NOPM Distribution

+ +
+ +

Storage Performance (238 samples)

+
+
Avg Write Latency
163 µs
+
Avg Read Latency
245 µs
+
Peak Write IOPS
426,361
+
Peak Read IOPS
391,969
+
Avg Write BW
2,156.3 MB/s
+
Avg Read BW
1,878.0 MB/s
+
Avg Write Block Size
9.8 KB
+
Avg Read Block Size
8.8 KB
+
+ + + +
MetricAvgP95P99
Write Latency162.5 µs173 µs228 µs
Read Latency245.4 µs293 µs312 µs
+ + +
MetricAvgMax
Write IOPS231,956.7426,361
Read IOPS226,166.7391,969
Write BW2,156.3 MB/s3,862.3 MB/s
Read BW1,878.0 MB/s5,554.3 MB/s
Avg Write Block Size9.8 KB
Avg Read Block Size8.8 KB
+ +
+

Latency Over Time (µs)

+ +
+
+

IOPS Over Time

+ +
+
+

Bandwidth Over Time (MB/s)

+ +
+ +
+ Configuration Snapshot +
{
+  "database_type": "mssql",
+  "target_count": 2,
+  "image": "sillidata/hammerdb-scale:latest",
+  "warehouses": 10000,
+  "virtual_users": 200,
+  "rampup_minutes": 5,
+  "duration_minutes": 15
+}
+
+
+ + + \ No newline at end of file diff --git a/mssql-report-scale-4.html b/mssql-report-scale-4.html new file mode 100644 index 0000000..410127b --- /dev/null +++ b/mssql-report-scale-4.html @@ -0,0 +1,267 @@ + + + + + +HammerDB-Scale Scorecard + + + +
+

HammerDB-Scale Scorecard

+
mssql-scale-4
+
Test ID: mssql-scale-4-20260401-2103Benchmark: TPROCCDatabase: mssqlTargets: 4Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 03:23 UTC
+
+
+ +
+
Total TPM
4,384,849
+
Total NOPM
1,887,224
+
Avg TPM / Target
1,096,212
+
Avg NOPM / Target
471,806
+
+

Per-Target Results

+ + + +
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 25s1,111,375478,305
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 26s1,093,589470,775
2sql-bench-03sql-bench-03.soln.local✓ Completed20m 24s1,095,975471,827
3sql-bench-04sql-bench-04.soln.local✓ Completed20m 26s1,083,910466,317
+ +
+

TPM Distribution

+ +
+
+

NOPM Distribution

+ +
+ +

Storage Performance (236 samples)

+
+
Avg Write Latency
321 µs
+
Avg Read Latency
303 µs
+
Peak Write IOPS
750,453
+
Peak Read IOPS
690,208
+
Avg Write BW
4,272.9 MB/s
+
Avg Read BW
3,717.5 MB/s
+
Avg Write Block Size
9.4 KB
+
Avg Read Block Size
9.1 KB
+
+ + + +
MetricAvgP95P99
Write Latency321.2 µs881 µs1,041 µs
Read Latency303.1 µs364 µs463 µs
+ + +
MetricAvgMax
Write IOPS458,576.7750,453
Read IOPS444,719.3690,208
Write BW4,272.9 MB/s6,915.8 MB/s
Read BW3,717.5 MB/s9,561.8 MB/s
Avg Write Block Size9.4 KB
Avg Read Block Size9.1 KB
+ +
+

Latency Over Time (µs)

+ +
+
+

IOPS Over Time

+ +
+
+

Bandwidth Over Time (MB/s)

+ +
+ +
+ Configuration Snapshot +
{
+  "database_type": "mssql",
+  "target_count": 4,
+  "image": "sillidata/hammerdb-scale:latest",
+  "warehouses": 10000,
+  "virtual_users": 200,
+  "rampup_minutes": 5,
+  "duration_minutes": 15
+}
+
+
+ + + \ No newline at end of file diff --git a/mssql-report-scale-6.html b/mssql-report-scale-6.html new file mode 100644 index 0000000..e1c70ac --- /dev/null +++ b/mssql-report-scale-6.html @@ -0,0 +1,267 @@ + + + + + +HammerDB-Scale Scorecard + + + +
+

HammerDB-Scale Scorecard

+
mssql-scale-8
+
Test ID: mssql-scale-8-20260401-2123Benchmark: TPROCCDatabase: mssqlTargets: 6Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 03:44 UTC
+
+
+ +
+
Total TPM
4,827,934
+
Total NOPM
2,078,379
+
Avg TPM / Target
804,655
+
Avg NOPM / Target
346,396
+
+

Per-Target Results

+ + + +
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 26s831,229357,771
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 29s816,572351,604
2sql-bench-03sql-bench-03.soln.local✓ Completed20m 27s812,807349,847
3sql-bench-04sql-bench-04.soln.local✓ Completed20m 26s794,078341,964
4sql-bench-05sql-bench-05.soln.local✓ Completed20m 30s788,697339,542
5sql-bench-06sql-bench-06.soln.local✓ Completed20m 30s784,551337,651
+ +
+

TPM Distribution

+ +
+
+

NOPM Distribution

+ +
+ +

Storage Performance (237 samples)

+
+
Avg Write Latency
1,357 µs
+
Avg Read Latency
447 µs
+
Peak Write IOPS
798,347
+
Peak Read IOPS
841,325
+
Avg Write BW
4,816.1 MB/s
+
Avg Read BW
4,310.9 MB/s
+
Avg Write Block Size
9.3 KB
+
Avg Read Block Size
8.8 KB
+
+ + + +
MetricAvgP95P99
Write Latency1,357.2 µs1,778 µs1,973 µs
Read Latency446.5 µs679 µs843 µs
+ + +
MetricAvgMax
Write IOPS526,148.1798,347
Read IOPS515,089.9841,325
Write BW4,816.1 MB/s7,524.7 MB/s
Read BW4,310.9 MB/s11,865.7 MB/s
Avg Write Block Size9.3 KB
Avg Read Block Size8.8 KB
+ +
+

Latency Over Time (µs)

+ +
+
+

IOPS Over Time

+ +
+
+

Bandwidth Over Time (MB/s)

+ +
+ +
+ Configuration Snapshot +
{
+  "database_type": "mssql",
+  "target_count": 6,
+  "image": "sillidata/hammerdb-scale:latest",
+  "warehouses": 10000,
+  "virtual_users": 200,
+  "rampup_minutes": 5,
+  "duration_minutes": 15
+}
+
+
+ + + \ No newline at end of file diff --git a/mssql-report-scale-8.html b/mssql-report-scale-8.html new file mode 100644 index 0000000..77a7cac --- /dev/null +++ b/mssql-report-scale-8.html @@ -0,0 +1,267 @@ + + + + + +HammerDB-Scale Scorecard + + + +
+

HammerDB-Scale Scorecard

+
mssql-scale-8
+
Test ID: mssql-scale-8-20260401-2144Benchmark: TPROCCDatabase: mssqlTargets: 8Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 04:05 UTC
+
+
+ +
+
Total TPM
4,759,717
+
Total NOPM
2,049,059
+
Avg TPM / Target
594,964
+
Avg NOPM / Target
256,132
+
+

Per-Target Results

+ + + +
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 29s604,100260,067
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 20s601,382258,856
2sql-bench-03sql-bench-03.soln.local✓ Completed20m 42s602,938259,650
3sql-bench-04sql-bench-04.soln.local✓ Completed20m 25s596,004256,497
4sql-bench-05sql-bench-05.soln.local✓ Completed20m 24s590,938254,440
5sql-bench-06sql-bench-06.soln.local✓ Completed20m 22s597,066257,131
6sql-bench-07sql-bench-07.soln.local✓ Completed20m 21s589,948253,893
7sql-bench-08sql-bench-08.soln.local✓ Completed20m 28s577,341248,525
+ +
+

TPM Distribution

+ +
+
+

NOPM Distribution

+ +
+ +

Storage Performance (236 samples)

+
+
Avg Write Latency
2,089 µs
+
Avg Read Latency
746 µs
+
Peak Write IOPS
750,582
+
Peak Read IOPS
676,604
+
Avg Write BW
4,999.0 MB/s
+
Avg Read BW
4,180.9 MB/s
+
Avg Write Block Size
9.3 KB
+
Avg Read Block Size
8.7 KB
+
+ + + +
MetricAvgP95P99
Write Latency2,089.1 µs2,539 µs2,736 µs
Read Latency746.5 µs1,091 µs1,223 µs
+ + +
MetricAvgMax
Write IOPS547,109.9750,582
Read IOPS501,697.1676,604
Write BW4,999.0 MB/s6,911.1 MB/s
Read BW4,180.9 MB/s8,120.9 MB/s
Avg Write Block Size9.3 KB
Avg Read Block Size8.7 KB
+ +
+

Latency Over Time (µs)

+ +
+
+

IOPS Over Time

+ +
+
+

Bandwidth Over Time (MB/s)

+ +
+ +
+ Configuration Snapshot +
{
+  "database_type": "mssql",
+  "target_count": 8,
+  "image": "sillidata/hammerdb-scale:latest",
+  "warehouses": 10000,
+  "virtual_users": 200,
+  "rampup_minutes": 5,
+  "duration_minutes": 15
+}
+
+
+ + + \ No newline at end of file diff --git a/mssql-scale-1.yaml b/mssql-scale-1.yaml new file mode 100644 index 0000000..5d9e689 --- /dev/null +++ b/mssql-scale-1.yaml @@ -0,0 +1,64 @@ +# SQL Server TPC-C scale test — 1 target +name: mssql-scale-1 +description: "MSSQL TPROCC benchmark — 1 target" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/mssql-scale-2.yaml b/mssql-scale-2.yaml new file mode 100644 index 0000000..627ff67 --- /dev/null +++ b/mssql-scale-2.yaml @@ -0,0 +1,66 @@ +# SQL Server TPC-C scale test — 2 targets +name: mssql-scale-2 +description: "MSSQL TPROCC benchmark — 2 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/mssql-scale-4.yaml b/mssql-scale-4.yaml new file mode 100644 index 0000000..a54c120 --- /dev/null +++ b/mssql-scale-4.yaml @@ -0,0 +1,70 @@ +# SQL Server TPC-C scale test — 4 targets +name: mssql-scale-4 +description: "MSSQL TPROCC benchmark — 4 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + - name: sql-bench-03 + host: "sql-bench-03.soln.local" + - name: sql-bench-04 + host: "sql-bench-04.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/mssql-scale-6.yaml b/mssql-scale-6.yaml new file mode 100644 index 0000000..054f466 --- /dev/null +++ b/mssql-scale-6.yaml @@ -0,0 +1,74 @@ +# SQL Server TPC-C scale test — 8 targets +name: mssql-scale-8 +description: "MSSQL TPROCC benchmark — 8 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + - name: sql-bench-03 + host: "sql-bench-03.soln.local" + - name: sql-bench-04 + host: "sql-bench-04.soln.local" + - name: sql-bench-05 + host: "sql-bench-05.soln.local" + - name: sql-bench-06 + host: "sql-bench-06.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/mssql-scale-8.yaml b/mssql-scale-8.yaml new file mode 100644 index 0000000..043f844 --- /dev/null +++ b/mssql-scale-8.yaml @@ -0,0 +1,78 @@ +# SQL Server TPC-C scale test — 8 targets +name: mssql-scale-8 +description: "MSSQL TPROCC benchmark — 8 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + - name: sql-bench-03 + host: "sql-bench-03.soln.local" + - name: sql-bench-04 + host: "sql-bench-04.soln.local" + - name: sql-bench-05 + host: "sql-bench-05.soln.local" + - name: sql-bench-06 + host: "sql-bench-06.soln.local" + - name: sql-bench-07 + host: "sql-bench-07.soln.local" + - name: sql-bench-08 + host: "sql-bench-08.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/ora-rac-hdb-scale.yaml b/ora-rac-hdb-scale.yaml new file mode 100644 index 0000000..e135188 --- /dev/null +++ b/ora-rac-hdb-scale.yaml @@ -0,0 +1,108 @@ +# ============================================================================ +# HammerDB-Scale Configuration +# ============================================================================ +# Docs: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md +# Guide: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md +# +# Quick start: +# 1. Review and edit the values below (especially targets, warehouses, and virtual users) +# 2. hammerdb-scale validate # check config, tools, and database connectivity +# 3. hammerdb-scale run --build --wait # build schemas and run the benchmark +# 4. hammerdb-scale results # aggregate results +# 5. hammerdb-scale report --open # generate HTML scorecard +# ============================================================================ + +name: ora-rac-hdb-scale +description: "ORACLE TPROCC benchmark — 2 targets" +default_benchmark: tprocc + +# ============================================================================ +# DATABASE TARGETS +# ============================================================================ +# "defaults" are inherited by all hosts — override any field per-host if needed. +# Add or remove hosts to match your environment. +targets: + defaults: + type: oracle + username: system + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale-oracle + tag: latest + pull_policy: Always # Always | IfNotPresent | Never + oracle: + service: "tpcc_svc.puretec.purestorage.com" + port: 1521 + tablespace: "TPCC" # bigfile tablespace for benchmark data + temp_tablespace: "TEMP" # temp tablespace (usually default) + tprocc: + user: "TPCC" # TPC-C schema owner (created during build) + password: "Osmium76" + tproch: + user: "tpch" # TPC-H schema owner (created during build) + password: "Osmium76" + degree_of_parallel: 8 # Oracle parallel query degree + + hosts: + - name: slc6-c480-n2-a21-05 + host: "slc6-c480-n2-a21-05" + - name: slc6-c480-n2-a21-09 + host: "slc6-c480-n2-a21-09" + +# ============================================================================ +# BENCHMARK PARAMETERS +# ============================================================================ +# Tune these to control data size, concurrency, and test duration. +# Uncomment the other benchmark section to switch between TPC-C and TPC-H. +hammerdb: + tprocc: + warehouses: 100 # data size: 100 = ~10GB, 1000 = ~100GB, 10000 = ~1TB per target + build_virtual_users: 4 # parallel threads for schema creation + load_virtual_users: 4 # concurrent users during benchmark (tune to match CPU cores) + driver: timed # "timed" = run for fixed duration, "test" = single iteration + rampup: 5 # warm-up period before measuring (minutes) + duration: 10 # measurement window (minutes), 5-60 typical + total_iterations: 10000000 # max iterations (effectively unlimited with timed driver) + all_warehouses: true # distribute load across all warehouses + checkpoint: true # issue checkpoint before benchmark + time_profile: false # detailed per-transaction timing (adds overhead) +# tproch: +# scale_factor: 1 # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB +# build_threads: 4 # parallel threads for data generation +# build_virtual_users: 1 # HammerDB orchestrator (keep at 1) +# load_virtual_users: 1 # query concurrency: 1 = Power run, >1 = Throughput run +# total_querysets: 1 # number of full 22-query runs + +# ============================================================================ +# KUBERNETES RESOURCES +# ============================================================================ +# Resource requests/limits for each HammerDB Job pod. +# Each target gets one pod — scale these based on virtual user count. +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +# ============================================================================ +# KUBERNETES SETTINGS +# ============================================================================ +kubernetes: + namespace: hammerdb # namespace for benchmark jobs (must exist) + job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h) + +# ============================================================================ +# PURE STORAGE METRICS (optional) +# Uncomment to collect IOPS, latency, and bandwidth from a FlashArray +# during benchmarks. Metrics appear in the HTML scorecard. +# ============================================================================ +storage_metrics: + enabled: false + # pure: + # host: "" # FlashArray management IP or hostname + # api_token: "" # REST API token (Settings > Users > API Tokens) + # volume: "" # leave empty for array-level metrics + # poll_interval: 5 # collection interval in seconds + # verify_ssl: false diff --git a/run-mssql-scale-tests.sh b/run-mssql-scale-tests.sh new file mode 100755 index 0000000..3888550 --- /dev/null +++ b/run-mssql-scale-tests.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# MSSQL 1,2,4,8 Scale Tests with per-scale reports +# Usage: ./run-mssql-scale-tests.sh + +set -e + +for scale in 1 2 4 6 8; do + echo "" + echo "============================================================" + echo " MSSQL SCALE TEST ${scale} — $(date)" + echo "============================================================" + + CONFIG="mssql-scale-${scale}.yaml" + + echo ">>> Running benchmark..." + hammerdb-scale run -f "$CONFIG" --wait --timeout 3600 + + echo ">>> Collecting results..." + hammerdb-scale results -f "$CONFIG" + + echo ">>> Generating report..." + hammerdb-scale report -f "$CONFIG" -o "mssql-report-scale-${scale}.html" + + echo ">>> Scale ${scale} complete." + echo "" +done + +echo "============================================================" +echo " ALL MSSQL SCALE TESTS COMPLETE — $(date)" +echo "============================================================" +echo "" +echo "Reports:" +ls -la mssql-report-scale-*.html diff --git a/run-scale-tests.sh b/run-scale-tests.sh new file mode 100755 index 0000000..a343c0d --- /dev/null +++ b/run-scale-tests.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Oracle 1-8 Scale Tests with per-scale reports +# Usage: ./run-scale-tests.sh + +set -e + +for scale in 1 2 3 4 5 6 7 8; do + echo "" + echo "============================================================" + echo " ORACLE SCALE TEST ${scale} of 8 — $(date)" + echo "============================================================" + + CONFIG="oracle-scale-${scale}.yaml" + + echo ">>> Running benchmark..." + hammerdb-scale run -f "$CONFIG" --wait --timeout 3600 + + echo ">>> Collecting results..." + hammerdb-scale results -f "$CONFIG" + + echo ">>> Generating report..." + hammerdb-scale report -f "$CONFIG" -o "oracle-report-scale-${scale}.html" + + echo ">>> Scale ${scale} complete." + echo "" +done + +echo "============================================================" +echo " ALL 8 SCALE TESTS COMPLETE — $(date)" +echo "============================================================" +echo "" +echo "Reports:" +ls -la oracle-report-scale-*.html diff --git a/scripts/oracle/rman_backup.sh b/scripts/oracle/rman_backup.sh new file mode 100755 index 0000000..7100d50 --- /dev/null +++ b/scripts/oracle/rman_backup.sh @@ -0,0 +1,267 @@ +#!/bin/bash +# ============================================================================ +# Oracle RMAN Backup Script +# ============================================================================ +# Performs a cold (NOARCHIVELOG) RMAN backup across multiple Oracle hosts. +# Shuts down each database, backs up in MOUNT mode, then reopens. +# +# Usage: +# ./rman_backup.sh # backup all 8 hosts in parallel +# ./rman_backup.sh 01 04 # backup only oracle-01 and oracle-04 +# ./rman_backup.sh --sequential # backup all hosts one at a time +# ./rman_backup.sh --sequential 01 04 # sequential backup of specific hosts +# +# Prerequisites: +# - sshpass installed on the runner +# - NFS mount at /oracle-backup on all hosts +# - Oracle user SSH access (password: Osmium76) +# - Database in NOARCHIVELOG mode +# ============================================================================ + +# ── Configuration ────────────────────────────────────────────────────────── +declare -A HOST_MAP=( + [01]="10.21.227.45" + [02]="10.21.227.46" + [03]="10.21.227.47" + [04]="10.21.227.48" + [05]="10.21.227.49" + [06]="10.21.227.52" + [07]="10.21.227.53" + [08]="10.21.227.54" +) + +SSH_USER="oracle" +SSH_PASS="Osmium76" +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" +BACKUP_BASE="/oracle-backup" +CHANNELS=32 +SECTION_SIZE="4G" +FILESPERSET=1 + +# ── Parse arguments ─────────────────────────────────────────────────────── +SEQUENTIAL=false +TARGETS=() + +for arg in "$@"; do + if [[ "$arg" == "--sequential" ]]; then + SEQUENTIAL=true + elif [[ -n "${HOST_MAP[$arg]+x}" ]]; then + TARGETS+=("$arg") + else + echo "ERROR: Unknown argument '$arg'. Use host numbers (01-08) or --sequential." + exit 1 + fi +done + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + TARGETS=(01 02 03 04 05 06 07 08) +fi + +# ── Functions ────────────────────────────────────────────────────────────── + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +# Write the remote backup script to a local temp file, scp it, execute it. +# This avoids all heredoc/quoting issues over SSH. +run_backup() { + local host_num=$1 + local ip="${HOST_MAP[$host_num]}" + local backup_path="${BACKUP_BASE}/oracle-${host_num}" + local local_tmp=$(mktemp /tmp/rman_backup_${host_num}_XXXX.sh) + local start_epoch=$(date '+%s') + + log "oracle-${host_num} (${ip}): Starting backup" + + # ── Build the remote script ── + cat > "$local_tmp" << 'SCRIPTEOF' +#!/bin/bash +source ~/.bash_profile + +BACKUP_PATH="__BACKUP_PATH__" +CHANNELS=__CHANNELS__ +SECTION_SIZE="__SECTION_SIZE__" +FILESPERSET=__FILESPERSET__ + +# Clean and prepare backup directory on NFS +mkdir -p "${BACKUP_PATH}" +rm -rf "${BACKUP_PATH}"/* + +# Build RMAN command file +RMAN_CMD="/tmp/rman_backup_$$.rmn" + +cat > "${RMAN_CMD}" << RMANEOF +CONNECT TARGET / + +SHUTDOWN IMMEDIATE; +STARTUP MOUNT; + +CONFIGURE CONTROLFILE AUTOBACKUP ON; +CONFIGURE CONTROLFILE AUTOBACKUP FORMAT FOR DEVICE TYPE DISK TO '${BACKUP_PATH}/%F'; + +RUN { +RMANEOF + +# Allocate channels +for ((i=1; i<=CHANNELS; i++)); do + echo " ALLOCATE CHANNEL ch${i} DEVICE TYPE DISK FORMAT '${BACKUP_PATH}/%U';" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF + + BACKUP + SECTION SIZE ${SECTION_SIZE} + FILESPERSET ${FILESPERSET} + DATABASE + TAG 'FULL_DB_BACKUP'; + + BACKUP CURRENT CONTROLFILE TAG 'CONTROLFILE_BACKUP'; + BACKUP SPFILE TAG 'SPFILE_BACKUP'; + +RMANEOF + +# Release channels +for ((i=1; i<=CHANNELS; i++)); do + echo " RELEASE CHANNEL ch${i};" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF +} + +ALTER DATABASE OPEN; +ALTER PLUGGABLE DATABASE ALL OPEN; + +EXIT; +RMANEOF + +# Run RMAN +RMAN_LOG="${BACKUP_PATH}/rman_backup_$(date '+%Y%m%d_%H%M%S').log" + +echo "START_EPOCH=$(date '+%s')" + +rman cmdfile="${RMAN_CMD}" log="${RMAN_LOG}" +RMAN_EXIT=$? + +rm -f "${RMAN_CMD}" + +# Report backup size +BACKUP_BYTES=$(du -sb "${BACKUP_PATH}" 2>/dev/null | awk '{print $1}') +echo "BACKUP_BYTES=${BACKUP_BYTES:-0}" + +echo "END_EPOCH=$(date '+%s')" +echo "RMAN_EXIT=${RMAN_EXIT}" + +exit ${RMAN_EXIT} +SCRIPTEOF + + # Substitute placeholders + sed -i "s|__BACKUP_PATH__|${backup_path}|g" "$local_tmp" + sed -i "s|__CHANNELS__|${CHANNELS}|g" "$local_tmp" + sed -i "s|__SECTION_SIZE__|${SECTION_SIZE}|g" "$local_tmp" + sed -i "s|__FILESPERSET__|${FILESPERSET}|g" "$local_tmp" + + # Copy script to remote host + sshpass -p "$SSH_PASS" scp $SSH_OPTS "$local_tmp" "${SSH_USER}@${ip}:/tmp/rman_backup_run.sh" 2>/dev/null + if [[ $? -ne 0 ]]; then + log "oracle-${host_num} (${ip}): FAILED — could not copy script" + rm -f "$local_tmp" + return 1 + fi + + # Execute on remote host + local output + output=$(sshpass -p "$SSH_PASS" ssh $SSH_OPTS "${SSH_USER}@${ip}" \ + "chmod +x /tmp/rman_backup_run.sh && /tmp/rman_backup_run.sh && rm -f /tmp/rman_backup_run.sh" 2>&1) + local exit_code=$? + + rm -f "$local_tmp" + + local end_epoch=$(date '+%s') + local duration=$((end_epoch - start_epoch)) + + # Parse metrics from output + local backup_bytes=$(echo "$output" | grep '^BACKUP_BYTES=' | tail -1 | cut -d= -f2) + local size_gb="0" + if [[ -n "$backup_bytes" && "$backup_bytes" -gt 0 ]] 2>/dev/null; then + size_gb=$(echo "scale=2; ${backup_bytes} / 1073741824" | bc 2>/dev/null || echo "0") + fi + + local throughput="N/A" + if [[ "$duration" -gt 0 && "$size_gb" != "0" ]]; then + throughput=$(echo "scale=2; (${size_gb} * 1024) / ${duration}" | bc 2>/dev/null || echo "N/A") + fi + + if [[ $exit_code -eq 0 ]]; then + log "oracle-${host_num} (${ip}): SUCCESS — ${duration}s, ${size_gb} GB, ${throughput} MB/s" + else + log "oracle-${host_num} (${ip}): FAILED (exit code ${exit_code})" + echo "$output" | grep -iE "ORA-|RMAN-|error|fail" | tail -10 + fi + + return $exit_code +} + +# ── Main ─────────────────────────────────────────────────────────────────── + +echo "=========================================" +echo "Oracle RMAN Backup" +echo "=========================================" +echo "Mode: $(if $SEQUENTIAL; then echo Sequential; else echo Parallel; fi)" +echo "Targets: ${TARGETS[*]}" +echo "Channels: ${CHANNELS}" +echo "NFS path: ${BACKUP_BASE}" +echo "Start: $(date '+%Y-%m-%d %H:%M:%S')" +echo "=========================================" +echo "" + +OVERALL_START=$(date '+%s') +PIDS=() +RESULTS=() +FAILED=0 + +if $SEQUENTIAL; then + for num in "${TARGETS[@]}"; do + if run_backup "$num"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + done +else + for num in "${TARGETS[@]}"; do + run_backup "$num" & + PIDS+=("$!:${num}") + done + + for entry in "${PIDS[@]}"; do + pid="${entry%%:*}" + num="${entry##*:}" + if wait "$pid"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + done +fi + +OVERALL_END=$(date '+%s') +OVERALL_DURATION=$((OVERALL_END - OVERALL_START)) + +echo "" +echo "=========================================" +echo "Backup Summary" +echo "=========================================" +printf "Total time: %ds (%s)\n" "$OVERALL_DURATION" "$(date -d@${OVERALL_DURATION} -u +%H:%M:%S 2>/dev/null || echo "${OVERALL_DURATION}s")" +echo "Targets: ${#TARGETS[@]}" +echo "Succeeded: $((${#TARGETS[@]} - FAILED))" +echo "Failed: ${FAILED}" +echo "" +for r in "${RESULTS[@]}"; do + echo " $r" +done +echo "=========================================" + +exit $FAILED diff --git a/scripts/oracle/rman_restore.sh b/scripts/oracle/rman_restore.sh new file mode 100755 index 0000000..7a38f8c --- /dev/null +++ b/scripts/oracle/rman_restore.sh @@ -0,0 +1,391 @@ +#!/bin/bash +# ============================================================================ +# Oracle RMAN Restore Script +# ============================================================================ +# Restores a cold RMAN backup from the NFS share to an Oracle host. +# +# Usage: +# ./rman_restore.sh 04 # restore oracle-04 from its own backup +# ./rman_restore.sh 04 --from 01 # restore oracle-04 using oracle-01's backup +# ./rman_restore.sh 03 04 05 # restore multiple hosts from own backups +# +# Redirected restore (--from): +# Copies backup pieces from the source host's NFS dir to the target, +# then uses RMAN DUPLICATE to clone the database. This works across +# different DBIDs because DUPLICATE creates a new database from the +# backup rather than trying to restore into the existing controlfile. +# +# NOTE: Redirected restore requires the source database to be accessible +# (for RMAN to read the backup metadata). If the source is down, use +# a normal restore from the target's own backup instead. +# +# Prerequisites: +# - sshpass installed on the runner +# - NFS mount at /oracle-backup on all hosts +# - Oracle user SSH access (password: Osmium76) +# - Backup exists in /oracle-backup/oracle-NN/ +# - Same ASM diskgroup layout (+DATA01, +REDO01) on all hosts +# ============================================================================ + +# ── Configuration ────────────────────────────────────────────────────────── +declare -A HOST_MAP=( + [01]="10.21.227.45" + [02]="10.21.227.46" + [03]="10.21.227.47" + [04]="10.21.227.48" + [05]="10.21.227.49" + [06]="10.21.227.52" + [07]="10.21.227.53" + [08]="10.21.227.54" +) + +SSH_USER="oracle" +SSH_PASS="Osmium76" +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" +BACKUP_BASE="/oracle-backup" +CHANNELS=32 + +# ── Parse arguments ─────────────────────────────────────────────────────── +FROM_HOST="" +TARGETS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --from) + FROM_HOST="$2" + if [[ -z "${HOST_MAP[$FROM_HOST]+x}" ]]; then + echo "ERROR: Invalid source host '$FROM_HOST'" + exit 1 + fi + shift 2 + ;; + *) + if [[ -n "${HOST_MAP[$1]+x}" ]]; then + TARGETS+=("$1") + else + echo "ERROR: Unknown argument '$1'. Use host numbers (01-08) or --from NN." + exit 1 + fi + shift + ;; + esac +done + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + echo "Usage: $0 [host_num ...] [--from source_host_num]" + echo "" + echo "Examples:" + echo " $0 04 # restore oracle-04 from its own backup" + echo " $0 04 --from 01 # restore oracle-04 using oracle-01's backup" + echo " $0 03 04 05 # restore multiple hosts from own backups" + exit 1 +fi + +if [[ -n "$FROM_HOST" && ${#TARGETS[@]} -gt 1 ]]; then + echo "ERROR: --from can only be used with a single target host" + exit 1 +fi + +# ── Functions ────────────────────────────────────────────────────────────── + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +# Standard restore: restore the host's own backup +run_restore_own() { + local target_num=$1 + local target_ip="${HOST_MAP[$target_num]}" + local backup_path="${BACKUP_BASE}/oracle-${target_num}" + local local_tmp=$(mktemp /tmp/rman_restore_${target_num}_XXXX.sh) + local start_epoch=$(date '+%s') + + log "oracle-${target_num} (${target_ip}): Restoring from own backup" + + cat > "$local_tmp" << 'SCRIPTEOF' +#!/bin/bash +source ~/.bash_profile + +BACKUP_PATH="__BACKUP_PATH__" +CHANNELS=__CHANNELS__ + +# Verify backup exists +if [[ ! -d "${BACKUP_PATH}" ]] || [[ -z "$(ls -A ${BACKUP_PATH}/ 2>/dev/null)" ]]; then + echo "ERROR: No backup found at ${BACKUP_PATH}" + exit 1 +fi + +echo "Backup files found: $(ls ${BACKUP_PATH}/ | wc -l)" + +# Build RMAN command file +RMAN_CMD="/tmp/rman_restore_$$.rmn" + +cat > "${RMAN_CMD}" << RMANEOF +CONNECT TARGET / + +SHUTDOWN ABORT; +STARTUP MOUNT; + +RUN { +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " ALLOCATE CHANNEL ch${i} DEVICE TYPE DISK;" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF + + RESTORE DATABASE; + RECOVER DATABASE; + +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " RELEASE CHANNEL ch${i};" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF +} + +ALTER DATABASE OPEN RESETLOGS; +ALTER PLUGGABLE DATABASE ALL OPEN; + +EXIT; +RMANEOF + +# Run RMAN +RMAN_LOG="${BACKUP_PATH}/rman_restore_$(date '+%Y%m%d_%H%M%S').log" +rman cmdfile="${RMAN_CMD}" log="${RMAN_LOG}" +RMAN_EXIT=$? +rm -f "${RMAN_CMD}" + +# Verify +sqlplus -s / as sysdba << SQLEOF +SET HEADING OFF FEEDBACK OFF PAGESIZE 0 +SELECT 'DB_STATUS=' || status FROM v\$instance; +SELECT 'PDB_STATUS=' || name || '=' || open_mode FROM v\$pdbs; +SQLEOF + +echo "RMAN_EXIT=${RMAN_EXIT}" +exit ${RMAN_EXIT} +SCRIPTEOF + + sed -i "s|__BACKUP_PATH__|${backup_path}|g" "$local_tmp" + sed -i "s|__CHANNELS__|${CHANNELS}|g" "$local_tmp" + + sshpass -p "$SSH_PASS" scp $SSH_OPTS "$local_tmp" "${SSH_USER}@${target_ip}:/tmp/rman_restore_run.sh" 2>/dev/null + if [[ $? -ne 0 ]]; then + log "oracle-${target_num}: FAILED — could not copy script" + rm -f "$local_tmp" + return 1 + fi + + local output + output=$(sshpass -p "$SSH_PASS" ssh $SSH_OPTS "${SSH_USER}@${target_ip}" \ + "chmod +x /tmp/rman_restore_run.sh && /tmp/rman_restore_run.sh && rm -f /tmp/rman_restore_run.sh" 2>&1) + local exit_code=$? + + rm -f "$local_tmp" + + local end_epoch=$(date '+%s') + local duration=$((end_epoch - start_epoch)) + + if [[ $exit_code -eq 0 ]]; then + log "oracle-${target_num}: SUCCESS — ${duration}s" + echo "$output" | grep -E "^(DB_STATUS=|PDB_STATUS=)" + else + log "oracle-${target_num}: FAILED (exit code ${exit_code})" + echo "$output" | grep -iE "ORA-|RMAN-|error|fail" | tail -10 + fi + + return $exit_code +} + +# Redirected restore: copy backup from source, use RMAN DUPLICATE +run_restore_from() { + local target_num=$1 + local source_num=$FROM_HOST + local target_ip="${HOST_MAP[$target_num]}" + local source_ip="${HOST_MAP[$source_num]}" + local source_path="${BACKUP_BASE}/oracle-${source_num}" + local target_path="${BACKUP_BASE}/oracle-${target_num}" + local local_tmp=$(mktemp /tmp/rman_restore_${target_num}_XXXX.sh) + local start_epoch=$(date '+%s') + + log "oracle-${target_num} (${target_ip}): Redirected restore FROM oracle-${source_num} (${source_ip})" + + cat > "$local_tmp" << 'SCRIPTEOF' +#!/bin/bash +source ~/.bash_profile + +SOURCE_PATH="__SOURCE_PATH__" +TARGET_PATH="__TARGET_PATH__" +CHANNELS=__CHANNELS__ + +# Copy backup pieces from source to target directory +echo "Copying backup from ${SOURCE_PATH} to ${TARGET_PATH}..." +mkdir -p "${TARGET_PATH}" +rm -f "${TARGET_PATH}"/* +cp "${SOURCE_PATH}"/* "${TARGET_PATH}/" 2>/dev/null +FILE_COUNT=$(ls "${TARGET_PATH}" | wc -l) +echo "Copied ${FILE_COUNT} files" + +if [[ ${FILE_COUNT} -eq 0 ]]; then + echo "ERROR: No backup files copied" + exit 1 +fi + +# Shut down existing database +echo "Shutting down existing database..." +sqlplus -s / as sysdba << SQLEOF +SHUTDOWN ABORT; +SQLEOF + +# Find the controlfile autobackup in the backup set +CTLFILE=$(ls "${TARGET_PATH}"/c-*-* 2>/dev/null | head -1) +if [[ -z "${CTLFILE}" ]]; then + echo "ERROR: No controlfile autobackup found in ${TARGET_PATH}" + exit 1 +fi +echo "Found controlfile backup: ${CTLFILE}" + +# Build RMAN command file +RMAN_CMD="/tmp/rman_restore_$$.rmn" + +cat > "${RMAN_CMD}" << RMANEOF +CONNECT TARGET / + +STARTUP NOMOUNT; + +# Restore controlfile from source backup +RESTORE CONTROLFILE FROM '${CTLFILE}'; +ALTER DATABASE MOUNT; + +# Catalog all backup pieces in the target path +CATALOG START WITH '${TARGET_PATH}/' NOPROMPT; + +RUN { +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " ALLOCATE CHANNEL ch${i} DEVICE TYPE DISK;" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF + + RESTORE DATABASE; + RECOVER DATABASE; + +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " RELEASE CHANNEL ch${i};" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF +} + +ALTER DATABASE OPEN RESETLOGS; +ALTER PLUGGABLE DATABASE ALL OPEN; + +EXIT; +RMANEOF + +# Run RMAN +RMAN_LOG="${TARGET_PATH}/rman_restore_$(date '+%Y%m%d_%H%M%S').log" +rman cmdfile="${RMAN_CMD}" log="${RMAN_LOG}" +RMAN_EXIT=$? +rm -f "${RMAN_CMD}" + +# Verify +sqlplus -s / as sysdba << SQLEOF +SET HEADING OFF FEEDBACK OFF PAGESIZE 0 +SELECT 'DB_STATUS=' || status FROM v\$instance; +SELECT 'PDB_STATUS=' || name || '=' || open_mode FROM v\$pdbs; +SQLEOF + +echo "RMAN_EXIT=${RMAN_EXIT}" +exit ${RMAN_EXIT} +SCRIPTEOF + + sed -i "s|__SOURCE_PATH__|${source_path}|g" "$local_tmp" + sed -i "s|__TARGET_PATH__|${target_path}|g" "$local_tmp" + sed -i "s|__CHANNELS__|${CHANNELS}|g" "$local_tmp" + + sshpass -p "$SSH_PASS" scp $SSH_OPTS "$local_tmp" "${SSH_USER}@${target_ip}:/tmp/rman_restore_run.sh" 2>/dev/null + if [[ $? -ne 0 ]]; then + log "oracle-${target_num}: FAILED — could not copy script" + rm -f "$local_tmp" + return 1 + fi + + local output + output=$(sshpass -p "$SSH_PASS" ssh $SSH_OPTS "${SSH_USER}@${target_ip}" \ + "chmod +x /tmp/rman_restore_run.sh && /tmp/rman_restore_run.sh && rm -f /tmp/rman_restore_run.sh" 2>&1) + local exit_code=$? + + rm -f "$local_tmp" + + local end_epoch=$(date '+%s') + local duration=$((end_epoch - start_epoch)) + + if [[ $exit_code -eq 0 ]]; then + log "oracle-${target_num}: SUCCESS — ${duration}s" + echo "$output" | grep -E "^(DB_STATUS=|PDB_STATUS=)" + else + log "oracle-${target_num}: FAILED (exit code ${exit_code})" + echo "$output" | grep -iE "ORA-|RMAN-|error|fail" | tail -15 + fi + + return $exit_code +} + +# ── Main ─────────────────────────────────────────────────────────────────── + +echo "=========================================" +echo "Oracle RMAN Restore" +echo "=========================================" +echo "Targets: ${TARGETS[*]}" +if [[ -n "$FROM_HOST" ]]; then + echo "Source: oracle-${FROM_HOST}" + echo "Method: Redirected restore (controlfile + catalog)" +else + echo "Method: Standard restore (own backup)" +fi +echo "Channels: ${CHANNELS}" +echo "NFS path: ${BACKUP_BASE}" +echo "Start: $(date '+%Y-%m-%d %H:%M:%S')" +echo "=========================================" +echo "" + +FAILED=0 +RESULTS=() + +for num in "${TARGETS[@]}"; do + if [[ -n "$FROM_HOST" ]]; then + if run_restore_from "$num"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + else + if run_restore_own "$num"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + fi +done + +echo "" +echo "=========================================" +echo "Restore Summary" +echo "=========================================" +for r in "${RESULTS[@]}"; do + echo " $r" +done +echo "=========================================" + +exit $FAILED diff --git a/scripts/oracle/setup_redo.sql b/scripts/oracle/setup_redo.sql new file mode 100644 index 0000000..ed64dc0 --- /dev/null +++ b/scripts/oracle/setup_redo.sql @@ -0,0 +1,117 @@ +-- ============================================================================ +-- Oracle Redo Log Layout Setup +-- ============================================================================ +-- Creates a standardized redo log layout: +-- 12 groups x 3 members each x 4GB on +REDO01 +-- +-- Usage: +-- sqlplus / as sysdba @setup_redo.sql +-- +-- Prerequisites: +-- - ASM diskgroup +REDO01 must exist +-- - Database must be OPEN +-- - Run as SYSDBA from CDB$ROOT +-- +-- The script is idempotent: it will drop and recreate all groups. +-- It handles CURRENT/ACTIVE groups by cycling log switches. +-- ============================================================================ + +SET SERVEROUTPUT ON SIZE UNLIMITED +SET FEEDBACK OFF +SET ECHO OFF + +WHENEVER SQLERROR CONTINUE + +-- Configure online log destination +ALTER SYSTEM SET db_create_online_log_dest_1='+REDO01' SCOPE=BOTH; + +PROMPT +PROMPT ============================================================ +PROMPT Current redo log layout (BEFORE) +PROMPT ============================================================ + +SELECT group#, members, bytes/1024/1024 size_mb, status +FROM v$log ORDER BY group#; + +PROMPT +PROMPT ============================================================ +PROMPT Rebuilding redo logs: 12 groups x 3 members x 4GB on +REDO01 +PROMPT ============================================================ + +DECLARE + v_target_groups CONSTANT PLS_INTEGER := 12; + v_target_members CONSTANT PLS_INTEGER := 3; + v_target_size CONSTANT VARCHAR2(10) := '4G'; + v_target_dg CONSTANT VARCHAR2(20) := '+REDO01'; + v_status VARCHAR2(20); + v_attempts PLS_INTEGER; + v_max_group PLS_INTEGER; +BEGIN + -- Phase 1: Drop all existing groups (cycle switches for CURRENT/ACTIVE) + DBMS_OUTPUT.PUT_LINE('--- Phase 1: Dropping existing groups ---'); + + SELECT NVL(MAX(group#), 0) INTO v_max_group FROM v$log; + + FOR g IN 1..GREATEST(v_max_group, v_target_groups) LOOP + BEGIN + SELECT status INTO v_status FROM v$log WHERE group# = g; + EXCEPTION + WHEN NO_DATA_FOUND THEN + DBMS_OUTPUT.PUT_LINE('Group ' || g || ': does not exist, skipping'); + CONTINUE; + END; + + -- If CURRENT or ACTIVE, cycle log switches until it becomes INACTIVE + IF v_status IN ('CURRENT', 'ACTIVE') THEN + v_attempts := 0; + WHILE v_status IN ('CURRENT', 'ACTIVE') AND v_attempts < 20 LOOP + EXECUTE IMMEDIATE 'ALTER SYSTEM SWITCH LOGFILE'; + EXECUTE IMMEDIATE 'ALTER SYSTEM CHECKPOINT'; + DBMS_LOCK.SLEEP(1); + SELECT status INTO v_status FROM v$log WHERE group# = g; + v_attempts := v_attempts + 1; + END LOOP; + + IF v_status IN ('CURRENT', 'ACTIVE') THEN + DBMS_OUTPUT.PUT_LINE('ERROR: Group ' || g || ' still ' || v_status || ' after ' || v_attempts || ' switches'); + CONTINUE; + END IF; + END IF; + + EXECUTE IMMEDIATE 'ALTER DATABASE DROP LOGFILE GROUP ' || g; + DBMS_OUTPUT.PUT_LINE('Group ' || g || ': dropped (' || v_status || ')'); + END LOOP; + + -- Phase 2: Create new groups with correct layout + DBMS_OUTPUT.PUT_LINE(''); + DBMS_OUTPUT.PUT_LINE('--- Phase 2: Creating ' || v_target_groups || ' groups ---'); + + FOR g IN 1..v_target_groups LOOP + -- Create group with 1 member (goes to db_create_online_log_dest_1 = +REDO01) + EXECUTE IMMEDIATE 'ALTER DATABASE ADD LOGFILE GROUP ' || g || ' SIZE ' || v_target_size; + + -- Add remaining members + FOR m IN 2..v_target_members LOOP + EXECUTE IMMEDIATE 'ALTER DATABASE ADD LOGFILE MEMBER ''' || v_target_dg || ''' TO GROUP ' || g; + END LOOP; + + DBMS_OUTPUT.PUT_LINE('Group ' || g || ': created with ' || v_target_members || ' members'); + END LOOP; + + DBMS_OUTPUT.PUT_LINE(''); + DBMS_OUTPUT.PUT_LINE('--- Done ---'); +END; +/ + +PROMPT +PROMPT ============================================================ +PROMPT Final redo log layout (AFTER) +PROMPT ============================================================ + +SELECT group#, members, bytes/1024/1024 size_mb, status +FROM v$log ORDER BY group#; + +COL member FORMAT A70 +SELECT group#, member FROM v$logfile ORDER BY group#, member; + +EXIT; diff --git a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml index 8ca35fd..f57c6af 100644 --- a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml +++ b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml @@ -51,10 +51,26 @@ spec: spec: hostNetwork: {{ $.Values.global.hostNetwork | default false }} restartPolicy: Never + {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} + # Satisfies the "restricted" Pod Security Standard so the chart is + # portable to plain Kubernetes clusters that enforce it. runAsUser is + # deliberately NOT set: OpenShift's SCC assigns an arbitrary UID from the + # namespace range, and hardcoding one conflicts with that. + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + {{- end }} containers: - name: hammerdb image: "{{ $.Values.global.image.repository }}:{{ $.Values.global.image.tag }}" imagePullPolicy: {{ $.Values.global.image.pullPolicy }} + {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + {{- end }} env: # Test identification - name: TEST_RUN_ID @@ -70,11 +86,27 @@ spec: - name: DATABASE_TYPE value: {{ $target.type | quote }} - # Database connection + # Database connection. Credentials come from a Secret when enabled so + # they are not readable via `kubectl describe job`. + {{- $useSecrets := ne (($.Values.kubernetes).useSecrets | toString) "false" }} + {{- $secretName := printf "%s-credentials" (include "hammerdb-scale-test.fullname" $) }} + {{- if $useSecrets }} + - name: USERNAME + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-username + - name: PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-password + {{- else }} - name: USERNAME value: {{ $target.username | quote }} - name: PASSWORD value: {{ $target.password | quote }} + {{- end }} - name: HOST value: {{ $target.host | quote }} @@ -124,12 +156,28 @@ spec: value: {{ $oracleConfig.tempTablespace }} - name: TPROCC_USER value: {{ $oracleConfig.tproccUser }} + {{- if $useSecrets }} + - name: TPROCC_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tprocc-password + {{- else }} - name: TPROCC_PASSWORD value: {{ $oracleConfig.tproccPassword }} + {{- end }} - name: TPROCH_USER value: {{ $oracleConfig.tprochUser }} + {{- if $useSecrets }} + - name: TPROCH_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tproch-password + {{- else }} - name: TPROCH_PASSWORD value: {{ $oracleConfig.tprochPassword }} + {{- end }} - name: TPROCH_DEGREE_OF_PARALLEL value: {{ $oracleConfig.degreeOfParallel | quote }} {{- end }} @@ -207,8 +255,16 @@ spec: value: {{ if eq $index 0 }}"true"{{ else }}"false"{{ end }} - name: PURE_HOST value: {{ $.Values.pureStorage.host | quote }} + {{- if $useSecrets }} + - name: PURE_API_TOKEN + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: pure-api-token + {{- else }} - name: PURE_API_TOKEN value: {{ $.Values.pureStorage.apiToken | quote }} + {{- end }} {{- if $.Values.pureStorage.volume }} - name: PURE_VOLUME value: {{ $.Values.pureStorage.volume | quote }} diff --git a/src/hammerdb_scale/chart/templates/secret-credentials.yaml b/src/hammerdb_scale/chart/templates/secret-credentials.yaml new file mode 100644 index 0000000..7c6cdae --- /dev/null +++ b/src/hammerdb_scale/chart/templates/secret-credentials.yaml @@ -0,0 +1,37 @@ +{{- if ne (($.Values.kubernetes).useSecrets | toString) "false" }} +{{- /* +Database credentials for each target. + +Without this, passwords are plain `value:` entries in the Job spec and anyone +with read access to the namespace can recover them with `kubectl describe job`. +Here they become secretKeyRef lookups instead. + +One Secret per release holds every target's credentials, keyed by target index, +so a single object is created and removed with the release. +*/ -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "hammerdb-scale-test.fullname" . }}-credentials + labels: + {{- include "hammerdb-scale-test.labels" . | nindent 4 }} + {{- if .Values.extraLabels }} + {{- range $key, $val := .Values.extraLabels }} + {{ $key }}: {{ $val | quote }} + {{- end }} + {{- end }} +type: Opaque +stringData: + {{- range $index, $target := .Values.targets }} + {{- $oracleConfig := include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" $target) | fromYaml }} + target-{{ $index }}-username: {{ $target.username | quote }} + target-{{ $index }}-password: {{ $target.password | quote }} + {{- if eq $target.type "oracle" }} + target-{{ $index }}-tprocc-password: {{ $oracleConfig.tproccPassword | quote }} + target-{{ $index }}-tproch-password: {{ $oracleConfig.tprochPassword | quote }} + {{- end }} + {{- end }} + {{- if .Values.pureStorage.enabled }} + pure-api-token: {{ .Values.pureStorage.apiToken | quote }} + {{- end }} +{{- end }} diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index e6026b5..a73bf3c 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -422,6 +422,11 @@ def validate( skip_connectivity: bool = typer.Option( False, "--skip-connectivity", help="Skip network connectivity checks." ), + from_cluster: bool = typer.Option( + False, + "--from-cluster", + help="Test connectivity from where the benchmark runs, not this machine.", + ), ) -> None: """Multi-layer validation with clear, actionable output.""" config_path = file or _state.get("file") @@ -579,6 +584,15 @@ def validate( conn_failures = _check_connectivity(config) if conn_failures: errors_found = True + + if from_cluster: + if _check_connectivity_from_cluster(config, namespace=None): + errors_found = True + else: + console.print( + "\n[dim]Checked from this machine. The benchmark runs elsewhere; " + "use --from-cluster to test from there.[/dim]" + ) else: console.print("\n[dim]Skipping connectivity checks (--skip-connectivity)[/dim]") @@ -593,6 +607,44 @@ def validate( console.print(f"\nValidation complete: {warning_count} warning(s), 0 errors.") +def _check_connectivity_from_cluster( + config: HammerDBScaleConfig, namespace: Optional[str] +) -> int: + """Probe the databases from where the benchmark actually runs. + + Passing the workstation check proves nothing about the pod's network + position, so this repeats it from there. Returns the failure count. + """ + from hammerdb_scale.runtime import get_backend + from hammerdb_scale.runtime.preflight import ( + check_from_container, + check_from_kubernetes, + ) + + image_cfg = config.targets.defaults.image + image = f"{image_cfg.repository}:{image_cfg.tag}" + backend = get_backend(config, namespace) + + console.print(f"\nChecking connectivity from {backend.name}...") + try: + if backend.name == "kubernetes": + results = check_from_kubernetes(config, backend.namespace, image) + else: + results = check_from_container(config, backend.runtime, image) + except Exception as e: + print_error(f"Could not run the in-cluster check: {e}") + return 1 + + failures = 0 + for entry in results: + if entry.get("ok"): + print_success(f"{entry['name']} reachable from {backend.name}") + else: + print_error(f"{entry['name']} {entry.get('error', 'unreachable')}") + failures += 1 + return failures + + def _check_connectivity(config: HammerDBScaleConfig) -> int: """Check database connectivity for all targets in parallel. Returns failure count.""" from hammerdb_scale.config.defaults import expand_targets diff --git a/src/hammerdb_scale/config/schema.py b/src/hammerdb_scale/config/schema.py index 8335c03..b516f65 100644 --- a/src/hammerdb_scale/config/schema.py +++ b/src/hammerdb_scale/config/schema.py @@ -183,6 +183,13 @@ class ResourcesConfig(BaseModel): class KubernetesConfig(BaseModel): namespace: str = "hammerdb" job_ttl: int = Field(default=86400, ge=0) + # Emit a restricted-compliant securityContext on the Job pods. Needed on + # plain Kubernetes clusters enforcing the restricted Pod Security Standard. + # OpenShift injects an equivalent context via its SCC either way. + security_context: bool = True + # Pass database credentials through a Secret rather than plain env values + # in the Job spec, where `kubectl describe job` would expose them. + use_secrets: bool = True class ContainerConfig(BaseModel): @@ -229,20 +236,22 @@ class HammerDBScaleConfig(BaseModel): storage_metrics: StorageMetricsConfig = StorageMetricsConfig() @model_validator(mode="after") - def validate_database_type_config(self) -> "HammerDBScaleConfig": - """Ensure the active database type has its config block present.""" + def apply_database_type_defaults(self) -> "HammerDBScaleConfig": + """Fill in the database-specific block when it was not supplied. + + Every field in OracleConfig and MssqlConfig has a working default, so + requiring the block was pure boilerplate: it forced roughly 15 lines of + config that only restated the defaults. Omitting it now means "use the + defaults", which is what lets a minimal config be a dozen lines. + + Oracle is the exception worth noting: its schema passwords default to + empty and fall back to the target password at render time. + """ defaults = self.targets.defaults - db_type = defaults.type - if db_type == DatabaseType.oracle and defaults.oracle is None: - raise ValueError( - "targets.defaults.type is 'oracle' but targets.defaults.oracle " - "is not configured. Add an oracle: block under targets.defaults." - ) - if db_type == DatabaseType.mssql and defaults.mssql is None: - raise ValueError( - "targets.defaults.type is 'mssql' but targets.defaults.mssql " - "is not configured. Add an mssql: block under targets.defaults." - ) + if defaults.type == DatabaseType.oracle and defaults.oracle is None: + defaults.oracle = OracleConfig() + if defaults.type == DatabaseType.mssql and defaults.mssql is None: + defaults.mssql = MssqlConfig() return self @model_validator(mode="after") diff --git a/src/hammerdb_scale/helm/values.py b/src/hammerdb_scale/helm/values.py index c01a094..21e3b04 100644 --- a/src/hammerdb_scale/helm/values.py +++ b/src/hammerdb_scale/helm/values.py @@ -42,9 +42,10 @@ def generate_helm_values( "tag": default_image.tag, "pullPolicy": default_image.pull_policy.value, }, - # Where the chart mounts the TCL scripts. Must match HAMMERDB_HOME - # in the image or entrypoint.sh will not find its scripts. + # Where the chart mounts the TCL scripts. entrypoint.sh searches + # for them, so this need not match the image's HAMMERDB_HOME. "hammerdbHome": default_image.hammerdb_home or DEFAULT_HAMMERDB_HOME, + "securityContext": {"enabled": config.kubernetes.security_context}, "resources": { "requests": config.resources.requests.model_dump(), "limits": config.resources.limits.model_dump(), @@ -65,6 +66,7 @@ def generate_helm_values( }, "kubernetes": { "job_ttl": config.kubernetes.job_ttl, + "useSecrets": config.kubernetes.use_secrets, }, } diff --git a/src/hammerdb_scale/runtime/preflight.py b/src/hammerdb_scale/runtime/preflight.py new file mode 100644 index 0000000..4794d5d --- /dev/null +++ b/src/hammerdb_scale/runtime/preflight.py @@ -0,0 +1,150 @@ +"""In-cluster connectivity checking. + +``validate`` normally tests database reachability from the workstation, but the +benchmark runs from inside a pod or container. Those are different network +positions: a config can pass validation cleanly and then have every job fail on +connectivity, which is the worst kind of failure because it arrives late and +points nowhere useful. + +This module runs the same reachability test from where the workload will +actually run. +""" + +from __future__ import annotations + +import json +import subprocess + +# Plain TCP connect, no database client needed, so this works with any image. +_PROBE = r""" +import json, socket, sys +results = [] +for name, host, port in json.loads(sys.argv[1]): + try: + s = socket.create_connection((host, int(port)), timeout=8) + s.close() + results.append({"name": name, "ok": True, "error": ""}) + except Exception as exc: + results.append({"name": name, "ok": False, "error": str(exc)}) +print("PROBE_RESULT:" + json.dumps(results)) +""" + + +def _target_endpoints(config) -> list[tuple[str, str, int]]: + """Build (name, host, port) for every target.""" + from hammerdb_scale.config.defaults import expand_targets + + endpoints = [] + for target in expand_targets(config): + if target["type"] == "oracle": + port = target.get("oracle", {}).get("port", 1521) + else: + mssql = config.targets.defaults.mssql + port = mssql.port if mssql else 1433 + endpoints.append((target["name"], target["host"], int(port))) + return endpoints + + +def _parse_probe_output(stdout: str) -> list[dict] | None: + """Pull the JSON result line out of pod or container output.""" + for line in stdout.splitlines(): + line = line.strip() + if line.startswith("PROBE_RESULT:"): + try: + return json.loads(line[len("PROBE_RESULT:") :]) + except json.JSONDecodeError: + return None + return None + + +def check_from_kubernetes(config, namespace: str, image: str) -> list[dict]: + """Run the probe as a one-shot pod in the target namespace. + + Uses the benchmark image so the test traverses the same network path, + namespace and policies the real jobs will. + """ + endpoints = _target_endpoints(config) + payload = json.dumps([[n, h, p] for n, h, p in endpoints]) + + overrides = json.dumps( + { + "spec": { + "securityContext": { + "runAsNonRoot": True, + "seccompProfile": {"type": "RuntimeDefault"}, + }, + "containers": [ + { + "name": "preflight", + "image": image, + "command": ["python3", "-c", _PROBE, payload], + "securityContext": { + "allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}, + }, + } + ], + } + } + ) + + result = subprocess.run( + [ + "kubectl", + "run", + "hammerdb-preflight", + "-n", + namespace, + "--rm", + "--attach", + "--restart=Never", + "--quiet", + f"--image={image}", + "--override-type=strategic", + f"--overrides={overrides}", + "--command", + "--", + "python3", + "-c", + _PROBE, + payload, + ], + capture_output=True, + text=True, + timeout=300, + ) + + parsed = _parse_probe_output(result.stdout) + if parsed is None: + detail = (result.stderr or result.stdout or "no output").strip() + raise RuntimeError(f"In-cluster probe produced no result: {detail[:400]}") + return parsed + + +def check_from_container(config, runtime: str, image: str) -> list[dict]: + """Run the probe in a local container using the benchmark image.""" + endpoints = _target_endpoints(config) + payload = json.dumps([[n, h, p] for n, h, p in endpoints]) + + result = subprocess.run( + [ + runtime, + "run", + "--rm", + "--entrypoint", + "python3", + image, + "-c", + _PROBE, + payload, + ], + capture_output=True, + text=True, + timeout=300, + ) + + parsed = _parse_probe_output(result.stdout) + if parsed is None: + detail = (result.stderr or result.stdout or "no output").strip() + raise RuntimeError(f"Container probe produced no result: {detail[:400]}") + return parsed diff --git a/templates/job-hammerdb-worker.yaml b/templates/job-hammerdb-worker.yaml index 8ca35fd..f57c6af 100644 --- a/templates/job-hammerdb-worker.yaml +++ b/templates/job-hammerdb-worker.yaml @@ -51,10 +51,26 @@ spec: spec: hostNetwork: {{ $.Values.global.hostNetwork | default false }} restartPolicy: Never + {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} + # Satisfies the "restricted" Pod Security Standard so the chart is + # portable to plain Kubernetes clusters that enforce it. runAsUser is + # deliberately NOT set: OpenShift's SCC assigns an arbitrary UID from the + # namespace range, and hardcoding one conflicts with that. + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + {{- end }} containers: - name: hammerdb image: "{{ $.Values.global.image.repository }}:{{ $.Values.global.image.tag }}" imagePullPolicy: {{ $.Values.global.image.pullPolicy }} + {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + {{- end }} env: # Test identification - name: TEST_RUN_ID @@ -70,11 +86,27 @@ spec: - name: DATABASE_TYPE value: {{ $target.type | quote }} - # Database connection + # Database connection. Credentials come from a Secret when enabled so + # they are not readable via `kubectl describe job`. + {{- $useSecrets := ne (($.Values.kubernetes).useSecrets | toString) "false" }} + {{- $secretName := printf "%s-credentials" (include "hammerdb-scale-test.fullname" $) }} + {{- if $useSecrets }} + - name: USERNAME + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-username + - name: PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-password + {{- else }} - name: USERNAME value: {{ $target.username | quote }} - name: PASSWORD value: {{ $target.password | quote }} + {{- end }} - name: HOST value: {{ $target.host | quote }} @@ -124,12 +156,28 @@ spec: value: {{ $oracleConfig.tempTablespace }} - name: TPROCC_USER value: {{ $oracleConfig.tproccUser }} + {{- if $useSecrets }} + - name: TPROCC_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tprocc-password + {{- else }} - name: TPROCC_PASSWORD value: {{ $oracleConfig.tproccPassword }} + {{- end }} - name: TPROCH_USER value: {{ $oracleConfig.tprochUser }} + {{- if $useSecrets }} + - name: TPROCH_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tproch-password + {{- else }} - name: TPROCH_PASSWORD value: {{ $oracleConfig.tprochPassword }} + {{- end }} - name: TPROCH_DEGREE_OF_PARALLEL value: {{ $oracleConfig.degreeOfParallel | quote }} {{- end }} @@ -207,8 +255,16 @@ spec: value: {{ if eq $index 0 }}"true"{{ else }}"false"{{ end }} - name: PURE_HOST value: {{ $.Values.pureStorage.host | quote }} + {{- if $useSecrets }} + - name: PURE_API_TOKEN + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: pure-api-token + {{- else }} - name: PURE_API_TOKEN value: {{ $.Values.pureStorage.apiToken | quote }} + {{- end }} {{- if $.Values.pureStorage.volume }} - name: PURE_VOLUME value: {{ $.Values.pureStorage.volume | quote }} diff --git a/templates/secret-credentials.yaml b/templates/secret-credentials.yaml new file mode 100644 index 0000000..7c6cdae --- /dev/null +++ b/templates/secret-credentials.yaml @@ -0,0 +1,37 @@ +{{- if ne (($.Values.kubernetes).useSecrets | toString) "false" }} +{{- /* +Database credentials for each target. + +Without this, passwords are plain `value:` entries in the Job spec and anyone +with read access to the namespace can recover them with `kubectl describe job`. +Here they become secretKeyRef lookups instead. + +One Secret per release holds every target's credentials, keyed by target index, +so a single object is created and removed with the release. +*/ -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "hammerdb-scale-test.fullname" . }}-credentials + labels: + {{- include "hammerdb-scale-test.labels" . | nindent 4 }} + {{- if .Values.extraLabels }} + {{- range $key, $val := .Values.extraLabels }} + {{ $key }}: {{ $val | quote }} + {{- end }} + {{- end }} +type: Opaque +stringData: + {{- range $index, $target := .Values.targets }} + {{- $oracleConfig := include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" $target) | fromYaml }} + target-{{ $index }}-username: {{ $target.username | quote }} + target-{{ $index }}-password: {{ $target.password | quote }} + {{- if eq $target.type "oracle" }} + target-{{ $index }}-tprocc-password: {{ $oracleConfig.tproccPassword | quote }} + target-{{ $index }}-tproch-password: {{ $oracleConfig.tprochPassword | quote }} + {{- end }} + {{- end }} + {{- if .Values.pureStorage.enabled }} + pure-api-token: {{ .Values.pureStorage.apiToken | quote }} + {{- end }} +{{- end }} diff --git a/tests/test_chart_security.py b/tests/test_chart_security.py new file mode 100644 index 0000000..96c0c29 --- /dev/null +++ b/tests/test_chart_security.py @@ -0,0 +1,133 @@ +"""Tests for credential handling and pod security in the rendered chart. + +These render the real chart with `helm template` and assert on the output, so +they catch template regressions that unit tests on the values dict cannot. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +from hammerdb_scale.config.loader import load_config +from hammerdb_scale.helm.values import generate_helm_values + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHART = REPO_ROOT / "src/hammerdb_scale/chart" + +PASSWORD = "sup3rs3cret" + +CONFIG = f""" +name: sectest +default_benchmark: tprocc +targets: + defaults: + type: oracle + username: system + password: "{PASSWORD}" + oracle: + service: TPCC + tprocc: + user: TPCC + password: "{PASSWORD}" + hosts: + - name: t1 + host: "10.0.0.1" + - name: t2 + host: "10.0.0.2" +""" + +pytestmark = pytest.mark.skipif( + shutil.which("helm") is None, reason="helm not installed" +) + + +def _render(tmp_path, **overrides) -> list[dict]: + """Render the chart and return the parsed manifests.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(CONFIG) + config = load_config(config_file) + for key, value in overrides.items(): + setattr(config.kubernetes, key, value) + + values = generate_helm_values(config, "run", "tprocc", "t1") + values_file = tmp_path / "values.yaml" + values_file.write_text(yaml.dump(values, sort_keys=False)) + + result = subprocess.run( + ["helm", "template", "t", str(CHART), "-f", str(values_file)], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr + return [d for d in yaml.safe_load_all(result.stdout) if d] + + +def _job(docs: list[dict]) -> dict: + return sorted( + (d for d in docs if d["kind"] == "Job"), key=lambda d: d["metadata"]["name"] + )[0] + + +class TestCredentialsUseSecrets: + def test_no_password_anywhere_in_job_spec(self, tmp_path): + """`kubectl describe job` must not reveal database credentials.""" + docs = _render(tmp_path) + for job in (d for d in docs if d["kind"] == "Job"): + assert PASSWORD not in yaml.dump(job) + + def test_credentials_come_from_secret_refs(self, tmp_path): + job = _job(_render(tmp_path)) + env = job["spec"]["template"]["spec"]["containers"][0]["env"] + by_ref = {e["name"] for e in env if "valueFrom" in e} + assert {"USERNAME", "PASSWORD", "TPROCC_PASSWORD"} <= by_ref + + def test_secret_holds_every_target(self, tmp_path): + docs = _render(tmp_path) + secrets = [d for d in docs if d["kind"] == "Secret"] + assert len(secrets) == 1 + keys = secrets[0]["stringData"] + assert keys["target-0-password"] == PASSWORD + assert keys["target-1-password"] == PASSWORD + + def test_can_be_disabled(self, tmp_path): + """Opting out restores inline values, for restricted RBAC setups.""" + docs = _render(tmp_path, use_secrets=False) + assert not [d for d in docs if d["kind"] == "Secret"] + job = _job(docs) + env = job["spec"]["template"]["spec"]["containers"][0]["env"] + inline = {e["name"]: e.get("value") for e in env if "value" in e} + assert inline["PASSWORD"] == PASSWORD + + +class TestPodSecurity: + def test_satisfies_restricted_standard(self, tmp_path): + """Required for plain Kubernetes enforcing restricted PodSecurity.""" + spec = _job(_render(tmp_path))["spec"]["template"]["spec"] + assert spec["securityContext"]["runAsNonRoot"] is True + assert spec["securityContext"]["seccompProfile"]["type"] == "RuntimeDefault" + + container = spec["containers"][0]["securityContext"] + assert container["allowPrivilegeEscalation"] is False + assert container["capabilities"]["drop"] == ["ALL"] + + def test_does_not_pin_runasuser(self, tmp_path): + """OpenShift's SCC assigns a UID from the namespace range. + + Hardcoding one conflicts with that assignment, so the chart must leave + runAsUser unset and let the platform choose. + """ + spec = _job(_render(tmp_path))["spec"]["template"]["spec"] + assert "runAsUser" not in spec.get("securityContext", {}) + assert "runAsUser" not in spec["containers"][0].get("securityContext", {}) + + def test_can_be_disabled(self, tmp_path): + spec = _job(_render(tmp_path, security_context=False))["spec"]["template"][ + "spec" + ] + assert "securityContext" not in spec diff --git a/tests/test_schema.py b/tests/test_schema.py index e87c6b6..f45375f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -181,18 +181,35 @@ def test_mssql_with_base_image_no_warning(self): class TestDatabaseTypeValidation: - def test_oracle_type_requires_oracle_block(self): + """An omitted database block means "use the defaults". + + It used to be a hard error, which forced ~15 lines of config that only + restated values the schema already defaults. Every field in OracleConfig + and MssqlConfig has a working default, so requiring it bought nothing. + """ + + def test_oracle_block_is_filled_in_when_omitted(self): data = _minimal_config() del data["targets"]["defaults"]["oracle"] - with pytest.raises(ValidationError, match="targets.defaults.oracle"): - HammerDBScaleConfig(**data) + config = HammerDBScaleConfig(**data) + assert config.targets.defaults.oracle is not None + assert config.targets.defaults.oracle.port == 1521 + assert config.targets.defaults.oracle.service == "ORCLPDB" - def test_mssql_type_requires_mssql_block(self): + def test_mssql_block_is_filled_in_when_omitted(self): data = _minimal_config() data["targets"]["defaults"]["type"] = "mssql" del data["targets"]["defaults"]["oracle"] - with pytest.raises(ValidationError, match="targets.defaults.mssql"): - HammerDBScaleConfig(**data) + config = HammerDBScaleConfig(**data) + assert config.targets.defaults.mssql is not None + assert config.targets.defaults.mssql.port == 1433 + + def test_explicit_block_is_not_overwritten(self): + data = _minimal_config() + data["targets"]["defaults"]["oracle"] = {"service": "CUSTOM", "port": 1600} + config = HammerDBScaleConfig(**data) + assert config.targets.defaults.oracle.service == "CUSTOM" + assert config.targets.defaults.oracle.port == 1600 def test_mssql_type_with_mssql_block_valid(self): data = _minimal_config() From 63d03209e8a656255ca8b815ca1d5520a97edc88 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 13:07:43 -0600 Subject: [PATCH 06/14] Fix version reporting, tool detection, chart duplication, and error masking Correctness items from the initial review, several of which affect the tool as shipped today. kubectl version detection was broken. Every call used --short, which kubectl removed in 1.28, so `version` and `validate` silently reported "version check failed" against any current install. Now tries JSON output first and falls back for older builds. validate also stopped assuming Kubernetes: with a container backend it checks the container runtime instead of demanding helm and kubectl, and it checks Secret permissions when credentials use a Secret. The CLI version was wrong. constants.py said 2.0.2 while pyproject said 2.0.3. Reading installed metadata alone was not enough either, since an editable install reports whatever version was current when pip last ran (observed as 2.0.0 against a 2.0.3 tree). Now reads pyproject.toml when in a source checkout, with metadata as the fallback. Reports 2.0.3 correctly. The chart is no longer duplicated. templates/, scripts/, Chart.yaml and values.yaml at the repo root are now symlinks into src/hammerdb_scale/chart/. The copies had already drifted (collect_pure_metrics.py differed), and since get_chart_path() prefers the packaged copy, a fix applied only at the root would never reach anyone installing from PyPI. Verified helm renders identically from both paths and the built wheel still contains all 25 chart files. Error masking: a backend that cannot be queried now says so. `results` used to report "No results found" when the real cause was an unreachable cluster or expired credentials, sending the user to look in entirely the wrong place. Housekeeping: scale configs moved to examples/scale-tests/, 1.2MB of generated HTML reports removed from version control (committed by an earlier `git add -A` of mine), and results/ plus *.html added to .gitignore. Adds test_cli.py: 18 command-level tests with the backend mocked, covering the orchestration layer that had none. That is where the bugs have been, including both recent upstream fixes and two found during this work. Verified end to end on both backends after the changes: podman against Oracle, and OpenShift 4.19 with 2 targets. Tests: 202 -> 222. Co-Authored-By: Claude Opus 5 (1M context) --- Chart.yaml | 20 +- .../scale-tests/mssql-scale-1.yaml | 0 .../scale-tests/mssql-scale-2.yaml | 0 .../scale-tests/mssql-scale-4.yaml | 0 .../scale-tests/mssql-scale-6.yaml | 0 .../scale-tests/mssql-scale-8.yaml | 0 .../scale-tests/ora-rac-hdb-scale.yaml | 0 .../scale-tests/run-mssql-scale-tests.sh | 0 .../scale-tests/run-scale-tests.sh | 0 mssql-report-scale-1.html | 267 --------- mssql-report-scale-2.html | 267 --------- mssql-report-scale-4.html | 267 --------- mssql-report-scale-6.html | 267 --------- mssql-report-scale-8.html | 267 --------- scripts | 1 + scripts/collect_pure_metrics.py | 521 ------------------ scripts/mssql/build_schema_tprocc.tcl | 98 ---- scripts/mssql/build_schema_tproch.tcl | 113 ---- scripts/mssql/generic_tprocc_result.tcl | 48 -- scripts/mssql/load_test_tprocc.tcl | 137 ----- scripts/mssql/load_test_tproch.tcl | 114 ---- scripts/mssql/parse_output_tprocc.tcl | 40 -- scripts/mssql/parse_output_tproch.tcl | 168 ------ scripts/oracle/build_schema_tprocc.tcl | 82 --- scripts/oracle/build_schema_tproch.tcl | 72 --- scripts/oracle/load_test_tprocc.tcl | 115 ---- scripts/oracle/load_test_tproch.tcl | 93 ---- scripts/oracle/parse_output_tprocc.tcl | 40 -- scripts/oracle/parse_output_tproch.tcl | 168 ------ .../chart/scripts}/oracle/rman_backup.sh | 0 .../chart/scripts}/oracle/rman_restore.sh | 0 .../chart/scripts}/oracle/setup_redo.sql | 0 src/hammerdb_scale/cli.py | 240 ++++---- src/hammerdb_scale/constants.py | 34 +- src/hammerdb_scale/runtime/resolve.py | 16 +- templates | 1 + templates/_helpers.tpl | 158 ------ templates/configmap-mssql.yaml | 28 - templates/configmap-oracle.yaml | 27 - templates/job-hammerdb-worker.yaml | 301 ---------- templates/secret-credentials.yaml | 37 -- tests/test_cli.py | 222 ++++++++ tests/test_hammerdb_version.py | 36 +- values.yaml | 110 +--- 44 files changed, 437 insertions(+), 3938 deletions(-) mode change 100644 => 120000 Chart.yaml rename mssql-scale-1.yaml => examples/scale-tests/mssql-scale-1.yaml (100%) rename mssql-scale-2.yaml => examples/scale-tests/mssql-scale-2.yaml (100%) rename mssql-scale-4.yaml => examples/scale-tests/mssql-scale-4.yaml (100%) rename mssql-scale-6.yaml => examples/scale-tests/mssql-scale-6.yaml (100%) rename mssql-scale-8.yaml => examples/scale-tests/mssql-scale-8.yaml (100%) rename ora-rac-hdb-scale.yaml => examples/scale-tests/ora-rac-hdb-scale.yaml (100%) rename run-mssql-scale-tests.sh => examples/scale-tests/run-mssql-scale-tests.sh (100%) rename run-scale-tests.sh => examples/scale-tests/run-scale-tests.sh (100%) delete mode 100644 mssql-report-scale-1.html delete mode 100644 mssql-report-scale-2.html delete mode 100644 mssql-report-scale-4.html delete mode 100644 mssql-report-scale-6.html delete mode 100644 mssql-report-scale-8.html create mode 120000 scripts delete mode 100644 scripts/collect_pure_metrics.py delete mode 100644 scripts/mssql/build_schema_tprocc.tcl delete mode 100644 scripts/mssql/build_schema_tproch.tcl delete mode 100644 scripts/mssql/generic_tprocc_result.tcl delete mode 100644 scripts/mssql/load_test_tprocc.tcl delete mode 100644 scripts/mssql/load_test_tproch.tcl delete mode 100644 scripts/mssql/parse_output_tprocc.tcl delete mode 100644 scripts/mssql/parse_output_tproch.tcl delete mode 100644 scripts/oracle/build_schema_tprocc.tcl delete mode 100644 scripts/oracle/build_schema_tproch.tcl delete mode 100644 scripts/oracle/load_test_tprocc.tcl delete mode 100644 scripts/oracle/load_test_tproch.tcl delete mode 100644 scripts/oracle/parse_output_tprocc.tcl delete mode 100644 scripts/oracle/parse_output_tproch.tcl rename {scripts => src/hammerdb_scale/chart/scripts}/oracle/rman_backup.sh (100%) rename {scripts => src/hammerdb_scale/chart/scripts}/oracle/rman_restore.sh (100%) rename {scripts => src/hammerdb_scale/chart/scripts}/oracle/setup_redo.sql (100%) create mode 120000 templates delete mode 100644 templates/_helpers.tpl delete mode 100644 templates/configmap-mssql.yaml delete mode 100644 templates/configmap-oracle.yaml delete mode 100644 templates/job-hammerdb-worker.yaml delete mode 100644 templates/secret-credentials.yaml create mode 100644 tests/test_cli.py mode change 100644 => 120000 values.yaml diff --git a/Chart.yaml b/Chart.yaml deleted file mode 100644 index 556f3e7..0000000 --- a/Chart.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v2 -name: hammerdb-scale -description: Modular Helm chart for parallel HammerDB scale testing across multiple databases (SQL Server, PostgreSQL, Oracle, MySQL) -type: application -version: 2.0.2 -appVersion: "6.0" -keywords: - - hammerdb - - benchmark - - tpc-c - - tpc-h - - sql-server - - postgresql - - oracle - - mysql - - load-testing - - performance -maintainers: - - name: Silli, Nocentino \ No newline at end of file diff --git a/Chart.yaml b/Chart.yaml new file mode 120000 index 0000000..d084241 --- /dev/null +++ b/Chart.yaml @@ -0,0 +1 @@ +src/hammerdb_scale/chart/Chart.yaml \ No newline at end of file diff --git a/mssql-scale-1.yaml b/examples/scale-tests/mssql-scale-1.yaml similarity index 100% rename from mssql-scale-1.yaml rename to examples/scale-tests/mssql-scale-1.yaml diff --git a/mssql-scale-2.yaml b/examples/scale-tests/mssql-scale-2.yaml similarity index 100% rename from mssql-scale-2.yaml rename to examples/scale-tests/mssql-scale-2.yaml diff --git a/mssql-scale-4.yaml b/examples/scale-tests/mssql-scale-4.yaml similarity index 100% rename from mssql-scale-4.yaml rename to examples/scale-tests/mssql-scale-4.yaml diff --git a/mssql-scale-6.yaml b/examples/scale-tests/mssql-scale-6.yaml similarity index 100% rename from mssql-scale-6.yaml rename to examples/scale-tests/mssql-scale-6.yaml diff --git a/mssql-scale-8.yaml b/examples/scale-tests/mssql-scale-8.yaml similarity index 100% rename from mssql-scale-8.yaml rename to examples/scale-tests/mssql-scale-8.yaml diff --git a/ora-rac-hdb-scale.yaml b/examples/scale-tests/ora-rac-hdb-scale.yaml similarity index 100% rename from ora-rac-hdb-scale.yaml rename to examples/scale-tests/ora-rac-hdb-scale.yaml diff --git a/run-mssql-scale-tests.sh b/examples/scale-tests/run-mssql-scale-tests.sh similarity index 100% rename from run-mssql-scale-tests.sh rename to examples/scale-tests/run-mssql-scale-tests.sh diff --git a/run-scale-tests.sh b/examples/scale-tests/run-scale-tests.sh similarity index 100% rename from run-scale-tests.sh rename to examples/scale-tests/run-scale-tests.sh diff --git a/mssql-report-scale-1.html b/mssql-report-scale-1.html deleted file mode 100644 index 5985235..0000000 --- a/mssql-report-scale-1.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - -HammerDB-Scale Scorecard - - - -
-

HammerDB-Scale Scorecard

-
mssql-scale-1
-
Test ID: mssql-scale-1-20260401-2022Benchmark: TPROCCDatabase: mssqlTargets: 1Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 02:42 UTC
-
-
- -
-
Total TPM
1,164,226
-
Total NOPM
501,117
-
Avg TPM / Target
1,164,226
-
Avg NOPM / Target
501,117
-
-

Per-Target Results

- - - -
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 23s1,164,226501,117
- -
-

TPM Distribution

- -
-
-

NOPM Distribution

- -
- -

Storage Performance (238 samples)

-
-
Avg Write Latency
135 µs
-
Avg Read Latency
215 µs
-
Peak Write IOPS
305,255
-
Peak Read IOPS
210,147
-
Avg Write BW
1,170.5 MB/s
-
Avg Read BW
922.6 MB/s
-
Avg Write Block Size
9.7 KB
-
Avg Read Block Size
8.2 KB
-
- - - -
MetricAvgP95P99
Write Latency135.3 µs150 µs157 µs
Read Latency215.4 µs295 µs307 µs
- - -
MetricAvgMax
Write IOPS126,475.1305,255
Read IOPS116,264.1210,147
Write BW1,170.5 MB/s2,603.2 MB/s
Read BW922.6 MB/s1,726.0 MB/s
Avg Write Block Size9.7 KB
Avg Read Block Size8.2 KB
- -
-

Latency Over Time (µs)

- -
-
-

IOPS Over Time

- -
-
-

Bandwidth Over Time (MB/s)

- -
- -
- Configuration Snapshot -
{
-  "database_type": "mssql",
-  "target_count": 1,
-  "image": "sillidata/hammerdb-scale:latest",
-  "warehouses": 10000,
-  "virtual_users": 200,
-  "rampup_minutes": 5,
-  "duration_minutes": 15
-}
-
-
- - - \ No newline at end of file diff --git a/mssql-report-scale-2.html b/mssql-report-scale-2.html deleted file mode 100644 index a310520..0000000 --- a/mssql-report-scale-2.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - -HammerDB-Scale Scorecard - - - -
-

HammerDB-Scale Scorecard

-
mssql-scale-2
-
Test ID: mssql-scale-2-20260401-2042Benchmark: TPROCCDatabase: mssqlTargets: 2Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 03:02 UTC
-
-
- -
-
Total TPM
2,189,438
-
Total NOPM
941,875
-
Avg TPM / Target
1,094,719
-
Avg NOPM / Target
470,937
-
-

Per-Target Results

- - - -
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 25s1,107,243476,415
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 27s1,082,195465,460
- -
-

TPM Distribution

- -
-
-

NOPM Distribution

- -
- -

Storage Performance (238 samples)

-
-
Avg Write Latency
163 µs
-
Avg Read Latency
245 µs
-
Peak Write IOPS
426,361
-
Peak Read IOPS
391,969
-
Avg Write BW
2,156.3 MB/s
-
Avg Read BW
1,878.0 MB/s
-
Avg Write Block Size
9.8 KB
-
Avg Read Block Size
8.8 KB
-
- - - -
MetricAvgP95P99
Write Latency162.5 µs173 µs228 µs
Read Latency245.4 µs293 µs312 µs
- - -
MetricAvgMax
Write IOPS231,956.7426,361
Read IOPS226,166.7391,969
Write BW2,156.3 MB/s3,862.3 MB/s
Read BW1,878.0 MB/s5,554.3 MB/s
Avg Write Block Size9.8 KB
Avg Read Block Size8.8 KB
- -
-

Latency Over Time (µs)

- -
-
-

IOPS Over Time

- -
-
-

Bandwidth Over Time (MB/s)

- -
- -
- Configuration Snapshot -
{
-  "database_type": "mssql",
-  "target_count": 2,
-  "image": "sillidata/hammerdb-scale:latest",
-  "warehouses": 10000,
-  "virtual_users": 200,
-  "rampup_minutes": 5,
-  "duration_minutes": 15
-}
-
-
- - - \ No newline at end of file diff --git a/mssql-report-scale-4.html b/mssql-report-scale-4.html deleted file mode 100644 index 410127b..0000000 --- a/mssql-report-scale-4.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - -HammerDB-Scale Scorecard - - - -
-

HammerDB-Scale Scorecard

-
mssql-scale-4
-
Test ID: mssql-scale-4-20260401-2103Benchmark: TPROCCDatabase: mssqlTargets: 4Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 03:23 UTC
-
-
- -
-
Total TPM
4,384,849
-
Total NOPM
1,887,224
-
Avg TPM / Target
1,096,212
-
Avg NOPM / Target
471,806
-
-

Per-Target Results

- - - -
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 25s1,111,375478,305
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 26s1,093,589470,775
2sql-bench-03sql-bench-03.soln.local✓ Completed20m 24s1,095,975471,827
3sql-bench-04sql-bench-04.soln.local✓ Completed20m 26s1,083,910466,317
- -
-

TPM Distribution

- -
-
-

NOPM Distribution

- -
- -

Storage Performance (236 samples)

-
-
Avg Write Latency
321 µs
-
Avg Read Latency
303 µs
-
Peak Write IOPS
750,453
-
Peak Read IOPS
690,208
-
Avg Write BW
4,272.9 MB/s
-
Avg Read BW
3,717.5 MB/s
-
Avg Write Block Size
9.4 KB
-
Avg Read Block Size
9.1 KB
-
- - - -
MetricAvgP95P99
Write Latency321.2 µs881 µs1,041 µs
Read Latency303.1 µs364 µs463 µs
- - -
MetricAvgMax
Write IOPS458,576.7750,453
Read IOPS444,719.3690,208
Write BW4,272.9 MB/s6,915.8 MB/s
Read BW3,717.5 MB/s9,561.8 MB/s
Avg Write Block Size9.4 KB
Avg Read Block Size9.1 KB
- -
-

Latency Over Time (µs)

- -
-
-

IOPS Over Time

- -
-
-

Bandwidth Over Time (MB/s)

- -
- -
- Configuration Snapshot -
{
-  "database_type": "mssql",
-  "target_count": 4,
-  "image": "sillidata/hammerdb-scale:latest",
-  "warehouses": 10000,
-  "virtual_users": 200,
-  "rampup_minutes": 5,
-  "duration_minutes": 15
-}
-
-
- - - \ No newline at end of file diff --git a/mssql-report-scale-6.html b/mssql-report-scale-6.html deleted file mode 100644 index e1c70ac..0000000 --- a/mssql-report-scale-6.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - -HammerDB-Scale Scorecard - - - -
-

HammerDB-Scale Scorecard

-
mssql-scale-8
-
Test ID: mssql-scale-8-20260401-2123Benchmark: TPROCCDatabase: mssqlTargets: 6Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 03:44 UTC
-
-
- -
-
Total TPM
4,827,934
-
Total NOPM
2,078,379
-
Avg TPM / Target
804,655
-
Avg NOPM / Target
346,396
-
-

Per-Target Results

- - - -
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 26s831,229357,771
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 29s816,572351,604
2sql-bench-03sql-bench-03.soln.local✓ Completed20m 27s812,807349,847
3sql-bench-04sql-bench-04.soln.local✓ Completed20m 26s794,078341,964
4sql-bench-05sql-bench-05.soln.local✓ Completed20m 30s788,697339,542
5sql-bench-06sql-bench-06.soln.local✓ Completed20m 30s784,551337,651
- -
-

TPM Distribution

- -
-
-

NOPM Distribution

- -
- -

Storage Performance (237 samples)

-
-
Avg Write Latency
1,357 µs
-
Avg Read Latency
447 µs
-
Peak Write IOPS
798,347
-
Peak Read IOPS
841,325
-
Avg Write BW
4,816.1 MB/s
-
Avg Read BW
4,310.9 MB/s
-
Avg Write Block Size
9.3 KB
-
Avg Read Block Size
8.8 KB
-
- - - -
MetricAvgP95P99
Write Latency1,357.2 µs1,778 µs1,973 µs
Read Latency446.5 µs679 µs843 µs
- - -
MetricAvgMax
Write IOPS526,148.1798,347
Read IOPS515,089.9841,325
Write BW4,816.1 MB/s7,524.7 MB/s
Read BW4,310.9 MB/s11,865.7 MB/s
Avg Write Block Size9.3 KB
Avg Read Block Size8.8 KB
- -
-

Latency Over Time (µs)

- -
-
-

IOPS Over Time

- -
-
-

Bandwidth Over Time (MB/s)

- -
- -
- Configuration Snapshot -
{
-  "database_type": "mssql",
-  "target_count": 6,
-  "image": "sillidata/hammerdb-scale:latest",
-  "warehouses": 10000,
-  "virtual_users": 200,
-  "rampup_minutes": 5,
-  "duration_minutes": 15
-}
-
-
- - - \ No newline at end of file diff --git a/mssql-report-scale-8.html b/mssql-report-scale-8.html deleted file mode 100644 index 77a7cac..0000000 --- a/mssql-report-scale-8.html +++ /dev/null @@ -1,267 +0,0 @@ - - - - - -HammerDB-Scale Scorecard - - - -
-

HammerDB-Scale Scorecard

-
mssql-scale-8
-
Test ID: mssql-scale-8-20260401-2144Benchmark: TPROCCDatabase: mssqlTargets: 8Warehouses: 10,000VUs: 200Rampup: 5mDuration: 15mTime: 2026-04-02 04:05 UTC
-
-
- -
-
Total TPM
4,759,717
-
Total NOPM
2,049,059
-
Avg TPM / Target
594,964
-
Avg NOPM / Target
256,132
-
-

Per-Target Results

- - - -
#TargetHostStatusDurationTPMNOPM
0sql-bench-01sql-bench-01.soln.local✓ Completed20m 29s604,100260,067
1sql-bench-02sql-bench-02.soln.local✓ Completed20m 20s601,382258,856
2sql-bench-03sql-bench-03.soln.local✓ Completed20m 42s602,938259,650
3sql-bench-04sql-bench-04.soln.local✓ Completed20m 25s596,004256,497
4sql-bench-05sql-bench-05.soln.local✓ Completed20m 24s590,938254,440
5sql-bench-06sql-bench-06.soln.local✓ Completed20m 22s597,066257,131
6sql-bench-07sql-bench-07.soln.local✓ Completed20m 21s589,948253,893
7sql-bench-08sql-bench-08.soln.local✓ Completed20m 28s577,341248,525
- -
-

TPM Distribution

- -
-
-

NOPM Distribution

- -
- -

Storage Performance (236 samples)

-
-
Avg Write Latency
2,089 µs
-
Avg Read Latency
746 µs
-
Peak Write IOPS
750,582
-
Peak Read IOPS
676,604
-
Avg Write BW
4,999.0 MB/s
-
Avg Read BW
4,180.9 MB/s
-
Avg Write Block Size
9.3 KB
-
Avg Read Block Size
8.7 KB
-
- - - -
MetricAvgP95P99
Write Latency2,089.1 µs2,539 µs2,736 µs
Read Latency746.5 µs1,091 µs1,223 µs
- - -
MetricAvgMax
Write IOPS547,109.9750,582
Read IOPS501,697.1676,604
Write BW4,999.0 MB/s6,911.1 MB/s
Read BW4,180.9 MB/s8,120.9 MB/s
Avg Write Block Size9.3 KB
Avg Read Block Size8.7 KB
- -
-

Latency Over Time (µs)

- -
-
-

IOPS Over Time

- -
-
-

Bandwidth Over Time (MB/s)

- -
- -
- Configuration Snapshot -
{
-  "database_type": "mssql",
-  "target_count": 8,
-  "image": "sillidata/hammerdb-scale:latest",
-  "warehouses": 10000,
-  "virtual_users": 200,
-  "rampup_minutes": 5,
-  "duration_minutes": 15
-}
-
-
- - - \ No newline at end of file diff --git a/scripts b/scripts new file mode 120000 index 0000000..370d71a --- /dev/null +++ b/scripts @@ -0,0 +1 @@ +src/hammerdb_scale/chart/scripts \ No newline at end of file diff --git a/scripts/collect_pure_metrics.py b/scripts/collect_pure_metrics.py deleted file mode 100644 index 677080f..0000000 --- a/scripts/collect_pure_metrics.py +++ /dev/null @@ -1,521 +0,0 @@ -#!/usr/bin/env python3 -""" -Pure Storage FlashArray Metrics Collector -Collects array-level performance metrics using REST API 2.x - -Usage: - python3 collect_pure_metrics.py --host 10.21.158.110 --token YOUR_TOKEN --duration 60 -""" - -import argparse -import json -import logging -import os -import signal -import sys -import time -import urllib3 -from datetime import datetime, timezone -from typing import Dict, List, Optional - -try: - import requests -except ImportError: - print("ERROR: 'requests' module not found. Install with: pip install requests") - sys.exit(1) - -# Disable SSL warnings if needed -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -# Setup logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) -logger = logging.getLogger(__name__) - - -class PureStorageCollector: - """Simple Pure Storage FlashArray performance metrics collector.""" - - def __init__( - self, - array_host: str, - api_token: str, - poll_interval: int = 5, - verify_ssl: bool = False, - api_version: str = "2.4" - ): - """ - Initialize the collector. - - Args: - array_host: FlashArray management IP or hostname - api_token: API token for authentication - poll_interval: Seconds between polls (default: 5) - verify_ssl: Verify SSL certificates (default: False) - api_version: REST API version (default: 2.4) - """ - self.array_host = array_host.strip() - self.api_token = api_token.strip() - self.poll_interval = poll_interval - self.verify_ssl = verify_ssl - self.api_version = api_version - - # Build base URL - self.base_url = f"https://{self.array_host}/api/{self.api_version}" - - # Storage for metrics - self.metrics_data: List[Dict] = [] - self.running = False - self.session_token = None - - logger.info(f"Initialized Pure Storage collector for {self.array_host}") - logger.info(f"API Version: {self.api_version}") - logger.info(f"Poll Interval: {self.poll_interval}s") - - if not self.verify_ssl: - logger.warning("SSL certificate verification is DISABLED - not recommended for production") - - def login(self) -> bool: - """ - Login to Pure Storage array and get session token. - - Returns: - True if successful, False otherwise - """ - logger.info("Logging in to Pure Storage array...") - - try: - response = requests.post( - f"{self.base_url}/login", - headers={"api-token": self.api_token, "Content-Type": "application/json"}, - verify=self.verify_ssl, - timeout=10 - ) - - if response.status_code == 200: - self.session_token = response.headers.get('x-auth-token') - if self.session_token: - logger.info("Login successful") - return True - else: - logger.error("No x-auth-token in login response") - return False - else: - logger.error(f"Login failed - HTTP {response.status_code}: {response.text}") - return False - - except requests.exceptions.RequestException as e: - logger.error(f"Login request error: {e}") - return False - - def _make_request(self, endpoint: str, method: str = "GET") -> Optional[Dict]: - """ - Make HTTP request to Pure Storage API. - - Args: - endpoint: API endpoint (e.g., "/arrays/performance") - method: HTTP method (default: GET) - - Returns: - JSON response as dict, or None on error - """ - if not self.session_token: - logger.error("No session token. Call login() first.") - return None - - url = f"{self.base_url}{endpoint}" - headers = { - "x-auth-token": self.session_token, - "Content-Type": "application/json" - } - - try: - response = requests.request( - method=method, - url=url, - headers=headers, - verify=self.verify_ssl, - timeout=10 - ) - response.raise_for_status() - return response.json() - - except requests.exceptions.HTTPError as e: - logger.error(f"HTTP error for {endpoint}: {e}") - if hasattr(e.response, 'text'): - logger.error(f"Response: {e.response.text}") - return None - except requests.exceptions.RequestException as e: - logger.error(f"Request error for {endpoint}: {e}") - return None - except json.JSONDecodeError as e: - logger.error(f"JSON decode error for {endpoint}: {e}") - return None - except Exception as e: - logger.error(f"Unexpected error for {endpoint}: {e}") - return None - - def test_connection(self) -> bool: - """ - Test connection and authentication to the array. - - Returns: - True if successful, False otherwise - """ - logger.info("Testing connection to Pure Storage array...") - - # First login - if not self.login(): - logger.error("Failed to login to Pure Storage array") - return False - - # Then test with a simple API call - result = self._make_request("/arrays") - - if result and 'items' in result and len(result['items']) > 0: - array_info = result['items'][0] - logger.info(f"Successfully connected to array: {array_info.get('name', 'Unknown')}") - logger.info(f"Array ID: {array_info.get('id', 'Unknown')}") - return True - else: - logger.error("Failed to get array information") - return False - - def collect_array_performance(self) -> Optional[Dict]: - """ - Collect array-level performance metrics. - - Returns: - Dict with performance metrics or None if failed - """ - result = self._make_request("/arrays/performance") - - if not result or 'items' not in result or len(result['items']) == 0: - logger.error("No performance data returned from array") - return None - - perf = result['items'][0] - - # Extract and calculate metrics - read_iops = perf.get('reads_per_sec', 0) - write_iops = perf.get('writes_per_sec', 0) - read_bw_bytes = perf.get('read_bytes_per_sec', 0) - write_bw_bytes = perf.get('write_bytes_per_sec', 0) - - # Calculate average block sizes (avoid division by zero) - avg_read_block_kb = (read_bw_bytes / read_iops / 1024) if read_iops > 0 else 0 - avg_write_block_kb = (write_bw_bytes / write_iops / 1024) if write_iops > 0 else 0 - - metrics = { - 'timestamp': datetime.now(timezone.utc).isoformat(), - 'read_latency_us': perf.get('usec_per_read_op', 0), - 'write_latency_us': perf.get('usec_per_write_op', 0), - 'read_iops': read_iops, - 'write_iops': write_iops, - 'read_bandwidth_mbps': read_bw_bytes / (1024 * 1024), - 'write_bandwidth_mbps': write_bw_bytes / (1024 * 1024), - 'avg_read_block_size_kb': avg_read_block_kb, - 'avg_write_block_size_kb': avg_write_block_kb, - 'queue_depth': perf.get('queue_depth', 0), - } - - return metrics - - def start_collection(self, duration: int): - """ - Start collecting metrics for specified duration. - - Args: - duration: Collection duration in seconds - """ - logger.info(f"Starting metrics collection for {duration} seconds...") - - if not self.test_connection(): - logger.error("Connection test failed. Exiting.") - sys.exit(1) - - self.running = True - start_time = time.time() - end_time = start_time + duration - collection_count = 0 - - # Handle SIGTERM for graceful early stop (sent by entrypoint when benchmark finishes) - def _handle_sigterm(signum, frame): - logger.info("Received SIGTERM — finishing collection") - self.running = False - - signal.signal(signal.SIGTERM, _handle_sigterm) - - try: - while self.running and time.time() < end_time: - metrics = self.collect_array_performance() - - if metrics: - self.metrics_data.append(metrics) - collection_count += 1 - logger.info( - f"Sample #{collection_count}: " - f"R:{metrics['read_iops']:.0f} IOPS " - f"W:{metrics['write_iops']:.0f} IOPS " - f"R_lat:{metrics['read_latency_us']:.0f}µs " - f"W_lat:{metrics['write_latency_us']:.0f}µs" - ) - else: - logger.warning(f"Failed to collect metrics (attempt {collection_count + 1})") - - # Sleep until next collection - remaining = end_time - time.time() - if remaining <= 0: - break - sleep_time = min(self.poll_interval, remaining) - time.sleep(sleep_time) - - except KeyboardInterrupt: - logger.info("Collection interrupted by user") - finally: - self.running = False - actual_duration = time.time() - start_time - logger.info( - f"Collection complete. Gathered {collection_count} samples " - f"over {actual_duration:.1f} seconds" - ) - - def get_summary_statistics(self) -> Dict: - """ - Calculate summary statistics from collected metrics. - - Returns: - Dict with min, max, average, and percentile statistics - """ - if not self.metrics_data: - return {} - - def calc_stats(values: List[float]) -> Dict: - """Calculate statistics for a list of values.""" - if not values: - return {'min': 0, 'max': 0, 'avg': 0, 'p95': 0, 'p99': 0} - - sorted_vals = sorted(values) - count = len(sorted_vals) - - # Calculate percentile indices - p95_idx = int(count * 0.95) - p99_idx = int(count * 0.99) - - return { - 'min': sorted_vals[0], - 'max': sorted_vals[-1], - 'avg': sum(sorted_vals) / count, - 'p95': sorted_vals[p95_idx] if p95_idx < count else sorted_vals[-1], - 'p99': sorted_vals[p99_idx] if p99_idx < count else sorted_vals[-1], - } - - # Extract metric lists - read_lat = [m['read_latency_us'] for m in self.metrics_data] - write_lat = [m['write_latency_us'] for m in self.metrics_data] - read_iops = [m['read_iops'] for m in self.metrics_data] - write_iops = [m['write_iops'] for m in self.metrics_data] - read_bw = [m['read_bandwidth_mbps'] for m in self.metrics_data] - write_bw = [m['write_bandwidth_mbps'] for m in self.metrics_data] - read_block = [m['avg_read_block_size_kb'] for m in self.metrics_data] - write_block = [m['avg_write_block_size_kb'] for m in self.metrics_data] - - # Calculate statistics - read_lat_stats = calc_stats(read_lat) - write_lat_stats = calc_stats(write_lat) - read_iops_stats = calc_stats(read_iops) - write_iops_stats = calc_stats(write_iops) - read_bw_stats = calc_stats(read_bw) - write_bw_stats = calc_stats(write_bw) - read_block_stats = calc_stats(read_block) - write_block_stats = calc_stats(write_block) - - return { - 'source': 'array', - 'source_name': self.array_host, - 'sample_count': len(self.metrics_data), - 'read_latency_us_min': read_lat_stats['min'], - 'read_latency_us_max': read_lat_stats['max'], - 'read_latency_us_avg': read_lat_stats['avg'], - 'read_latency_us_p95': read_lat_stats['p95'], - 'read_latency_us_p99': read_lat_stats['p99'], - 'write_latency_us_min': write_lat_stats['min'], - 'write_latency_us_max': write_lat_stats['max'], - 'write_latency_us_avg': write_lat_stats['avg'], - 'write_latency_us_p95': write_lat_stats['p95'], - 'write_latency_us_p99': write_lat_stats['p99'], - 'read_iops_min': read_iops_stats['min'], - 'read_iops_max': read_iops_stats['max'], - 'read_iops_avg': read_iops_stats['avg'], - 'write_iops_min': write_iops_stats['min'], - 'write_iops_max': write_iops_stats['max'], - 'write_iops_avg': write_iops_stats['avg'], - 'read_bandwidth_mbps_min': read_bw_stats['min'], - 'read_bandwidth_mbps_max': read_bw_stats['max'], - 'read_bandwidth_mbps_avg': read_bw_stats['avg'], - 'write_bandwidth_mbps_min': write_bw_stats['min'], - 'write_bandwidth_mbps_max': write_bw_stats['max'], - 'write_bandwidth_mbps_avg': write_bw_stats['avg'], - 'avg_read_block_size_kb_min': read_block_stats['min'], - 'avg_read_block_size_kb_max': read_block_stats['max'], - 'avg_read_block_size_kb_avg': read_block_stats['avg'], - 'avg_write_block_size_kb_min': write_block_stats['min'], - 'avg_write_block_size_kb_max': write_block_stats['max'], - 'avg_write_block_size_kb_avg': write_block_stats['avg'], - } - - def save_results(self, output_file: str): - """ - Save collected metrics and summary to JSON file. - - Args: - output_file: Path to output JSON file - """ - results = { - 'metadata': { - 'array_host': self.array_host, - 'poll_interval_sec': self.poll_interval, - 'api_version': self.api_version, - }, - 'summary': self.get_summary_statistics(), - 'raw_metrics': self.metrics_data, - } - - try: - output_dir = os.path.dirname(output_file) - if output_dir: - os.makedirs(output_dir, exist_ok=True) - - with open(output_file, 'w') as f: - json.dump(results, f, indent=2) - logger.info(f"Results saved to {output_file}") - except Exception as e: - logger.error(f"Failed to save results: {e}") - - -def main(): - """Main entry point.""" - parser = argparse.ArgumentParser( - description='Collect Pure Storage FlashArray performance metrics', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=''' -Examples: - # Collect array-level metrics for 60 seconds - %(prog)s --host 10.21.158.110 --token abc123 --duration 60 - - # Use environment variables for credentials - export PURE_HOST=10.21.158.110 - export PURE_API_TOKEN=abc123 - %(prog)s --duration 60 --output /tmp/metrics.json - ''' - ) - - parser.add_argument( - '--host', - default=os.getenv('PURE_HOST'), - help='FlashArray management IP or hostname (or set PURE_HOST env var)' - ) - parser.add_argument( - '--token', - default=os.getenv('PURE_API_TOKEN'), - help='API token for authentication (or set PURE_API_TOKEN env var)' - ) - parser.add_argument( - '--duration', - type=int, - default=int(os.getenv('PURE_DURATION', '60')), - help='Collection duration in seconds (default: 60)' - ) - parser.add_argument( - '--interval', - type=int, - default=int(os.getenv('PURE_INTERVAL', '5')), - help='Polling interval in seconds (default: 5)' - ) - parser.add_argument( - '--output', - default=os.getenv('PURE_OUTPUT', 'pure_metrics.json'), - help='Output JSON file path (default: pure_metrics.json)' - ) - parser.add_argument( - '--no-verify-ssl', - action='store_true', - default=os.getenv('PURE_NO_VERIFY_SSL', '').lower() in ('true', '1', 'yes'), - help='Disable SSL certificate verification' - ) - parser.add_argument( - '--api-version', - default=os.getenv('PURE_API_VERSION', '2.4'), - help='Pure Storage REST API version (default: 2.4)' - ) - parser.add_argument( - '--verbose', - action='store_true', - help='Enable verbose logging' - ) - - args = parser.parse_args() - - # Validate required arguments - if not args.host: - parser.error("--host is required (or set PURE_HOST environment variable)") - if not args.token: - parser.error("--token is required (or set PURE_API_TOKEN environment variable)") - - # Set log level - if args.verbose: - logger.setLevel(logging.DEBUG) - - # Create collector - collector = PureStorageCollector( - array_host=args.host, - api_token=args.token, - poll_interval=args.interval, - verify_ssl=not args.no_verify_ssl, - api_version=args.api_version - ) - - # Start collection - try: - collector.start_collection(args.duration) - collector.save_results(args.output) - - # Print summary - summary = collector.get_summary_statistics() - if summary: - print("\n" + "=" * 60) - print("PURE STORAGE PERFORMANCE SUMMARY") - print("=" * 60) - print(f"Source: {summary['source']} - {summary['source_name']}") - print(f"Samples: {summary['sample_count']}") - print(f"\nRead Latency (µs): avg={summary.get('read_latency_us_avg', 0):.0f} " - f"p95={summary.get('read_latency_us_p95', 0):.0f} " - f"p99={summary.get('read_latency_us_p99', 0):.0f}") - print(f"Write Latency (µs): avg={summary.get('write_latency_us_avg', 0):.0f} " - f"p95={summary.get('write_latency_us_p95', 0):.0f} " - f"p99={summary.get('write_latency_us_p99', 0):.0f}") - print(f"\nRead IOPS: avg={summary.get('read_iops_avg', 0):.0f} " - f"max={summary.get('read_iops_max', 0):.0f}") - print(f"Write IOPS: avg={summary.get('write_iops_avg', 0):.0f} " - f"max={summary.get('write_iops_max', 0):.0f}") - print(f"\nRead BW (MB/s): avg={summary.get('read_bandwidth_mbps_avg', 0):.1f} " - f"max={summary.get('read_bandwidth_mbps_max', 0):.1f}") - print(f"Write BW (MB/s): avg={summary.get('write_bandwidth_mbps_avg', 0):.1f} " - f"max={summary.get('write_bandwidth_mbps_max', 0):.1f}") - print(f"\nAvg Read Block (KB): {summary.get('avg_read_block_size_kb_avg', 0):.1f}") - print(f"Avg Write Block (KB): {summary.get('avg_write_block_size_kb_avg', 0):.1f}") - print("=" * 60) - else: - logger.warning("No metrics collected") - sys.exit(1) - - except Exception as e: - logger.error(f"Collection failed: {e}") - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/scripts/mssql/build_schema_tprocc.tcl b/scripts/mssql/build_schema_tprocc.tcl deleted file mode 100644 index a7b8d1e..0000000 --- a/scripts/mssql/build_schema_tprocc.tcl +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/tclsh -# Load environment variables -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) - -# TPROC-C variables -set tprocc_database_name $::env(TPROCC_DATABASE_NAME) -set tprocc_build_virtual_users $::env(TPROCC_BUILD_VIRTUAL_USERS) -set warehouses $::env(WAREHOUSES) -set tprocc_driver_type $::env(TPROCC_DRIVER_TYPE) -set tprocc_allwarehouse $::env(TPROCC_ALLWAREHOUSE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD SQL_SERVER_HOST TPROCC_DATABASE_NAME WAREHOUSES TPROCC_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Initialize HammerDB -puts "SETTING UP TPROC-C SCHEMA BUILD" -dbset db mssqls - -# Set benchmark to TPC-C -dbset bm TPC-C - -# Configure connection -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-C Schema Build -diset tpcc mssqls_count_ware $warehouses -diset tpcc mssqls_num_vu $tprocc_build_virtual_users -diset tpcc mssqls_dbase $tprocc_database_name -diset tpcc mssqls_driver $tprocc_driver_type - -# Handle allwarehouse setting -if {$tprocc_allwarehouse eq "true"} { - diset tpcc mssqls_allwarehouse true -} else { - diset tpcc mssqls_allwarehouse false -} - -# Check if BCP option is enabled (now using common USE_BCP variable) -if {[info exists ::env(USE_BCP)] && $::env(USE_BCP) eq "true"} { - puts "Using BCP for data loading" - diset tpcc mssqls_use_bcp true -} else { - diset tpcc mssqls_use_bcp false - puts "Using standard data loading (BCP disabled)" -} - -# Load the TPC-C script -loadscript - -# Print current configuration -puts "Current TPROC-C configuration:" -print dict - -# Build the schema -puts "Starting TPROC-C schema build..." -buildschema - -puts "TPROC-C SCHEMA BUILD COMPLETE" \ No newline at end of file diff --git a/scripts/mssql/build_schema_tproch.tcl b/scripts/mssql/build_schema_tproch.tcl deleted file mode 100644 index 7dc176c..0000000 --- a/scripts/mssql/build_schema_tproch.tcl +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/tclsh -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) - -# TPROC-H variables -set tproch_database_name $::env(TPROCH_DATABASE_NAME) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_build_virtual_users $::env(TPROCH_BUILD_VIRTUAL_USERS) -set tproch_clustered_columnstore $::env(TPROCH_USE_CLUSTERED_COLUMNSTORE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD SQL_SERVER_HOST TPROCH_DATABASE_NAME TPROCH_SCALE_FACTOR TPROCH_DRIVER TPROCH_BUILD_THREADS TPROCH_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Initialize HammerDB -puts "SETTING UP TPROC-H SCHEMA BUILD" -puts "Target database: $tproch_database_name" -puts "Scale factor: $tproch_scale_factor" -puts "Build threads: $tproch_build_threads" -puts "Build virtual users: $tproch_build_virtual_users (for logging; build uses threads)" - -# Set database to SQL Server -dbset db $tproch_driver - -# Set benchmark to TPC-H -dbset bm TPC-H - -# Configure connection -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-H Schema Build -diset tpch mssqls_tpch_dbase $tproch_database_name -diset tpch mssqls_scale_fact $tproch_scale_factor -diset tpch mssqls_num_tpch_threads $tproch_build_threads - -# Configure columnstore if enabled -if {$tproch_clustered_columnstore eq "true"} { - puts "Enabling Clustered Columnstore Indexes" - diset tpch mssqls_colstore true -} else { - puts "Using standard row-based storage" - diset tpch mssqls_colstore false -} - -# Check if BCP option is enabled (now using common USE_BCP variable) -if {[info exists ::env(USE_BCP)] && $::env(USE_BCP) eq "true"} { - puts "Using BCP for data loading" - diset tpch mssqls_tpch_use_bcp true -} else { - diset tpch mssqls_tpch_use_bcp false - puts "Using standard data loading (BCP disabled)" -} - -# Set MAXDOP if environment variable exists -if {[info exists ::env(TPROCH_MAXDOP)]} { - diset tpch mssqls_maxdop $::env(TPROCH_MAXDOP) -} else { - # Use default value of 2 - diset tpch mssqls_maxdop 2 -} - -# Load the TPC-H script -loadscript - -# Print current configuration -puts "Current TPROC-H configuration:" -print dict - -# Build the schema -puts "Starting TPROC-H schema build..." -buildschema - -puts "TPROC-H SCHEMA BUILD COMPLETE" \ No newline at end of file diff --git a/scripts/mssql/generic_tprocc_result.tcl b/scripts/mssql/generic_tprocc_result.tcl deleted file mode 100644 index ae7f3ce..0000000 --- a/scripts/mssql/generic_tprocc_result.tcl +++ /dev/null @@ -1,48 +0,0 @@ -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to get the output from the output file -proc getoutput {filename} { - set fd [open $filename r] - set output [read $fd] - close $fd - return $output -} - -# Main script execution -set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/mssqls_tprocc -set filename $::outputfile -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "Job ID not found in the output file." - exit 1 -} - -set output_filename [file normalize "${filename}_${jobid}.out"] - -# Open the file for writing -set fileId [open $output_filename "w"] - -# Write to the file -puts $fileId "TRANSACTION RESPONSE TIMES" -puts $fileId [job $jobid timing] - -puts $fileId "TRANSACTION COUNT" -puts $fileId [job $jobid tcount] - -puts $fileId "HAMMERDB RESULT" -puts $fileId [job $jobid result] - -# Close the file -close $fileId - -# Optionally, print the output to the console -set output [getoutput $output_filename] -puts $output diff --git a/scripts/mssql/load_test_tprocc.tcl b/scripts/mssql/load_test_tprocc.tcl deleted file mode 100644 index 92408b6..0000000 --- a/scripts/mssql/load_test_tprocc.tcl +++ /dev/null @@ -1,137 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for SQL Server connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) - -# TPROC-C specific variables -set virtual_users $::env(VIRTUAL_USERS) -set tprocc_database_name $::env(TPROCC_DATABASE_NAME) -set tprocc_driver $::env(TPROCC_DRIVER) -set rampup $::env(RAMPUP) -set duration $::env(DURATION) -set total_iterations $::env(TOTAL_ITERATIONS) -set tmpdir $::env(TMPDIR) -set warehouses $::env(WAREHOUSES) -set tprocc_log_to_temp $::env(TPROCC_LOG_TO_TEMP) -set tprocc_use_transaction_counter $::env(TPROCC_USE_TRANSACTION_COUNTER) -set tprocc_checkpoint $::env(TPROCC_CHECKPOINT) -set tprocc_timeprofile $::env(TPROCC_TIMEPROFILE) - -# Check if all required environment variables are set -if {![info exists username] || ![info exists password] || ![info exists sql_server_host]} { - puts "Error: Environment variables USERNAME, PASSWORD, and SQL_SERVER_HOST must be set." - exit 1 -} - -# Initialize HammerDB -puts "SETTING UP TPROC-C LOAD TEST" -puts "Environment variables loaded:" -puts " Database: $tprocc_database_name" -puts " Virtual Users: $virtual_users" -puts " Duration: $duration minutes" -puts " Rampup: $rampup minutes" -puts " Total Iterations: $total_iterations" - -# Set up the database connection details for MSSQL -dbset db $tprocc_driver - -# Set the benchmark to TPC-C -dbset bm TPC-C - -# Set up the database connection details for MSSQL -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-C benchmark parameters -diset tpcc mssqls_dbase $tprocc_database_name -diset tpcc mssqls_driver timed -diset tpcc mssqls_total_iterations $total_iterations -diset tpcc mssqls_rampup $rampup -diset tpcc mssqls_duration $duration -diset tpcc mssqls_allwarehouse true -diset tpcc mssqls_count_ware $warehouses - -# Disable keying and thinking time for maximum storage stress testing -# This runs transactions back-to-back without simulated human delays -diset tpcc mssqls_keyandthink false - -# Set checkpoint and timeprofile if they are true -if {$tprocc_checkpoint eq "true"} { - diset tpcc mssqls_checkpoint true -} -if {$tprocc_timeprofile eq "true"} { - diset tpcc mssqls_timeprofile true -} - -# Configure test options and load scripts -vuset logtotemp $tprocc_log_to_temp -loadscript - -puts "STARTING TPROC-C VIRTUAL USERS" -puts "Virtual Users: $virtual_users" -puts "Duration: $duration minutes" -puts "Output will be logged to: $tmpdir/mssqls_tprocc" - -vuset vu $virtual_users -vucreate -puts "TEST STARTED" - -# Handle transaction counter based on environment variable -if {$tprocc_use_transaction_counter eq "true"} { - puts "Starting transaction counter..." - tcstart - tcstatus -} - -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -vudestroy - -if {$tprocc_use_transaction_counter eq "true"} { - puts "Stopping transaction counter..." - tcstop -} - -puts "Virtual users destroyed" -puts "TPROC-C LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/mssqls_tprocc" -set of [ open $tmpdir/mssqls_tprocc w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/mssqls_tprocc" \ No newline at end of file diff --git a/scripts/mssql/load_test_tproch.tcl b/scripts/mssql/load_test_tproch.tcl deleted file mode 100644 index 272a995..0000000 --- a/scripts/mssql/load_test_tproch.tcl +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for SQL Server connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) -set tmpdir $::env(TMPDIR) - -# TPROC-H specific variables -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_database_name $::env(TPROCH_DATABASE_NAME) -set tproch_virtual_users $::env(TPROCH_VIRTUAL_USERS) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_use_clustered_columnstore $::env(TPROCH_USE_CLUSTERED_COLUMNSTORE) -set tproch_total_querysets $::env(TPROCH_TOTAL_QUERYSETS) -set tproch_log_to_temp $::env(TPROCH_LOG_TO_TEMP) - -# Validate required environment variables -foreach var {USERNAME PASSWORD SQL_SERVER_HOST TPROCH_DRIVER TPROCH_DATABASE_NAME} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty." - exit 1 - } -} - -# Initialize HammerDB -puts "SETTING UP TPROC-H LOAD TEST" -puts "Environment variables loaded:" -puts "Database: $tproch_database_name" -puts "Virtual Users: $tproch_virtual_users" - -# Set up the database connection details for MSSQL -dbset db $tproch_driver - -# Set the benchmark to TPC-H -dbset bm TPC-H - -# Set up the database connection details for MSSQL -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-H benchmark parameters -diset tpch mssqls_tpch_dbase $tproch_database_name -diset tpch mssqls_total_querysets $tproch_total_querysets -diset tpch mssqls_scale_fact $tproch_scale_factor -diset tpch mssqls_num_tpch_threads $tproch_build_threads -if {$tproch_use_clustered_columnstore eq "true"} { - diset tpch mssqls_colstore true -} else { - diset tpch mssqls_colstore false -} - -# Test run parameters -set vuser_count $tproch_virtual_users - -# Configure test options and load scripts -vuset logtotemp $tproch_log_to_temp -loadscript - -puts "STARTING TPROC-H VIRTUAL USERS" -puts "Virtual Users: $vuser_count" -puts "Output will be logged to: $tmpdir/mssqls_tproch" - -vuset vu $vuser_count -vucreate -puts "TEST STARTED" -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -puts "Waiting for test completion..." -vucomplete -puts "Test completion confirmed" -vudestroy -puts "Virtual users destroyed" -puts "TPROC-H LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/mssqls_tproch" -set of [ open $tmpdir/mssqls_tproch w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/mssqls_tproch" \ No newline at end of file diff --git a/scripts/mssql/parse_output_tprocc.tcl b/scripts/mssql/parse_output_tprocc.tcl deleted file mode 100644 index 561bad0..0000000 --- a/scripts/mssql/parse_output_tprocc.tcl +++ /dev/null @@ -1,40 +0,0 @@ -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to get the output from the output file -proc getoutput {filename} { - set fd [open $filename r] - set output [read $fd] - close $fd - return $output -} - -# Main script execution -set tmpdir /tmp -set ::outputfile $tmpdir/mssqls_tprocc -set filename $::outputfile -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "Job ID not found in the output file." - exit 1 -} - -# Set output as JSON -jobs format JSON - - -# Write output -puts "TRANSACTION RESPONSE TIMES" -puts [job $jobid timing] - -puts "TRANSACTION COUNT" -puts [jobs $jobid tcount] - -puts "HAMMERDB RESULT" -puts [jobs $jobid result] diff --git a/scripts/mssql/parse_output_tproch.tcl b/scripts/mssql/parse_output_tproch.tcl deleted file mode 100644 index e8a91b3..0000000 --- a/scripts/mssql/parse_output_tproch.tcl +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/tclsh -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to extract total elapsed time from result data -proc extract_elapsed_time {result_data} { - # HammerDB result format: "Completed 1 query set(s) in 3812 seconds" - # This is the most reliable way to get timing data - - # Try to match "Completed X query set(s) in Y seconds" - if {[regexp {Completed\s+\d+\s+query set\(s\)\s+in\s+([0-9]+)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "elapsed X seconds" or "X seconds elapsed" - if {[regexp {elapsed\s+([0-9]+\.?[0-9]*)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "Total elapsed: X" or similar - if {[regexp {Total elapsed:\s+([0-9]+\.?[0-9]*)} $result_data match elapsed]} { - return $elapsed - } - - # If no pattern matched, return 0 - return 0 -} - -# Procedure to calculate QphH (Queries per Hour @ Scale Factor) -proc calculate_qphh {elapsed_seconds scale_factor} { - if {$elapsed_seconds <= 0} { - return 0 - } - - # TPC-H QphH = (22 queries / elapsed_seconds) * 3600 seconds/hour * scale_factor - # Standard formula: QphH@SF = (3600 / elapsed_time) * scale_factor - # For a single query set (22 queries), this represents queries per hour - set qphh [expr {(3600.0 / $elapsed_seconds) * $scale_factor}] - return [format "%.2f" $qphh] -} - -# Procedure to extract per-query timing from result data -proc extract_query_times {result_data} { - # HammerDB logs queries in format: "Vuser 1:query 14 completed in 61.676 seconds" - # Return a dict mapping query number to elapsed time - - array set query_times {} - - # Match pattern: "query X completed in Y seconds" - foreach line [split $result_data "\n"] { - if {[regexp {query\s+(\d+)\s+completed in\s+([0-9]+\.?[0-9]*)\s+seconds} $line match query_num elapsed]} { - set query_times($query_num) $elapsed - } - } - - return [array get query_times] -} - -# Main script execution -set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/mssqls_tproch -set filename $::outputfile - -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "ERROR: Job ID not found in the output file." - exit 1 -} - -# Get scale factor from environment (default to 1 if not set) -if {[info exists ::env(TPROCH_SCALE_FACTOR)]} { - set scale_factor $::env(TPROCH_SCALE_FACTOR) -} else { - set scale_factor 1 -} - -set output_filename [file normalize "${filename}_${jobid}.out"] - -# IMPORTANT: HammerDB prints job results to stdout before our script runs -# The job object itself is empty, so we need to read from the log file where stdout was captured - -# Try to read the hammerdb log file which should contain the output -set log_file "/tmp/hammerdb.log" -set result_string "" - -if {[file exists $log_file]} { - set fd [open $log_file r] - set result_string [read $fd] - close $fd -} else { - puts "WARNING: Log file not found at $log_file" -} - -# Extract elapsed time from result data and calculate QphH -set elapsed_seconds [extract_elapsed_time $result_string] -set qphh [calculate_qphh $elapsed_seconds $scale_factor] - -# Extract per-query timing data -array set query_times_array [extract_query_times $result_string] - -# For reference - also get timing data (likely empty but shown for completeness) -set timing_data [job $jobid timing] - -# Open the file for writing -set fileId [open $output_filename "w"] - -# Write to the file -puts $fileId "TPC-H QUERY EXECUTION RESULTS" -puts $fileId "=============================" -puts $fileId "" -puts $fileId "PERFORMANCE METRICS" -puts $fileId "-------------------" -puts $fileId "Total Elapsed Time: $elapsed_seconds seconds" -puts $fileId "Scale Factor: $scale_factor" -puts $fileId "QphH@$scale_factor: $qphh" -puts $fileId "" -puts $fileId "PER-QUERY TIMING (SECONDS)" -puts $fileId "-------------------------" - -# Sort queries numerically and display -set query_nums [lsort -integer [array names query_times_array]] -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts $fileId [format "Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts $fileId "No per-query timing data found in logs" -} - -puts $fileId "" -puts $fileId "QUERY TIMING RESULTS (from job object)" -puts $fileId "---------------------------------------" -puts $fileId $timing_data -puts $fileId "" -puts $fileId "HAMMERDB RESULT SUMMARY" -puts $fileId "-----------------------" -puts $fileId $result_string - -# Close the file -close $fileId - -# Print the output to the console -set output [exec cat $output_filename] -puts $output - -# Also output the QphH metric in a parseable format for aggregation -puts "" -puts "=== TPC-H PERFORMANCE SUMMARY ===" -puts "Elapsed Time: $elapsed_seconds seconds" -puts "QphH@$scale_factor: $qphh" -puts "" -puts "Per-Query Timing:" -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts [format " Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts " No per-query data available" -} -puts "=================================" \ No newline at end of file diff --git a/scripts/oracle/build_schema_tprocc.tcl b/scripts/oracle/build_schema_tprocc.tcl deleted file mode 100644 index 5ebf9a7..0000000 --- a/scripts/oracle/build_schema_tprocc.tcl +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/tclsh -# Load environment variables -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) - -# TPROC-C variables -set tprocc_user $::env(TPROCC_USER) -set tprocc_password $::env(TPROCC_PASSWORD) -set tprocc_build_virtual_users $::env(TPROCC_BUILD_VIRTUAL_USERS) -set warehouses $::env(WAREHOUSES) -set tprocc_driver_type $::env(TPROCC_DRIVER_TYPE) -set tprocc_allwarehouse $::env(TPROCC_ALLWAREHOUSE) - -# Oracle-specific variables -set oracle_tablespace $::env(ORACLE_TABLESPACE) -set oracle_temp_tablespace $::env(ORACLE_TEMP_TABLESPACE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD HOST ORACLE_PORT ORACLE_SERVICE TPROCC_USER WAREHOUSES TPROCC_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-C SCHEMA BUILD FOR ORACLE" -puts "Target connection: $oracle_connection" -puts "TPCC schema user: $tprocc_user" -puts "Warehouses: $warehouses" -puts "Build virtual users: $tprocc_build_virtual_users" - -# Set database to Oracle -dbset db oracle - -# Set benchmark to TPC-C -dbset bm TPC-C - -# Configure connection -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-C Schema Build -diset tpcc tpcc_user $tprocc_user -diset tpcc tpcc_pass $tprocc_password -diset tpcc tpcc_def_tab $oracle_tablespace -diset tpcc tpcc_ol_tab $oracle_tablespace -diset tpcc tpcc_def_temp $oracle_temp_tablespace -diset tpcc count_ware $warehouses -diset tpcc num_vu $tprocc_build_virtual_users - -# Oracle-specific features (disabled by default for compatibility) -diset tpcc partition false -diset tpcc hash_clusters false -diset tpcc tpcc_tt_compat false - -# Handle allwarehouse setting -if {$tprocc_allwarehouse eq "true"} { - diset tpcc allwarehouse true -} else { - diset tpcc allwarehouse false -} - -# Load the TPC-C script -loadscript - -# Print current configuration -puts "Current TPROC-C configuration:" -print dict - -# Build the schema -puts "Starting TPROC-C schema build..." -buildschema - -puts "TPROC-C SCHEMA BUILD COMPLETE" diff --git a/scripts/oracle/build_schema_tproch.tcl b/scripts/oracle/build_schema_tproch.tcl deleted file mode 100644 index 9f4bcfc..0000000 --- a/scripts/oracle/build_schema_tproch.tcl +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/tclsh -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) - -# TPROC-H variables -set tproch_user $::env(TPROCH_USER) -set tproch_password $::env(TPROCH_PASSWORD) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_build_virtual_users $::env(TPROCH_BUILD_VIRTUAL_USERS) - -# Oracle-specific variables -set oracle_tablespace $::env(ORACLE_TABLESPACE) -set oracle_temp_tablespace $::env(ORACLE_TEMP_TABLESPACE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD HOST ORACLE_PORT ORACLE_SERVICE TPROCH_USER TPROCH_SCALE_FACTOR TPROCH_DRIVER TPROCH_BUILD_THREADS TPROCH_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-H SCHEMA BUILD FOR ORACLE" -puts "Target connection: $oracle_connection" -puts "TPCH schema user: $tproch_user" -puts "Scale factor: $tproch_scale_factor" -puts "Build threads: $tproch_build_threads" -puts "Build virtual users: $tproch_build_virtual_users (for logging; build uses threads)" - -# Set database to Oracle -dbset db $tproch_driver - -# Set benchmark to TPC-H -dbset bm TPC-H - -# Configure connection -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-H Schema Build -diset tpch tpch_user $tproch_user -diset tpch tpch_pass $tproch_password -diset tpch tpch_def_tab $oracle_tablespace -diset tpch tpch_def_temp $oracle_temp_tablespace -diset tpch scale_fact $tproch_scale_factor -diset tpch num_tpch_threads $tproch_build_threads - -# Oracle-specific features (disabled by default for compatibility) -diset tpch tpch_tt_compat false - -# Load the TPC-H script -loadscript - -# Print current configuration -puts "Current TPROC-H configuration:" -print dict - -# Build the schema -puts "Starting TPROC-H schema build..." -buildschema - -puts "TPROC-H SCHEMA BUILD COMPLETE" diff --git a/scripts/oracle/load_test_tprocc.tcl b/scripts/oracle/load_test_tprocc.tcl deleted file mode 100644 index 828702f..0000000 --- a/scripts/oracle/load_test_tprocc.tcl +++ /dev/null @@ -1,115 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for Oracle connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) - -# TPROC-C specific variables -set virtual_users $::env(VIRTUAL_USERS) -set tprocc_user $::env(TPROCC_USER) -set tprocc_password $::env(TPROCC_PASSWORD) -set tprocc_driver $::env(TPROCC_DRIVER) -set rampup $::env(RAMPUP) -set duration $::env(DURATION) -set total_iterations $::env(TOTAL_ITERATIONS) -set tmpdir $::env(TMPDIR) -set warehouses $::env(WAREHOUSES) -set tprocc_log_to_temp $::env(TPROCC_LOG_TO_TEMP) -set tprocc_use_transaction_counter $::env(TPROCC_USE_TRANSACTION_COUNTER) -set tprocc_checkpoint $::env(TPROCC_CHECKPOINT) -set tprocc_timeprofile $::env(TPROCC_TIMEPROFILE) - -# Check if all required environment variables are set -if {![info exists username] || ![info exists password] || ![info exists host] || ![info exists oracle_port] || ![info exists oracle_service]} { - puts "Error: Environment variables USERNAME, PASSWORD, HOST, ORACLE_PORT, and ORACLE_SERVICE must be set." - exit 1 -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-C LOAD TEST FOR ORACLE" -puts "Environment variables loaded:" -puts " Connection: $oracle_connection" -puts " TPCC User: $tprocc_user" -puts " Virtual Users: $virtual_users" -puts " Duration: $duration minutes" -puts " Rampup: $rampup minutes" -puts " Total Iterations: $total_iterations" - -# Set up the database connection details for Oracle -dbset db $tprocc_driver - -# Set the benchmark to TPC-C -dbset bm TPC-C - -# Set up the database connection details for Oracle -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-C benchmark parameters -diset tpcc tpcc_user $tprocc_user -diset tpcc tpcc_pass $tprocc_password -diset tpcc count_ware $warehouses - -# Oracle driver settings -diset tpcc ora_driver timed -diset tpcc total_iterations $total_iterations -diset tpcc rampup $rampup -diset tpcc duration $duration -diset tpcc allwarehouse true - -# Set checkpoint and timeprofile if they are true -if {$tprocc_checkpoint eq "true"} { - diset tpcc checkpoint true -} -if {$tprocc_timeprofile eq "true"} { - diset tpcc ora_timeprofile true -} - -# Disable keying and thinking time for consistent results -diset tpcc keyandthink false - -# Configure test options and load scripts -vuset logtotemp $tprocc_log_to_temp -loadscript - -puts "STARTING TPROC-C VIRTUAL USERS" -puts "Virtual Users: $virtual_users" -puts "Duration: $duration minutes" -puts "Output will be logged to: $tmpdir/oracle_tprocc" - -vuset vu $virtual_users -vucreate -puts "TEST STARTED" - -# Handle transaction counter based on environment variable -if {$tprocc_use_transaction_counter eq "true"} { - puts "Starting transaction counter..." - tcstart - tcstatus -} - -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -vudestroy - -if {$tprocc_use_transaction_counter eq "true"} { - puts "Stopping transaction counter..." - tcstop -} - -puts "Virtual users destroyed" -puts "TPROC-C LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/oracle_tprocc" -set of [ open $tmpdir/oracle_tprocc w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/oracle_tprocc" diff --git a/scripts/oracle/load_test_tproch.tcl b/scripts/oracle/load_test_tproch.tcl deleted file mode 100644 index 416c7ba..0000000 --- a/scripts/oracle/load_test_tproch.tcl +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for Oracle connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) -set tmpdir $::env(TMPDIR) - -# TPROC-H specific variables -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_user $::env(TPROCH_USER) -set tproch_password $::env(TPROCH_PASSWORD) -set tproch_virtual_users $::env(TPROCH_VIRTUAL_USERS) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_total_querysets $::env(TPROCH_TOTAL_QUERYSETS) -set tproch_log_to_temp $::env(TPROCH_LOG_TO_TEMP) -set tproch_degree_of_parallel $::env(TPROCH_DEGREE_OF_PARALLEL) - -# Validate required environment variables -foreach var {USERNAME PASSWORD HOST ORACLE_PORT ORACLE_SERVICE TPROCH_DRIVER TPROCH_USER} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty." - exit 1 - } -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-H LOAD TEST FOR ORACLE" -puts "Environment variables loaded:" -puts "Connection: $oracle_connection" -puts "TPCH User: $tproch_user" -puts "Virtual Users: $tproch_virtual_users" -puts "Degree of Parallel: $tproch_degree_of_parallel" - -# Set up the database connection details for Oracle -dbset db $tproch_driver - -# Set the benchmark to TPC-H -dbset bm TPC-H - -# Set up the database connection details for Oracle -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-H benchmark parameters -diset tpch tpch_user $tproch_user -diset tpch tpch_pass $tproch_password -diset tpch total_querysets $tproch_total_querysets -diset tpch scale_fact $tproch_scale_factor -diset tpch num_tpch_threads $tproch_build_threads - -# Oracle-specific settings -diset tpch degree_of_parallel $tproch_degree_of_parallel -diset tpch verbose false -diset tpch refresh_on false -diset tpch raise_query_error false - -# Test run parameters -set vuser_count $tproch_virtual_users - -# Configure test options and load scripts -vuset logtotemp $tproch_log_to_temp -loadscript - -puts "STARTING TPROC-H VIRTUAL USERS" -puts "Virtual Users: $vuser_count" -puts "Output will be logged to: $tmpdir/oracle_tproch" - -vuset vu $vuser_count -vucreate -puts "TEST STARTED" -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -puts "Waiting for test completion..." -vucomplete -puts "Test completion confirmed" -vudestroy -puts "Virtual users destroyed" -puts "TPROC-H LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/oracle_tproch" -set of [ open $tmpdir/oracle_tproch w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/oracle_tproch" diff --git a/scripts/oracle/parse_output_tprocc.tcl b/scripts/oracle/parse_output_tprocc.tcl deleted file mode 100644 index 28835a2..0000000 --- a/scripts/oracle/parse_output_tprocc.tcl +++ /dev/null @@ -1,40 +0,0 @@ -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to get the output from the output file -proc getoutput {filename} { - set fd [open $filename r] - set output [read $fd] - close $fd - return $output -} - -# Main script execution -set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/oracle_tprocc -set filename $::outputfile -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "Job ID not found in the output file." - exit 1 -} - -# Set output as JSON -jobs format JSON - - -# Write output -puts "TRANSACTION RESPONSE TIMES" -puts [job $jobid timing] - -puts "TRANSACTION COUNT" -puts [jobs $jobid tcount] - -puts "HAMMERDB RESULT" -puts [jobs $jobid result] diff --git a/scripts/oracle/parse_output_tproch.tcl b/scripts/oracle/parse_output_tproch.tcl deleted file mode 100644 index c48e1e3..0000000 --- a/scripts/oracle/parse_output_tproch.tcl +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/tclsh -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to extract total elapsed time from result data -proc extract_elapsed_time {result_data} { - # HammerDB result format: "Completed 1 query set(s) in 3812 seconds" - # This is the most reliable way to get timing data - - # Try to match "Completed X query set(s) in Y seconds" - if {[regexp {Completed\s+\d+\s+query set\(s\)\s+in\s+([0-9]+)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "elapsed X seconds" or "X seconds elapsed" - if {[regexp {elapsed\s+([0-9]+\.?[0-9]*)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "Total elapsed: X" or similar - if {[regexp {Total elapsed:\s+([0-9]+\.?[0-9]*)} $result_data match elapsed]} { - return $elapsed - } - - # If no pattern matched, return 0 - return 0 -} - -# Procedure to calculate QphH (Queries per Hour @ Scale Factor) -proc calculate_qphh {elapsed_seconds scale_factor} { - if {$elapsed_seconds <= 0} { - return 0 - } - - # TPC-H QphH = (22 queries / elapsed_seconds) * 3600 seconds/hour * scale_factor - # Standard formula: QphH@SF = (3600 / elapsed_time) * scale_factor - # For a single query set (22 queries), this represents queries per hour - set qphh [expr {(3600.0 / $elapsed_seconds) * $scale_factor}] - return [format "%.2f" $qphh] -} - -# Procedure to extract per-query timing from result data -proc extract_query_times {result_data} { - # HammerDB logs queries in format: "Vuser 1:query 14 completed in 61.676 seconds" - # Return a dict mapping query number to elapsed time - - array set query_times {} - - # Match pattern: "query X completed in Y seconds" - foreach line [split $result_data "\n"] { - if {[regexp {query\s+(\d+)\s+completed in\s+([0-9]+\.?[0-9]*)\s+seconds} $line match query_num elapsed]} { - set query_times($query_num) $elapsed - } - } - - return [array get query_times] -} - -# Main script execution -set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/oracle_tproch -set filename $::outputfile - -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "ERROR: Job ID not found in the output file." - exit 1 -} - -# Get scale factor from environment (default to 1 if not set) -if {[info exists ::env(TPROCH_SCALE_FACTOR)]} { - set scale_factor $::env(TPROCH_SCALE_FACTOR) -} else { - set scale_factor 1 -} - -set output_filename [file normalize "${filename}_${jobid}.out"] - -# IMPORTANT: HammerDB prints job results to stdout before our script runs -# The job object itself is empty, so we need to read from the log file where stdout was captured - -# Try to read the hammerdb log file which should contain the output -set log_file "/tmp/hammerdb.log" -set result_string "" - -if {[file exists $log_file]} { - set fd [open $log_file r] - set result_string [read $fd] - close $fd -} else { - puts "WARNING: Log file not found at $log_file" -} - -# Extract elapsed time from result data and calculate QphH -set elapsed_seconds [extract_elapsed_time $result_string] -set qphh [calculate_qphh $elapsed_seconds $scale_factor] - -# Extract per-query timing data -array set query_times_array [extract_query_times $result_string] - -# For reference - also get timing data (likely empty but shown for completeness) -set timing_data [job $jobid timing] - -# Open the file for writing -set fileId [open $output_filename "w"] - -# Write to the file -puts $fileId "TPC-H QUERY EXECUTION RESULTS FOR ORACLE" -puts $fileId "==========================================" -puts $fileId "" -puts $fileId "PERFORMANCE METRICS" -puts $fileId "-------------------" -puts $fileId "Total Elapsed Time: $elapsed_seconds seconds" -puts $fileId "Scale Factor: $scale_factor" -puts $fileId "QphH@$scale_factor: $qphh" -puts $fileId "" -puts $fileId "PER-QUERY TIMING (SECONDS)" -puts $fileId "-------------------------" - -# Sort queries numerically and display -set query_nums [lsort -integer [array names query_times_array]] -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts $fileId [format "Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts $fileId "No per-query timing data found in logs" -} - -puts $fileId "" -puts $fileId "QUERY TIMING RESULTS (from job object)" -puts $fileId "---------------------------------------" -puts $fileId $timing_data -puts $fileId "" -puts $fileId "HAMMERDB RESULT SUMMARY" -puts $fileId "-----------------------" -puts $fileId $result_string - -# Close the file -close $fileId - -# Print the output to the console -set output [exec cat $output_filename] -puts $output - -# Also output the QphH metric in a parseable format for aggregation -puts "" -puts "=== TPC-H PERFORMANCE SUMMARY ===" -puts "Elapsed Time: $elapsed_seconds seconds" -puts "QphH@$scale_factor: $qphh" -puts "" -puts "Per-Query Timing:" -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts [format " Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts " No per-query data available" -} -puts "=================================" diff --git a/scripts/oracle/rman_backup.sh b/src/hammerdb_scale/chart/scripts/oracle/rman_backup.sh similarity index 100% rename from scripts/oracle/rman_backup.sh rename to src/hammerdb_scale/chart/scripts/oracle/rman_backup.sh diff --git a/scripts/oracle/rman_restore.sh b/src/hammerdb_scale/chart/scripts/oracle/rman_restore.sh similarity index 100% rename from scripts/oracle/rman_restore.sh rename to src/hammerdb_scale/chart/scripts/oracle/rman_restore.sh diff --git a/scripts/oracle/setup_redo.sql b/src/hammerdb_scale/chart/scripts/oracle/setup_redo.sql similarity index 100% rename from scripts/oracle/setup_redo.sql rename to src/hammerdb_scale/chart/scripts/oracle/setup_redo.sql diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index a73bf3c..5d353cb 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -43,6 +43,63 @@ def main( _state["verbose"] = verbose +def _tool_version(tool_path: str, tool: str) -> str | None: + """Return a tool's version string, or None if it could not be determined. + + kubectl removed --short in 1.28, so asking for it fails outright on any + current install. Use the JSON output it has supported for years and fall + back to plain `version` for older builds and for helm. + """ + attempts: list[list[str]] = [] + if tool == "kubectl": + attempts.append([tool_path, "version", "--client", "-o", "json"]) + if tool in ("podman", "docker"): + # `podman version` prints a multi-line client/server block. + attempts.append([tool_path, "version", "--format", "{{.Client.Version}}"]) + attempts.append([tool_path, "--version"]) + attempts.append([tool_path, "version", "--short"]) + attempts.append([tool_path, "version"]) + + for args in attempts: + try: + result = subprocess.run( + args, capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.SubprocessError): + continue + if result.returncode != 0 or not result.stdout.strip(): + continue + + text = result.stdout.strip() + if "-o" in args: + try: + import json as json_mod + + data = json_mod.loads(text) + git_version = data.get("clientVersion", {}).get("gitVersion") + if git_version: + return git_version + except (ValueError, AttributeError): + continue + else: + return text.splitlines()[0].strip() + return None + + +def _kubectl_context(kubectl_path: str) -> str: + """Current kubeconfig context, or 'unknown'.""" + try: + result = subprocess.run( + [kubectl_path, "config", "current-context"], + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout.strip() if result.returncode == 0 else "unknown" + except (OSError, subprocess.SubprocessError): + return "unknown" + + @app.command() def version() -> None: """Show CLI and tooling versions.""" @@ -52,15 +109,10 @@ def version() -> None: # helm helm_path = shutil.which("helm") if helm_path: - try: - result = subprocess.run( - [helm_path, "version", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - console.print(f" helm {result.stdout.strip()}") - except Exception: + helm_version = _tool_version(helm_path, "helm") + if helm_version: + console.print(f" helm {helm_version}") + else: console.print( " helm [yellow]found but version check failed[/yellow]" ) @@ -72,23 +124,11 @@ def version() -> None: # kubectl kubectl_path = shutil.which("kubectl") if kubectl_path: - try: - result = subprocess.run( - [kubectl_path, "version", "--client", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - version_str = result.stdout.strip() - ctx_result = subprocess.run( - [kubectl_path, "config", "current-context"], - capture_output=True, - text=True, - timeout=10, - ) - ctx = ctx_result.stdout.strip() if ctx_result.returncode == 0 else "unknown" - console.print(f" kubectl {version_str} (context: {ctx})") - except Exception: + kubectl_version = _tool_version(kubectl_path, "kubectl") + ctx = _kubectl_context(kubectl_path) + if kubectl_version: + console.print(f" kubectl {kubectl_version} (context: {ctx})") + else: console.print( " kubectl [yellow]found but version check failed[/yellow]" ) @@ -98,6 +138,14 @@ def version() -> None: "(install from https://kubernetes.io/docs/tasks/tools/)" ) + # container runtime + for runtime in ("podman", "docker"): + runtime_path = shutil.which(runtime) + if runtime_path: + runtime_version = _tool_version(runtime_path, runtime) + if runtime_version: + console.print(f" {runtime:<15} {runtime_version}") + def _build_config_yaml( *, @@ -485,52 +533,52 @@ def validate( for w in warnings: print_warning(w) - # Layer 4: Prerequisites + # Layer 4: Prerequisites for the configured backend console.print("\nChecking prerequisites...") - helm_path = shutil.which("helm") - if helm_path: - try: - result = subprocess.run( - [helm_path, "version", "--short"], - capture_output=True, - text=True, - timeout=10, + uses_kubernetes = config.backend.value == "kubernetes" + + if uses_kubernetes: + helm_path = shutil.which("helm") + if helm_path: + helm_version = _tool_version(helm_path, "helm") + print_success(f"helm found ({helm_version or 'version unknown'})") + else: + print_error("helm not found (install from https://helm.sh)") + errors_found = True + + kubectl_path = shutil.which("kubectl") + if kubectl_path: + kubectl_version = _tool_version(kubectl_path, "kubectl") + print_success(f"kubectl found ({kubectl_version or 'version unknown'})") + print_success(f"Current context: {_kubectl_context(kubectl_path)}") + else: + print_error( + "kubectl not found " + "(install from https://kubernetes.io/docs/tasks/tools/)" ) - print_success(f"helm found ({result.stdout.strip()})") - except Exception: - print_warning("helm found but version check failed") + errors_found = True else: - print_error("helm not found (install from https://helm.sh)") - errors_found = True + # Container backend: helm and kubectl are irrelevant. + from hammerdb_scale.runtime.container import ( + ContainerRuntimeError, + detect_runtime, + ) - kubectl_path = shutil.which("kubectl") - if kubectl_path: try: - result = subprocess.run( - [kubectl_path, "version", "--client", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - ctx_result = subprocess.run( - [kubectl_path, "config", "current-context"], - capture_output=True, - text=True, - timeout=10, + requested = config.container.runtime.value + runtime = detect_runtime(None if requested == "auto" else requested) + runtime_path = shutil.which(runtime) + runtime_version = ( + _tool_version(runtime_path, runtime) if runtime_path else None ) - ctx = ctx_result.stdout.strip() if ctx_result.returncode == 0 else "unknown" - print_success(f"kubectl found ({result.stdout.strip()})") - print_success(f"Current context: {ctx}") - except Exception: - print_warning("kubectl found but version check failed") - else: - print_error( - "kubectl not found (install from https://kubernetes.io/docs/tasks/tools/)" - ) - errors_found = True + print_success(f"{runtime} found ({runtime_version or 'version unknown'})") + except ContainerRuntimeError as e: + print_error(str(e)) + errors_found = True # Layer 5: K8s access - if kubectl_path: + if uses_kubernetes and shutil.which("kubectl"): + kubectl_path = shutil.which("kubectl") console.print("\nChecking Kubernetes access...") ns = config.kubernetes.namespace try: @@ -547,36 +595,28 @@ def validate( f"Namespace '{ns}' does not exist. " f"It will be created during deployment." ) - except Exception: + except (OSError, subprocess.SubprocessError): print_warning("Could not check namespace") - try: - result = subprocess.run( - [kubectl_path, "auth", "can-i", "create", "jobs", "-n", ns], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0 and "yes" in result.stdout.lower(): - print_success("Can create Jobs in namespace") - else: - print_warning("Cannot verify Job creation permissions") - except Exception: - print_warning("Could not check permissions") + # Secrets are only needed when credentials go through one. + resources = ["jobs", "configmaps"] + if config.kubernetes.use_secrets: + resources.append("secrets") - try: - result = subprocess.run( - [kubectl_path, "auth", "can-i", "create", "configmaps", "-n", ns], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0 and "yes" in result.stdout.lower(): - print_success("Can create ConfigMaps in namespace") - else: - print_warning("Cannot verify ConfigMap creation permissions") - except Exception: - print_warning("Could not check permissions") + for resource in resources: + try: + result = subprocess.run( + [kubectl_path, "auth", "can-i", "create", resource, "-n", ns], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and "yes" in result.stdout.lower(): + print_success(f"Can create {resource} in namespace") + else: + print_warning(f"Cannot verify {resource} creation permissions") + except (OSError, subprocess.SubprocessError): + print_warning(f"Could not check {resource} permissions") # Layer 6: Database connectivity if not skip_connectivity: @@ -1150,12 +1190,24 @@ def results( raise typer.Exit(1) except typer.Exit: raise - except Exception: - # Fall back to local results + except Exception as e: + # The backend could not be queried (unreachable cluster, expired + # credentials, container runtime down). Stored results may still exist, + # so try those, but never let the real cause vanish: reporting + # "No results found" for an auth failure sends the user hunting in + # entirely the wrong place. summary = load_results(test_id, results_dir) if not summary: - console.print("[red]No results found.[/red]") + print_error(f"Could not read results from the backend: {e}") + console.print( + f"[red]No stored results for '{test_id}' either.[/red]\n" + f"If the backend is reachable, check that the test ID is correct." + ) raise typer.Exit(1) + print_warning( + f"Could not reach the backend ({type(e).__name__}); " + f"showing previously stored results." + ) if json_output: console.print(json_mod.dumps(summary, indent=2)) diff --git a/src/hammerdb_scale/constants.py b/src/hammerdb_scale/constants.py index e4297cd..b7a2110 100644 --- a/src/hammerdb_scale/constants.py +++ b/src/hammerdb_scale/constants.py @@ -2,7 +2,39 @@ from pathlib import Path -VERSION = "2.0.2" +def _package_version() -> str: + """Resolve the CLI version, with pyproject.toml as the source of truth. + + Duplicating the version in this module drifted: it said 2.0.2 while + pyproject said 2.0.3, so `hammerdb-scale version` reported the wrong + number. Installed metadata alone is not enough either, because an editable + install keeps whatever version was current when `pip install -e` last ran + (observed reporting 2.0.0 against a 2.0.3 source tree). + + So: prefer pyproject.toml when running from a source checkout, and fall + back to installed metadata for a normal wheel install. + """ + pyproject = Path(__file__).resolve().parent.parent.parent / "pyproject.toml" + if pyproject.is_file(): + try: + import tomllib + + with open(pyproject, "rb") as fh: + declared = tomllib.load(fh).get("project", {}).get("version") + if declared: + return str(declared) + except (OSError, ValueError, ImportError): + pass + + from importlib.metadata import PackageNotFoundError, version + + try: + return version("hammerdb-scale") + except PackageNotFoundError: + return "0.0.0+unknown" + + +VERSION = _package_version() DEFAULT_CONFIG_FILENAMES = ["hammerdb-scale.yaml", "hammerdb-scale.yml"] CONFIG_ENV_VAR = "HAMMERDB_SCALE_CONFIG" diff --git a/src/hammerdb_scale/runtime/resolve.py b/src/hammerdb_scale/runtime/resolve.py index 9e01af6..2868826 100644 --- a/src/hammerdb_scale/runtime/resolve.py +++ b/src/hammerdb_scale/runtime/resolve.py @@ -12,7 +12,7 @@ from pathlib import Path from hammerdb_scale.constants import POLL_INTERVAL, NoResultsError -from hammerdb_scale.output import console, print_error, print_success +from hammerdb_scale.output import console, print_error, print_success, print_warning from hammerdb_scale.runtime.base import STATUS_COMPLETED, STATUS_FAILED @@ -50,7 +50,12 @@ def resolve_test_id( def _find_backend_test_id(backend, deployment_name: str | None) -> str | None: - """Ask the backend for its most recent test ID.""" + """Ask the backend for its most recent test ID. + + A backend that cannot be queried is not the same as a backend with no + runs, so the reason is surfaced as a warning rather than swallowed. The + caller still falls back to locally stored results. + """ finder = getattr(backend, "find_test_ids", None) if finder is not None: try: @@ -58,8 +63,8 @@ def _find_backend_test_id(backend, deployment_name: str | None) -> str | None: if deployment_name and not test_id.startswith(deployment_name + "-"): continue return test_id - except Exception: - return None + except Exception as e: + print_warning(f"Could not list runs from {backend.name}: {e}") return None # Kubernetes backend: reuse the existing Helm-release-based lookup. @@ -67,7 +72,8 @@ def _find_backend_test_id(backend, deployment_name: str | None) -> str | None: from hammerdb_scale.k8s.jobs import _find_most_recent_k8s_test_id return _find_most_recent_k8s_test_id(backend.namespace, deployment_name) - except Exception: + except Exception as e: + print_warning(f"Could not list runs from Kubernetes: {e}") return None diff --git a/templates b/templates new file mode 120000 index 0000000..7e95878 --- /dev/null +++ b/templates @@ -0,0 +1 @@ +src/hammerdb_scale/chart/templates \ No newline at end of file diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl deleted file mode 100644 index 4fd29c3..0000000 --- a/templates/_helpers.tpl +++ /dev/null @@ -1,158 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "hammerdb-scale-test.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -*/}} -{{- define "hammerdb-scale-test.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "hammerdb-scale-test.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "hammerdb-scale-test.labels" -}} -helm.sh/chart: {{ include "hammerdb-scale-test.chart" . }} -{{ include "hammerdb-scale-test.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "hammerdb-scale-test.selectorLabels" -}} -app.kubernetes.io/name: {{ include "hammerdb-scale-test.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Get database driver for a given type -*/}} -{{- define "hammerdb-scale-test.dbDriver" -}} -{{- $dbType := . -}} -{{- if eq $dbType "mssql" -}} -mssqls -{{- else if eq $dbType "postgres" -}} -pg -{{- else if eq $dbType "oracle" -}} -oracle -{{- else if eq $dbType "mysql" -}} -mysql -{{- else -}} -{{- fail (printf "Unsupported database type: %s" $dbType) -}} -{{- end -}} -{{- end }} - -{{/* -Get merged TPC-C configuration for a target -Merges global hammerdb.tprocc defaults with target-specific overrides -Usage: include "hammerdb-scale-test.tprocc-config" (dict "root" $ "target" .) -*/}} -{{- define "hammerdb-scale-test.tprocc-config" -}} -{{- $global := .root.Values.hammerdb.tprocc | default dict -}} -{{- $target := .target.tprocc | default dict -}} -{{- $merged := merge (deepCopy $target) $global -}} -{{- toYaml $merged -}} -{{- end }} - -{{/* -Get merged TPC-H configuration for a target -Usage: include "hammerdb-scale-test.tproch-config" (dict "root" $ "target" .) -*/}} -{{- define "hammerdb-scale-test.tproch-config" -}} -{{- $global := .root.Values.hammerdb.tproch | default dict -}} -{{- $target := .target.tproch | default dict -}} -{{- $merged := merge (deepCopy $target) $global -}} -{{- toYaml $merged -}} -{{- end }} - -{{/* -Get merged connection configuration for a target -Usage: include "hammerdb-scale-test.connection-config" (dict "root" $ "target" .) -*/}} -{{- define "hammerdb-scale-test.connection-config" -}} -{{- $global := .root.Values.hammerdb.connection | default dict -}} -{{- $target := .target.connection | default dict -}} -{{- $merged := merge (deepCopy $target) $global -}} -{{- toYaml $merged -}} -{{- end }} - -{{/* -Get merged Oracle configuration for a target -Merges databases.oracle defaults with target-specific Oracle overrides -Usage: include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" .) -Returns: service, tablespace, tempTablespace, tproccUser, tprochUser, degreeOfParallel -*/}} -{{- define "hammerdb-scale-test.oracle-config" -}} -{{- $oracleDefaults := .root.Values.databases.oracle | default dict -}} -{{- $target := .target -}} - -{{/* Service name or SID */}} -service: {{ $target.oracleService | default $oracleDefaults.service | default "ORCL" | quote }} -{{- if $target.oracleSid }} -sid: {{ $target.oracleSid | quote }} -{{- end }} - -{{/* Tablespaces */}} -tablespace: {{ $target.oracleTablespace | default $oracleDefaults.tablespace | default "USERS" | quote }} -tempTablespace: {{ $target.oracleTempTablespace | default $oracleDefaults.tempTablespace | default "TEMP" | quote }} -port: {{ $target.oraclePort | default $oracleDefaults.port | default 1521 }} - -{{/* TPC-C user and password */}} -{{- if $target.tprocc }} -tproccUser: {{ $target.tprocc.user | default (($oracleDefaults.tprocc | default dict).user) | default "tpcc" | quote }} -tproccPassword: {{ $target.tprocc.password | default (($oracleDefaults.tprocc | default dict).password) | default $target.password | quote }} -{{- else }} -tproccUser: {{ (($oracleDefaults.tprocc | default dict).user) | default "tpcc" | quote }} -tproccPassword: {{ (($oracleDefaults.tprocc | default dict).password) | default $target.password | quote }} -{{- end }} - -{{/* TPC-H user, password, and parallelism */}} -{{- if $target.tproch }} -tprochUser: {{ $target.tproch.user | default (($oracleDefaults.tproch | default dict).user) | default "tpch" | quote }} -tprochPassword: {{ $target.tproch.password | default (($oracleDefaults.tproch | default dict).password) | default $target.password | quote }} -degreeOfParallel: {{ $target.tproch.degreeOfParallel | default (($oracleDefaults.tproch | default dict).degreeOfParallel) | default 8 }} -{{- else }} -tprochUser: {{ (($oracleDefaults.tproch | default dict).user) | default "tpch" | quote }} -tprochPassword: {{ (($oracleDefaults.tproch | default dict).password) | default $target.password | quote }} -degreeOfParallel: {{ (($oracleDefaults.tproch | default dict).degreeOfParallel) | default 8 }} -{{- end }} -{{- end }} - -{{/* -Get list of database types used in targets -Returns a JSON object with a "types" array containing unique database types -Usage: include "hammerdb-scale-test.usedDatabaseTypes" . | fromJson -*/}} -{{- define "hammerdb-scale-test.usedDatabaseTypes" -}} -{{- $types := list -}} -{{- range .Values.targets -}} - {{- if not (has .type $types) -}} - {{- $types = append $types .type -}} - {{- end -}} -{{- end -}} -{{- dict "types" $types | toJson -}} -{{- end }} diff --git a/templates/configmap-mssql.yaml b/templates/configmap-mssql.yaml deleted file mode 100644 index bc02f2d..0000000 --- a/templates/configmap-mssql.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- $hasMssql := false -}} -{{- range .Values.targets -}} - {{- if eq .type "mssql" -}} - {{- $hasMssql = true -}} - {{- end -}} -{{- end -}} -{{- if $hasMssql }} -# ConfigMap for SQL Server (MSSQL) Scripts -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "hammerdb-scale-test.fullname" . }}-scripts-mssql - labels: - {{- include "hammerdb-scale-test.labels" . | nindent 4 }} - hammerdb.io/database-type: mssql -data: - # HammerDB TCL scripts - {{- range $path, $_ := .Files.Glob "scripts/mssql/*.tcl" }} - {{ base $path }}: |- -{{ $.Files.Get $path | indent 4 }} - {{- end }} - - # Pure Storage metrics collector - {{- if .Values.pureStorage.enabled }} - collect_pure_metrics.py: |- -{{ .Files.Get "scripts/collect_pure_metrics.py" | indent 4 }} - {{- end }} -{{- end }} diff --git a/templates/configmap-oracle.yaml b/templates/configmap-oracle.yaml deleted file mode 100644 index 7bc5ccd..0000000 --- a/templates/configmap-oracle.yaml +++ /dev/null @@ -1,27 +0,0 @@ -{{- $hasOracle := false -}} -{{- range .Values.targets -}} - {{- if eq .type "oracle" -}} - {{- $hasOracle = true -}} - {{- end -}} -{{- end -}} -{{- if $hasOracle }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "hammerdb-scale-test.fullname" . }}-scripts-oracle - labels: - {{- include "hammerdb-scale-test.labels" . | nindent 4 }} - hammerdb.io/database-type: oracle -data: - # HammerDB TCL scripts - {{- range $path, $_ := .Files.Glob "scripts/oracle/*.tcl" }} - {{ base $path }}: |- -{{ $.Files.Get $path | indent 4 }} - {{- end }} - - # Pure Storage metrics collector - {{- if .Values.pureStorage.enabled }} - collect_pure_metrics.py: |- -{{ .Files.Get "scripts/collect_pure_metrics.py" | indent 4 }} - {{- end }} -{{- end }} diff --git a/templates/job-hammerdb-worker.yaml b/templates/job-hammerdb-worker.yaml deleted file mode 100644 index f57c6af..0000000 --- a/templates/job-hammerdb-worker.yaml +++ /dev/null @@ -1,301 +0,0 @@ -{{- if not (has .Values.testRun.phase (list "build" "load")) -}} - {{- fail "testRun.phase must be 'build' or 'load'" -}} -{{- end -}} -{{- if not (has .Values.testRun.benchmark (list "tprocc" "tproch")) -}} - {{- fail "testRun.benchmark must be 'tprocc' or 'tproch'" -}} -{{- end -}} -{{- range $index, $target := .Values.targets }} -{{- $tproccConfig := include "hammerdb-scale-test.tprocc-config" (dict "root" $ "target" $target) | fromYaml }} -{{- $tprochConfig := include "hammerdb-scale-test.tproch-config" (dict "root" $ "target" $target) | fromYaml }} -{{- $connConfig := include "hammerdb-scale-test.connection-config" (dict "root" $ "target" $target) | fromYaml }} -{{- $oracleConfig := include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" $target) | fromYaml }} ---- -apiVersion: batch/v1 -kind: Job -metadata: - {{- if and $.Values.naming $.Values.naming.useV2Naming }} - name: "{{ $.Values.naming.prefix }}-{{ $.Values.testRun.phase }}-{{ printf "%02d" $index }}-{{ $.Values.naming.runHash }}" - {{- else }} - name: {{ printf "%s-%s-%s-%s" (include "hammerdb-scale-test.fullname" $) $.Values.testRun.phase $target.name $.Values.testRun.id | trunc 63 | trimSuffix "-" }} - {{- end }} - labels: - {{- include "hammerdb-scale-test.labels" $ | nindent 4 }} - app.kubernetes.io/component: worker - hammerdb.io/test-run: {{ $.Values.testRun.id | quote }} - hammerdb.io/phase: {{ $.Values.testRun.phase | quote }} - hammerdb.io/target: {{ $target.name | quote }} - hammerdb.io/target-name: {{ $target.name | quote }} - hammerdb.io/benchmark: {{ $.Values.testRun.benchmark | quote }} - hammerdb.io/database-type: {{ $target.type | quote }} - {{- if $.Values.extraLabels }} - {{- range $key, $val := $.Values.extraLabels }} - {{ $key }}: {{ $val | quote }} - {{- end }} - {{- end }} - annotations: - hammerdb.io/target-host: {{ $target.host | quote }} - {{- if $.Values.naming }} - hammerdb.io/target-index: {{ $index | quote }} - {{- end }} -spec: - backoffLimit: 1 - ttlSecondsAfterFinished: {{ $.Values.kubernetes.job_ttl | default 86400 }} - template: - metadata: - labels: - {{- include "hammerdb-scale-test.selectorLabels" $ | nindent 8 }} - app.kubernetes.io/component: worker - hammerdb.io/test-run: {{ $.Values.testRun.id | quote }} - hammerdb.io/phase: {{ $.Values.testRun.phase | quote }} - hammerdb.io/target: {{ $target.name | quote }} - spec: - hostNetwork: {{ $.Values.global.hostNetwork | default false }} - restartPolicy: Never - {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} - # Satisfies the "restricted" Pod Security Standard so the chart is - # portable to plain Kubernetes clusters that enforce it. runAsUser is - # deliberately NOT set: OpenShift's SCC assigns an arbitrary UID from the - # namespace range, and hardcoding one conflicts with that. - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - {{- end }} - containers: - - name: hammerdb - image: "{{ $.Values.global.image.repository }}:{{ $.Values.global.image.tag }}" - imagePullPolicy: {{ $.Values.global.image.pullPolicy }} - {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: ["ALL"] - {{- end }} - env: - # Test identification - - name: TEST_RUN_ID - value: {{ $.Values.testRun.id | quote }} - - name: TARGET_NAME - value: {{ $target.name | quote }} - - name: TARGET_INDEX - value: {{ $index | quote }} - - name: RUN_MODE - value: {{ $.Values.testRun.phase | quote }} - - name: BENCHMARK - value: {{ $.Values.testRun.benchmark | quote }} - - name: DATABASE_TYPE - value: {{ $target.type | quote }} - - # Database connection. Credentials come from a Secret when enabled so - # they are not readable via `kubectl describe job`. - {{- $useSecrets := ne (($.Values.kubernetes).useSecrets | toString) "false" }} - {{- $secretName := printf "%s-credentials" (include "hammerdb-scale-test.fullname" $) }} - {{- if $useSecrets }} - - name: USERNAME - valueFrom: - secretKeyRef: - name: {{ $secretName }} - key: target-{{ $index }}-username - - name: PASSWORD - valueFrom: - secretKeyRef: - name: {{ $secretName }} - key: target-{{ $index }}-password - {{- else }} - - name: USERNAME - value: {{ $target.username | quote }} - - name: PASSWORD - value: {{ $target.password | quote }} - {{- end }} - - name: HOST - value: {{ $target.host | quote }} - - # Global settings - - name: USE_BCP - value: {{ $tproccConfig.use_bcp | quote }} - - name: TMP - value: /tmp - - name: TMPDIR - value: /tmp - - {{- if eq $target.type "mssql" }} - # SQL Server specific connection config - - name: SQL_SERVER_HOST - value: {{ $target.host | quote }} - - name: MSSQLS_TCP - value: {{ $connConfig.tcp | quote }} - - name: MSSQLS_PORT - value: {{ $connConfig.port | quote }} - - name: MSSQLS_AUTHENTICATION - value: {{ $connConfig.authentication | quote }} - - name: MSSQLS_AZURE - value: {{ $connConfig.azure | quote }} - - name: MSSQLS_LINUX_ODBC - value: {{ $connConfig.linux_odbc | quote }} - - name: MSSQLS_ODBC_DRIVER - value: {{ $connConfig.odbc_driver | quote }} - - name: MSSQLS_ENCRYPT_CONNECTION - value: {{ $connConfig.encrypt_connection | quote }} - - name: MSSQLS_TRUST_SERVER_CERT - value: {{ $connConfig.trust_server_cert | quote }} - {{- end }} - - {{- if eq $target.type "oracle" }} - # Oracle specific connection config (merged from defaults and target overrides) - - name: ORACLE_SERVICE - value: {{ $oracleConfig.service }} - - name: ORACLE_PORT - value: {{ $oracleConfig.port | quote }} - {{- if $oracleConfig.sid }} - - name: ORACLE_SID - value: {{ $oracleConfig.sid }} - {{- end }} - - name: ORACLE_TABLESPACE - value: {{ $oracleConfig.tablespace }} - - name: ORACLE_TEMP_TABLESPACE - value: {{ $oracleConfig.tempTablespace }} - - name: TPROCC_USER - value: {{ $oracleConfig.tproccUser }} - {{- if $useSecrets }} - - name: TPROCC_PASSWORD - valueFrom: - secretKeyRef: - name: {{ $secretName }} - key: target-{{ $index }}-tprocc-password - {{- else }} - - name: TPROCC_PASSWORD - value: {{ $oracleConfig.tproccPassword }} - {{- end }} - - name: TPROCH_USER - value: {{ $oracleConfig.tprochUser }} - {{- if $useSecrets }} - - name: TPROCH_PASSWORD - valueFrom: - secretKeyRef: - name: {{ $secretName }} - key: target-{{ $index }}-tproch-password - {{- else }} - - name: TPROCH_PASSWORD - value: {{ $oracleConfig.tprochPassword }} - {{- end }} - - name: TPROCH_DEGREE_OF_PARALLEL - value: {{ $oracleConfig.degreeOfParallel | quote }} - {{- end }} - - {{- if eq $.Values.testRun.benchmark "tprocc" }} - # TPC-C Configuration (from merged config) - {{- if eq $target.type "mssql" }} - - name: TPROCC_DATABASE_NAME - value: {{ $target.tprocc.databaseName | quote }} - {{- end }} - - name: TPROCC_DRIVER - value: {{ include "hammerdb-scale-test.dbDriver" $target.type | quote }} - - name: TPROCC_BUILD_VIRTUAL_USERS - value: {{ $tproccConfig.build_num_vu | quote }} - - name: WAREHOUSES - value: {{ $tproccConfig.warehouses | quote }} - - name: TPROCC_DRIVER_TYPE - value: {{ $tproccConfig.driver | quote }} - - name: TPROCC_ALLWAREHOUSE - value: {{ $tproccConfig.allwarehouse | quote }} - - name: VIRTUAL_USERS - value: {{ $tproccConfig.load_num_vu | quote }} - - name: RAMPUP - value: {{ $tproccConfig.rampup | quote }} - - name: DURATION - value: {{ $tproccConfig.duration | quote }} - - name: TOTAL_ITERATIONS - # printf %.0f avoids Go rendering large ints in scientific notation - # (10000000 -> "1e+07"), which the TCL scripts cannot parse. - value: {{ printf "%.0f" (float64 $tproccConfig.total_iterations) | quote }} - - name: TPROCC_LOG_TO_TEMP - value: "0" - - name: TPROCC_USE_TRANSACTION_COUNTER - value: "true" - - name: TPROCC_CHECKPOINT - value: {{ $tproccConfig.checkpoint | quote }} - - name: TPROCC_TIMEPROFILE - value: {{ $tproccConfig.timeprofile | quote }} - {{- end }} - - {{- if eq $.Values.testRun.benchmark "tproch" }} - # TPC-H Configuration (from merged config) - {{- if eq $target.type "mssql" }} - - name: TPROCH_DATABASE_NAME - value: {{ $target.tproch.databaseName | quote }} - {{- end }} - - name: TPROCH_DRIVER - value: {{ include "hammerdb-scale-test.dbDriver" $target.type | quote }} - - name: TPROCH_SCALE_FACTOR - value: {{ $tprochConfig.scaleFactor | quote }} - - name: TPROCH_BUILD_THREADS - value: {{ $tprochConfig.buildThreads | quote }} - - name: TPROCH_BUILD_VIRTUAL_USERS - value: {{ $tprochConfig.build_num_vu | quote }} - - name: TPROCH_VIRTUAL_USERS - value: {{ $tprochConfig.load_num_vu | quote }} - {{- if eq $target.type "mssql" }} - - name: TPROCH_USE_CLUSTERED_COLUMNSTORE - value: {{ $tprochConfig.useClusteredColumnstore | quote }} - - name: TPROCH_MAXDOP - value: {{ $tprochConfig.maxdop | quote }} - {{- end }} - - name: TPROCH_TOTAL_QUERYSETS - value: {{ $tprochConfig.totalQuerysets | quote }} - - name: TPROCH_LOG_TO_TEMP - value: "1" - {{- end }} - - {{- if $.Values.pureStorage.enabled }} - # Pure Storage metrics collection - - name: PURE_ENABLED - value: "true" - - name: PURE_COLLECT_METRICS - # Only first target (index 0) collects metrics to avoid duplicate API calls - value: {{ if eq $index 0 }}"true"{{ else }}"false"{{ end }} - - name: PURE_HOST - value: {{ $.Values.pureStorage.host | quote }} - {{- if $useSecrets }} - - name: PURE_API_TOKEN - valueFrom: - secretKeyRef: - name: {{ $secretName }} - key: pure-api-token - {{- else }} - - name: PURE_API_TOKEN - value: {{ $.Values.pureStorage.apiToken | quote }} - {{- end }} - {{- if $.Values.pureStorage.volume }} - - name: PURE_VOLUME - value: {{ $.Values.pureStorage.volume | quote }} - {{- end }} - - name: PURE_INTERVAL - value: {{ $.Values.pureStorage.pollInterval | quote }} - - name: PURE_NO_VERIFY_SSL - value: {{ ternary "false" "true" $.Values.pureStorage.verifySSL | quote }} - - name: PURE_API_VERSION - value: {{ $.Values.pureStorage.apiVersion | quote }} - - name: PURE_OUTPUT - value: "/tmp/pure_metrics.json" - {{- if $.Values.pureStorage.duration }} - - name: PURE_DURATION - value: {{ $.Values.pureStorage.duration | quote }} - {{- end }} - {{- end }} - - resources: - {{- toYaml $.Values.global.resources | nindent 10 }} - - volumeMounts: - - name: scripts - # entrypoint.sh searches for its scripts, so this need not match - # the image's HAMMERDB_HOME exactly. Set global.hammerdbHome to pin - # it for an image built with a different HAMMERDB_VERSION. - mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-5.0") }} - - volumes: - - name: scripts - configMap: - name: {{ include "hammerdb-scale-test.fullname" $ }}-scripts-{{ $target.type }} - defaultMode: 0755 # Make scripts executable -{{- end }} diff --git a/templates/secret-credentials.yaml b/templates/secret-credentials.yaml deleted file mode 100644 index 7c6cdae..0000000 --- a/templates/secret-credentials.yaml +++ /dev/null @@ -1,37 +0,0 @@ -{{- if ne (($.Values.kubernetes).useSecrets | toString) "false" }} -{{- /* -Database credentials for each target. - -Without this, passwords are plain `value:` entries in the Job spec and anyone -with read access to the namespace can recover them with `kubectl describe job`. -Here they become secretKeyRef lookups instead. - -One Secret per release holds every target's credentials, keyed by target index, -so a single object is created and removed with the release. -*/ -}} -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "hammerdb-scale-test.fullname" . }}-credentials - labels: - {{- include "hammerdb-scale-test.labels" . | nindent 4 }} - {{- if .Values.extraLabels }} - {{- range $key, $val := .Values.extraLabels }} - {{ $key }}: {{ $val | quote }} - {{- end }} - {{- end }} -type: Opaque -stringData: - {{- range $index, $target := .Values.targets }} - {{- $oracleConfig := include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" $target) | fromYaml }} - target-{{ $index }}-username: {{ $target.username | quote }} - target-{{ $index }}-password: {{ $target.password | quote }} - {{- if eq $target.type "oracle" }} - target-{{ $index }}-tprocc-password: {{ $oracleConfig.tproccPassword | quote }} - target-{{ $index }}-tproch-password: {{ $oracleConfig.tprochPassword | quote }} - {{- end }} - {{- end }} - {{- if .Values.pureStorage.enabled }} - pure-api-token: {{ .Values.pureStorage.apiToken | quote }} - {{- end }} -{{- end }} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e9cbb07 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,222 @@ +"""Command-level tests driving the Typer app with the backend mocked. + +The orchestration layer had no tests, and that is exactly where the bugs have +been: the two most recent upstream fixes ("test ID resolution filtering by +deployment name", "YAML scientific notation in run hash generation") were both +here, as were the phase-label and boolean-default bugs found during this work. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from hammerdb_scale.cli import app +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + WorkloadRef, +) + +runner = CliRunner() + +CONFIG = """ +name: clitest +default_benchmark: tprocc +backend: podman +targets: + defaults: + type: oracle + username: system + password: "pw" + oracle: + service: TPCC + hosts: + - name: t1 + host: "10.0.0.1" +hammerdb: + tprocc: + warehouses: 10 + load_virtual_users: 2 + rampup: 0 + duration: 1 +""" + + +@pytest.fixture +def config_file(tmp_path): + path = tmp_path / "hammerdb-scale.yaml" + path.write_text(CONFIG) + return path + + +@pytest.fixture +def fake_backend(): + """A backend that deploys cleanly and reports one completed workload.""" + backend = MagicMock() + backend.name = "podman" + backend.preflight.return_value = [] + backend.deploy.return_value = ["hdb-load-00-clitest-1"] + backend.list_workloads.return_value = [ + WorkloadRef( + name="hdb-load-00-clitest-1", + target_name="t1", + target_host="10.0.0.1", + database_type="oracle", + index=0, + status=STATUS_COMPLETED, + duration_seconds=65, + ) + ] + backend.get_logs.return_value = ( + "TEST RESULT : System achieved 9108 NOPM from 18882 Oracle TPM" + ) + backend.find_test_ids.return_value = ["clitest-1"] + return backend + + +class TestVersion: + def test_reports_a_version(self): + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert "hammerdb-scale" in result.stdout + + def test_does_not_use_removed_kubectl_flag(self): + """kubectl dropped --short in 1.28, so it must not be the only attempt.""" + result = runner.invoke(app, ["version"]) + assert "unknown flag" not in result.stdout + + +class TestRun: + def test_deploys_via_the_configured_backend(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run"]) + assert result.exit_code == 0, result.stdout + assert fake_backend.deploy.called + assert "podman" in result.stdout + + def test_passes_run_phase_to_the_backend(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + runner.invoke(app, ["-f", str(config_file), "run"]) + assert fake_backend.deploy.call_args.kwargs["phase"] == "run" + + def test_aborts_when_preflight_fails(self, config_file, fake_backend): + fake_backend.preflight.return_value = ["no container runtime found"] + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run"]) + assert result.exit_code == 1 + assert "no container runtime" in result.stdout + assert not fake_backend.deploy.called + + def test_wait_reports_failure_and_exits_nonzero(self, config_file, fake_backend): + """A failed benchmark must not look like success to a CI pipeline.""" + fake_backend.list_workloads.return_value = [ + WorkloadRef( + name="hdb-load-00-x", + target_name="t1", + target_host="10.0.0.1", + database_type="oracle", + index=0, + status=STATUS_FAILED, + duration_seconds=5, + ) + ] + fake_backend.get_logs.return_value = "ORA-01017: invalid username/password" + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run", "--wait"]) + assert result.exit_code == 1 + + def test_wait_surfaces_the_database_error(self, config_file, fake_backend): + """The reason should be inline, not something to go hunting for.""" + fake_backend.list_workloads.return_value = [ + WorkloadRef( + name="hdb-load-00-x", + target_name="t1", + target_host="10.0.0.1", + database_type="oracle", + index=0, + status=STATUS_FAILED, + duration_seconds=5, + ) + ] + fake_backend.get_logs.return_value = "ORA-01017: invalid username/password" + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run", "--wait"]) + assert "ORA-01017" in result.stdout + + +class TestStatus: + def test_shows_parsed_metrics_for_completed_work(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "status"]) + assert result.exit_code == 0, result.stdout + assert "18,882" in result.stdout + + def test_json_output_is_machine_readable(self, config_file, fake_backend): + import json + + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "status", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data[0]["target"] == "t1" + assert data[0]["status"] == STATUS_COMPLETED + + def test_errors_when_no_workloads_exist(self, config_file, fake_backend): + fake_backend.list_workloads.return_value = [] + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "status"]) + assert result.exit_code == 1 + + +class TestLogs: + def test_prefixes_each_line_with_its_target(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "logs"]) + assert result.exit_code == 0 + assert "t1" in result.stdout + + def test_unknown_target_is_an_error(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke( + app, ["-f", str(config_file), "logs", "--target", "nope"] + ) + assert result.exit_code == 1 + + +class TestClean: + def test_requires_a_scope_flag(self, config_file): + result = runner.invoke(app, ["-f", str(config_file), "clean"]) + assert result.exit_code == 1 + assert "scope flag" in result.stdout + + def test_database_scope_requires_a_benchmark(self, config_file): + """Dropping the wrong benchmark's tables is destructive and silent.""" + result = runner.invoke(app, ["-f", str(config_file), "clean", "--database"]) + assert result.exit_code == 1 + assert "--benchmark" in result.stdout + + def test_rejects_an_invalid_benchmark(self, config_file): + result = runner.invoke( + app, + ["-f", str(config_file), "clean", "--database", "--benchmark", "tpcx"], + ) + assert result.exit_code == 1 + + def test_resources_scope_requires_id_or_everything(self, config_file): + result = runner.invoke(app, ["-f", str(config_file), "clean", "--resources"]) + assert result.exit_code == 1 + + +class TestConfigDiscovery: + def test_missing_config_is_a_clear_error(self, tmp_path): + result = runner.invoke(app, ["-f", str(tmp_path / "nope.yaml"), "run"]) + assert result.exit_code != 0 + + def test_invalid_yaml_is_reported(self, tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("name: [unclosed\n") + result = runner.invoke(app, ["-f", str(bad), "validate"]) + assert result.exit_code == 1 diff --git a/tests/test_hammerdb_version.py b/tests/test_hammerdb_version.py index 2386dc8..c8e3f77 100644 --- a/tests/test_hammerdb_version.py +++ b/tests/test_hammerdb_version.py @@ -100,23 +100,29 @@ def test_oracle_client_check_is_version_agnostic(self): assert "/opt/oracle/instantclient_*" in text -class TestChartsAreInSync: - """The repo-root chart and the packaged chart must not drift. - - get_chart_path() prefers the packaged copy, so a fix applied only at the - repo root never reaches anyone who installed from PyPI. +class TestChartIsNotDuplicated: + """The repo-root chart paths must be symlinks into the packaged chart. + + They used to be real duplicates, and they had already drifted: + collect_pure_metrics.py differed between the two copies. Because + get_chart_path() prefers the packaged copy, a fix applied only at the repo + root would never reach anyone installing from PyPI. Symlinks make the + packaged chart the single source of truth while keeping `helm ... .` + working from the repo root. """ @pytest.mark.parametrize( - "relative_path", - [CHART_TEMPLATE, "templates/_helpers.tpl", "Chart.yaml"], + "entry", ["templates", "scripts", "Chart.yaml", "values.yaml"] ) - def test_chart_files_match(self, relative_path): - root_file = REPO_ROOT / relative_path - packaged = REPO_ROOT / "src/hammerdb_scale/chart" / relative_path - if not root_file.exists() or not packaged.exists(): - pytest.skip(f"{relative_path} not present in both charts") - assert root_file.read_text() == packaged.read_text(), ( - f"{relative_path} differs between the repo-root chart and the " - f"packaged chart; apply changes to both" + def test_repo_root_entry_is_a_symlink(self, entry): + path = REPO_ROOT / entry + assert path.is_symlink(), ( + f"{entry} should be a symlink into src/hammerdb_scale/chart/, " + f"not a second copy that can drift" ) + assert path.resolve() == (REPO_ROOT / "src/hammerdb_scale/chart" / entry).resolve() + + def test_both_paths_reach_the_same_template(self): + root = (REPO_ROOT / CHART_TEMPLATE).read_text() + packaged = (REPO_ROOT / "src/hammerdb_scale/chart" / CHART_TEMPLATE).read_text() + assert root == packaged diff --git a/values.yaml b/values.yaml deleted file mode 100644 index 04dc5f3..0000000 --- a/values.yaml +++ /dev/null @@ -1,109 +0,0 @@ -# ============================================================================ -# HammerDB-Scale Configuration -# ============================================================================ -# Docs: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md -# Guide: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md -# -# Quick start: -# 1. Review and edit the values below (especially targets, warehouses, and virtual users) -# 2. hammerdb-scale validate # check config, tools, and database connectivity -# 3. hammerdb-scale run --build --wait # build schemas and run the benchmark -# 4. hammerdb-scale results # aggregate results -# 5. hammerdb-scale report --open # generate HTML scorecard -# ============================================================================ - -name: my-benchmark -description: "MSSQL TPROCC benchmark — 2 targets" -default_benchmark: tprocc - -# ============================================================================ -# DATABASE TARGETS -# ============================================================================ -# "defaults" are inherited by all hosts — override any field per-host if needed. -# Add or remove hosts to match your environment. -targets: - defaults: - type: mssql - username: sa - password: "YourPassword" - image: - repository: sillidata/hammerdb-scale - tag: latest - pull_policy: Always # Always | IfNotPresent | Never - mssql: - port: 1433 - tprocc: - database_name: tpcc # database created during build phase - tproch: - database_name: tpch # database created during build phase - - hosts: - - name: sql-01 - host: "sql-01.example.com" - - name: sql-02 - host: "sql-02.example.com" - -# ============================================================================ -# BENCHMARK PARAMETERS -# ============================================================================ -# Tune these to control data size, concurrency, and test duration. -# Uncomment the other benchmark section to switch between TPC-C and TPC-H. -hammerdb: - tprocc: - warehouses: 100 # data size: 100 = ~10GB, 1000 = ~100GB, 10000 = ~1TB per target - build_virtual_users: 4 # parallel threads for schema creation - load_virtual_users: 4 # concurrent users during benchmark (tune to match CPU cores) - driver: timed # "timed" = run for fixed duration, "test" = single iteration - rampup: 5 # warm-up period before measuring (minutes) - duration: 10 # measurement window (minutes), 5-60 typical - total_iterations: 10000000 # max iterations (effectively unlimited with timed driver) - all_warehouses: true # distribute load across all warehouses - checkpoint: true # issue checkpoint before benchmark - time_profile: false # detailed per-transaction timing (adds overhead) -# tproch: -# scale_factor: 1 # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB -# build_threads: 4 # parallel threads for data generation -# build_virtual_users: 1 # HammerDB orchestrator (keep at 1) -# load_virtual_users: 1 # query concurrency: 1 = Power run, >1 = Throughput run -# total_querysets: 1 # number of full 22-query runs - mssql: - connection: - tcp: true - authentication: sql - odbc_driver: "ODBC Driver 18 for SQL Server" - encrypt_connection: true # TLS encryption (recommended) - trust_server_cert: true # accept self-signed certs - -# ============================================================================ -# KUBERNETES RESOURCES -# ============================================================================ -# Resource requests/limits for each HammerDB Job pod. -# Each target gets one pod — scale these based on virtual user count. -resources: - requests: - memory: "4Gi" - cpu: "4" - limits: - memory: "8Gi" - cpu: "8" - -# ============================================================================ -# KUBERNETES SETTINGS -# ============================================================================ -kubernetes: - namespace: hammerdb # namespace for benchmark jobs (must exist) - job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h) - -# ============================================================================ -# PURE STORAGE METRICS (optional) -# Uncomment to collect IOPS, latency, and bandwidth from a FlashArray -# during benchmarks. Metrics appear in the HTML scorecard. -# ============================================================================ -storage_metrics: - enabled: false - # pure: - # host: "" # FlashArray management IP or hostname - # api_token: "" # REST API token (Settings > Users > API Tokens) - # volume: "" # leave empty for array-level metrics - # poll_interval: 5 # collection interval in seconds - # verify_ssl: false diff --git a/values.yaml b/values.yaml new file mode 120000 index 0000000..f02311d --- /dev/null +++ b/values.yaml @@ -0,0 +1 @@ +src/hammerdb_scale/chart/values.yaml \ No newline at end of file From e04eba587fb3f8cbffd49f416d282e81be2f04cd Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 14:00:25 -0600 Subject: [PATCH 07/14] Add PostgreSQL support and provision the 8-host benchmark fleet PostgreSQL joins Oracle and SQL Server as a first-class database: schema, TCL scripts, parser, entrypoint dispatch, connectivity check, Helm ConfigMap and Secret wiring, and report labelling. The base image could not actually run PostgreSQL despite advertising it. The Dockerfile has claimed "mssql,postgresql,mysql" support since the start, but HammerDB bundles Pgtcl and links the system libpq at runtime, so the driver failed with "Failed to load Pgtcl - libpq.so.5: cannot open shared object file". Adding libpq5 to the base image fixes it; no extension image is needed the way Oracle needs one. Stored procedures default to on, HammerDB's recommended form for PostgreSQL TPROC-C. The setting is exposed because it must match between build and run: the driver calls procedures that only exist if the schema was built with them. Fixes a test-ID ordering bug that affects all backends, found when `results` reported zero metrics after a successful run. find_test_ids relied on the container runtime's ps ordering, which is not guaranteed, so a stale build run surfaced ahead of the run just finished. Now sorted explicitly by creation time, handling podman's unix timestamps and docker's RFC3339 strings. Fleet provisioning: examples/postgres-setup/provision_postgres.sh builds a host from bare RHEL 9 to a tuned PostgreSQL 18.4. Idempotent, and --force rebuilds the cluster so a fleet is provably identical rather than whatever was there before. Sizing derives from the host (shared_buffers 25% of RAM), and it opens 5432 in firewalld, which was blocking access on 7 of the 8 hosts. All 8 PostgreSQL-Bench hosts provisioned identically: PostgreSQL 18.4, 32GB shared_buffers, 32GB max_wal_size, data checksums on, 48 cores / 125GB RAM. Verified end to end: 8 targets in parallel at 100 warehouses, all completed, 2,977,855 TPM / 1,301,691 NOPM aggregate with tight per-host variance confirming uniform configuration. Kubernetes rendering verified for the PostgreSQL path. Tests: 223 -> 234. Co-Authored-By: Claude Opus 5 (1M context) --- dockerfile | 5 + entrypoint.sh | 58 ++++-- examples/postgres-setup/provision_postgres.sh | 197 ++++++++++++++++++ .../scripts/postgres/build_schema_tprocc.tcl | 79 +++++++ .../scripts/postgres/build_schema_tproch.tcl | 59 ++++++ .../scripts/postgres/load_test_tprocc.tcl | 110 ++++++++++ .../scripts/postgres/load_test_tproch.tcl | 73 +++++++ .../scripts/postgres/parse_output_tprocc.tcl | 29 +++ .../scripts/postgres/parse_output_tproch.tcl | 26 +++ .../chart/templates/configmap-postgres.yaml | 27 +++ .../chart/templates/job-hammerdb-worker.yaml | 51 +++++ .../chart/templates/secret-credentials.yaml | 4 + src/hammerdb_scale/cli.py | 28 +++ src/hammerdb_scale/config/defaults.py | 6 + src/hammerdb_scale/config/schema.py | 38 ++++ src/hammerdb_scale/constants.py | 2 + src/hammerdb_scale/reports/generator.py | 10 +- src/hammerdb_scale/results/parsers.py | 52 +++++ src/hammerdb_scale/runtime/container.py | 40 +++- src/hammerdb_scale/runtime/environment.py | 33 +++ tests/test_container_backend.py | 28 +++ tests/test_environment.py | 59 ++++++ tests/test_parsers.py | 7 +- 23 files changed, 993 insertions(+), 28 deletions(-) create mode 100755 examples/postgres-setup/provision_postgres.sh create mode 100644 src/hammerdb_scale/chart/scripts/postgres/build_schema_tprocc.tcl create mode 100644 src/hammerdb_scale/chart/scripts/postgres/build_schema_tproch.tcl create mode 100644 src/hammerdb_scale/chart/scripts/postgres/load_test_tprocc.tcl create mode 100644 src/hammerdb_scale/chart/scripts/postgres/load_test_tproch.tcl create mode 100644 src/hammerdb_scale/chart/scripts/postgres/parse_output_tprocc.tcl create mode 100644 src/hammerdb_scale/chart/scripts/postgres/parse_output_tproch.tcl create mode 100644 src/hammerdb_scale/chart/templates/configmap-postgres.yaml diff --git a/dockerfile b/dockerfile index b480cce..0075297 100644 --- a/dockerfile +++ b/dockerfile @@ -45,6 +45,11 @@ RUN apt-get update && \ rm packages-microsoft-prod.deb && \ apt-get update && \ ACCEPT_EULA=Y apt-get install -y mssql-tools18 msodbcsql18 unixodbc unixodbc-dev && \ + # HammerDB bundles Pgtcl but links against the system libpq at runtime; + # without libpq5 the PostgreSQL driver fails with + # "Failed to load Pgtcl - libpq.so.5: cannot open shared object file". + # postgresql-client provides psql for diagnostics. + apt-get install -y libpq5 postgresql-client && \ echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /var/apt/cache/* /tmp/* /var/tmp/* diff --git a/entrypoint.sh b/entrypoint.sh index b3b791b..9d8c216 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -99,8 +99,6 @@ case "$DATABASE_TYPE" in ;; postgres) log "Database: PostgreSQL" - log "ERROR: PostgreSQL support not yet implemented" - exit 1 ;; oracle) log "Database: Oracle" @@ -227,25 +225,43 @@ elif [[ "$DATABASE_TYPE" == "oracle" ]]; then log "ERROR: Unknown BENCHMARK: '$BENCHMARK'. Supported: tprocc, tproch" exit 1 fi -# EXTENSION POINT: Add PostgreSQL script selection here -# elif [[ "$DATABASE_TYPE" == "postgres" ]]; then -# if [[ "$BENCHMARK" == "tprocc" ]]; then -# case "$RUN_MODE" in -# build) -# SCRIPT_NAME="build_schema_tprocc_pg.tcl" -# ;; -# load) -# SCRIPT_NAME="load_test_tprocc_pg.tcl" -# ;; -# parse) -# SCRIPT_NAME="parse_output_tprocc_pg.tcl" -# ;; -# esac -# fi - -# EXTENSION POINT: Add Oracle script selection here -# elif [[ "$DATABASE_TYPE" == "oracle" ]]; then -# ... +elif [[ "$DATABASE_TYPE" == "postgres" ]]; then + if [[ "$BENCHMARK" == "tprocc" ]]; then + case "$RUN_MODE" in + build) + SCRIPT_NAME="build_schema_tprocc.tcl" + ;; + load) + SCRIPT_NAME="load_test_tprocc.tcl" + ;; + parse) + SCRIPT_NAME="parse_output_tprocc.tcl" + ;; + *) + log "ERROR: Unknown RUN_MODE: '$RUN_MODE' for benchmark '$BENCHMARK'" + exit 1 + ;; + esac + elif [[ "$BENCHMARK" == "tproch" ]]; then + case "$RUN_MODE" in + build) + SCRIPT_NAME="build_schema_tproch.tcl" + ;; + load) + SCRIPT_NAME="load_test_tproch.tcl" + ;; + parse) + SCRIPT_NAME="parse_output_tproch.tcl" + ;; + *) + log "ERROR: Unknown RUN_MODE: '$RUN_MODE' for benchmark '$BENCHMARK'" + exit 1 + ;; + esac + else + log "ERROR: Unknown BENCHMARK: '$BENCHMARK'. Supported: tprocc, tproch" + exit 1 + fi # EXTENSION POINT: Add MySQL script selection here # elif [[ "$DATABASE_TYPE" == "mysql" ]]; then diff --git a/examples/postgres-setup/provision_postgres.sh b/examples/postgres-setup/provision_postgres.sh new file mode 100755 index 0000000..5052b8c --- /dev/null +++ b/examples/postgres-setup/provision_postgres.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# Provision a PostgreSQL benchmark host for HammerDB TPROC-C / TPROC-H. +# +# Idempotent: safe to re-run. With --force it wipes any existing cluster and +# rebuilds from scratch, which is what makes a fleet provably identical rather +# than "whatever was there before". +# +# Target hardware (the PostgreSQL-Bench fleet): +# RHEL 9.x, 48 cores, 125GB RAM, dedicated volume mounted at /var/lib/pgsql +# +# Usage: +# ./provision_postgres.sh [--force] [--version 18] [--password PW] + +set -euo pipefail + +PG_VERSION="${PG_VERSION:-18}" +PG_PASSWORD="${PG_PASSWORD:-Osmium76}" +FORCE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --force) FORCE=1; shift ;; + --version) PG_VERSION="$2"; shift 2 ;; + --password) PG_PASSWORD="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +PGBIN="/usr/pgsql-${PG_VERSION}/bin" +PGDATA="/var/lib/pgsql/${PG_VERSION}/data" +SERVICE="postgresql-${PG_VERSION}" + +log() { echo "[$(date +'%H:%M:%S')] $*"; } + +# --- Sizing, derived from the actual host --------------------------------- +TOTAL_MB=$(free -m | awk '/^Mem:/{print $2}') +CPUS=$(nproc) + +# PostgreSQL guidance: shared_buffers ~25% of RAM, effective_cache_size ~75%. +SHARED_BUFFERS_MB=$(( TOTAL_MB / 4 )) +EFFECTIVE_CACHE_MB=$(( TOTAL_MB * 3 / 4 )) +MAINTENANCE_WORK_MEM_MB=2048 +# TPROC-C is short OLTP transactions, so work_mem stays modest; a large value +# here multiplies per sort node across hundreds of connections. +WORK_MEM_MB=64 + +MAX_PARALLEL=$(( CPUS * 5 / 6 )) +PARALLEL_PER_GATHER=$(( CPUS / 6 )) +[ "$PARALLEL_PER_GATHER" -lt 2 ] && PARALLEL_PER_GATHER=2 + +log "Host: ${CPUS} cores, ${TOTAL_MB}MB RAM" +log "Sizing: shared_buffers=${SHARED_BUFFERS_MB}MB effective_cache_size=${EFFECTIVE_CACHE_MB}MB" + +# --- Install --------------------------------------------------------------- +if ! rpm -q "postgresql${PG_VERSION}-server" >/dev/null 2>&1; then + log "Installing PostgreSQL ${PG_VERSION} from PGDG" + dnf install -y \ + "https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm" \ + >/dev/null 2>&1 || true + # The PGDG repo ships its own libpq; RHEL's AppStream module would shadow it. + dnf -qy module disable postgresql >/dev/null 2>&1 || true + dnf install -y \ + "postgresql${PG_VERSION}-server" \ + "postgresql${PG_VERSION}-contrib" >/dev/null + log "Installed $(rpm -q postgresql${PG_VERSION}-server)" +else + log "Already installed: $(rpm -q postgresql${PG_VERSION}-server)" +fi + +# --- Cluster --------------------------------------------------------------- +if [ -s "${PGDATA}/PG_VERSION" ] && [ "$FORCE" -eq 1 ]; then + log "--force: removing existing cluster at ${PGDATA}" + systemctl stop "$SERVICE" 2>/dev/null || true + rm -rf "${PGDATA:?}"/* +fi + +if [ ! -s "${PGDATA}/PG_VERSION" ]; then + log "Initialising cluster" + # Checksums cost a little CPU but make silent storage corruption visible, + # which matters when the point of the exercise is testing storage. + PGSETUP_INITDB_OPTIONS="--data-checksums" \ + "${PGBIN}/postgresql-${PG_VERSION}-setup" initdb >/dev/null +else + log "Cluster already initialised" +fi + +# --- Configuration --------------------------------------------------------- +# Written as a conf.d drop-in rather than ALTER SYSTEM so the settings are +# visible in the repo, reproducible, and easy to diff across the fleet. +log "Writing tuning configuration" +mkdir -p "${PGDATA}/conf.d" +cat > "${PGDATA}/conf.d/hammerdb.conf" <> "${PGDATA}/postgresql.conf" +fi + +# --- Authentication -------------------------------------------------------- +log "Configuring pg_hba.conf for remote benchmark clients" +if ! grep -q "hammerdb-scale" "${PGDATA}/pg_hba.conf"; then + cat >> "${PGDATA}/pg_hba.conf" <<'EOF' + +# hammerdb-scale: allow benchmark clients from the lab network. +host all all 10.0.0.0/8 scram-sha-256 +EOF +fi + +# --- Firewall -------------------------------------------------------------- +# RHEL runs firewalld by default with only ssh open, so PostgreSQL listening on +# 0.0.0.0 is still unreachable from the benchmark clients without this. +if systemctl is-active --quiet firewalld 2>/dev/null; then + if ! firewall-cmd --list-ports 2>/dev/null | grep -q "5432/tcp"; then + log "Opening 5432/tcp in firewalld" + firewall-cmd --permanent --add-port=5432/tcp >/dev/null + firewall-cmd --reload >/dev/null + else + log "firewalld already allows 5432/tcp" + fi +else + log "firewalld inactive; no firewall change needed" +fi + +# --- Start ----------------------------------------------------------------- +log "Starting ${SERVICE}" +systemctl enable "$SERVICE" >/dev/null 2>&1 +systemctl restart "$SERVICE" + +for _ in $(seq 1 30); do + "${PGBIN}/pg_isready" -q && break + sleep 2 +done +"${PGBIN}/pg_isready" >/dev/null || { log "ERROR: server did not become ready"; exit 1; } + +# --- Benchmark role -------------------------------------------------------- +# HammerDB creates the tpcc schema itself but needs a superuser to do so. +log "Ensuring benchmark role exists" +sudo -u postgres "${PGBIN}/psql" -qtAc \ + "DO \$\$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') THEN + CREATE ROLE postgres SUPERUSER LOGIN; + END IF; + END \$\$;" >/dev/null + +sudo -u postgres "${PGBIN}/psql" -qtAc \ + "ALTER ROLE postgres WITH PASSWORD '${PG_PASSWORD}'" >/dev/null + +log "Ready: $(sudo -u postgres ${PGBIN}/psql -tAc 'select version()' | cut -c1-40)" +log "shared_buffers=$(sudo -u postgres ${PGBIN}/psql -tAc 'show shared_buffers')" diff --git a/src/hammerdb_scale/chart/scripts/postgres/build_schema_tprocc.tcl b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tprocc.tcl new file mode 100644 index 0000000..c712c21 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tprocc.tcl @@ -0,0 +1,79 @@ +#!/bin/tclsh +# Build the TPROC-C schema on PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set tprocc_user $::env(TPROCC_USER) +set tprocc_password $::env(TPROCC_PASSWORD) +set tprocc_database_name $::env(TPROCC_DATABASE_NAME) +set tprocc_build_virtual_users $::env(TPROCC_BUILD_VIRTUAL_USERS) +set warehouses $::env(WAREHOUSES) + +foreach var {USERNAME PASSWORD HOST PG_PORT TPROCC_DATABASE_NAME WAREHOUSES TPROCC_BUILD_VIRTUAL_USERS} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-C SCHEMA BUILD FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tprocc_database_name" +puts " Warehouses: $warehouses" +puts " Build Virtual Users: $tprocc_build_virtual_users" + +dbset db pg +dbset bm TPC-C + +# Connection +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +# The superuser creates the schema owner role and the database. +diset tpcc pg_superuser $username +diset tpcc pg_superuserpass $password +diset tpcc pg_defaultdbase postgres + +diset tpcc pg_user $tprocc_user +diset tpcc pg_pass $tprocc_password +diset tpcc pg_dbase $tprocc_database_name +diset tpcc pg_count_ware $warehouses +diset tpcc pg_num_vu $tprocc_build_virtual_users + +if {[info exists ::env(PG_TABLESPACE)] && $::env(PG_TABLESPACE) ne ""} { + diset tpcc pg_tspace $::env(PG_TABLESPACE) +} + +# Stored procedures must be chosen at build time: the driver script that runs +# later expects whichever form was built, so build and load must agree. +if {[info exists ::env(PG_STOREDPROCS)] && $::env(PG_STOREDPROCS) eq "true"} { + diset tpcc pg_storedprocs true + puts " Stored procedures: enabled" +} else { + diset tpcc pg_storedprocs false + puts " Stored procedures: disabled (prepared statements)" +} + +if {[info exists ::env(PG_PARTITION)] && $::env(PG_PARTITION) eq "true"} { + diset tpcc pg_partition true +} + +if {[info exists ::env(PG_VACUUM)] && $::env(PG_VACUUM) eq "true"} { + diset tpcc pg_vacuum true +} + +loadscript + +puts "Current TPROC-C configuration:" +print dict + +puts "Starting TPROC-C schema build..." +buildschema + +puts "TPROC-C SCHEMA BUILD COMPLETE" diff --git a/src/hammerdb_scale/chart/scripts/postgres/build_schema_tproch.tcl b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tproch.tcl new file mode 100644 index 0000000..a497984 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tproch.tcl @@ -0,0 +1,59 @@ +#!/bin/tclsh +# Build the TPROC-H schema on PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set tproch_user $::env(TPROCH_USER) +set tproch_password $::env(TPROCH_PASSWORD) +set tproch_database_name $::env(TPROCH_DATABASE_NAME) +set scale_factor $::env(TPROCH_SCALE_FACTOR) +set build_threads $::env(TPROCH_BUILD_THREADS) + +foreach var {USERNAME PASSWORD HOST PG_PORT TPROCH_DATABASE_NAME TPROCH_SCALE_FACTOR} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-H SCHEMA BUILD FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tproch_database_name" +puts " Scale Factor: $scale_factor" +puts " Build Threads: $build_threads" + +dbset db pg +dbset bm TPC-H + +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +diset tpch pg_tpch_superuser $username +diset tpch pg_tpch_superuserpass $password +diset tpch pg_tpch_defaultdbase postgres + +diset tpch pg_tpch_user $tproch_user +diset tpch pg_tpch_pass $tproch_password +diset tpch pg_tpch_dbase $tproch_database_name +diset tpch pg_scale_fact $scale_factor +diset tpch pg_num_tpch_threads $build_threads + +if {[info exists ::env(PG_TABLESPACE)] && $::env(PG_TABLESPACE) ne ""} { + diset tpch pg_tpch_tspace $::env(PG_TABLESPACE) +} + +loadscript + +puts "Current TPROC-H configuration:" +print dict + +puts "Starting TPROC-H schema build..." +buildschema + +puts "TPROC-H SCHEMA BUILD COMPLETE" diff --git a/src/hammerdb_scale/chart/scripts/postgres/load_test_tprocc.tcl b/src/hammerdb_scale/chart/scripts/postgres/load_test_tprocc.tcl new file mode 100644 index 0000000..abdeae0 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/load_test_tprocc.tcl @@ -0,0 +1,110 @@ +#!/bin/tclsh +# Run the TPROC-C workload against PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set virtual_users $::env(VIRTUAL_USERS) +set tprocc_user $::env(TPROCC_USER) +set tprocc_password $::env(TPROCC_PASSWORD) +set tprocc_database_name $::env(TPROCC_DATABASE_NAME) +set rampup $::env(RAMPUP) +set duration $::env(DURATION) +set total_iterations $::env(TOTAL_ITERATIONS) +set tmpdir $::env(TMPDIR) +set tprocc_log_to_temp $::env(TPROCC_LOG_TO_TEMP) +set tprocc_use_transaction_counter $::env(TPROCC_USE_TRANSACTION_COUNTER) +set tprocc_timeprofile $::env(TPROCC_TIMEPROFILE) + +foreach var {USERNAME PASSWORD HOST PG_PORT VIRTUAL_USERS TPROCC_DATABASE_NAME} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-C LOAD TEST FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tprocc_database_name" +puts " Virtual Users: $virtual_users" +puts " Duration: $duration minutes" +puts " Rampup: $rampup minutes" + +dbset db pg +dbset bm TPC-C + +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +diset tpcc pg_superuser $username +diset tpcc pg_superuserpass $password +diset tpcc pg_defaultdbase postgres + +diset tpcc pg_user $tprocc_user +diset tpcc pg_pass $tprocc_password +diset tpcc pg_dbase $tprocc_database_name + +diset tpcc pg_driver timed +diset tpcc pg_total_iterations $total_iterations +diset tpcc pg_rampup $rampup +diset tpcc pg_duration $duration + +# Must match how the schema was built, or the driver calls procedures that +# do not exist. +if {[info exists ::env(PG_STOREDPROCS)] && $::env(PG_STOREDPROCS) eq "true"} { + diset tpcc pg_storedprocs true + puts " Stored procedures: enabled" +} else { + diset tpcc pg_storedprocs false + puts " Stored procedures: disabled (prepared statements)" +} + +if {[info exists ::env(TPROCC_ALLWAREHOUSE)] && $::env(TPROCC_ALLWAREHOUSE) eq "true"} { + diset tpcc pg_allwarehouse true +} + +if {$tprocc_timeprofile eq "true"} { + diset tpcc pg_timeprofile true +} + +# Keying and thinking time off: this measures maximum throughput, not a +# simulated user population. +diset tpcc pg_keyandthink false + +vuset logtotemp $tprocc_log_to_temp +loadscript + +puts "STARTING TPROC-C VIRTUAL USERS" +vuset vu $virtual_users +vucreate +puts "TEST STARTED" + +if {$tprocc_use_transaction_counter eq "true"} { + puts "Starting transaction counter..." + tcstart + tcstatus +} + +puts "About to run vurun command..." +set jobid [ vurun ] +puts "vurun completed with job ID: $jobid" +vudestroy + +if {$tprocc_use_transaction_counter eq "true"} { + puts "Stopping transaction counter..." + tcstop +} + +puts "Virtual users destroyed" +puts "TPROC-C LOAD TEST COMPLETE" + +puts "Creating output file at: $tmpdir/postgres_tprocc" +set of [ open $tmpdir/postgres_tprocc w ] +puts $of $jobid +close $of +puts "Job ID $jobid written to $tmpdir/postgres_tprocc" diff --git a/src/hammerdb_scale/chart/scripts/postgres/load_test_tproch.tcl b/src/hammerdb_scale/chart/scripts/postgres/load_test_tproch.tcl new file mode 100644 index 0000000..04edf97 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/load_test_tproch.tcl @@ -0,0 +1,73 @@ +#!/bin/tclsh +# Run the TPROC-H query set against PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set tproch_user $::env(TPROCH_USER) +set tproch_password $::env(TPROCH_PASSWORD) +set tproch_database_name $::env(TPROCH_DATABASE_NAME) +set scale_factor $::env(TPROCH_SCALE_FACTOR) +set virtual_users $::env(TPROCH_VIRTUAL_USERS) +set total_querysets $::env(TPROCH_TOTAL_QUERYSETS) +set tmpdir $::env(TMPDIR) + +foreach var {USERNAME PASSWORD HOST PG_PORT TPROCH_DATABASE_NAME TPROCH_VIRTUAL_USERS} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-H LOAD TEST FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tproch_database_name" +puts " Scale Factor: $scale_factor" +puts " Virtual Users: $virtual_users" +puts " Query Sets: $total_querysets" + +dbset db pg +dbset bm TPC-H + +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +diset tpch pg_tpch_superuser $username +diset tpch pg_tpch_superuserpass $password +diset tpch pg_tpch_defaultdbase postgres + +diset tpch pg_tpch_user $tproch_user +diset tpch pg_tpch_pass $tproch_password +diset tpch pg_tpch_dbase $tproch_database_name +diset tpch pg_scale_fact $scale_factor +diset tpch pg_total_querysets $total_querysets + +if {[info exists ::env(PG_MAX_PARALLEL_WORKERS)]} { + diset tpch pg_degree_of_parallel $::env(PG_MAX_PARALLEL_WORKERS) +} + +# TPROC-H writes per-query timings to the temp log, which the parse phase reads. +vuset logtotemp 1 +loadscript + +puts "STARTING TPROC-H VIRTUAL USERS" +vuset vu $virtual_users +vucreate +puts "TEST STARTED" + +set jobid [ vurun ] +puts "vurun completed with job ID: $jobid" +vudestroy + +puts "TPROC-H LOAD TEST COMPLETE" + +puts "Creating output file at: $tmpdir/postgres_tproch" +set of [ open $tmpdir/postgres_tproch w ] +puts $of $jobid +close $of +puts "Job ID $jobid written to $tmpdir/postgres_tproch" diff --git a/src/hammerdb_scale/chart/scripts/postgres/parse_output_tprocc.tcl b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tprocc.tcl new file mode 100644 index 0000000..c0cef60 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tprocc.tcl @@ -0,0 +1,29 @@ +# Emit TPROC-C results for the job recorded by load_test_tprocc.tcl. + +proc getjobid {filename} { + set fd [open $filename r] + set jobid [lindex [split [gets $fd] =] 1] + close $fd + return $jobid +} + +set tmpdir $::env(TMPDIR) +set ::outputfile $tmpdir/postgres_tprocc +set filename $::outputfile +set jobid [getjobid $filename] + +if {$jobid eq ""} { + puts "Job ID not found in the output file." + exit 1 +} + +jobs format JSON + +puts "TRANSACTION RESPONSE TIMES" +puts [job $jobid timing] + +puts "TRANSACTION COUNT" +puts [jobs $jobid tcount] + +puts "HAMMERDB RESULT" +puts [jobs $jobid result] diff --git a/src/hammerdb_scale/chart/scripts/postgres/parse_output_tproch.tcl b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tproch.tcl new file mode 100644 index 0000000..dad82b3 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tproch.tcl @@ -0,0 +1,26 @@ +# Emit TPROC-H results for the job recorded by load_test_tproch.tcl. + +proc getjobid {filename} { + set fd [open $filename r] + set jobid [lindex [split [gets $fd] =] 1] + close $fd + return $jobid +} + +set tmpdir $::env(TMPDIR) +set ::outputfile $tmpdir/postgres_tproch +set filename $::outputfile +set jobid [getjobid $filename] + +if {$jobid eq ""} { + puts "Job ID not found in the output file." + exit 1 +} + +jobs format JSON + +puts "TRANSACTION RESPONSE TIMES" +puts [job $jobid timing] + +puts "HAMMERDB RESULT" +puts [jobs $jobid result] diff --git a/src/hammerdb_scale/chart/templates/configmap-postgres.yaml b/src/hammerdb_scale/chart/templates/configmap-postgres.yaml new file mode 100644 index 0000000..83668cd --- /dev/null +++ b/src/hammerdb_scale/chart/templates/configmap-postgres.yaml @@ -0,0 +1,27 @@ +{{- $hasPostgreSQL := false -}} +{{- range .Values.targets -}} + {{- if eq .type "postgres" -}} + {{- $hasPostgreSQL = true -}} + {{- end -}} +{{- end -}} +{{- if $hasPostgreSQL }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "hammerdb-scale-test.fullname" . }}-scripts-postgres + labels: + {{- include "hammerdb-scale-test.labels" . | nindent 4 }} + hammerdb.io/database-type: postgres +data: + # HammerDB TCL scripts + {{- range $path, $_ := .Files.Glob "scripts/postgres/*.tcl" }} + {{ base $path }}: |- +{{ $.Files.Get $path | indent 4 }} + {{- end }} + + # Pure Storage metrics collector + {{- if .Values.pureStorage.enabled }} + collect_pure_metrics.py: |- +{{ .Files.Get "scripts/collect_pure_metrics.py" | indent 4 }} + {{- end }} +{{- end }} diff --git a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml index f57c6af..a7bab28 100644 --- a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml +++ b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml @@ -182,6 +182,57 @@ spec: value: {{ $oracleConfig.degreeOfParallel | quote }} {{- end }} + {{- if eq $target.type "postgres" }} + # PostgreSQL connection config + - name: PG_PORT + value: {{ $target.postgres.port | default 5432 | quote }} + - name: PG_SSLMODE + value: {{ $target.postgres.sslmode | default "prefer" | quote }} + {{- if $target.postgres.tablespace }} + - name: PG_TABLESPACE + value: {{ $target.postgres.tablespace | quote }} + {{- end }} + {{- if eq $.Values.testRun.benchmark "tprocc" }} + - name: TPROCC_USER + value: {{ $target.postgres.tprocc.user | default "tpcc" | quote }} + - name: TPROCC_DATABASE_NAME + value: {{ $target.postgres.tprocc.database_name | default "tpcc" | quote }} + {{- if $useSecrets }} + - name: TPROCC_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tprocc-password + {{- else }} + - name: TPROCC_PASSWORD + value: {{ $target.postgres.tprocc.password | default "tpcc" | quote }} + {{- end }} + - name: PG_STOREDPROCS + value: {{ $target.postgres.tprocc.stored_procedures | default true | quote }} + - name: PG_PARTITION + value: {{ $target.postgres.tprocc.partition | default false | quote }} + - name: PG_VACUUM + value: {{ $target.postgres.tprocc.vacuum | default false | quote }} + {{- else }} + - name: TPROCH_USER + value: {{ $target.postgres.tproch.user | default "tpch" | quote }} + - name: TPROCH_DATABASE_NAME + value: {{ $target.postgres.tproch.database_name | default "tpch" | quote }} + {{- if $useSecrets }} + - name: TPROCH_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tproch-password + {{- else }} + - name: TPROCH_PASSWORD + value: {{ $target.postgres.tproch.password | default "tpch" | quote }} + {{- end }} + - name: PG_MAX_PARALLEL_WORKERS + value: {{ $target.postgres.tproch.max_parallel_workers | default 8 | quote }} + {{- end }} + {{- end }} + {{- if eq $.Values.testRun.benchmark "tprocc" }} # TPC-C Configuration (from merged config) {{- if eq $target.type "mssql" }} diff --git a/src/hammerdb_scale/chart/templates/secret-credentials.yaml b/src/hammerdb_scale/chart/templates/secret-credentials.yaml index 7c6cdae..303bd32 100644 --- a/src/hammerdb_scale/chart/templates/secret-credentials.yaml +++ b/src/hammerdb_scale/chart/templates/secret-credentials.yaml @@ -30,6 +30,10 @@ stringData: target-{{ $index }}-tprocc-password: {{ $oracleConfig.tproccPassword | quote }} target-{{ $index }}-tproch-password: {{ $oracleConfig.tprochPassword | quote }} {{- end }} + {{- if eq $target.type "postgres" }} + target-{{ $index }}-tprocc-password: {{ $target.postgres.tprocc.password | default "tpcc" | quote }} + target-{{ $index }}-tproch-password: {{ $target.postgres.tproch.password | default "tpch" | quote }} + {{- end }} {{- end }} {{- if .Values.pureStorage.enabled }} pure-api-token: {{ .Values.pureStorage.apiToken | quote }} diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index 5d353cb..aa26135 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -755,6 +755,34 @@ def check_target(target: dict) -> tuple[str, bool, str]: except Exception as e: return (name, False, f"{host}:{port} {e}") + elif db_type == "postgres": + pg_cfg = target.get("postgres", {}) + port = pg_cfg.get("port", 5432) + try: + import psycopg2 + + conn = psycopg2.connect( + host=host, + port=port, + user=username, + password=password, + dbname="postgres", + connect_timeout=10, + ) + with conn.cursor() as cur: + cur.execute("select version()") + server = cur.fetchone()[0].split(",")[0] + conn.close() + return (name, True, f"{host}:{port} Connected ({server})") + except ImportError: + return ( + name, + False, + "psycopg2 not installed. Install with: pip install psycopg2-binary", + ) + except Exception as e: + return (name, False, f"{host}:{port} {e}") + return (name, False, f"Unknown database type: {db_type}") failures = 0 diff --git a/src/hammerdb_scale/config/defaults.py b/src/hammerdb_scale/config/defaults.py index ebe0b44..0d27b3a 100644 --- a/src/hammerdb_scale/config/defaults.py +++ b/src/hammerdb_scale/config/defaults.py @@ -70,6 +70,12 @@ def expand_targets(config: HammerDBScaleConfig) -> list[dict]: target["tproch"] = { "databaseName": mssql.get("tproch", {}).get("database_name", "tpch") } + elif effective_type == DatabaseType.postgres: + postgres = _deep_merge( + defaults.postgres.model_dump() if defaults.postgres else {}, + host.postgres.model_dump(exclude_none=True) if host.postgres else {}, + ) + target["postgres"] = postgres expanded.append(target) diff --git a/src/hammerdb_scale/config/schema.py b/src/hammerdb_scale/config/schema.py index b516f65..48fb7ef 100644 --- a/src/hammerdb_scale/config/schema.py +++ b/src/hammerdb_scale/config/schema.py @@ -12,6 +12,7 @@ class DatabaseType(str, Enum): oracle = "oracle" mssql = "mssql" + postgres = "postgres" class BenchmarkType(str, Enum): @@ -97,6 +98,39 @@ class MssqlConfig(BaseModel): connection: MssqlConnectionConfig = MssqlConnectionConfig() +# --- PostgreSQL Config Models --- + + +class PostgresTproccConfig(BaseModel): + database_name: str = "tpcc" + user: str = "tpcc" + password: str = "tpcc" + # HammerDB's recommended default for PostgreSQL TPROC-C, and generally the + # higher-throughput form. Must match between build and run: the driver + # calls procedures that only exist if the schema was built with them. + stored_procedures: bool = True + partition: bool = False + vacuum: bool = False + + +class PostgresTprochConfig(BaseModel): + database_name: str = "tpch" + user: str = "tpch" + password: str = "tpch" + # Degree of parallelism for the query phase. + max_parallel_workers: int = Field(default=8, ge=1) + + +class PostgresConfig(BaseModel): + port: int = Field(default=5432, ge=1, le=65535) + # PostgreSQL defaults to "prefer": TLS when the server offers it. + sslmode: str = "prefer" + # Empty means pg_default, which is the volume the data directory lives on. + tablespace: str = "" + tprocc: PostgresTproccConfig = PostgresTproccConfig() + tproch: PostgresTprochConfig = PostgresTprochConfig() + + # --- Image Config --- @@ -122,6 +156,7 @@ class TargetHost(BaseModel): image: Optional[ImageConfig] = None oracle: Optional[OracleConfig] = None mssql: Optional[MssqlConfig] = None + postgres: Optional[PostgresConfig] = None class TargetDefaults(BaseModel): @@ -131,6 +166,7 @@ class TargetDefaults(BaseModel): image: ImageConfig = ImageConfig() oracle: Optional[OracleConfig] = None mssql: Optional[MssqlConfig] = None + postgres: Optional[PostgresConfig] = None class TargetsConfig(BaseModel): @@ -252,6 +288,8 @@ def apply_database_type_defaults(self) -> "HammerDBScaleConfig": defaults.oracle = OracleConfig() if defaults.type == DatabaseType.mssql and defaults.mssql is None: defaults.mssql = MssqlConfig() + if defaults.type == DatabaseType.postgres and defaults.postgres is None: + defaults.postgres = PostgresConfig() return self @model_validator(mode="after") diff --git a/src/hammerdb_scale/constants.py b/src/hammerdb_scale/constants.py index b7a2110..2607008 100644 --- a/src/hammerdb_scale/constants.py +++ b/src/hammerdb_scale/constants.py @@ -109,6 +109,8 @@ def get_chart_path() -> str: DEFAULT_IMAGES = { "oracle": "sillidata/hammerdb-scale-oracle", "mssql": "sillidata/hammerdb-scale", + # The base image already carries libpq, so PostgreSQL needs no extension. + "postgres": "sillidata/hammerdb-scale", } diff --git a/src/hammerdb_scale/reports/generator.py b/src/hammerdb_scale/reports/generator.py index 1f7205b..3a29a4c 100644 --- a/src/hammerdb_scale/reports/generator.py +++ b/src/hammerdb_scale/reports/generator.py @@ -7,6 +7,14 @@ from datetime import datetime +# Presentation names for the scorecard. The config carries the internal type. +DB_DISPLAY_NAMES = { + "oracle": "Oracle", + "mssql": "SQL Server", + "postgres": "PostgreSQL", +} + + def _load_chartjs() -> str: """Load the embedded Chart.js minified source.""" ref = importlib.resources.files("hammerdb_scale.reports").joinpath("chartjs.min.js") @@ -163,7 +171,7 @@ def _header_html(summary: dict) -> str: meta_items = [ f"Test ID: {_escape(test_id)}", f"Benchmark: {benchmark.upper()}", - f"Database: {db_type}", + f"Database: {DB_DISPLAY_NAMES.get(db_type, db_type)}", f"Targets: {target_count}", ] if "warehouses" in cfg: diff --git a/src/hammerdb_scale/results/parsers.py b/src/hammerdb_scale/results/parsers.py index a67c91d..5ff31bf 100644 --- a/src/hammerdb_scale/results/parsers.py +++ b/src/hammerdb_scale/results/parsers.py @@ -142,6 +142,57 @@ def detect_error(self, log_text: str) -> str | None: return _detect_generic_error(log_text) +class PostgresParser(OutputParser): + # HammerDB reports "System achieved N NOPM from M PostgreSQL TPM". + TPM_PATTERN = re.compile(r"(\d+)\s+(?:PostgreSQL\s+)?TPM") + NOPM_PATTERN = re.compile(r"(\d+)\s+(?:PostgreSQL\s+)?NOPM") + SYSTEM_TPM_PATTERN = re.compile( + r"System achieved\s+(\d+)\s+(?:PostgreSQL\s+)?NOPM\s+from\s+" + r"(\d+)\s+(?:PostgreSQL\s+)?TPM" + ) + + QPHH_PATTERN = re.compile(r"QphH(?:@\d+)?[:\s]+([0-9]+\.?[0-9]*)") + QUERY_PATTERN = re.compile(r"Query\s+(\d+)[:\s]+([0-9]+\.?[0-9]*)\s+seconds?") + + def parse_tprocc(self, log_text: str) -> TproccResult | None: + sys_matches = self.SYSTEM_TPM_PATTERN.findall(log_text) + if sys_matches: + nopm, tpm = sys_matches[-1] + return TproccResult(tpm=int(tpm), nopm=int(nopm)) + + tpm_matches = self.TPM_PATTERN.findall(log_text) + nopm_matches = self.NOPM_PATTERN.findall(log_text) + if tpm_matches and nopm_matches: + return TproccResult( + tpm=int(tpm_matches[-1]), + nopm=int(nopm_matches[-1]), + ) + return None + + def parse_tproch(self, log_text: str) -> TprochResult | None: + qphh_matches = self.QPHH_PATTERN.findall(log_text) + if not qphh_matches: + return None + + qphh = float(qphh_matches[-1]) + seen: dict[int, float] = {} + for qnum, qtime in self.QUERY_PATTERN.findall(log_text): + seen[int(qnum)] = float(qtime) + queries = [ + TprochQueryResult(query_number=qn, time_seconds=seen[qn]) + for qn in sorted(seen) + ] + return TprochResult(qphh=qphh, queries=queries) + + def detect_error(self, log_text: str) -> str | None: + # PostgreSQL errors carry a five-character SQLSTATE, e.g. + # "ERROR: relation "warehouse" does not exist". + match = re.search(r"(?:FATAL|PANIC|ERROR):\s+.*?(?:\n|$)", log_text) + if match: + return match.group(0).strip() + return _detect_generic_error(log_text) + + def _detect_generic_error(log_text: str) -> str | None: """Generic error detection shared across parsers.""" if "Error" in log_text or "FATAL" in log_text: @@ -154,6 +205,7 @@ def _detect_generic_error(log_text: str) -> str | None: PARSERS: dict[str, OutputParser] = { "oracle": OracleParser(), "mssql": MssqlParser(), + "postgres": PostgresParser(), } diff --git a/src/hammerdb_scale/runtime/container.py b/src/hammerdb_scale/runtime/container.py index 189d038..e96a72a 100644 --- a/src/hammerdb_scale/runtime/container.py +++ b/src/hammerdb_scale/runtime/container.py @@ -448,7 +448,13 @@ def wait(self, workload_names: list[str], timeout: int) -> None: self._run(["wait"] + workload_names, timeout=timeout, check=False) def find_test_ids(self) -> list[str]: - """List test IDs that have managed containers, most recent first.""" + """List test IDs that have managed containers, most recent first. + + Ordering matters: `results` and `report` act on the first entry, so a + stale build run appearing ahead of the run the user just finished sends + them to the wrong data. The runtime does not guarantee ps ordering, so + sort explicitly by container creation time. + """ result = self._run( [ "ps", @@ -463,7 +469,7 @@ def find_test_ids(self) -> list[str]: if result.returncode != 0 or not result.stdout.strip(): return [] - seen: list[str] = [] + newest: dict[str, float] = {} for entry in self._parse_ps_json(result.stdout): labels = entry.get("Labels") or {} if isinstance(labels, str): @@ -471,9 +477,33 @@ def find_test_ids(self) -> list[str]: pair.split("=", 1) for pair in labels.split(",") if "=" in pair ) test_id = labels.get(LABEL_TEST_ID) - if test_id and test_id not in seen: - seen.append(test_id) - return seen + if not test_id: + continue + created = _created_sort_key(entry) + if created > newest.get(test_id, float("-inf")): + newest[test_id] = created + + return sorted(newest, key=lambda t: newest[t], reverse=True) + + +def _created_sort_key(entry: dict) -> float: + """Creation time of a ps entry as a sortable number. + + podman reports `Created` as a unix timestamp, docker as an RFC3339 string. + Unparseable entries sort oldest so they never displace a known-good one. + """ + created = entry.get("Created") or entry.get("CreatedAt") + if isinstance(created, (int, float)): + return float(created) + if isinstance(created, str): + parsed = _parse_ts(created) + if parsed: + return parsed.timestamp() + try: + return float(created) + except ValueError: + return float("-inf") + return float("-inf") def _parse_ts(value: str) -> datetime | None: diff --git a/src/hammerdb_scale/runtime/environment.py b/src/hammerdb_scale/runtime/environment.py index 43cfcaa..949bdb7 100644 --- a/src/hammerdb_scale/runtime/environment.py +++ b/src/hammerdb_scale/runtime/environment.py @@ -91,6 +91,8 @@ def build_target_env( env.update(_mssql_connection_env(target, mssql_cfg)) elif db_type == "oracle": env.update(_oracle_env(target)) + elif db_type == "postgres": + env.update(_postgres_env(target, benchmark)) if benchmark == "tprocc": env.update(_tprocc_env(config, target, db_type)) @@ -142,6 +144,37 @@ def _oracle_env(target: dict) -> dict[str, str]: return env +def _postgres_env(target: dict, benchmark: str) -> dict[str, str]: + """PostgreSQL connection and schema variables.""" + pg = target.get("postgres", {}) + tprocc = pg.get("tprocc", {}) + tproch = pg.get("tproch", {}) + + env = { + "PG_PORT": str(pg.get("port", 5432)), + "PG_SSLMODE": pg.get("sslmode", "prefer"), + } + if pg.get("tablespace"): + env["PG_TABLESPACE"] = pg["tablespace"] + + if benchmark == "tprocc": + env["TPROCC_USER"] = tprocc.get("user") or "tpcc" + env["TPROCC_PASSWORD"] = tprocc.get("password") or "tpcc" + env["TPROCC_DATABASE_NAME"] = tprocc.get("database_name") or "tpcc" + # Build and run must agree: the driver calls stored procedures that + # only exist if the schema was built with them. + env["PG_STOREDPROCS"] = _bool_str(tprocc.get("stored_procedures", True)) + env["PG_PARTITION"] = _bool_str(tprocc.get("partition", False)) + env["PG_VACUUM"] = _bool_str(tprocc.get("vacuum", False)) + else: + env["TPROCH_USER"] = tproch.get("user") or "tpch" + env["TPROCH_PASSWORD"] = tproch.get("password") or "tpch" + env["TPROCH_DATABASE_NAME"] = tproch.get("database_name") or "tpch" + env["PG_MAX_PARALLEL_WORKERS"] = str(tproch.get("max_parallel_workers", 8)) + + return env + + def _tprocc_env( config: HammerDBScaleConfig, target: dict, db_type: str ) -> dict[str, str]: diff --git a/tests/test_container_backend.py b/tests/test_container_backend.py index 22e85bd..ee49a5c 100644 --- a/tests/test_container_backend.py +++ b/tests/test_container_backend.py @@ -17,6 +17,7 @@ STATUS_RUNNING, ) from hammerdb_scale.runtime.container import ( + _created_sort_key, LABEL_DB_TYPE, LABEL_INDEX, LABEL_TARGET, @@ -192,3 +193,30 @@ def test_matches_kubernetes_job_naming(self): """ assert ContainerBackend._container_name("run", 0, "abc") == "hdb-load-00-abc" assert ContainerBackend._container_name("build", 11, "abc") == "hdb-build-11-abc" + + +class TestCreatedSortKey: + """Test-ID ordering decides which run `results` and `report` act on. + + Regression: podman does not guarantee ps ordering, so a stale build run + surfaced ahead of the run just finished and results showed no metrics. + """ + + def test_podman_unix_timestamp(self): + assert _created_sort_key({"Created": 1769000000}) == 1769000000.0 + + def test_docker_rfc3339_string(self): + key = _created_sort_key({"Created": "2026-07-25T13:46:00Z"}) + assert key > 0 + + def test_numeric_string(self): + assert _created_sort_key({"Created": "1769000000"}) == 1769000000.0 + + def test_missing_or_unparseable_sorts_oldest(self): + assert _created_sort_key({}) == float("-inf") + assert _created_sort_key({"Created": "nonsense"}) == float("-inf") + + def test_newer_sorts_higher(self): + older = _created_sort_key({"Created": 1769000000}) + newer = _created_sort_key({"Created": 1769009999}) + assert newer > older diff --git a/tests/test_environment.py b/tests/test_environment.py index 51d809a..a820667 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -213,3 +213,62 @@ def test_scale_factor_propagates(self): hammerdb=HammerDBConfig(tproch=TprochConfig(scale_factor=100)) ) assert _env_for(config, benchmark="tproch")["TPROCH_SCALE_FACTOR"] == "100" + + +def _postgres_config(**overrides) -> HammerDBScaleConfig: + from hammerdb_scale.config.schema import PostgresConfig, PostgresTproccConfig + + base = dict( + name="pg", + targets=TargetsConfig( + defaults=TargetDefaults( + type="postgres", + username="postgres", + password="secret", + postgres=PostgresConfig( + port=5432, + tprocc=PostgresTproccConfig( + database_name="tpcc", user="tpcc", password="tpccpw" + ), + ), + ), + hosts=[TargetHost(name="pg-01", host="10.0.0.3")], + ), + ) + base.update(overrides) + return HammerDBScaleConfig(**base) + + +class TestPostgresEnv: + def test_connection_vars(self): + env = _env_for(_postgres_config()) + assert env["HOST"] == "10.0.0.3" + assert env["PG_PORT"] == "5432" + assert env["DATABASE_TYPE"] == "postgres" + assert env["TPROCC_DRIVER"] == "pg" + + def test_schema_credentials(self): + env = _env_for(_postgres_config()) + assert env["TPROCC_USER"] == "tpcc" + assert env["TPROCC_PASSWORD"] == "tpccpw" + assert env["TPROCC_DATABASE_NAME"] == "tpcc" + + def test_stored_procedures_default_on(self): + """HammerDB's recommended default for PostgreSQL TPROC-C.""" + assert _env_for(_postgres_config())["PG_STOREDPROCS"] == "true" + + def test_stored_procedures_can_be_disabled(self): + config = _postgres_config() + config.targets.defaults.postgres.tprocc.stored_procedures = False + assert _env_for(config)["PG_STOREDPROCS"] == "false" + + def test_no_other_engine_vars_leak_in(self): + env = _env_for(_postgres_config()) + assert "ORACLE_SERVICE" not in env + assert "MSSQLS_PORT" not in env + + def test_tproch_uses_tproch_credentials(self): + env = _env_for(_postgres_config(), benchmark="tproch") + assert env["TPROCH_USER"] == "tpch" + assert env["TPROCH_DATABASE_NAME"] == "tpch" + assert "PG_STOREDPROCS" not in env diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 74623f1..2dcafa8 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -198,6 +198,11 @@ def test_oracle_returns_oracle_parser(self): def test_mssql_returns_mssql_parser(self): assert isinstance(get_parser("mssql"), MssqlParser) + def test_postgres_returns_postgres_parser(self): + from hammerdb_scale.results.parsers import PostgresParser + + assert isinstance(get_parser("postgres"), PostgresParser) + def test_unknown_raises(self): with pytest.raises(ValueError, match="No parser"): - get_parser("postgres") + get_parser("mysql") From 393be6cc34559f735d387a92a1090c3d5be4d312 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 15:31:45 -0600 Subject: [PATCH 08/14] Make the 6.0 image build reproducible and verifiable Turns two working local images into a repeatable release process. hack/build-images.sh builds the base image, then the Oracle extension FROM that base, verifies both, and only then applies moving tags. Ordering is not incidental: Dockerfile.oracle defaults to BASE_IMAGE=sillidata/hammerdb-scale :latest, so a careless build produces an "Oracle 6.0" image containing the published 5.0 base. The script wires BASE_IMAGE explicitly and then checks that hammerdbcli actually reports v6.0, which catches that whole class of error in two seconds. Verification covers the failures seen during this work: HAMMERDB_HOME resolves, hammerdbcli reports the expected version, libpq is present (without it PostgreSQL fails at runtime with a confusing Pgtcl error), the entrypoint is where the image expects it, and sqlplus works for Oracle. Nothing is tagged :latest until all of it passes. Oracle Instant Client version is now four build args instead of being spelled out in the download URLs, install path, ld.so config and libclntsh soname. That was the same hardcoding problem already fixed for HammerDB: a client upgrade meant editing five lines with no way to confirm you had caught them all. Both images now carry OCI provenance labels, so a published artifact traces back to a git commit. The release script marks a dirty tree as such. Also: renamed dockerfile to Dockerfile (the lowercase name breaks tooling that looks for the standard one), and corrected the database.support label, which claimed PostgreSQL support for as long as the image lacked libpq. Architecture is x86_64 for both, documented rather than implicit. HammerDB 6.0 does ship ARM64 tarballs and the base image would build, but Oracle Instant Client is x86_64-only, so a multi-arch base alongside an x86-only Oracle image would fail confusingly for ARM users. Verified: build-images.sh runs clean, all checks pass, and all three engines (Oracle, SQL Server, PostgreSQL) complete real benchmarks on the release- tagged images. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 35 +++--- dockerfile => Dockerfile | 31 +++-- Dockerfile.oracle | 99 ++++++++++------ docs/CONTAINER-IMAGES.md | 6 +- hack/build-images.sh | 202 +++++++++++++++++++++++++++++++++ tests/test_hammerdb_version.py | 6 +- 6 files changed, 318 insertions(+), 61 deletions(-) rename dockerfile => Dockerfile (69%) create mode 100755 hack/build-images.sh diff --git a/CLAUDE.md b/CLAUDE.md index 6afd1d6..de232bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,24 +26,33 @@ one Kubernetes Job per database target, then collects and parses the job logs in aggregated results and a self-contained HTML scorecard. - `src/hammerdb_scale/` — the CLI package (see MEMORY.md for the module map) -- `src/hammerdb_scale/chart/` — the bundled Helm chart shipped inside the wheel -- `templates/`, `scripts/`, `Chart.yaml`, `values.yaml` — repo-root copies of the same chart +- `src/hammerdb_scale/chart/` — the bundled Helm chart, the single source of truth +- `templates/`, `scripts/`, `Chart.yaml`, `values.yaml` — symlinks into the chart above +- `src/hammerdb_scale/runtime/` — execution backends (Kubernetes and container) - `entrypoint.sh` — the in-container dispatcher that selects and runs a TCL script -- `dockerfile`, `Dockerfile.oracle` — base image and Oracle Instant Client extension +- `Dockerfile`, `Dockerfile.oracle` — base image and Oracle Instant Client extension +- `hack/build-images.sh` — builds and verifies both images ### Things worth knowing -- The repo-root chart files and `src/hammerdb_scale/chart/` are duplicates. Changes to - TCL scripts or templates must be applied to both, or the packaged wheel drifts from - the repo. -- HammerDB version is hardcoded as the path `/opt/HammerDB-5.0` in `entrypoint.sh`, - the Dockerfiles, and the Helm job template `volumeMounts.mountPath`. All three must - change together for a version bump. -- Database support is gated in four places: the `DatabaseType` enum in +- The repo-root chart paths are symlinks, not copies. They used to be duplicates and + had already drifted; `get_chart_path()` prefers the packaged copy, so a fix applied + only at the repo root would never reach anyone installing from PyPI. +- The HammerDB version lives in `ARG HAMMERDB_VERSION` and flows through + `HAMMERDB_HOME`. `entrypoint.sh` *searches* for its mounted scripts rather than + requiring the chart's mountPath to match the image, because those are set by + different artifacts and can always disagree. +- `DEFAULT_HAMMERDB_VERSION` is what this repo builds; `PUBLISHED_HAMMERDB_VERSION` + is what the published images contain and drives the chart default. Move the latter + only after pushing new images. +- Database support is gated in five places: the `DatabaseType` enum in `config/schema.py`, the `case` blocks in `entrypoint.sh`, the parser registry in - `results/parsers.py`, and the per-database TCL script directories. -- Credentials are currently passed as plain environment variables in the Job spec and - are visible via `kubectl describe job`. + `results/parsers.py`, the per-database TCL script directories, and the per-database + ConfigMap template in the chart. +- The base image needs `libpq5` for PostgreSQL: HammerDB bundles Pgtcl but links the + system libpq at runtime. +- Credentials go through a Kubernetes Secret by default (`kubernetes.use_secrets`), + so they are not readable via `kubectl describe job`. ## Working preferences diff --git a/dockerfile b/Dockerfile similarity index 69% rename from dockerfile rename to Dockerfile index 0075297..1a100bb 100644 --- a/dockerfile +++ b/Dockerfile @@ -1,17 +1,22 @@ # HammerDB Scale - Base Image -# Supports: SQL Server, PostgreSQL, MySQL (Oracle requires extension image) # -# This is the public base image containing HammerDB and open-source database -# drivers. For Oracle support, build the extension image using Dockerfile.oracle +# Verified working: SQL Server and PostgreSQL. +# Oracle needs the extension image (Dockerfile.oracle) because Oracle Instant +# Client cannot be redistributed. +# +# ARCHITECTURE: x86_64. HammerDB 6.0 does publish ARM64 tarballs and this image +# would build for ARM, but the Oracle extension cannot (Instant Client is +# x86_64-only), so the pair is kept uniformly x86_64 to avoid shipping a base +# image that works on ARM alongside an Oracle image that does not. # # BUILD: -# docker build -t sillidata/hammerdb-scale:latest . +# docker build -t sillidata/hammerdb-scale:6.0 . # # To build a different HammerDB version: # docker build --build-arg HAMMERDB_VERSION=5.0 -t sillidata/hammerdb-scale:5.0 . # -# ORACLE USERS: -# docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:latest . +# Prefer hack/build-images.sh, which builds this and the Oracle +# image in the right order and verifies both. FROM ubuntu:24.04 @@ -20,10 +25,22 @@ FROM ubuntu:24.04 # than hardcoding a path, so a version bump is this one argument. ARG HAMMERDB_VERSION=6.0 +# Provenance. Populated by the release script; harmless when built by hand. +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown +ARG IMAGE_VERSION=dev + LABEL maintainer="hammerdb-scale" LABEL description="HammerDB Scale Test Runner - Multi-Database Performance Testing" LABEL hammerdb.version="${HAMMERDB_VERSION}" -LABEL database.support="mssql,postgresql,mysql (oracle via extension)" +LABEL database.support="mssql,postgresql (oracle via extension image)" +LABEL org.opencontainers.image.title="hammerdb-scale" +LABEL org.opencontainers.image.description="HammerDB Scale Test Runner - Multi-Database Performance Testing" +LABEL org.opencontainers.image.source="https://github.com/PureStorage-OpenConnect/hammerdb-scale" +LABEL org.opencontainers.image.licenses="Apache-2.0" +LABEL org.opencontainers.image.version="${IMAGE_VERSION}" +LABEL org.opencontainers.image.revision="${VCS_REF}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" # Set environment variables ENV DEBIAN_FRONTEND=noninteractive diff --git a/Dockerfile.oracle b/Dockerfile.oracle index e5f3e36..e7112f8 100644 --- a/Dockerfile.oracle +++ b/Dockerfile.oracle @@ -1,27 +1,63 @@ # Oracle Extension Image -# Extends the public hammerdb-scale image with Oracle Instant Client +# Extends the hammerdb-scale base image with Oracle Instant Client. # # LICENSING: Oracle Instant Client is proprietary software. By building this # image, you download Oracle software directly from Oracle's servers and agree # to Oracle's license terms: https://www.oracle.com/downloads/licenses/instant-client-lic.html +# This is why Oracle support ships as a separate image you build yourself +# rather than being part of the base image. # -# BUILD: -# docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:latest . -# docker push myregistry/hammerdb-scale-oracle:latest +# ARCHITECTURE: x86_64 only. Oracle publishes Instant Client for linux.x64 at +# the URLs used below; there is no ARM64 equivalent in this download path. # -# The base image (sillidata/hammerdb-scale) includes HammerDB and drivers for -# SQL Server, PostgreSQL, and MySQL. This extension adds Oracle support. +# BUILD (against the published base): +# docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:6.0 . # -# To build against a locally-built base image: -# docker build -f Dockerfile.oracle --build-arg BASE_IMAGE=hammerdb-scale:local \ -# -t hammerdb-scale-oracle:local . +# BUILD (against a locally built base — required when bumping HammerDB, since +# the published base may still be an older version): +# docker build -f Dockerfile.oracle \ +# --build-arg BASE_IMAGE=hammerdb-scale:6.0 \ +# -t hammerdb-scale-oracle:6.0 . +# +# Prefer hack/build-images.sh, which wires the base image through +# correctly and verifies the result. ARG BASE_IMAGE=sillidata/hammerdb-scale:latest FROM ${BASE_IMAGE} +# Instant Client version, defined once. It appears in the download URLs, the +# install directory, the ld.so config and the libclntsh soname, so hardcoding +# it meant a client upgrade was a five-line edit with no way to verify you had +# caught every occurrence. +# IC_VERSION full version, used in filenames (21.11.0.0.0) +# IC_DIR_VER directory suffix (21_11) +# IC_URL_VER version path segment in the URL (2111000) +# IC_SONAME libclntsh major.minor (21.1) +ARG IC_VERSION=21.11.0.0.0 +ARG IC_DIR_VER=21_11 +ARG IC_URL_VER=2111000 +ARG IC_SONAME=21.1 + +# Provenance. Populated by the release script; harmless when built by hand. +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown +ARG IMAGE_VERSION=dev + LABEL maintainer="hammerdb-scale" LABEL description="HammerDB Scale - Oracle Extension" -LABEL oracle.instantclient.version="21.11.0.0.0" +LABEL oracle.instantclient.version="${IC_VERSION}" +LABEL org.opencontainers.image.title="hammerdb-scale-oracle" +LABEL org.opencontainers.image.description="HammerDB Scale runner with Oracle Instant Client" +LABEL org.opencontainers.image.source="https://github.com/PureStorage-OpenConnect/hammerdb-scale" +LABEL org.opencontainers.image.licenses="Apache-2.0" +LABEL org.opencontainers.image.version="${IMAGE_VERSION}" +LABEL org.opencontainers.image.revision="${VCS_REF}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +ENV ORACLE_HOME=/opt/oracle/instantclient_${IC_DIR_VER} +ENV LD_LIBRARY_PATH=/opt/oracle/instantclient_${IC_DIR_VER}:$LD_LIBRARY_PATH +ENV PATH=/opt/oracle/instantclient_${IC_DIR_VER}:$PATH +ENV TNS_ADMIN=/opt/oracle/instantclient_${IC_DIR_VER}/network/admin # Install Oracle Instant Client dependencies and download from Oracle RUN apt-get update && \ @@ -29,40 +65,33 @@ RUN apt-get update && \ wget \ unzip \ libaio1t64 && \ - # Create symlink for libaio compatibility (Ubuntu 24.04) - ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 && \ - # Download Oracle Instant Client from Oracle's servers + # Ubuntu 24.04 ships libaio as .so.1t64; Instant Client links against .so.1 + ln -sf /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 && \ mkdir -p /opt/oracle && \ cd /opt/oracle && \ - echo "Downloading Oracle Instant Client 21.11 from Oracle..." && \ + echo "Downloading Oracle Instant Client ${IC_VERSION} from Oracle..." && \ + BASE_URL="https://download.oracle.com/otn_software/linux/instantclient/${IC_URL_VER}" && \ wget --progress=dot:mega \ - https://download.oracle.com/otn_software/linux/instantclient/2111000/instantclient-basic-linux.x64-21.11.0.0.0dbru.zip && \ + "${BASE_URL}/instantclient-basic-linux.x64-${IC_VERSION}dbru.zip" && \ wget --progress=dot:mega \ - https://download.oracle.com/otn_software/linux/instantclient/2111000/instantclient-sqlplus-linux.x64-21.11.0.0.0dbru.zip && \ - unzip -q instantclient-basic-linux.x64-21.11.0.0.0dbru.zip && \ - unzip -q instantclient-sqlplus-linux.x64-21.11.0.0.0dbru.zip && \ - rm *.zip && \ - # Configure library paths - echo '/opt/oracle/instantclient_21_11' > /etc/ld.so.conf.d/oracle.conf && \ + "${BASE_URL}/instantclient-sqlplus-linux.x64-${IC_VERSION}dbru.zip" && \ + unzip -q "instantclient-basic-linux.x64-${IC_VERSION}dbru.zip" && \ + unzip -q "instantclient-sqlplus-linux.x64-${IC_VERSION}dbru.zip" && \ + rm ./*.zip && \ + echo "/opt/oracle/instantclient_${IC_DIR_VER}" > /etc/ld.so.conf.d/oracle.conf && \ ldconfig && \ - # Cleanup apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Set Oracle environment variables -ENV ORACLE_HOME=/opt/oracle/instantclient_21_11 -ENV LD_LIBRARY_PATH=$ORACLE_HOME:$LD_LIBRARY_PATH -ENV PATH=$ORACLE_HOME:$PATH -ENV TNS_ADMIN=$ORACLE_HOME/network/admin - -# Create lib subdirectory symlinks for HammerDB's Oratcl compatibility -RUN mkdir -p $ORACLE_HOME/lib && \ - cd $ORACLE_HOME && \ +# HammerDB's Oratcl looks for the client libraries under $ORACLE_HOME/lib, +# but Instant Client unpacks them flat into $ORACLE_HOME. +RUN mkdir -p "${ORACLE_HOME}/lib" && \ + cd "${ORACLE_HOME}" && \ for lib in *.so*; do \ - [ -f "$lib" ] && ln -sf $ORACLE_HOME/$lib $ORACLE_HOME/lib/$lib; \ + [ -f "$lib" ] && ln -sf "${ORACLE_HOME}/${lib}" "${ORACLE_HOME}/lib/${lib}"; \ done && \ - ln -sf $ORACLE_HOME/libclntsh.so.21.1 $ORACLE_HOME/libclntsh.so && \ - ln -sf $ORACLE_HOME/libclntsh.so.21.1 $ORACLE_HOME/lib/libclntsh.so + ln -sf "${ORACLE_HOME}/libclntsh.so.${IC_SONAME}" "${ORACLE_HOME}/libclntsh.so" && \ + ln -sf "${ORACLE_HOME}/libclntsh.so.${IC_SONAME}" "${ORACLE_HOME}/lib/libclntsh.so" -# Verify Oracle client is working +# Fail the build here rather than at benchmark time if the client is broken. RUN sqlplus -v && echo "Oracle Instant Client ready" diff --git a/docs/CONTAINER-IMAGES.md b/docs/CONTAINER-IMAGES.md index 1c373db..bf097c5 100644 --- a/docs/CONTAINER-IMAGES.md +++ b/docs/CONTAINER-IMAGES.md @@ -6,7 +6,7 @@ HammerDB-Scale deploys HammerDB as Kubernetes Jobs using pre-built container ima | Image | Database | Source | |-------|----------|--------| -| `sillidata/hammerdb-scale:latest` | SQL Server | [dockerfile](../dockerfile) | +| `sillidata/hammerdb-scale:latest` | SQL Server | [Dockerfile](../Dockerfile) | | `sillidata/hammerdb-scale-oracle:latest` | Oracle | [Dockerfile.oracle](../Dockerfile.oracle) | These images are hosted on Docker Hub and can be pulled without authentication. @@ -33,7 +33,7 @@ The **Oracle image** extends the MSSQL image and adds: ### SQL Server Image ```bash -docker build -f dockerfile -t my-org/hammerdb-scale:latest . +docker build -f Dockerfile -t my-org/hammerdb-scale:latest . ``` ### Oracle Image @@ -42,7 +42,7 @@ The Oracle image extends the base image: ```bash # Build base first -docker build -f dockerfile -t my-org/hammerdb-scale:latest . +docker build -f Dockerfile -t my-org/hammerdb-scale:latest . # Then build Oracle (references base image) docker build -f Dockerfile.oracle -t my-org/hammerdb-scale-oracle:latest . diff --git a/hack/build-images.sh b/hack/build-images.sh new file mode 100755 index 0000000..cacca12 --- /dev/null +++ b/hack/build-images.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Build and verify the hammerdb-scale container images. +# +# Builds the base image, then the Oracle extension FROM that base, then runs +# verification against both. Nothing is pushed and no moving tag is applied +# until every check passes. +# +# The ordering is not incidental: Dockerfile.oracle defaults to +# BASE_IMAGE=sillidata/hammerdb-scale:latest, so a careless build produces an +# "Oracle 6.0" image containing whatever the published base happens to be. +# This script always wires BASE_IMAGE explicitly and then verifies the result. +# +# USAGE +# hack/build-images.sh # local build, localhost/ +# hack/build-images.sh -r docker.io/sillidata # tag for a registry +# hack/build-images.sh -r docker.io/sillidata --push +# +# OPTIONS +# -r, --registry REG registry/namespace prefix (default: localhost) +# -v, --version VER HammerDB version to build (default: from Dockerfile) +# -t, --tag TAG primary immutable tag (default: the HammerDB version) +# --latest also apply the :latest tag (implied by --push) +# --push push all tags after verification +# --skip-verify build only; not recommended +# --base-only skip the Oracle image +# +# Requires podman or docker. Oracle Instant Client is x86_64 only, so both +# images are x86_64. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +REGISTRY="localhost" +HDB_VERSION="" +PRIMARY_TAG="" +APPLY_LATEST=0 +DO_PUSH=0 +DO_VERIFY=1 +BASE_ONLY=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -r|--registry) REGISTRY="${2%/}"; shift 2 ;; + -v|--version) HDB_VERSION="$2"; shift 2 ;; + -t|--tag) PRIMARY_TAG="$2"; shift 2 ;; + --latest) APPLY_LATEST=1; shift ;; + --push) DO_PUSH=1; APPLY_LATEST=1; shift ;; + --skip-verify) DO_VERIFY=0; shift ;; + --base-only) BASE_ONLY=1; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# --- Runtime --------------------------------------------------------------- +if command -v podman >/dev/null 2>&1; then + RUNTIME=podman +elif command -v docker >/dev/null 2>&1; then + RUNTIME=docker +else + echo "ERROR: neither podman nor docker found" >&2 + exit 1 +fi + +log() { echo -e "\033[1m[build]\033[0m $*"; } +fail() { echo -e "\033[31m[FAIL]\033[0m $*" >&2; exit 1; } +ok() { echo -e "\033[32m ok\033[0m $*"; } + +# --- Version resolution ---------------------------------------------------- +# The Dockerfile is the source of truth so the script cannot drift from it. +if [ -z "$HDB_VERSION" ]; then + HDB_VERSION="$(grep -oP '^ARG HAMMERDB_VERSION=\K\S+' Dockerfile)" +fi +[ -n "$HDB_VERSION" ] || fail "could not determine HAMMERDB_VERSION from Dockerfile" + +PRIMARY_TAG="${PRIMARY_TAG:-$HDB_VERSION}" +MAJOR_TAG="${HDB_VERSION%%.*}" + +CLI_VERSION="$(grep -oP '^version = "\K[^"]+' pyproject.toml || echo unknown)" +VCS_REF="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +if ! git diff --quiet HEAD 2>/dev/null; then + VCS_REF="${VCS_REF}-dirty" +fi + +BASE_IMAGE="${REGISTRY}/hammerdb-scale" +ORACLE_IMAGE="${REGISTRY}/hammerdb-scale-oracle" + +log "runtime=${RUNTIME} registry=${REGISTRY}" +log "HammerDB=${HDB_VERSION} CLI=${CLI_VERSION} commit=${VCS_REF}" +log "tags: ${PRIMARY_TAG}, ${MAJOR_TAG}, ${CLI_VERSION}-hdb${HDB_VERSION}$([ $APPLY_LATEST -eq 1 ] && echo ', latest')" + +COMMON_ARGS=( + --build-arg "VCS_REF=${VCS_REF}" + --build-arg "BUILD_DATE=${BUILD_DATE}" + --build-arg "IMAGE_VERSION=${CLI_VERSION}" +) + +# --- Build: base ----------------------------------------------------------- +log "building ${BASE_IMAGE}:${PRIMARY_TAG}" +$RUNTIME build \ + -f Dockerfile \ + --build-arg "HAMMERDB_VERSION=${HDB_VERSION}" \ + "${COMMON_ARGS[@]}" \ + -t "${BASE_IMAGE}:${PRIMARY_TAG}" \ + . + +# --- Build: Oracle --------------------------------------------------------- +if [ "$BASE_ONLY" -eq 0 ]; then + log "building ${ORACLE_IMAGE}:${PRIMARY_TAG} FROM ${BASE_IMAGE}:${PRIMARY_TAG}" + $RUNTIME build \ + -f Dockerfile.oracle \ + --build-arg "BASE_IMAGE=${BASE_IMAGE}:${PRIMARY_TAG}" \ + "${COMMON_ARGS[@]}" \ + -t "${ORACLE_IMAGE}:${PRIMARY_TAG}" \ + . +fi + +# --- Verify ---------------------------------------------------------------- +verify_image() { + local image="$1" want_oracle="$2" + log "verifying ${image}" + + local home + home="$($RUNTIME run --rm --entrypoint /bin/bash "$image" -c 'echo -n "$HAMMERDB_HOME"')" + [ "$home" = "/opt/HammerDB-${HDB_VERSION}" ] \ + || fail "${image}: HAMMERDB_HOME is '${home}', expected /opt/HammerDB-${HDB_VERSION}" + ok "HAMMERDB_HOME=${home}" + + # The single check that catches an Oracle image built on a stale base. + local reported + reported="$($RUNTIME run --rm --entrypoint /bin/bash "$image" \ + -c 'cd $HAMMERDB_HOME && printf "" | ./hammerdbcli 2>&1 | head -1')" + grep -q "v${HDB_VERSION}" <<<"$reported" \ + || fail "${image}: hammerdbcli reports '${reported}', expected v${HDB_VERSION}" + ok "hammerdbcli: ${reported}" + + # PostgreSQL fails at runtime without the system libpq, which is easy to + # miss because HammerDB bundles Pgtcl itself. + $RUNTIME run --rm --entrypoint /bin/bash "$image" \ + -c 'ldconfig -p | grep -q libpq.so.5' \ + || fail "${image}: libpq.so.5 missing; PostgreSQL will fail to load Pgtcl" + ok "libpq present (PostgreSQL driver can load)" + + $RUNTIME run --rm --entrypoint /bin/bash "$image" \ + -c 'command -v /usr/local/bin/entrypoint.sh >/dev/null' \ + || fail "${image}: entrypoint.sh not at /usr/local/bin" + ok "entrypoint in place" + + if [ "$want_oracle" -eq 1 ]; then + $RUNTIME run --rm --entrypoint /bin/bash "$image" -c 'sqlplus -v >/dev/null' \ + || fail "${image}: sqlplus not working" + ok "Oracle Instant Client working" + fi +} + +if [ "$DO_VERIFY" -eq 1 ]; then + verify_image "${BASE_IMAGE}:${PRIMARY_TAG}" 0 + [ "$BASE_ONLY" -eq 0 ] && verify_image "${ORACLE_IMAGE}:${PRIMARY_TAG}" 1 +else + log "skipping verification (--skip-verify)" +fi + +# --- Tag ------------------------------------------------------------------- +# Only after verification, so a broken build never claims :latest. +apply_tags() { + local image="$1" + $RUNTIME tag "${image}:${PRIMARY_TAG}" "${image}:${MAJOR_TAG}" + $RUNTIME tag "${image}:${PRIMARY_TAG}" "${image}:${CLI_VERSION}-hdb${HDB_VERSION}" + [ "$APPLY_LATEST" -eq 1 ] && $RUNTIME tag "${image}:${PRIMARY_TAG}" "${image}:latest" + return 0 +} + +log "applying tags" +apply_tags "${BASE_IMAGE}" +[ "$BASE_ONLY" -eq 0 ] && apply_tags "${ORACLE_IMAGE}" + +# --- Push ------------------------------------------------------------------ +if [ "$DO_PUSH" -eq 1 ]; then + [ "$REGISTRY" = "localhost" ] && fail "refusing to push to 'localhost'; pass --registry" + push_tags() { + local image="$1" + for tag in "${PRIMARY_TAG}" "${MAJOR_TAG}" "${CLI_VERSION}-hdb${HDB_VERSION}" latest; do + log "pushing ${image}:${tag}" + $RUNTIME push "${image}:${tag}" + done + } + push_tags "${BASE_IMAGE}" + [ "$BASE_ONLY" -eq 0 ] && push_tags "${ORACLE_IMAGE}" + log "push complete" +else + echo + log "built and verified. Not pushed." + echo " To push: $0 --registry ${REGISTRY} --push" +fi + +echo +$RUNTIME images --format " {{.Repository}}:{{.Tag}} {{.Size}}" \ + | grep -E "hammerdb-scale(-oracle)?:" | sort -u diff --git a/tests/test_hammerdb_version.py b/tests/test_hammerdb_version.py index c8e3f77..01d8d2b 100644 --- a/tests/test_hammerdb_version.py +++ b/tests/test_hammerdb_version.py @@ -25,9 +25,9 @@ def _dockerfile_version() -> str: - text = (REPO_ROOT / "dockerfile").read_text() + text = (REPO_ROOT / "Dockerfile").read_text() match = re.search(r"^ARG HAMMERDB_VERSION=(\S+)", text, re.MULTILINE) - assert match, "dockerfile must declare ARG HAMMERDB_VERSION" + assert match, "Dockerfile must declare ARG HAMMERDB_VERSION" return match.group(1) @@ -59,7 +59,7 @@ class TestNoHardcodedPaths: @pytest.mark.parametrize( "relative_path", - ["entrypoint.sh", "dockerfile", "Dockerfile.oracle"], + ["entrypoint.sh", "Dockerfile", "Dockerfile.oracle"], ) def test_no_versioned_hammerdb_paths(self, relative_path): text = (REPO_ROOT / relative_path).read_text() From a13ca2950f4e27eedf6246d833182fefc1ada78f Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 17:01:44 -0600 Subject: [PATCH 09/14] Point the chart default at the published HammerDB 6.0 images The 6.0 base and Oracle images are now on Docker Hub under sillidata, tagged 6.0, 6, 2.0.3-hdb6.0 and latest, so PUBLISHED_HAMMERDB_VERSION can move off 5.0 and the chart can mount scripts at the matching path. Verified before moving it that an unmodified CLI 2.0.3 install, whose chart hardcodes /opt/HammerDB-5.0/scripts, still runs against a 6.0 image: the entrypoint search resolves HAMMERDB_HOME from the image and SCRIPT_DIR from wherever the scripts were actually mounted. Full TPC-C runs completed on both images against live SQL Server and Oracle, and 2.0.3 ships TCL parse scripts byte-identical to these, so existing users are not broken by latest moving to 6.0. Co-Authored-By: Claude Opus 5 (1M context) --- .../chart/templates/job-hammerdb-worker.yaml | 2 +- src/hammerdb_scale/constants.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml index a7bab28..8abefd0 100644 --- a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml +++ b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml @@ -342,7 +342,7 @@ spec: # entrypoint.sh searches for its scripts, so this need not match # the image's HAMMERDB_HOME exactly. Set global.hammerdbHome to pin # it for an image built with a different HAMMERDB_VERSION. - mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-5.0") }} + mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-6.0") }} volumes: - name: scripts diff --git a/src/hammerdb_scale/constants.py b/src/hammerdb_scale/constants.py index 2607008..1925161 100644 --- a/src/hammerdb_scale/constants.py +++ b/src/hammerdb_scale/constants.py @@ -48,12 +48,13 @@ def _package_version() -> str: # Where the chart mounts TCL scripts by default. # -# This deliberately tracks the *published* image, which is still 5.0, not the -# version we build locally. entrypoint.sh searches for its scripts rather than -# requiring an exact match, so a mismatch is tolerated either way; but pointing -# the default at a path the published image does not have would break every -# out-of-the-box Kubernetes run. Move this to 6.0 when 6.0 images are published. -PUBLISHED_HAMMERDB_VERSION = "5.0" +# This deliberately tracks the *published* image rather than the version this +# repo builds, because the two can differ between a local build and a registry +# push. entrypoint.sh searches for its scripts rather than requiring an exact +# match, so a mismatch is tolerated either way; but pointing the default at a +# path the published image does not have would break every out-of-the-box +# Kubernetes run. Only move this after the matching images are pushed. +PUBLISHED_HAMMERDB_VERSION = "6.0" DEFAULT_HAMMERDB_HOME = f"/opt/HammerDB-{PUBLISHED_HAMMERDB_VERSION}" DEFAULT_RESULTS_DIR = "results" From 60cfb7983b0bf6ee2873587653ace58e8f59f736 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 20:36:26 -0600 Subject: [PATCH 10/14] Capture HammerDB 6.0 transaction response times in the scorecard 6.0 reports per-transaction response time distributions from reservoir sampling, so tail percentiles are exact rather than bucket approximations. The TCL already asked for them and TPROCC_TIMEPROFILE already flowed through, but time_profile defaulted to false and the parser discarded the block, so the data reached the container logs and stopped there. Adds parse_transaction_timings, carries the result through the aggregator, and renders a percentile table plus a p50/p95/p99 chart. Sits directly above the storage section: array latency is hard to argue from without the application's view of the same moment, and the max column surfaces stalls that averages hide (one run showed SLEV at 12,639ms max against a 2.96ms median). Cross-target aggregation weights percentiles by call count but keeps max as the worst seen anywhere, since a single stall on one target is the finding rather than noise to be averaged away. time_profile now defaults on. Measured on an 8-VU SQL Server run the overhead sat inside run-to-run variance: 100,371 TPM profiled against 98,575 and 106,322 unprofiled on the same host and schema. Every layer treats missing timings as normal, matching how pure_metrics already behaved: profiling off renders a report with the section absent rather than empty. Verified across all four combinations of timing/array data present. Co-Authored-By: Claude Opus 5 (1M context) --- src/hammerdb_scale/config/schema.py | 7 +- src/hammerdb_scale/reports/generator.py | 110 ++++++++++++++++ src/hammerdb_scale/results/aggregator.py | 20 ++- src/hammerdb_scale/results/parsers.py | 97 +++++++++++++- src/hammerdb_scale/runtime/__init__.py | 3 + src/hammerdb_scale/runtime/container.py | 80 ++++++++++++ tests/test_container_backend.py | 52 ++++++++ tests/test_timings.py | 153 +++++++++++++++++++++++ 8 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 tests/test_timings.py diff --git a/src/hammerdb_scale/config/schema.py b/src/hammerdb_scale/config/schema.py index 48fb7ef..e8857c5 100644 --- a/src/hammerdb_scale/config/schema.py +++ b/src/hammerdb_scale/config/schema.py @@ -187,7 +187,12 @@ class TproccConfig(BaseModel): total_iterations: int = Field(default=10000000, ge=1) all_warehouses: bool = True checkpoint: bool = True - time_profile: bool = False + # On by default: this is what produces the per-transaction response time + # percentiles in the scorecard, and array-side latency is hard to argue + # from without the application's view of the same moment. Measured at + # under run-to-run variance on an 8-VU SQL Server run, so the data is + # effectively free. + time_profile: bool = True class TprochConfig(BaseModel): diff --git a/src/hammerdb_scale/reports/generator.py b/src/hammerdb_scale/reports/generator.py index 3a29a4c..d9863c4 100644 --- a/src/hammerdb_scale/reports/generator.py +++ b/src/hammerdb_scale/reports/generator.py @@ -95,6 +95,8 @@ def generate_scorecard( tbody tr:nth-child(even) { background: var(--gray-50); } tbody tr:nth-child(odd) { background: var(--white); } .num { text-align: right; font-variant-numeric: tabular-nums; font-family: "SF Mono", "Cascadia Code", Consolas, monospace; } +.txn-name { font-weight: 600; letter-spacing: 0.02em; } +.worst { color: #b91c1c; } .status-ok { color: var(--green); font-weight: 600; } .status-fail { color: var(--red); font-weight: 600; } /* Charts */ @@ -337,6 +339,112 @@ def _storage_charts_js(pure_metrics: dict | None) -> str: return html +def _aggregate_timings(targets: list[dict]) -> list[dict]: + """Combine per-target transaction timings into one call-weighted view. + + Averages and percentiles are weighted by call count, because a target + that ran ten times the transactions should dominate the mean. Maximum is + the worst seen anywhere: a single stall on one target is exactly the + outlier a storage validation needs to surface, so it must not be averaged + away. + """ + combined: dict[str, dict] = {} + for target in targets: + for timing in (target.get("tprocc") or {}).get("timings", []): + name = timing.get("name") + if not name: + continue + calls = int(timing.get("calls", 0) or 0) + if calls <= 0: + continue + entry = combined.setdefault( + name, + { + "name": name, + "calls": 0, + "max_ms": 0.0, + "_weighted": {k: 0.0 for k in ("avg_ms", "p50_ms", "p95_ms", "p99_ms")}, + }, + ) + entry["calls"] += calls + entry["max_ms"] = max(entry["max_ms"], float(timing.get("max_ms", 0) or 0)) + for key in entry["_weighted"]: + entry["_weighted"][key] += float(timing.get(key, 0) or 0) * calls + + rows = [] + for entry in combined.values(): + calls = entry["calls"] + row = {"name": entry["name"], "calls": calls, "max_ms": entry["max_ms"]} + for key, total in entry["_weighted"].items(): + row[key] = total / calls if calls else 0.0 + rows.append(row) + rows.sort(key=lambda r: r["calls"], reverse=True) + return rows + + +def _latency_section_html(targets: list[dict]) -> str: + """Render the transaction response time section. + + Returns "" when the run had time profiling disabled, so the report simply + omits the section rather than showing an empty panel. + """ + rows = _aggregate_timings(targets) + if not rows: + return "" + + body = "\n".join( + f""" + {r['name']} + {_fmt_number(r['calls'])} + {r['avg_ms']:.2f} + {r['p50_ms']:.2f} + {r['p95_ms']:.2f} + {r['p99_ms']:.2f} + {r['max_ms']:,.2f} + """ + for r in rows + ) + + labels = json.dumps([r["name"] for r in rows]) + p50 = json.dumps([round(r["p50_ms"], 3) for r in rows]) + p95 = json.dumps([round(r["p95_ms"], 3) for r in rows]) + p99 = json.dumps([round(r["p99_ms"], 3) for r in rows]) + + return f"""

Transaction Response Times + + (milliseconds, call-weighted across targets) +

+ + + + + + + +{body} + +
TransactionCallsAvgp50p95p99Max
+
+""" + + def _storage_section_html(pure_metrics: dict | None) -> str: """Render the full Storage Performance section: cards, stats table, and charts.""" if not pure_metrics: @@ -570,6 +678,7 @@ def _render_tprocc(summary: dict, pure_metrics: dict | None) -> str: }}); """ + latency_section = _latency_section_html(targets) storage_section = _storage_section_html(pure_metrics) if has_storage else "" chartjs_src = _load_chartjs() @@ -590,6 +699,7 @@ def _render_tprocc(summary: dict, pure_metrics: dict | None) -> str: {table_html} {chart_html} +{latency_section} {storage_section} {_config_snapshot(summary)} diff --git a/src/hammerdb_scale/results/aggregator.py b/src/hammerdb_scale/results/aggregator.py index 28474e0..a1c5b09 100644 --- a/src/hammerdb_scale/results/aggregator.py +++ b/src/hammerdb_scale/results/aggregator.py @@ -9,7 +9,7 @@ from hammerdb_scale.config.schema import HammerDBScaleConfig from hammerdb_scale.constants import VERSION -from hammerdb_scale.results.parsers import get_parser +from hammerdb_scale.results.parsers import get_parser, parse_transaction_timings _PURE_JSON_START = ">>>PURE_METRICS_JSON_START<<<" @@ -191,6 +191,24 @@ def aggregate_results( "tpm": parsed.tpm, "nopm": parsed.nopm, } + # Present only when the run had time profiling on. Absent + # is normal, so downstream must treat it as optional. + timings = parse_transaction_timings(log_text) + if timings: + target_result["tprocc"]["timings"] = [ + { + "name": t.name, + "calls": t.calls, + "min_ms": t.min_ms, + "avg_ms": t.avg_ms, + "max_ms": t.max_ms, + "p50_ms": t.p50_ms, + "p95_ms": t.p95_ms, + "p99_ms": t.p99_ms, + "ratio_pct": t.ratio_pct, + } + for t in timings + ] elif benchmark == "tproch": parsed = parser.parse_tproch(log_text) if parsed: diff --git a/src/hammerdb_scale/results/parsers.py b/src/hammerdb_scale/results/parsers.py index 5ff31bf..bca22df 100644 --- a/src/hammerdb_scale/results/parsers.py +++ b/src/hammerdb_scale/results/parsers.py @@ -2,15 +2,110 @@ from __future__ import annotations +import json import re from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field + + +@dataclass +class TransactionTiming: + """Response time distribution for one TPC-C transaction type. + + HammerDB 6.0 reports these from reservoir sampling, so the tail + percentiles are exact rather than bucket approximations. All times are + milliseconds as HammerDB emits them. + """ + + name: str + calls: int + min_ms: float + avg_ms: float + max_ms: float + p50_ms: float + p95_ms: float + p99_ms: float + ratio_pct: float @dataclass class TproccResult: tpm: int nopm: int + # Populated only when the run had time profiling enabled. Storage + # validation cares about this: array-side latency means little without + # the application's view of the same moment. + timings: list[TransactionTiming] = field(default_factory=list) + + +# The parse script prints this header immediately before the timing JSON. +_TIMING_HEADER = "TRANSACTION RESPONSE TIMES" + + +def _extract_json_object(text: str) -> str | None: + """Return the first balanced {...} block in text, or None.""" + start = text.find("{") + if start == -1: + return None + depth = 0 + for index in range(start, len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return text[start : index + 1] + return None + + +def parse_transaction_timings(log_text: str) -> list[TransactionTiming]: + """Pull per-transaction response times out of HammerDB 6.0 output. + + Returns an empty list when profiling was disabled, which is the common + case: HammerDB then emits a placeholder object saying the job has no + timing data, and callers must treat that exactly like absent data rather + than rendering an empty latency panel. + """ + header_at = log_text.find(_TIMING_HEADER) + if header_at == -1: + return [] + + block = _extract_json_object(log_text[header_at + len(_TIMING_HEADER) :]) + if not block: + return [] + try: + payload = json.loads(block) + except json.JSONDecodeError: + return [] + + timings: list[TransactionTiming] = [] + for name, entry in payload.items(): + if not isinstance(entry, dict) or "calls" not in entry: + # The "no timing data" placeholder, or a job-id wrapper. + continue + + def _number(key: str) -> float: + try: + return float(entry.get(key, 0) or 0) + except (TypeError, ValueError): + return 0.0 + + timings.append( + TransactionTiming( + name=name, + calls=int(_number("calls")), + min_ms=_number("min_ms"), + avg_ms=_number("avg_ms"), + max_ms=_number("max_ms"), + p50_ms=_number("p50_ms"), + p95_ms=_number("p95_ms"), + p99_ms=_number("p99_ms"), + ratio_pct=_number("ratio_pct"), + ) + ) + # Busiest transaction first: that is the one a reader wants to see. + timings.sort(key=lambda t: t.calls, reverse=True) + return timings @dataclass diff --git a/src/hammerdb_scale/runtime/__init__.py b/src/hammerdb_scale/runtime/__init__.py index 20922e7..bfdd46d 100644 --- a/src/hammerdb_scale/runtime/__init__.py +++ b/src/hammerdb_scale/runtime/__init__.py @@ -46,10 +46,13 @@ def get_backend(config, namespace: str | None = None) -> Backend: if backend_name in ("docker", "podman"): runtime = backend_name + limits = config.resources.limits return ContainerBackend( runtime=runtime, network=getattr(container_cfg, "network", None), hammerdb_home=config.targets.defaults.image.hammerdb_home, + memory_limit=limits.memory, + cpu_limit=limits.cpu, ) from hammerdb_scale.runtime.kubernetes import KubernetesBackend diff --git a/src/hammerdb_scale/runtime/container.py b/src/hammerdb_scale/runtime/container.py index e96a72a..9dd76c4 100644 --- a/src/hammerdb_scale/runtime/container.py +++ b/src/hammerdb_scale/runtime/container.py @@ -47,11 +47,66 @@ MANAGED_VALUE = "hammerdb-scale" +# Kubernetes memory suffixes. The binary ones are what resources.limits.memory +# normally uses; the decimal ones are accepted by Kubernetes so we accept them +# too rather than silently mis-sizing a container. +_MEMORY_SUFFIXES = { + "Ki": 1024, + "Mi": 1024**2, + "Gi": 1024**3, + "Ti": 1024**4, + "K": 1000, + "M": 1000**2, + "G": 1000**3, + "T": 1000**4, +} + class ContainerRuntimeError(HammerDBScaleError): """The container runtime is missing or a command against it failed.""" +def k8s_memory_to_bytes(value: str) -> int | None: + """Convert a Kubernetes memory quantity to bytes. + + The `resources` block is shared by both backends and is written in + Kubernetes units, so the container path has to translate rather than + ignore it. Returns None when the value cannot be understood, so an odd + quantity degrades to "no limit" rather than aborting the run. + """ + text = str(value).strip() + if not text: + return None + for suffix, factor in sorted( + _MEMORY_SUFFIXES.items(), key=lambda kv: -len(kv[0]) + ): + if text.endswith(suffix): + try: + return int(float(text[: -len(suffix)]) * factor) + except ValueError: + return None + try: + return int(float(text)) + except ValueError: + return None + + +def k8s_cpu_to_cores(value: str) -> float | None: + """Convert a Kubernetes CPU quantity to a core count. + + Accepts both plain cores ("8") and millicores ("500m"). + """ + text = str(value).strip() + if not text: + return None + try: + if text.endswith("m"): + return float(text[:-1]) / 1000.0 + return float(text) + except ValueError: + return None + + def detect_runtime(preferred: str | None = None) -> str: """Find an available container runtime. @@ -77,12 +132,35 @@ def __init__( network: str | None = None, scripts_dir: Path | None = None, hammerdb_home: str | None = None, + memory_limit: str | None = None, + cpu_limit: str | None = None, ) -> None: self.runtime = detect_runtime(runtime) self.name = self.runtime self.network = network self._scripts_dir = scripts_dir self.hammerdb_home = hammerdb_home + self.memory_limit = memory_limit + self.cpu_limit = cpu_limit + + def _resource_args(self) -> list[str]: + """Translate the shared `resources.limits` block for this runtime. + + Both podman and docker accept --memory/--cpus, so a limit set in the + config must be applied here too. Previously the block was read only by + the Helm path, so a container run silently ignored it and the user + believed the driver was capped when it was not. + """ + args: list[str] = [] + if self.memory_limit: + as_bytes = k8s_memory_to_bytes(self.memory_limit) + if as_bytes: + args.extend(["--memory", str(as_bytes)]) + if self.cpu_limit: + cores = k8s_cpu_to_cores(self.cpu_limit) + if cores: + args.extend(["--cpus", f"{cores:g}"]) + return args # --- internals --- @@ -191,6 +269,8 @@ def deploy( if self.network: args.extend(["--network", self.network]) + args.extend(self._resource_args()) + # Env vars go via a file so that passwords never appear in the # process list or the shell history of the calling user. env_file = tempfile.NamedTemporaryFile( diff --git a/tests/test_container_backend.py b/tests/test_container_backend.py index ee49a5c..7650986 100644 --- a/tests/test_container_backend.py +++ b/tests/test_container_backend.py @@ -24,6 +24,8 @@ LABEL_TARGET_HOST, ContainerBackend, _parse_ts, + k8s_cpu_to_cores, + k8s_memory_to_bytes, ) @@ -220,3 +222,53 @@ def test_newer_sorts_higher(self): older = _created_sort_key({"Created": 1769000000}) newer = _created_sort_key({"Created": 1769009999}) assert newer > older + + +class TestResourceTranslation: + """The shared `resources` block is written in Kubernetes units. + + It used to be read only by the Helm path, so setting a limit and running + on containers silently did nothing: the user believed the driver was + capped when it was not. + """ + + @pytest.mark.parametrize( + "value,expected", + [ + ("8Gi", 8 * 1024**3), + ("512Mi", 512 * 1024**2), + ("1G", 1000**3), + ("1024", 1024), + ("", None), + ("garbage", None), + ], + ) + def test_memory_conversion(self, value, expected): + assert k8s_memory_to_bytes(value) == expected + + @pytest.mark.parametrize( + "value,expected", + [("8", 8.0), ("500m", 0.5), ("0.5", 0.5), ("", None), ("bad", None)], + ) + def test_cpu_conversion(self, value, expected): + assert k8s_cpu_to_cores(value) == expected + + def test_limits_become_runtime_flags(self, backend): + backend.memory_limit = "8Gi" + backend.cpu_limit = "4" + args = backend._resource_args() + assert "--memory" in args + assert str(8 * 1024**3) in args + assert "--cpus" in args + assert "4" in args + + def test_unset_limits_produce_no_flags(self, backend): + backend.memory_limit = None + backend.cpu_limit = None + assert backend._resource_args() == [] + + def test_unparseable_limit_degrades_to_no_limit(self, backend): + """An odd quantity must not abort the run.""" + backend.memory_limit = "not-a-size" + backend.cpu_limit = "nonsense" + assert backend._resource_args() == [] diff --git a/tests/test_timings.py b/tests/test_timings.py new file mode 100644 index 0000000..182996c --- /dev/null +++ b/tests/test_timings.py @@ -0,0 +1,153 @@ +"""Tests for HammerDB 6.0 transaction response time capture. + +Time profiling is optional, so every layer must treat missing timing data as +normal rather than as an error: a run with profiling off must still parse, +aggregate and render cleanly. +""" + +from __future__ import annotations + +from hammerdb_scale.reports.generator import ( + _aggregate_timings, + _latency_section_html, + generate_scorecard, +) +from hammerdb_scale.results.parsers import parse_transaction_timings + +PROFILED = """ +TRANSACTION RESPONSE TIMES +{ + "NEWORD": { + "elapsed_ms": "238147.5", "calls": "173647", "min_ms": "1.942", + "avg_ms": "7.083", "max_ms": "133.943", "total_ms": "1229911.98", + "p99_ms": "11.522", "p95_ms": "10.272", "p75_ms": "8.516", + "p50_ms": "6.993", "p25_ms": "5.551", "sd": "1.961", "ratio_pct": "64.556" + }, + "PAYMENT": { + "elapsed_ms": "238147.5", "calls": "174104", "min_ms": "0.782", + "avg_ms": "1.895", "max_ms": "49.163", "total_ms": "329840.155", + "p99_ms": "3.131", "p95_ms": "2.502", "p75_ms": "2.180", + "p50_ms": "1.950", "p25_ms": "1.520", "sd": "0.9", "ratio_pct": "17.31" + } +} + +TRANSACTION COUNT +""" + +# What HammerDB emits when the run had profiling disabled. +UNPROFILED = """ +TRANSACTION RESPONSE TIMES +{"6A653C6E3D5C03E283238343": { + "Jobid": "has", + "no": "timing", + "data": "" + }} + +TRANSACTION COUNT +""" + + +class TestParsing: + def test_extracts_every_transaction(self): + timings = parse_transaction_timings(PROFILED) + assert {t.name for t in timings} == {"NEWORD", "PAYMENT"} + + def test_sorted_by_call_count(self): + timings = parse_transaction_timings(PROFILED) + assert timings[0].name == "PAYMENT" # 174104 > 173647 + + def test_numeric_conversion(self): + neword = next(t for t in parse_transaction_timings(PROFILED) if t.name == "NEWORD") + assert neword.calls == 173647 + assert neword.p99_ms == 11.522 + assert neword.max_ms == 133.943 + + def test_placeholder_yields_nothing(self): + """Profiling off must look identical to absent, not produce a row.""" + assert parse_transaction_timings(UNPROFILED) == [] + + def test_absent_block(self): + assert parse_transaction_timings("no timing section here") == [] + + def test_malformed_json_does_not_raise(self): + assert parse_transaction_timings("TRANSACTION RESPONSE TIMES\n{broken") == [] + + +class TestAggregation: + def _target(self, calls, p99, max_ms): + return { + "tprocc": { + "timings": [ + {"name": "NEWORD", "calls": calls, "avg_ms": 5.0, "p50_ms": 4.0, + "p95_ms": p99 - 1, "p99_ms": p99, "max_ms": max_ms} + ] + } + } + + def test_percentiles_are_call_weighted(self): + rows = _aggregate_timings([self._target(100, 10.0, 50.0), + self._target(900, 20.0, 60.0)]) + # 900 calls at p99=20 must dominate 100 calls at p99=10. + assert rows[0]["p99_ms"] == 19.0 + + def test_max_is_worst_seen_not_averaged(self): + """A single stall on one target is the finding, so it must survive.""" + rows = _aggregate_timings([self._target(100, 10.0, 50.0), + self._target(100, 10.0, 12638.98)]) + assert rows[0]["max_ms"] == 12638.98 + + def test_calls_sum_across_targets(self): + rows = _aggregate_timings([self._target(100, 10.0, 1.0), + self._target(250, 10.0, 1.0)]) + assert rows[0]["calls"] == 350 + + def test_no_timings_yields_no_rows(self): + assert _aggregate_timings([{"tprocc": {"tpm": 1, "nopm": 1}}]) == [] + + def test_zero_call_entries_ignored(self): + assert _aggregate_timings([self._target(0, 10.0, 1.0)]) == [] + + +class TestReportDegradation: + """The section must vanish when there is no data, never render empty.""" + + def _summary(self, timings=None): + target = {"name": "sql-01", "host": "10.0.0.1", "status": "completed", + "duration_seconds": 245, "tprocc": {"tpm": 98575, "nopm": 42457}} + if timings: + target["tprocc"]["timings"] = timings + return {"test_id": "t1", "benchmark": "tprocc", + "config": {"database_type": "mssql", "target_count": 1}, + "targets": [target], + "aggregate": {"total_tpm": 98575, "total_nopm": 42457, + "targets_completed": 1, "avg_tpm": 98575}} + + ROW = [{"name": "NEWORD", "calls": 1000, "avg_ms": 7.0, "p50_ms": 6.9, + "p95_ms": 10.2, "p99_ms": 11.5, "max_ms": 133.9}] + + def test_section_omitted_without_timings(self): + assert _latency_section_html([{"tprocc": {"tpm": 1}}]) == "" + + def test_section_present_with_timings(self): + html = _latency_section_html([{"tprocc": {"timings": self.ROW}}]) + assert "Transaction Response Times" in html + assert "NEWORD" in html + + def test_report_renders_without_timings_or_array(self): + html = generate_scorecard(self._summary(), pure_metrics=None) + assert "Transaction Response Times" not in html + assert "Storage Performance" not in html + + def test_report_renders_with_timings_only(self): + """Latency data must be usable even when no array was polled.""" + html = generate_scorecard(self._summary(self.ROW), pure_metrics=None) + assert "Transaction Response Times" in html + assert "Storage Performance" not in html + + def test_report_renders_with_both(self): + pure = {"raw_metrics": [{"timestamp": "t", "read_latency_us": 300, + "write_latency_us": 400, "read_iops": 10, "write_iops": 5, + "read_bandwidth_mbps": 1, "write_bandwidth_mbps": 1}], "summary": {}} + html = generate_scorecard(self._summary(self.ROW), pure_metrics=pure) + assert "Transaction Response Times" in html + assert "Storage Performance" in html From 4530eeee70d2bb9059355afbaed9ce55dfe65009 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 20:58:55 -0600 Subject: [PATCH 11/14] Fix run isolation, Docker Desktop mounts, and per-database preflight ports Four defects that produced confidently wrong output rather than an error. validate --from-cluster probed every non-Oracle target on the SQL Server port, so a healthy PostgreSQL fleet was reported unreachable. Port now resolves per database type, preferring the target block, then the shared defaults, then the well-known port. Container runs carried no deployment-name label, so two configs sharing a host saw each other's test IDs and `results` could report another run's numbers. Applies and filters on the same hammerdb.io/deployment-name label the Kubernetes path already uses. `clean --all` is scoped the same way, since "everything" should mean this config's containers, not a colleague's. Runs created before the label fall back to the unscoped query rather than appearing to have vanished. The :z SELinux relabel was applied to every script mount. It is correct on enforcing Linux hosts and has historically been rejected by Docker Desktop's VM, so it made the first run on a Mac fail at mount time. Now conditional on /sys/fs/selinux reporting enforcing. --file also accepts -c and --config; -c is what people reach for by habit. Verified against live podman: two configs run back to back on the same host now each see only their own test ID and report their own throughput. Co-Authored-By: Claude Opus 5 (1M context) --- src/hammerdb_scale/cli.py | 14 +-- src/hammerdb_scale/runtime/__init__.py | 1 + src/hammerdb_scale/runtime/container.py | 94 ++++++++++++++--- src/hammerdb_scale/runtime/preflight.py | 23 +++-- tests/test_scoping_and_mounts.py | 128 ++++++++++++++++++++++++ 5 files changed, 233 insertions(+), 27 deletions(-) create mode 100644 tests/test_scoping_and_mounts.py diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index aa26135..10936ff 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -35,7 +35,7 @@ @app.callback() def main( - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file path."), + file: Optional[Path] = typer.Option(None, "-f", "-c", "--file", "--config", help="Config file path."), verbose: bool = typer.Option(False, "-v", "--verbose", help="Verbose output."), ) -> None: """HammerDB-Scale: orchestrate parallel database benchmarks on Kubernetes.""" @@ -466,7 +466,7 @@ def init( @app.command() def validate( - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option(None, "-f", "-c", "--file", "--config", help="Config file."), skip_connectivity: bool = typer.Option( False, "--skip-connectivity", help="Skip network connectivity checks." ), @@ -818,7 +818,7 @@ def build( benchmark: Optional[str] = typer.Option( None, "--benchmark", help="tprocc or tproch." ), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option(None, "-f", "-c", "--file", "--config", help="Config file."), id: Optional[str] = typer.Option(None, "--id", help="Test ID override."), namespace: Optional[str] = typer.Option( None, "-n", "--namespace", help="Override namespace." @@ -886,7 +886,7 @@ def run( build_first: bool = typer.Option( False, "--build", help="Build schemas before running." ), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option(None, "-f", "-c", "--file", "--config", help="Config file."), id: Optional[str] = typer.Option(None, "--id", help="Test ID override."), namespace: Optional[str] = typer.Option( None, "-n", "--namespace", help="Override namespace." @@ -1173,7 +1173,7 @@ def results( None, "--benchmark", help="tprocc or tproch." ), id: Optional[str] = typer.Option(None, "--id", help="Test ID."), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option(None, "-f", "-c", "--file", "--config", help="Config file."), namespace: Optional[str] = typer.Option( None, "-n", "--namespace", help="Namespace." ), @@ -1262,7 +1262,7 @@ def report( None, "-o", "--output", help="Output HTML file." ), open_browser: bool = typer.Option(False, "--open", help="Open in default browser."), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option(None, "-f", "-c", "--file", "--config", help="Config file."), ) -> None: """Generate a self-contained HTML scorecard.""" import webbrowser @@ -1363,7 +1363,7 @@ def clean( benchmark: Optional[str] = typer.Option( None, "--benchmark", help="Required for --database." ), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option(None, "-f", "-c", "--file", "--config", help="Config file."), target: Optional[str] = typer.Option( None, "--target", help="Clean specific target only." ), diff --git a/src/hammerdb_scale/runtime/__init__.py b/src/hammerdb_scale/runtime/__init__.py index bfdd46d..d7edcff 100644 --- a/src/hammerdb_scale/runtime/__init__.py +++ b/src/hammerdb_scale/runtime/__init__.py @@ -53,6 +53,7 @@ def get_backend(config, namespace: str | None = None) -> Backend: hammerdb_home=config.targets.defaults.image.hammerdb_home, memory_limit=limits.memory, cpu_limit=limits.cpu, + deployment_name=config.name, ) from hammerdb_scale.runtime.kubernetes import KubernetesBackend diff --git a/src/hammerdb_scale/runtime/container.py b/src/hammerdb_scale/runtime/container.py index 9dd76c4..ea2b796 100644 --- a/src/hammerdb_scale/runtime/container.py +++ b/src/hammerdb_scale/runtime/container.py @@ -44,6 +44,10 @@ LABEL_TARGET_HOST = "hammerdb.io/target-host" LABEL_DB_TYPE = "hammerdb.io/database-type" LABEL_INDEX = "hammerdb.io/target-index" +# Mirrors the Kubernetes label of the same name. Without it, every run on a +# host shares one namespace, so `results` after one config's run can pick up a +# different config's containers. +LABEL_DEPLOYMENT = "hammerdb.io/deployment-name" MANAGED_VALUE = "hammerdb-scale" @@ -107,6 +111,25 @@ def k8s_cpu_to_cores(value: str) -> float | None: return None +def selinux_is_enforcing() -> bool: + """True when the host runs SELinux and would block an unlabelled mount. + + Only such hosts need the :z relabel suffix. Docker Desktop's VM has + historically rejected or mishandled it, so applying it unconditionally + turns the first run on a Mac into a mount error. + """ + try: + enforce = Path("/sys/fs/selinux/enforce") + return enforce.exists() and enforce.read_text().strip() == "1" + except OSError: + return False + + +def mount_suffix() -> str: + """Volume options for the read-only script mount.""" + return "ro,z" if selinux_is_enforcing() else "ro" + + def detect_runtime(preferred: str | None = None) -> str: """Find an available container runtime. @@ -134,6 +157,7 @@ def __init__( hammerdb_home: str | None = None, memory_limit: str | None = None, cpu_limit: str | None = None, + deployment_name: str | None = None, ) -> None: self.runtime = detect_runtime(runtime) self.name = self.runtime @@ -142,6 +166,21 @@ def __init__( self.hammerdb_home = hammerdb_home self.memory_limit = memory_limit self.cpu_limit = cpu_limit + self.deployment_name = deployment_name + + def _scope_filters(self) -> list[str]: + """ps filters restricting results to this config's containers. + + Containers deployed before deployment-name labelling carry no such + label, so scoping is applied only when a name is known; that keeps a + mixed host readable rather than hiding older runs entirely. + """ + filters = ["--filter", f"label={LABEL_MANAGED}={MANAGED_VALUE}"] + if self.deployment_name: + filters.extend( + ["--filter", f"label={LABEL_DEPLOYMENT}={self.deployment_name}"] + ) + return filters def _resource_args(self) -> list[str]: """Translate the shared `resources.limits` block for this runtime. @@ -266,6 +305,11 @@ def deploy( "--label", f"{LABEL_INDEX}={index}", ] + if self.deployment_name: + args.extend( + ["--label", f"{LABEL_DEPLOYMENT}={self.deployment_name}"] + ) + if self.network: args.extend(["--network", self.network]) @@ -283,9 +327,10 @@ def deploy( args.extend(["--env-file", env_file.name]) # Mount the TCL scripts where entrypoint.sh expects them. The - # :ro,z suffix keeps SELinux hosts working; docker ignores z. + # The :z relabel is applied only where SELinux needs it; see + # mount_suffix(). mount_target = self._script_mount_target(image) - args.extend(["-v", f"{scripts}:{mount_target}:ro,z"]) + args.extend(["-v", f"{scripts}:{mount_target}:{mount_suffix()}"]) args.append(image) if dry_run: @@ -506,8 +551,13 @@ def get_logs(self, workload_name: str, tail: int | None = None) -> str: return (result.stdout or "") + (result.stderr or "") def remove(self, test_id: str | None = None, everything: bool = False) -> int: - """Remove containers for a test run, or every managed container.""" - filters = ["--filter", f"label={LABEL_MANAGED}={MANAGED_VALUE}"] + """Remove containers for a test run, or every managed container. + + "Everything" means everything belonging to this config, not every + hammerdb-scale container on the host: cleaning up one validation must + not delete a colleague's run. + """ + filters = self._scope_filters() if test_id and not everything: filters.extend(["--filter", f"label={LABEL_TEST_ID}={test_id}"]) @@ -534,23 +584,39 @@ def find_test_ids(self) -> list[str]: stale build run appearing ahead of the run the user just finished sends them to the wrong data. The runtime does not guarantee ps ordering, so sort explicitly by container creation time. + + Scoped to this config where possible. Containers created before + deployment-name labelling carry no such label, so an empty scoped + result falls back to the unscoped query rather than telling the user + their completed run does not exist. """ result = self._run( - [ - "ps", - "--all", - "--filter", - f"label={LABEL_MANAGED}={MANAGED_VALUE}", - "--format", - "json", - ], + ["ps", "--all"] + self._scope_filters() + ["--format", "json"], check=False, ) - if result.returncode != 0 or not result.stdout.strip(): + entries = ( + self._parse_ps_json(result.stdout) if result.returncode == 0 else [] + ) + if not entries and self.deployment_name: + result = self._run( + [ + "ps", + "--all", + "--filter", + f"label={LABEL_MANAGED}={MANAGED_VALUE}", + "--format", + "json", + ], + check=False, + ) + entries = ( + self._parse_ps_json(result.stdout) if result.returncode == 0 else [] + ) + if not entries: return [] newest: dict[str, float] = {} - for entry in self._parse_ps_json(result.stdout): + for entry in entries: labels = entry.get("Labels") or {} if isinstance(labels, str): labels = dict( diff --git a/src/hammerdb_scale/runtime/preflight.py b/src/hammerdb_scale/runtime/preflight.py index 4794d5d..ea64107 100644 --- a/src/hammerdb_scale/runtime/preflight.py +++ b/src/hammerdb_scale/runtime/preflight.py @@ -30,18 +30,29 @@ """ +# Fallback listening port per database type, used only when the config does +# not carry one. Probing the wrong port reports a healthy fleet as unreachable, +# which reads as "the tool is broken" rather than "the check is wrong". +_DEFAULT_PORTS = {"oracle": 1521, "mssql": 1433, "postgres": 5432} + + def _target_endpoints(config) -> list[tuple[str, str, int]]: """Build (name, host, port) for every target.""" from hammerdb_scale.config.defaults import expand_targets + defaults = config.targets.defaults endpoints = [] for target in expand_targets(config): - if target["type"] == "oracle": - port = target.get("oracle", {}).get("port", 1521) - else: - mssql = config.targets.defaults.mssql - port = mssql.port if mssql else 1433 - endpoints.append((target["name"], target["host"], int(port))) + db_type = target["type"] + fallback = _DEFAULT_PORTS.get(db_type, 1433) + + # The per-target block wins; then the shared defaults block for that + # database; then the well-known port. + port = (target.get(db_type) or {}).get("port") + if port is None: + block = getattr(defaults, db_type, None) + port = getattr(block, "port", None) if block else None + endpoints.append((target["name"], target["host"], int(port or fallback))) return endpoints diff --git a/tests/test_scoping_and_mounts.py b/tests/test_scoping_and_mounts.py new file mode 100644 index 0000000..2ad279c --- /dev/null +++ b/tests/test_scoping_and_mounts.py @@ -0,0 +1,128 @@ +"""Tests for run isolation, mount options, and per-database preflight ports. + +These cover three defects that all produced confidently wrong output rather +than an error, which is the failure mode most likely to mislead a user. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest +import yaml + +from hammerdb_scale.config.loader import load_config +from hammerdb_scale.runtime.container import ( + LABEL_DEPLOYMENT, + LABEL_MANAGED, + ContainerBackend, + mount_suffix, + selinux_is_enforcing, +) +from hammerdb_scale.runtime.preflight import _target_endpoints + + +def _config(db_type: str, extra: dict | None = None): + raw = { + "name": "t", + "targets": { + "defaults": {"type": db_type, "username": "u", "password": "p", + **(extra or {})}, + "hosts": [{"name": "a", "host": "10.0.0.1"}], + }, + } + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as handle: + yaml.safe_dump(raw, handle) + path = handle.name + try: + return load_config(Path(path)) + finally: + Path(path).unlink(missing_ok=True) + + +class TestPreflightPorts: + """Probing the wrong port reports a healthy fleet as unreachable.""" + + @pytest.mark.parametrize( + "db_type,expected", [("oracle", 1521), ("mssql", 1433), ("postgres", 5432)] + ) + def test_default_port_per_database(self, db_type, expected): + assert _target_endpoints(_config(db_type))[0][2] == expected + + def test_postgres_is_not_probed_on_the_mssql_port(self): + """Regression: postgres fell through to the mssql branch.""" + assert _target_endpoints(_config("postgres"))[0][2] != 1433 + + @pytest.mark.parametrize( + "db_type,block,expected", + [ + ("postgres", {"postgres": {"port": 5433}}, 5433), + ("mssql", {"mssql": {"port": 1435}}, 1435), + ("oracle", {"oracle": {"port": 1522}}, 1522), + ], + ) + def test_configured_port_wins(self, db_type, block, expected): + assert _target_endpoints(_config(db_type, block))[0][2] == expected + + +class TestMountSuffix: + """Docker Desktop mishandles the :z relabel, so it must be conditional.""" + + def test_enforcing_host_gets_relabel(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.selinux_is_enforcing", lambda: True + ) + assert mount_suffix() == "ro,z" + + def test_non_selinux_host_gets_plain_readonly(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.selinux_is_enforcing", lambda: False + ) + assert mount_suffix() == "ro" + + def test_detection_survives_missing_sysfs(self, monkeypatch): + """macOS and WSL2 have no /sys/fs/selinux at all.""" + monkeypatch.setattr(Path, "exists", lambda self: False) + assert selinux_is_enforcing() is False + + +class TestDeploymentScoping: + """Two runs on one host must not see each other's containers.""" + + @pytest.fixture + def backend(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.detect_runtime", + lambda preferred=None: "podman", + ) + return ContainerBackend(deployment_name="alpha") + + def test_scope_filters_include_deployment(self, backend): + filters = backend._scope_filters() + assert f"label={LABEL_DEPLOYMENT}=alpha" in filters + assert any(LABEL_MANAGED in f for f in filters) + + def test_unnamed_backend_filters_on_managed_only(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.detect_runtime", + lambda preferred=None: "podman", + ) + filters = ContainerBackend()._scope_filters() + assert not any(LABEL_DEPLOYMENT in f for f in filters) + + def test_remove_is_scoped_to_this_config(self, backend, monkeypatch): + """`clean --all` must not delete a colleague's run.""" + seen = {} + + def fake_run(args, **kwargs): + seen["args"] = args + class R: + returncode = 0 + stdout = "" + stderr = "" + return R() + + monkeypatch.setattr(backend, "_run", fake_run) + backend.remove(everything=True) + assert f"label={LABEL_DEPLOYMENT}=alpha" in seen["args"] From 388e9e7193d8a6b2730b1b1aff764955d28a5f8f Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 21:05:27 -0600 Subject: [PATCH 12/14] Lead with the container backend in the docs, wizard and examples The container backend removed the Kubernetes prerequisite, but both first-contact surfaces still funnelled every user into needing a cluster. The README listed a Kubernetes cluster under Requirements, and `init` asked for a namespace and pod resource limits without ever asking where the workers should run. A user with no cluster was being interrogated about cluster settings. README now lists Python plus podman or docker as the requirements, with Kubernetes as a section for people who already have one. Adds a platform support table recording that Oracle is x86_64-only, since Oracle publishes no ARM64 Instant Client and Apple Silicon users otherwise discover this at run time. Documents that Oracle needs a locally built image for licensing reasons, and that schema build time dominates the wall clock. `init` asks where to run the workers as its second question, ordered so the runtime actually installed comes first, and emits only the relevant backend block. PostgreSQL was missing from the wizard's database list and, worse, fell through to the mssql branch of the config template, so choosing it produced ODBC settings. Adds container-backend examples for SQL Server and PostgreSQL, and tests that every shipped example still loads, so a stale example fails CI rather than a customer's first run. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 147 +++++++++++++++++++----- examples/container-mssql-tprocc.yaml | 82 +++++++++++++ examples/container-postgres-tprocc.yaml | 68 +++++++++++ src/hammerdb_scale/cli.py | 47 ++++++-- src/hammerdb_scale/wizard.py | 59 +++++++++- tests/test_examples_and_wizard.py | 109 ++++++++++++++++++ tests/test_wizard.py | 75 +++++++++++- 7 files changed, 537 insertions(+), 50 deletions(-) create mode 100644 examples/container-mssql-tprocc.yaml create mode 100644 examples/container-postgres-tprocc.yaml create mode 100644 tests/test_examples_and_wizard.py diff --git a/README.md b/README.md index 17d0520..4a49b4e 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,63 @@ [![Python versions](https://img.shields.io/pypi/pyversions/hammerdb-scale)](https://pypi.org/project/hammerdb-scale/) [![License](https://img.shields.io/pypi/l/hammerdb-scale)](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/LICENSE) -A Python CLI for orchestrating parallel HammerDB database benchmarks at scale on Kubernetes. +A Python CLI for orchestrating parallel HammerDB database benchmarks at scale. ## What is HammerDB-Scale? -HammerDB-Scale runs **synchronized database performance tests across multiple database instances simultaneously**. It deploys HammerDB as Kubernetes Jobs targeting multiple databases in parallel, making it ideal for: +HammerDB-Scale runs **synchronized database performance tests across multiple database instances simultaneously**, making it ideal for: - **Storage Platform Testing**: Validate storage array performance under realistic multi-database workloads - **Scale Testing**: Test how storage performs when serving 2, 4, 8+ databases concurrently - **Capacity Planning**: Understand how many database workloads your storage can support -## How It Works +It runs one HammerDB container per database target. Those containers can run +on a single machine with podman or docker, or as Jobs on a Kubernetes cluster. +Most people should start with containers: there is nothing to install beyond a +container runtime, and the driver is light enough that one host comfortably +saturates a large fleet of databases. -HammerDB-Scale is a CLI orchestrator that sits on your workstation and drives benchmarks through Kubernetes: +## How It Works ``` - +--------------------------------------+ - | 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 | +--------------+ | - | | +-----------+ | - | +--------------------------------------+ +hammerdb-scale CLI +-----------+ +--------------+ + (your machine, or a | HammerDB |--->| Database 1 | + host near the databases) | worker 1 | +--------------+ + | +-----------+ + | deploy +-----------+ +--------------+ + |------------------->| HammerDB |--->| Database 2 | + | | worker 2 | +--------------+ + | collect logs +-----------+ + |<-------------------+-----------+ +--------------+ + | | HammerDB |--->| Database N | + | results/report | worker N | +--------------+ + v +-----------+ + scorecard.html ``` 1. You define your database targets and benchmark parameters in a YAML config file -2. The CLI translates your config into Helm values and deploys one Kubernetes Job per database target -3. Each Job runs a HammerDB container that connects to its assigned database and executes the benchmark -4. All Jobs run in parallel, producing synchronized load across all targets -5. The CLI collects results from Job logs, aggregates metrics (TPM/NOPM for TPC-C, QphH for TPC-H), and generates an HTML scorecard +2. The CLI starts one HammerDB worker per database target, as a local container or a Kubernetes Job +3. Each worker connects to its assigned database and executes the benchmark +4. All workers run in parallel, producing synchronized load across all targets +5. The CLI collects results, aggregates metrics (TPM/NOPM for TPC-C, QphH for TPC-H), and generates an HTML scorecard + +### Where to run the workers + +Put the driver host close to the databases. Driver-to-database latency counts +against your benchmark numbers, so running from a laptop over a VPN will make +your storage look slower than it is. + +| You have | Use | Notes | +|---|---|---| +| A Linux host near the databases | `backend: podman` | Recommended. No cluster needed. | +| A laptop with Docker Desktop | `backend: docker` | Fine for smaller runs; mind the network path. | +| An existing Kubernetes cluster | `backend: kubernetes` | Schedules workers across nodes. | +| Databases spread across sites | `backend: kubernetes` | The container backend drives from one host only. | + +A worker is a TCL process issuing SQL and waiting on the network, so it costs +very little: measured at roughly 57 MB and a fifth of a CPU core while driving +8 virtual users at ~100,000 TPM. Eight concurrent targets on one host used +under 500 MB in total. ## Quick Start @@ -49,24 +68,55 @@ hammerdb-scale CLI | | # Install pip install hammerdb-scale -# Generate config interactively +# Generate config interactively (asks where to run the workers) hammerdb-scale init -# Validate config and database connectivity +# Check the config and that every database answers hammerdb-scale validate -# Build schema, run benchmark, collect results +# Build schemas, run the benchmark, collect results hammerdb-scale run --build --wait hammerdb-scale results hammerdb-scale report --open ``` +A minimal config for two SQL Server targets on a machine with podman: + +```yaml +name: storage-validation +backend: podman + +targets: + defaults: + type: mssql + username: sa + password: "your-password" + hosts: + - name: sql-01 + host: 10.0.0.11 + - name: sql-02 + host: 10.0.0.12 + +hammerdb: + tprocc: + warehouses: 1000 + load_virtual_users: 8 + duration: 10 +``` + +See [examples/](examples/) for complete configs per database. + +**Budget time for the schema build.** It is a separate phase and it dominates +the wall clock: 100 warehouses takes minutes, 10,000 warehouses takes hours. +Build once, then run as many benchmarks against that schema as you like. + ## Supported Databases | Database | Benchmarks | Container Image | |----------|-----------|-----------------| | **SQL Server** | TPC-C, TPC-H | `sillidata/hammerdb-scale:latest` | -| **Oracle** | TPC-C, TPC-H | `sillidata/hammerdb-scale-oracle:latest` | +| **PostgreSQL** | TPC-C, TPC-H | `sillidata/hammerdb-scale:latest` | +| **Oracle** | TPC-C, TPC-H | build locally, see [Platform support](#platform-support) | ## Workflow @@ -107,10 +157,45 @@ init → validate → run --build → results → report ## Requirements - **Python 3.10+** -- **Helm 3.x** — used to template and deploy Kubernetes Jobs +- **podman or docker** — either one; podman is preferred when both are present +- **Database targets** — one or more Oracle, SQL Server or PostgreSQL instances reachable from the machine running the workers + +That is the whole list for the default container backend. No cluster, no Helm, +no kubectl. + +### Running on Kubernetes instead + +Set `backend: kubernetes` in your config and add: + +- **Helm 3.x** — used to template and deploy the Jobs - **kubectl** — configured with a context that has access to your cluster -- **Kubernetes cluster** — with permissions to create Jobs and Namespaces -- **Database targets** — one or more Oracle or SQL Server instances reachable from the cluster +- **A cluster** — with permissions to create Jobs, ConfigMaps and Secrets in your namespace +- Databases reachable **from the cluster**, which is not always the same as reachable from your workstation. `hammerdb-scale validate --from-cluster` checks from where the workers actually run. + +### Platform support + +| Platform | SQL Server | PostgreSQL | Oracle | +|---|---|---|---| +| Linux x86_64 (podman or docker) | Yes | Yes | Yes | +| Kubernetes on x86_64 nodes | Yes | Yes | Yes | +| macOS, Intel | Yes | Yes | Yes | +| macOS, Apple Silicon | Yes | Yes | **No** | +| Windows via WSL2 | Yes | Yes | Yes | + +Oracle support requires Oracle Instant Client, which Oracle publishes for +linux.x64 only. There is no ARM64 build, so Oracle benchmarks cannot run on +Apple Silicon. SQL Server and PostgreSQL are unaffected. + +Oracle also ships as a **separate image you build yourself**, because Oracle's +licence does not permit redistributing Instant Client: + +```bash +git clone https://github.com/PureStorage-OpenConnect/hammerdb-scale +cd hammerdb-scale +./hack/build-images.sh # builds base + Oracle, verifies both +``` + +By building it you accept [Oracle's licence terms](https://www.oracle.com/downloads/licenses/instant-client-lic.html). ### Optional diff --git a/examples/container-mssql-tprocc.yaml b/examples/container-mssql-tprocc.yaml new file mode 100644 index 0000000..0d02d3b --- /dev/null +++ b/examples/container-mssql-tprocc.yaml @@ -0,0 +1,82 @@ +# ============================================================================ +# SQL Server TPC-C, run from a single host with podman or docker +# ============================================================================ +# The lowest-friction way to use this tool: no Kubernetes, no Helm, no +# kubectl. One container per database target, all on the machine you run +# the CLI from. +# +# hammerdb-scale validate -c examples/container-mssql-tprocc.yaml +# hammerdb-scale run --build --wait -c examples/container-mssql-tprocc.yaml +# hammerdb-scale report --open -c examples/container-mssql-tprocc.yaml +# +# Put this host close to the databases. Driver-to-database latency counts +# against your numbers, so a laptop over a VPN will understate your storage. +# ============================================================================ + +name: mssql-storage-validation +description: "SQL Server TPC-C across 4 targets" +default_benchmark: tprocc +backend: podman # podman|docker|kubernetes + +container: + runtime: auto # auto picks podman, then docker + +targets: + defaults: + type: mssql + username: sa + password: "CHANGE-ME" + mssql: + port: 1433 + tprocc: + database_name: tpcc # created during the build phase + use_bcp: false # bulk copy load, faster but noisier + connection: + encrypt_connection: true + trust_server_cert: true # accept self-signed certs + + hosts: + - name: sql-01 + host: "10.0.0.11" + - name: sql-02 + host: "10.0.0.12" + - name: sql-03 + host: "10.0.0.13" + - name: sql-04 + host: "10.0.0.14" + +hammerdb: + tprocc: + # Warehouses drive the dataset size and therefore the build time. + # 100 builds in minutes; 10,000 takes hours and is roughly 1 TB per + # target. Build once, then run as many benchmarks as you like. + warehouses: 1000 + build_virtual_users: 8 # parallelism during the build phase + load_virtual_users: 8 # concurrent users during the run + driver: timed + rampup: 2 # minutes of warm-up, not measured + duration: 10 # minutes of measured workload + all_warehouses: true + checkpoint: false + time_profile: true # per-transaction response percentiles + +# Resource limits apply to both backends: --memory/--cpus for containers, +# pod requests and limits on Kubernetes. A worker is light, so these are +# generous; measured usage is around 57 MB and a fifth of a core. +resources: + limits: + memory: "2Gi" + cpu: "2" + +# ============================================================================ +# Optional: collect FlashArray metrics alongside the benchmark so array +# latency can be read against the database's own response times. +# ============================================================================ +storage_metrics: + enabled: false + # provider: pure + # pure: + # host: "" # FlashArray management IP + # api_token: "" # Settings > Users > API Tokens + # poll_interval: 5 + # verify_ssl: false diff --git a/examples/container-postgres-tprocc.yaml b/examples/container-postgres-tprocc.yaml new file mode 100644 index 0000000..c9f2f5a --- /dev/null +++ b/examples/container-postgres-tprocc.yaml @@ -0,0 +1,68 @@ +# ============================================================================ +# PostgreSQL TPC-C, run from a single host with podman or docker +# ============================================================================ +# hammerdb-scale validate -c examples/container-postgres-tprocc.yaml +# hammerdb-scale run --build --wait -c examples/container-postgres-tprocc.yaml +# hammerdb-scale report --open -c examples/container-postgres-tprocc.yaml +# +# The PostgreSQL hosts need to accept connections from wherever the workers +# run: an entry in pg_hba.conf and listen_addresses set accordingly. See +# examples/postgres-setup/provision_postgres.sh for a tuned setup. +# ============================================================================ + +name: postgres-storage-validation +description: "PostgreSQL TPC-C across 4 targets" +default_benchmark: tprocc +backend: podman + +container: + runtime: auto + +targets: + defaults: + type: postgres + username: postgres # superuser, used to create the schema + password: "CHANGE-ME" + postgres: + port: 5432 + sslmode: prefer # prefer|require|disable + tprocc: + database_name: tpcc # created during the build phase + user: tpcc # schema owner, created during build + password: tpcc + # Stored procedures must match between build and run: the driver + # calls procedures that only exist if the schema was built with + # them. Change this before building, not after. + stored_procedures: true + partition: false + vacuum: false + + hosts: + - name: pg-01 + host: "10.0.0.21" + - name: pg-02 + host: "10.0.0.22" + - name: pg-03 + host: "10.0.0.23" + - name: pg-04 + host: "10.0.0.24" + +hammerdb: + tprocc: + warehouses: 1000 + build_virtual_users: 8 + load_virtual_users: 8 + driver: timed + rampup: 2 + duration: 10 + all_warehouses: true + checkpoint: false + time_profile: true + +resources: + limits: + memory: "2Gi" + cpu: "2" + +storage_metrics: + enabled: false diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index 10936ff..6bf667d 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -157,7 +157,8 @@ def _build_config_yaml( password: str, oracle_config: dict | None, warehouses: int = 100, - namespace: str = "hammerdb", + namespace: str | None = "hammerdb", + backend_str: str = "kubernetes", storage_metrics: dict | None = None, build_virtual_users: int = 4, load_virtual_users: int = 4, @@ -195,6 +196,20 @@ def _build_config_yaml( user: "tpch" # TPC-H schema owner (created during build) password: "{oracle_config["tproch"]["password"]}" degree_of_parallel: 8 # Oracle parallel query degree""" + elif db_type_str == "postgres": + db_defaults = """ postgres: + port: 5432 + sslmode: prefer # prefer|require|disable + tprocc: + database_name: tpcc # database created during build phase + user: tpcc # schema owner (created during build) + password: tpcc + stored_procedures: true # build and drive via stored procedures + tproch: + database_name: tpch # database created during build phase + user: tpch + password: tpch + max_parallel_workers: 8 # parallel workers for analytic queries""" else: db_defaults = """ mssql: port: 1433 @@ -269,6 +284,26 @@ def _build_config_yaml( # poll_interval: 5 # collection interval in seconds # verify_ssl: false""" + # Only the selected backend's settings are emitted. A container user who + # finds a Kubernetes block in their config reasonably concludes they need + # a cluster. + if backend_str == "kubernetes": + backend_section = f""" +# ============================================================================ +# KUBERNETES SETTINGS +# ============================================================================ +kubernetes: + namespace: {namespace or "hammerdb"} # namespace for benchmark jobs (must exist) + job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h)""" + else: + backend_section = """ +# ============================================================================ +# CONTAINER SETTINGS +# ============================================================================ +container: + runtime: auto # auto|podman|docker + # network: "" # attach workers to a named network""" + # Assemble full config return f"""# ============================================================================ # HammerDB-Scale Configuration @@ -285,8 +320,9 @@ def _build_config_yaml( # ============================================================================ name: {name} -description: "{db_type_str.upper()} {benchmark_str.upper()} benchmark — {len(hosts)} target{"s" if len(hosts) != 1 else ""}" +description: "{db_type_str.upper()} {benchmark_str.upper()} benchmark, {len(hosts)} target{"s" if len(hosts) != 1 else ""}" default_benchmark: {benchmark_str} +backend: {backend_str} # podman|docker|kubernetes # ============================================================================ # DATABASE TARGETS @@ -329,12 +365,7 @@ def _build_config_yaml( memory: "{lim_memory}" cpu: "{lim_cpu}" -# ============================================================================ -# KUBERNETES SETTINGS -# ============================================================================ -kubernetes: - namespace: {namespace} # namespace for benchmark jobs (must exist) - job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h) +{backend_section} {storage_section} """ diff --git a/src/hammerdb_scale/wizard.py b/src/hammerdb_scale/wizard.py index 0f1bb23..882978f 100644 --- a/src/hammerdb_scale/wizard.py +++ b/src/hammerdb_scale/wizard.py @@ -23,6 +23,19 @@ def _step_header(step: int, total: int, title: str, subtitle: str) -> None: ) +def _detect_local_runtime() -> str | None: + """Which container runtime is installed here, if any. + + Used only to order the backend choices so the obvious answer is first. + """ + import shutil + + for candidate in ("podman", "docker"): + if shutil.which(candidate): + return candidate + return None + + def _prompt_required(prompt_text: str, **kwargs: object) -> str: """Prompt for a non-empty string, re-asking if blank.""" while True: @@ -79,7 +92,10 @@ def _build_summary_table(values: dict) -> Table: else: table.add_row("Scale factor", str(values.get("scale_factor", 1))) - table.add_row("Namespace", values.get("namespace", "hammerdb")) + backend = values.get("backend_str", "kubernetes") + table.add_row("Workers run on", backend) + if values.get("namespace"): + table.add_row("Namespace", values["namespace"]) storage = values.get("storage_metrics") if storage and storage.get("enabled"): @@ -139,6 +155,27 @@ def run_wizard() -> dict | None: console.print() name = _prompt_required("Deployment name") + console.print() + console.print("[dim]Where should the HammerDB workers run?[/dim]") + detected = _detect_local_runtime() + # Lead with whatever is actually installed: the common case is a + # single host with one runtime, and that user should not have to + # think about the choice. Kubernetes stays last either way, so the + # menu positions of the container options are the only ones that + # move. + container_choices = [ + ("podman", "podman on this machine"), + ("docker", "docker on this machine"), + ] + if detected: + container_choices.sort(key=lambda choice: choice[0] != detected) + backend_str = _select_option( + "Run workers on", + container_choices + + [("kubernetes", "Kubernetes cluster (needs helm and kubectl)")], + ) + is_kubernetes = backend_str == "kubernetes" + # ── Step 2: Database & Benchmark ──────────────────────────── _step_header( 2, @@ -150,7 +187,11 @@ def run_wizard() -> dict | None: db_type_str = _select_option( "Database type", - [("oracle", "Oracle"), ("mssql", "Microsoft SQL Server")], + [ + ("mssql", "Microsoft SQL Server"), + ("postgres", "PostgreSQL"), + ("oracle", "Oracle (needs a locally built image)"), + ], ) console.print() @@ -252,11 +293,18 @@ def run_wizard() -> dict | None: 6, total_steps, "Infrastructure", - "Kubernetes and storage settings.", + "Cluster and storage settings." + if is_kubernetes + else "Storage metrics collection.", ) - console.print() - namespace = Prompt.ask("Kubernetes namespace", default="hammerdb") + # Only ask about a namespace when the answer can matter. Asking a + # container-backend user for Kubernetes settings makes the tool look + # like it needs a cluster when it does not. + namespace = None + if is_kubernetes: + console.print() + namespace = Prompt.ask("Kubernetes namespace", default="hammerdb") storage_metrics = None console.print() @@ -291,6 +339,7 @@ def run_wizard() -> dict | None: "warehouses": warehouses, "scale_factor": scale_factor, "namespace": namespace, + "backend_str": backend_str, "storage_metrics": storage_metrics, } diff --git a/tests/test_examples_and_wizard.py b/tests/test_examples_and_wizard.py new file mode 100644 index 0000000..d3531ee --- /dev/null +++ b/tests/test_examples_and_wizard.py @@ -0,0 +1,109 @@ +"""Tests for shipped example configs and the init wizard's generated YAML. + +Examples are the entry path for anyone who would rather read a file than +answer prompts, so a stale example is a support burden. These tests fail if +one stops loading or drifts from the documented backend guidance. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from hammerdb_scale.cli import _build_config_yaml +from hammerdb_scale.config.defaults import expand_targets +from hammerdb_scale.config.loader import load_config + +REPO_ROOT = Path(__file__).resolve().parent.parent +EXAMPLES = sorted((REPO_ROOT / "examples").glob("*.yaml")) + +ORACLE_BLOCK = { + "service": "ORCLPDB", "port": 1521, "tablespace": "TPCC", + "temp_tablespace": "TEMP", + "tprocc": {"user": "TPCC", "password": "p"}, + "tproch": {"user": "tpch", "password": "p"}, +} + + +def _load_yaml_string(text: str): + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as handle: + handle.write(text) + path = handle.name + try: + return load_config(Path(path)) + finally: + Path(path).unlink(missing_ok=True) + + +class TestShippedExamples: + @pytest.mark.parametrize("path", EXAMPLES, ids=lambda p: p.name) + def test_example_loads(self, path): + config = load_config(path) + assert expand_targets(config), f"{path.name} expands to no targets" + + def test_container_examples_exist(self): + """The README points people at the container path first.""" + names = {p.name for p in EXAMPLES} + assert "container-mssql-tprocc.yaml" in names + assert "container-postgres-tprocc.yaml" in names + + @pytest.mark.parametrize( + "name", ["container-mssql-tprocc.yaml", "container-postgres-tprocc.yaml"] + ) + def test_container_examples_do_not_need_a_cluster(self, name): + config = load_config(REPO_ROOT / "examples" / name) + assert config.backend.value in ("podman", "docker") + + +class TestWizardOutput: + """init must emit a config that loads, for every backend and database.""" + + @pytest.mark.parametrize("backend", ["podman", "docker", "kubernetes"]) + @pytest.mark.parametrize("db_type", ["mssql", "postgres", "oracle"]) + def test_generated_config_round_trips(self, backend, db_type): + yaml_text = _build_config_yaml( + name="t", db_type_str=db_type, benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", password="p", + oracle_config=ORACLE_BLOCK if db_type == "oracle" else None, + backend_str=backend, + namespace="hammerdb" if backend == "kubernetes" else None, + ) + config = _load_yaml_string(yaml_text) + assert config.backend.value == backend + assert expand_targets(config)[0]["type"] == db_type + + def test_container_config_has_no_kubernetes_block(self): + """A cluster block implies a cluster is needed. It is not.""" + yaml_text = _build_config_yaml( + name="t", db_type_str="mssql", benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", password="p", oracle_config=None, + backend_str="podman", namespace=None, + ) + assert "kubernetes:" not in yaml_text + assert "container:" in yaml_text + + def test_kubernetes_config_keeps_its_namespace(self): + yaml_text = _build_config_yaml( + name="t", db_type_str="mssql", benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", password="p", oracle_config=None, + backend_str="kubernetes", namespace="my-ns", + ) + assert "kubernetes:" in yaml_text + assert "my-ns" in yaml_text + assert "container:" not in yaml_text + + def test_postgres_does_not_emit_mssql_settings(self): + """Regression: postgres fell into the mssql branch of the template.""" + yaml_text = _build_config_yaml( + name="t", db_type_str="postgres", benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", password="p", oracle_config=None, + backend_str="podman", namespace=None, + ) + assert "postgres:" in yaml_text + assert "odbc_driver" not in yaml_text diff --git a/tests/test_wizard.py b/tests/test_wizard.py index b7102cb..80d4452 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -144,7 +144,8 @@ 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 + "3", # kubernetes backend + "3", # oracle "1", # tprocc "ora-01", # target name "10.0.0.1", # target host @@ -182,7 +183,8 @@ 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 + "3", # kubernetes backend + "1", # mssql "2", # tproch "sql-01", # target name "10.0.0.5", # target host @@ -217,8 +219,9 @@ def test_user_cancels_at_confirmation( """Wizard returns None when user declines at the write confirmation.""" mock_prompt.ask.side_effect = [ "test", - "2", - "1", + "3", # kubernetes backend + "1", # mssql + "1", # tprocc "db-01", "10.0.0.1", "sa", @@ -242,8 +245,9 @@ def test_with_advanced_options(self, mock_prompt, mock_int, mock_confirm) -> Non """Wizard with advanced options for TPC-C.""" mock_prompt.ask.side_effect = [ "adv-bench", - "2", - "1", # name, mssql, tprocc + "3", # kubernetes backend + "1", + "1", # mssql, tprocc "db-01", "10.0.0.1", # target "sa", @@ -345,3 +349,62 @@ def test_default_values_match_original(self) -> None: assert "duration: 10" in yaml_str assert 'memory: "4Gi"' in yaml_str assert 'cpu: "4"' in yaml_str + + +class TestBackendSelection: + """The wizard must not interrogate a container user about a cluster. + + Asking for a Kubernetes namespace when the workers run locally is what + made the tool look like it required a cluster when it does not. + """ + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_container_backend_skips_namespace( + self, mock_prompt, mock_int, mock_confirm + ) -> None: + mock_prompt.ask.side_effect = [ + "ctr-bench", + "1", # first container runtime offered + "1", # mssql + "1", # tprocc + "db-01", + "10.0.0.1", + "sa", + "pass", + # No namespace answer supplied: asking for one would raise + # StopIteration and fail this test, which is the point. + ] + mock_int.ask.side_effect = [1, 100] + mock_confirm.ask.side_effect = [False, False, True] + + result = run_wizard() + assert result is not None + assert result["backend_str"] in ("podman", "docker") + assert result["namespace"] is None + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_kubernetes_backend_still_asks_for_namespace( + self, mock_prompt, mock_int, mock_confirm + ) -> None: + mock_prompt.ask.side_effect = [ + "k8s-bench", + "3", # kubernetes is always last + "1", # mssql + "1", # tprocc + "db-01", + "10.0.0.1", + "sa", + "pass", + "my-namespace", + ] + mock_int.ask.side_effect = [1, 100] + mock_confirm.ask.side_effect = [False, False, True] + + result = run_wizard() + assert result is not None + assert result["backend_str"] == "kubernetes" + assert result["namespace"] == "my-namespace" From d8e235c6b220ab9b496943b09eb4670f8f0e4842 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sat, 25 Jul 2026 22:27:15 -0600 Subject: [PATCH 13/14] Release 2.0.4: version bump, changelog, and doc cleanup 2.0.3 is already on PyPI, so this branch cannot ship under that version. Bumps the CLI to 2.0.4 and brings the Helm chart with it: the chart was still at 2.0.2, a version behind the last release, so `helm list` reported a version that did not match the tool that deployed it. Adds tests pinning chart, pyproject and CLI versions together, and appVersion to the HammerDB the published images actually carry. CHANGELOG had no entry since 2.0.1 in March. Adds 2.0.2, 2.0.3 and a full 2.0.4 entry covering the container backend, PostgreSQL, HammerDB 6.0 and the response time metrics, with the known limitations stated plainly: Oracle is x86_64-only, Oracle needs a locally built image, the container backend drives from one host, and only Linux with podman has been exercised end to end. Drops "on Kubernetes" from the package description and the remaining Kubernetes-only wording in the README workflow diagram and command table, which contradicted the container-first framing. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 76 +++++++++++++++++++++++++++++ README.md | 16 +++--- pyproject.toml | 4 +- src/hammerdb_scale/chart/Chart.yaml | 2 +- tests/test_hammerdb_version.py | 36 ++++++++++++++ 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9556b7..8a92c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [2.0.4] - 2026-07-26 + +Kubernetes is no longer required. Benchmarks can run as local containers under +podman or docker, and HammerDB moves to 6.0. + +### Added +- Container backend: set `backend: podman` or `backend: docker` to run one + HammerDB container per target on a single host, with no cluster, Helm or + kubectl. Verified equivalent to Kubernetes on throughput across SQL Server, + Oracle and PostgreSQL at 1, 4 and 8 targets. +- PostgreSQL support, including TPC-C and TPC-H scripts and schema build. +- Transaction response times in the scorecard. HammerDB 6.0 reports per + transaction percentiles from reservoir sampling, so p50/p95/p99 and max are + exact rather than bucket approximations. `time_profile` now defaults on; the + overhead measured smaller than run-to-run variance. +- `validate --from-cluster` checks database reachability from where the + workers actually run, which is not always reachable from your workstation. +- Credentials pass through a Kubernetes Secret by default, so they are not + readable via `kubectl describe job`. +- Pod securityContext compatible with the restricted Pod Security Standard. +- `resources.limits` applies to the container backend as `--memory`/`--cpus`. +- `-c` and `--config` accepted as aliases for `--file`. + +### Changed +- HammerDB 6.0. Existing 5.0-built schemas are read without rebuilding, and + the published `latest` images now contain 6.0. Verified that an unmodified + 2.0.3 install still runs against them. +- `init` asks where to run the workers and emits only the relevant backend + block, instead of always asking for a Kubernetes namespace. +- README leads with the container path; Kubernetes is documented as the option + for people who already have a cluster. +- The repo-root chart paths are symlinks into the packaged chart rather than a + second copy, which had already drifted. + +### Fixed +- `total_iterations` rendered in scientific notation (`1e+07`) in the Helm + template, which TCL cannot parse. +- `find_test_ids` returned a stale build run ahead of the completed run, so + `results` reported no metrics after a successful benchmark. +- Container runs carried no deployment-name label, so two configs sharing a + host saw each other's test IDs. +- The base image was missing `libpq5`, so PostgreSQL could never load its + driver despite being advertised as supported. +- `securityContext` and `use_secrets` opt-outs were inert, because Go's + `default true` treats an explicit `false` as absent. +- `validate --from-cluster` probed every non-Oracle target on the SQL Server + port, reporting healthy PostgreSQL fleets as unreachable. +- The `:z` SELinux mount relabel was applied unconditionally, which breaks + Docker Desktop. It is now gated on the host actually enforcing SELinux. +- `init` produced SQL Server ODBC settings when PostgreSQL was selected. +- `VERSION` reported `2.0.2` in the 2.0.3 release; it now reads from + `pyproject.toml`. + +### Known limitations +- Oracle requires Oracle Instant Client, which Oracle publishes for linux.x64 + only, so Oracle benchmarks cannot run on Apple Silicon. +- Oracle ships as a separate image you build yourself, because Oracle's licence + does not permit redistributing Instant Client. +- The container backend drives from a single host. Spreading workers across + hosts requires the Kubernetes backend. +- Verified on Linux with podman. The docker, macOS and WSL2 paths are + implemented and unit tested but have not been exercised end to end. + +## [2.0.3] - 2026-07-11 + +### Fixed +- Test ID resolution now filters by deployment name, so `results` and `report` + act on the intended run. + +## [2.0.2] - 2026-07-10 + +### Fixed +- Run hash generation mis-parsed YAML values in scientific notation. +- Helm chart version aligned with the CLI release. +- LICENSE badge link corrected for PyPI rendering. + ## [2.0.1] - 2026-03-05 ### Added diff --git a/README.md b/README.md index 4a49b4e..113b226 100644 --- a/README.md +++ b/README.md @@ -125,8 +125,8 @@ init → validate → run --build → results → report │ │ │ │ │ │ │ │ │ └─ HTML scorecard │ │ │ └─ aggregate TPM/NOPM/QphH - │ │ └─ build schema + run benchmark (parallel K8s jobs) - │ └─ check config, helm, kubectl, DB connectivity + │ │ └─ build schema + run benchmark (one worker per target) + │ └─ check config, tooling, DB connectivity └─ generate config interactively ``` @@ -143,7 +143,7 @@ init → validate → run --build → results → report | `logs` | View HammerDB output logs | | `results` | Aggregate and display benchmark results | | `report` | Generate self-contained HTML scorecard | -| `clean` | Remove K8s resources and/or database tables | +| `clean` | Remove workers (containers or K8s jobs) and/or database tables | ## Documentation @@ -157,8 +157,8 @@ init → validate → run --build → results → report ## Requirements - **Python 3.10+** -- **podman or docker** — either one; podman is preferred when both are present -- **Database targets** — one or more Oracle, SQL Server or PostgreSQL instances reachable from the machine running the workers +- **podman or docker**, either one. podman is preferred when both are present. +- **Database targets**, one or more Oracle, SQL Server or PostgreSQL instances reachable from the machine running the workers That is the whole list for the default container backend. No cluster, no Helm, no kubectl. @@ -167,9 +167,9 @@ no kubectl. Set `backend: kubernetes` in your config and add: -- **Helm 3.x** — used to template and deploy the Jobs -- **kubectl** — configured with a context that has access to your cluster -- **A cluster** — with permissions to create Jobs, ConfigMaps and Secrets in your namespace +- **Helm 3.x**, used to template and deploy the Jobs +- **kubectl**, configured with a context that has access to your cluster +- **A cluster** with permissions to create Jobs, ConfigMaps and Secrets in your namespace - Databases reachable **from the cluster**, which is not always the same as reachable from your workstation. `hammerdb-scale validate --from-cluster` checks from where the workers actually run. ### Platform support diff --git a/pyproject.toml b/pyproject.toml index 55753b9..bdd772f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "hammerdb-scale" -version = "2.0.3" -description = "CLI for orchestrating parallel HammerDB database benchmarks at scale on Kubernetes" +version = "2.0.4" +description = "CLI for orchestrating parallel HammerDB database benchmarks at scale" readme = {file = "README.md", content-type = "text/markdown"} license = "Apache-2.0" requires-python = ">=3.10" diff --git a/src/hammerdb_scale/chart/Chart.yaml b/src/hammerdb_scale/chart/Chart.yaml index 556f3e7..c463a31 100644 --- a/src/hammerdb_scale/chart/Chart.yaml +++ b/src/hammerdb_scale/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: hammerdb-scale description: Modular Helm chart for parallel HammerDB scale testing across multiple databases (SQL Server, PostgreSQL, Oracle, MySQL) type: application -version: 2.0.2 +version: 2.0.4 appVersion: "6.0" keywords: - hammerdb diff --git a/tests/test_hammerdb_version.py b/tests/test_hammerdb_version.py index 01d8d2b..cded4fb 100644 --- a/tests/test_hammerdb_version.py +++ b/tests/test_hammerdb_version.py @@ -126,3 +126,39 @@ def test_both_paths_reach_the_same_template(self): root = (REPO_ROOT / CHART_TEMPLATE).read_text() packaged = (REPO_ROOT / "src/hammerdb_scale/chart" / CHART_TEMPLATE).read_text() assert root == packaged + + +class TestReleaseVersions: + """Chart and CLI versions must move together. + + They drifted before: the chart shipped 2.0.2 against a 2.0.3 CLI, so a + `helm list` reported a version that did not match the tool that deployed + it. + """ + + def _chart_version(self) -> str: + text = (REPO_ROOT / "src/hammerdb_scale/chart/Chart.yaml").read_text() + match = re.search(r"^version:\s*(\S+)", text, re.MULTILINE) + assert match, "Chart.yaml must declare a version" + return match.group(1) + + def _pyproject_version(self) -> str: + text = (REPO_ROOT / "pyproject.toml").read_text() + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + assert match, "pyproject.toml must declare a version" + return match.group(1) + + def test_chart_matches_pyproject(self): + assert self._chart_version() == self._pyproject_version() + + def test_cli_reports_pyproject_version(self): + from hammerdb_scale.constants import VERSION + + assert VERSION == self._pyproject_version() + + def test_chart_appversion_tracks_published_hammerdb(self): + """appVersion advertises the HammerDB the published images carry.""" + text = (REPO_ROOT / "src/hammerdb_scale/chart/Chart.yaml").read_text() + match = re.search(r'^appVersion:\s*"?([^"\s]+)"?', text, re.MULTILINE) + assert match, "Chart.yaml must declare an appVersion" + assert match.group(1) == PUBLISHED_HAMMERDB_VERSION From 6d2b6aa7a46dd959430303583ac480cf909d4be9 Mon Sep 17 00:00:00 2001 From: AndrewSillifant Date: Sun, 26 Jul 2026 21:36:43 -0600 Subject: [PATCH 14/14] Fix container-backend documentation gaps and remove leaked credentials CONFIGURATION.md, CONTAINER-IMAGES.md, SECURITY.md, and USAGE-GUIDE.md still described the tool as Kubernetes-only and referenced HammerDB 5.0, predating the podman/docker backend and the 6.0 upgrade. Added the missing backend, container, and PostgreSQL field references, corrected stale version numbers, and split backend-specific behavior (validate layers, troubleshooting, resource limits) where the two paths actually differ. Removed docs/oracle-scale-environment.md, a personal handoff note with a live database password checked into public docs. Rewrote em dashes and fragment-heavy bullet lists into plain sentences throughout. --- CHANGELOG.md | 2 +- README.md | 15 +-- docs/CONFIGURATION.md | 140 +++++++++++++++++++++----- docs/CONTAINER-IMAGES.md | 52 +++++----- docs/MIGRATION.md | 2 + docs/SECURITY.md | 46 +++++---- docs/USAGE-GUIDE.md | 126 ++++++++++------------- docs/oracle-scale-environment.md | 167 ------------------------------- 8 files changed, 233 insertions(+), 317 deletions(-) delete mode 100644 docs/oracle-scale-environment.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a92c15..4b731b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,7 +87,7 @@ podman or docker, and HammerDB moves to 6.0. - 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. +- 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 diff --git a/README.md b/README.md index 113b226..2e5e14b 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ init → validate → run --build → results → report | Command | Description | |---------|-------------| -| `version` | Show CLI, Python, helm, kubectl versions | +| `version` | Show CLI, Python, and backend tooling versions | | `init` | Generate config file interactively | | `validate` | Validate config, prerequisites, and connectivity | | `build` | Create benchmark schema on database targets | @@ -147,11 +147,11 @@ init → validate → run --build → results → report ## Documentation -- [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 +- [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 @@ -199,7 +199,7 @@ By building it you accept [Oracle's licence terms](https://www.oracle.com/downlo ### Optional -- [pipx](https://pipx.pypa.io/) — recommended for installing CLI tools in isolated environments: `pipx install hammerdb-scale` +- [pipx](https://pipx.pypa.io/): recommended for installing CLI tools in isolated environments, `pipx install hammerdb-scale` ## Configuration @@ -207,6 +207,7 @@ See the [Configuration Reference](https://github.com/PureStorage-OpenConnect/ham ```yaml name: my-benchmark +backend: podman default_benchmark: tprocc targets: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 58b5fde..222bbf2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,10 +4,11 @@ HammerDB-Scale uses a YAML config file to define database targets and benchmark ## Minimal Example -The smallest valid config — two SQL Server targets running TPC-C: +The smallest valid config: two SQL Server targets running TPC-C on a machine with podman. ```yaml name: my-benchmark +backend: podman default_benchmark: tprocc targets: @@ -31,7 +32,52 @@ hammerdb: duration: 5 ``` -Everything else has sensible defaults. For complete examples covering all database and benchmark combinations, see the [examples/](../examples/) directory. +Everything else has sensible defaults. + +### Running on Kubernetes + +Omit `backend` to run on Kubernetes instead; it is the default. The same config works, minus the `backend` line, but the `kubernetes` and `resources` sections now apply: + +```yaml +name: my-benchmark +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "YourPassword" + mssql: {} + hosts: + - name: sql-01 + host: sql-01.example.com + - name: sql-02 + host: sql-02.example.com + +hammerdb: + tprocc: + warehouses: 100 + load_virtual_users: 4 + driver: timed + rampup: 2 + duration: 5 + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 +``` + +This needs Helm, kubectl, and a cluster context with permission to create Jobs, ConfigMaps, and Secrets in the target namespace. See the README's [Running on Kubernetes instead](../README.md#running-on-kubernetes-instead) section for the full requirements. + +For complete examples covering all database and benchmark combinations, see the [examples/](../examples/) directory. ## Config File Discovery @@ -44,17 +90,19 @@ The CLI looks for the config file in this order: ## Schema Overview -A config file has five top-level sections: +A config file has seven top-level sections: | Section | Purpose | |---------|---------| -| `targets` | **Where** to benchmark — database hosts, credentials, and database-specific settings | -| `hammerdb` | **How** to benchmark — shared benchmark parameters (warehouses, VUs, duration) | -| `resources` | Kubernetes pod resource requests and limits | -| `kubernetes` | Namespace and job TTL settings | +| `backend` | Where workers run: `kubernetes` (default), `podman`, `docker`, or `container` to auto-detect podman or docker | +| `targets` | Where to benchmark: database hosts, credentials, and database-specific settings | +| `hammerdb` | How to benchmark: shared benchmark parameters (warehouses, VUs, duration) | +| `resources` | Memory and CPU limits, applied as Kubernetes pod resources or as `--memory`/`--cpus` on the container backend. `requests` is Kubernetes-only | +| `kubernetes` | Namespace and job TTL settings. Ignored for `podman`/`docker`/`container` backends | +| `container` | Runtime selection and network settings for the `podman`/`docker`/`container` backends. Ignored on Kubernetes | | `storage_metrics` | Optional Pure Storage metrics collection | -Key design principle: **all database-specific settings live under `targets.defaults.`**, not under `hammerdb`. The `hammerdb` section contains only shared benchmark parameters that apply regardless of database type. This makes every database-specific setting overridable per-host. +All database-specific settings live under `targets.defaults.`, not under `hammerdb`. The `hammerdb` section contains only shared benchmark parameters that apply regardless of database type. This makes every database-specific setting overridable per-host. ## Complete Field Reference @@ -62,9 +110,10 @@ Key design principle: **all database-specific settings live under `targets.defau | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `name` | string | yes | | Deployment identifier. Used in K8s job names and test run IDs. | +| `name` | string | yes | | Deployment identifier. Used in job names and test run IDs. | | `description` | string | no | `""` | Optional description for this benchmark configuration. | | `default_benchmark` | string | no | `null` | Default benchmark type when not specified on the CLI. Valid values: `tprocc`, `tproch`. | +| `backend` | string | no | `"kubernetes"` | Where workers run. Valid values: `kubernetes`, `podman`, `docker`, `container` (auto-detect podman or docker). | ### `targets` @@ -77,7 +126,7 @@ Key design principle: **all database-specific settings live under `targets.defau | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `type` | string | yes | | Database type: `oracle` or `mssql`. | +| `type` | string | yes | | Database type: `oracle`, `mssql`, or `postgres`. | | `username` | string | yes | | Database login username. Typical values: `system` (Oracle), `sa` (MSSQL). | | `password` | string | yes | | Database login password. | | `image.repository` | string | no | `"sillidata/hammerdb-scale"` | Container image repository. Use `sillidata/hammerdb-scale-oracle` for Oracle targets. | @@ -85,6 +134,7 @@ Key design principle: **all database-specific settings live under `targets.defau | `image.pull_policy` | string | no | `"Always"` | K8s image pull policy: `Always`, `IfNotPresent`, or `Never`. | | `oracle` | object | conditional | | Oracle-specific settings. **Required** when `type: oracle`. | | `mssql` | object | conditional | | MSSQL-specific settings. **Required** when `type: mssql`. | +| `postgres` | object | conditional | | PostgreSQL-specific settings. **Required** when `type: postgres`. | ### `targets.defaults.oracle` @@ -120,13 +170,33 @@ All fields are per-host overridable. | `tproch.maxdop` | int | `2` | Maximum degree of parallelism for TPC-H queries. Min: 1. | | `tproch.use_clustered_columnstore` | bool | `false` | Use clustered columnstore indexes for TPC-H tables. Improves analytical query performance at the cost of longer build times. | +### `targets.defaults.postgres` + +All fields are per-host overridable. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `port` | int | `5432` | PostgreSQL listener port. Range: 1-65535. | +| `sslmode` | string | `"prefer"` | TLS mode: `prefer`, `require`, or `disable`. | +| `tablespace` | string | `""` | Tablespace for benchmark schema objects. Empty uses the default tablespace. | +| `tprocc.database_name` | string | `"tpcc"` | Database created during the schema build. | +| `tprocc.user` | string | `"tpcc"` | Schema owner, created during the schema build. | +| `tprocc.password` | string | `"tpcc"` | Password for the TPC-C schema user. | +| `tprocc.stored_procedures` | bool | `true` | Use stored procedures for the TPC-C driver. HammerDB's recommended setting; must match between build and run, since the driver calls procedures that only exist if the schema was built with them. | +| `tprocc.partition` | bool | `false` | Partition TPC-C tables during the schema build. | +| `tprocc.vacuum` | bool | `false` | Run `VACUUM` after the schema build. | +| `tproch.database_name` | string | `"tpch"` | Database created during the schema build. | +| `tproch.user` | string | `"tpch"` | Schema owner, created during the schema build. | +| `tproch.password` | string | `"tpch"` | Password for the TPC-H schema user. | +| `tproch.max_parallel_workers` | int | `8` | Degree of parallelism for the TPC-H query phase. Min: 1. | + ### `targets.hosts[]` -Each host inherits all fields from `targets.defaults`. Any field can be overridden per-host, including nested fields under `oracle` or `mssql`. +Each host inherits all fields from `targets.defaults`. Any field can be overridden per-host, including nested fields under `oracle`, `mssql`, or `postgres`. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `name` | string | yes | Unique identifier for this host. Used in K8s job names. | +| `name` | string | yes | Unique identifier for this host. Used in job names. | | `host` | string | yes | Hostname or IP address of the database server. | | All `targets.defaults` fields | | no | Any field from defaults can be overridden here. | @@ -159,23 +229,36 @@ Shared benchmark parameters. These apply to all database types. **No database-sp | `load_virtual_users` | int | `1` | Number of virtual users for the query execution phase. Min: 1. | | `total_querysets` | int | `1` | Number of complete TPC-H query set iterations to execute. Min: 1. | +### `container` + +Only used when `backend` is `podman`, `docker`, or `container`. Ignored on Kubernetes. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `runtime` | string | `"auto"` | Which container runtime to use: `auto` (prefers podman if both are present), `podman`, or `docker`. Only meaningful when `backend: container`; the `podman` and `docker` backend values already pick the runtime directly. | +| `network` | string | `null` | Container network name. `null` uses the runtime's default network. | + ### `resources` -Standard Kubernetes resource requests and limits for HammerDB worker pods. +`limits` applies to both backends: Kubernetes pod resource limits, or `--memory`/`--cpus` on the podman/docker container. `requests` only has a Kubernetes equivalent and is ignored on the container backend. | Field | Type | Default | Description | |-------|------|---------|-------------| -| `requests.memory` | string | `"4Gi"` | Minimum memory reservation. | -| `requests.cpu` | string | `"4"` | Minimum CPU reservation. | -| `limits.memory` | string | `"8Gi"` | Maximum memory limit. | -| `limits.cpu` | string | `"8"` | Maximum CPU limit. | +| `requests.memory` | string | `"4Gi"` | Minimum memory reservation. Kubernetes only. | +| `requests.cpu` | string | `"4"` | Minimum CPU reservation. Kubernetes only. | +| `limits.memory` | string | `"8Gi"` | Maximum memory limit. Both backends. | +| `limits.cpu` | string | `"8"` | Maximum CPU limit. Both backends. | ### `kubernetes` +Only used when `backend` is `kubernetes`. Ignored for `podman`, `docker`, and `container` backends. + | Field | Type | Default | Description | |-------|------|---------|-------------| | `namespace` | string | `"hammerdb"` | Kubernetes namespace for benchmark jobs. | -| `job_ttl` | int | `86400` | Time-to-live in seconds for completed K8s jobs. Default is 24 hours. Min: 0. | +| `job_ttl` | int | `86400` | Time-to-live in seconds for completed jobs. Default is 24 hours. Min: 0. | +| `security_context` | bool | `true` | Emit a restricted-compliant `securityContext` on the job pods. Needed on clusters enforcing the restricted Pod Security Standard. OpenShift applies an equivalent context on its own either way. | +| `use_secrets` | bool | `true` | Pass database credentials through a Kubernetes Secret rather than plain environment values on the Job spec, where `kubectl describe job` would expose them. | ### `storage_metrics` @@ -293,12 +376,21 @@ targets: Ready-to-use config files for every database and benchmark combination: -- [examples/values-oracle.yaml](../examples/values-oracle.yaml) — Oracle reference config (all fields) -- [examples/values-mssql.yaml](../examples/values-mssql.yaml) — MSSQL reference config (all fields) -- [examples/oracle-tprocc.yaml](../examples/oracle-tprocc.yaml) — Oracle TPC-C (4 targets) -- [examples/oracle-tproch.yaml](../examples/oracle-tproch.yaml) — Oracle TPC-H (4 targets) -- [examples/mssql-tprocc.yaml](../examples/mssql-tprocc.yaml) — SQL Server TPC-C (4 targets) -- [examples/mssql-tproch.yaml](../examples/mssql-tproch.yaml) — SQL Server TPC-H (4 targets) +**Kubernetes backend:** + +- [examples/values-oracle.yaml](../examples/values-oracle.yaml): Oracle reference config, all fields +- [examples/values-mssql.yaml](../examples/values-mssql.yaml): MSSQL reference config, all fields +- [examples/oracle-tprocc.yaml](../examples/oracle-tprocc.yaml): Oracle TPC-C, 4 targets +- [examples/oracle-tproch.yaml](../examples/oracle-tproch.yaml): Oracle TPC-H, 4 targets +- [examples/mssql-tprocc.yaml](../examples/mssql-tprocc.yaml): SQL Server TPC-C, 4 targets +- [examples/mssql-tproch.yaml](../examples/mssql-tproch.yaml): SQL Server TPC-H, 4 targets +- [examples/scale-tests/](../examples/scale-tests/): progressive 1 to 8 target configs for Oracle and MSSQL, used to build a scale curve + +**Container backend (podman or docker):** + +- [examples/container-mssql-tprocc.yaml](../examples/container-mssql-tprocc.yaml): SQL Server TPC-C, single host +- [examples/container-postgres-tprocc.yaml](../examples/container-postgres-tprocc.yaml): PostgreSQL TPC-C, single host +- [examples/postgres-setup/provision_postgres.sh](../examples/postgres-setup/provision_postgres.sh): reference script for configuring `pg_hba.conf` and `listen_addresses` so PostgreSQL accepts connections from the worker host ## v1 Config Auto-Migration diff --git a/docs/CONTAINER-IMAGES.md b/docs/CONTAINER-IMAGES.md index bf097c5..f5ac3dc 100644 --- a/docs/CONTAINER-IMAGES.md +++ b/docs/CONTAINER-IMAGES.md @@ -1,12 +1,12 @@ # Container Images -HammerDB-Scale deploys HammerDB as Kubernetes Jobs using pre-built container images. Each Job runs a container that connects to a single database target, executes the benchmark, and outputs results to its logs. +HammerDB-Scale runs one container per database target, using pre-built container images. Each container connects to a single database target, executes the benchmark, and writes its results to log output. Depending on `backend` in your config, that container runs as a plain podman or docker container on a local host, or as a Kubernetes Job. ## Pre-Built Images -| Image | Database | Source | -|-------|----------|--------| -| `sillidata/hammerdb-scale:latest` | SQL Server | [Dockerfile](../Dockerfile) | +| Image | Covers | Source | +|-------|--------|--------| +| `sillidata/hammerdb-scale:latest` | SQL Server, PostgreSQL | [Dockerfile](../Dockerfile) | | `sillidata/hammerdb-scale-oracle:latest` | Oracle | [Dockerfile.oracle](../Dockerfile.oracle) | These images are hosted on Docker Hub and can be pulled without authentication. @@ -15,40 +15,34 @@ These images are hosted on Docker Hub and can be pulled without authentication. Both images include: -- **Ubuntu 24.04** base -- **HammerDB 5.0** — the benchmark engine -- **Python 3** — for the Pure Storage metrics collector -- **entrypoint.sh** — orchestration script that receives configuration via environment variables, runs HammerDB, and manages metrics collection +- Ubuntu 24.04 base +- HammerDB 6.0, the benchmark engine +- Python 3, for the Pure Storage metrics collector +- `entrypoint.sh`, an orchestration script that receives configuration via environment variables, runs HammerDB, and manages metrics collection -The **MSSQL image** additionally includes: -- Microsoft ODBC Driver 18 for SQL Server -- `mssql-tools18` (including `bcp` for bulk data loading) +The base image (`sillidata/hammerdb-scale`) covers SQL Server and PostgreSQL targets. It includes Microsoft ODBC Driver 18 for SQL Server, `mssql-tools18` (including `bcp` for bulk data loading), and `libpq5` with `postgresql-client` for PostgreSQL. HammerDB bundles Pgtcl but links the system `libpq` at runtime, so `libpq5` has to be present even though HammerDB never calls `psql` directly. -The **Oracle image** extends the MSSQL image and adds: -- Oracle Instant Client 21.11 (Basic + SQL*Plus) -- Required shared libraries for HammerDB's Oratcl interface +The Oracle image extends the base image and adds Oracle Instant Client 21.11 (Basic and SQL*Plus) and the shared libraries HammerDB's Oratcl interface needs. ## Building Your Own Images -### SQL Server Image +The Oracle image extends the base image, and `Dockerfile.oracle` defaults to pulling the published base from Docker Hub rather than the one you just built locally. `hack/build-images.sh` builds both in the correct order, wires the base image explicitly, and verifies the result: ```bash -docker build -f Dockerfile -t my-org/hammerdb-scale:latest . +git clone https://github.com/PureStorage-OpenConnect/hammerdb-scale +cd hammerdb-scale +./hack/build-images.sh ``` -### Oracle Image - -The Oracle image extends the base image: +To build manually instead, build the base image first, then pass it to the Oracle build explicitly: ```bash -# Build base first docker build -f Dockerfile -t my-org/hammerdb-scale:latest . - -# Then build Oracle (references base image) -docker build -f Dockerfile.oracle -t my-org/hammerdb-scale-oracle:latest . +docker build -f Dockerfile.oracle -t my-org/hammerdb-scale-oracle:latest \ + --build-arg BASE_IMAGE=my-org/hammerdb-scale:latest . ``` -> **Note:** Building the Oracle image downloads Oracle Instant Client from Oracle's servers. By building and using this image, you accept the [Oracle Technology Network License Agreement](https://www.oracle.com/downloads/licenses/instant-client-lic.html). +Building the Oracle image downloads Oracle Instant Client from Oracle's servers. By building and using this image, you accept the [Oracle Technology Network License Agreement](https://www.oracle.com/downloads/licenses/instant-client-lic.html). ### Using Custom Images @@ -63,7 +57,7 @@ targets: pull_policy: Always ``` -Ensure your Kubernetes cluster can pull from your registry (configure `imagePullSecrets` if needed). +On Kubernetes, make sure the cluster can pull from your registry (configure `imagePullSecrets` if needed). On the container backend, make sure the local podman or docker daemon can pull from it, or has the image already loaded. ## Image Architecture @@ -77,17 +71,17 @@ entrypoint.sh (container startup) └── Writes metadata.json with results and timing ``` -The CLI never connects to the container directly. All configuration passes through environment variables set by the Helm chart, and all results are extracted from pod logs via `kubectl`. +This is the same for both backends. The CLI never connects to the container directly; all configuration passes through environment variables, and results are read back from the container's log output, either with `kubectl logs` on Kubernetes or the container runtime's own log command on the container backend. ## Environment Variables -The Helm chart sets these environment variables on each Job pod. You don't need to set these manually — they're populated from your config file automatically. +These environment variables are set on each container, either by the Helm chart (Kubernetes backend) or directly by the CLI (container backend). You don't need to set them manually; they're populated from your config file automatically. | Variable | Description | |----------|-------------| | `RUN_MODE` | `build` or `load` | | `BENCHMARK` | `tprocc` or `tproch` | -| `DATABASE_TYPE` | `mssql` or `oracle` | +| `DATABASE_TYPE` | `mssql`, `postgres`, or `oracle` | | `HOST` | Database hostname | | `USERNAME` | Database admin user | | `PASSWORD` | Database admin password | @@ -95,4 +89,4 @@ The Helm chart sets these environment variables on each Job pod. You don't need | `TARGET_INDEX` | 0-based index of this target | | `TEST_RUN_ID` | Test run identifier | -Additional database-specific and benchmark-specific variables are documented in the [Helm template](../templates/job-hammerdb-worker.yaml). +Additional database-specific and benchmark-specific variables are documented in the [Helm template](../templates/job-hammerdb-worker.yaml), which both backends draw from for the variable set. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index fcad3a0..e301c67 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -6,6 +6,8 @@ pip install hammerdb-scale ``` +v1.x only ran on Kubernetes, and everything below describes that same path in v2: `helm uninstall`, Job cleanup, and the rest. If you'd rather run against a single host with podman or docker instead, most of the command mapping below still applies; the difference is `backend: podman` (or `docker`) in your config instead of `backend: kubernetes` (or omitting it), and no Helm release or cluster involved. See [CONFIGURATION.md](CONFIGURATION.md) for the container-backend config shape. + ## Migrate Config ```bash diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 71b9be0..a32d9d6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,34 +2,46 @@ ## Credentials -HammerDB-Scale v2.0.0 stores database credentials in plaintext YAML config files. This is consistent with Helm values.yaml patterns but should be handled carefully. +HammerDB-Scale stores database credentials in plaintext YAML config files. This is consistent with Helm values.yaml patterns but should be handled carefully. -**Recommendations:** +Recommendations: -- Do not commit config files containing passwords to version control -- Use file permissions to restrict access to config files (`chmod 600`) -- Consider using environment variable substitution in CI/CD pipelines +- Do not commit config files containing passwords to version control. +- Use file permissions to restrict access to config files (`chmod 600`). +- Consider using environment variable substitution in CI/CD pipelines. + +On the Kubernetes backend, credentials also pass through a Kubernetes Secret by default (`kubernetes.use_secrets: true`), so they do not appear in plain environment values on the Job spec, where `kubectl describe job` would expose them. Set `use_secrets: false` only if your cluster policy requires it; there is no equivalent protection on the container backend, since podman and docker containers run as plain local processes. ## Database Connectivity -- MSSQL connections use encrypted connections by default (`encrypt_connection: true`) -- Oracle connections use `oracledb` thin mode (no Oracle client required) -- The `validate` command tests connectivity as the admin user only, not schema users +- MSSQL connections use encrypted connections by default (`encrypt_connection: true`). +- The `validate` and `clean` commands connect to Oracle using `oracledb` in thin mode, so they need no Oracle client installed on the machine running the CLI. The benchmark itself is a separate matter: HammerDB's Oratcl interface links against Oracle Instant Client's native libraries, which is why the Oracle container image bundles Instant Client even though the CLI does not need it locally. +- The `validate` command tests connectivity as the admin user only, not the schema users the benchmark itself connects as. ## Container Images -- Default images are pulled from `sillidata/hammerdb-scale` (MSSQL) and `sillidata/hammerdb-scale-oracle` (Oracle) -- Images embed HammerDB 5.0 and database client libraries -- Use `pull_policy: Always` in production to ensure latest patches +- Default images are pulled from `sillidata/hammerdb-scale` (SQL Server, PostgreSQL) and `sillidata/hammerdb-scale-oracle` (Oracle). +- Images embed HammerDB 6.0 and database client libraries. +- Use `pull_policy: Always` in production to ensure the latest patches, on Kubernetes. The container backend pulls once and reuses the local image unless you remove it. ## Kubernetes -- Jobs run in the configured namespace (default: `hammerdb`) -- Resource limits are enforced via the `resources` config section -- Jobs have a configurable TTL (`job_ttl`) after which K8s garbage-collects them +Applies only when `backend: kubernetes`. + +- Jobs run in the configured namespace (default: `hammerdb`). +- Resource limits are enforced via the `resources` config section. +- Jobs have a configurable TTL (`job_ttl`) after which Kubernetes garbage-collects them. + +## Container Backend + +Applies only when `backend` is `podman`, `docker`, or `container`. + +- Containers run as plain local processes under whichever user runs the CLI, with no cluster boundary between them and the rest of that host. +- Credentials pass through environment variables on the container directly. Anyone able to run `podman inspect` or `docker inspect` against a running or recently-stopped container on that host can read them. +- This backend is meant for a trusted single host close to the databases, not for multi-tenant or shared infrastructure. ## Network -- The CLI communicates with databases directly during `validate --connectivity` -- Helm and kubectl commands use the current kubeconfig context -- Pure Storage API calls (if enabled) use the configured API token and endpoint +- The CLI communicates with databases directly during `validate` and `clean` operations. +- On the Kubernetes backend, Helm and kubectl commands use the current kubeconfig context. `validate --from-cluster` checks database reachability from where the workers actually run, which is not always the same as reachable from your workstation. +- Pure Storage API calls, if enabled, use the configured API token and endpoint. diff --git a/docs/USAGE-GUIDE.md b/docs/USAGE-GUIDE.md index 2981cba..9c9e908 100644 --- a/docs/USAGE-GUIDE.md +++ b/docs/USAGE-GUIDE.md @@ -8,10 +8,10 @@ pip install hammerdb-scale This installs the `hammerdb-scale` CLI globally, including the bundled Helm chart. You can run it from any directory. -Requires: -- **Python 3.10+** -- **Helm 3.x** and **kubectl** installed and on your `PATH` -- A Kubernetes cluster accessible via your current `kubectl` context +Requires Python 3.10 or later, and one of the two backends: + +- Container backend (recommended): podman or docker on the machine that will run the workers. No cluster, Helm, or kubectl needed. +- Kubernetes backend: Helm 3.x and kubectl on your `PATH`, plus a cluster accessible via your current `kubectl` context. Set `backend: kubernetes` in your config to use this path; see [CONFIGURATION.md](CONFIGURATION.md#running-on-kubernetes) for the full requirements. For isolated installations, use [pipx](https://pipx.pypa.io/): @@ -50,7 +50,7 @@ hammerdb-scale report --open ### `hammerdb-scale version` -Prints CLI, Python, helm, and kubectl versions along with the current Kubernetes context. +Prints the CLI and Python versions, plus whichever backend tooling it finds installed: Helm and kubectl with the current Kubernetes context, and podman or docker if present. ### `hammerdb-scale init` @@ -73,29 +73,22 @@ 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 +1. **Deployment**: name your benchmark and choose where the workers run (podman, docker, or Kubernetes; it leads with whatever runtime it finds installed) +2. **Database & Benchmark**: select Oracle, SQL Server, or PostgreSQL, and TPC-C or TPC-H +3. **Database Targets**: enter hostnames or IPs, with auto-generated names like `db-01` +4. **Credentials**: database username and password, plus Oracle's service name and schema password where relevant +5. **Benchmark Parameters**: warehouses for TPC-C, or scale factor for TPC-H +6. **Infrastructure**: Kubernetes namespace, only asked if you chose that backend, and 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. +After the core steps, an optional Advanced Options prompt lets you configure virtual users, rampup and duration, and (on Kubernetes) 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. +A Configuration Summary table is shown before writing, giving you a chance to review all values, with 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. +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: - -1. YAML syntax -2. Schema validation (Pydantic) -3. Image/database type consistency -4. Tool prerequisites (helm, kubectl) -5. Kubernetes cluster access -6. Database connectivity (actual login test) +Validates configuration through several layers: YAML syntax, schema validation, image and database type consistency, and database connectivity, an actual login test against every target. The remaining layers depend on `backend`. On Kubernetes, it also checks that Helm and kubectl are installed and that the current context can create Jobs, ConfigMaps, and Secrets in the target namespace. On the container backend, it checks that podman or docker is installed instead, and there is no cluster-access layer. ```bash hammerdb-scale validate # Full validation @@ -105,13 +98,13 @@ hammerdb-scale validate -f config.yaml # Explicit config ### `hammerdb-scale build` -Creates benchmark schema (tables, indexes) on all database targets. Deploys one Kubernetes Job per target. +Creates benchmark schema (tables, indexes) on all database targets. Starts one worker per target, either a local container or a Kubernetes Job depending on `backend`. ```bash hammerdb-scale build --benchmark tprocc hammerdb-scale build --benchmark tprocc --id my-test-001 hammerdb-scale build --wait # Poll until all jobs complete -hammerdb-scale build --dry-run # Render Helm templates only +hammerdb-scale build --dry-run # Render without deploying ``` `--benchmark` is optional if `default_benchmark` is set in your config. @@ -153,7 +146,7 @@ hammerdb-scale logs --tail 50 # Last 50 lines ### `hammerdb-scale results` -Aggregates results from K8s job logs, parses benchmark metrics, and saves to a local directory. +Aggregates results from worker logs, parses benchmark metrics, and saves to a local directory. ```bash hammerdb-scale results @@ -163,7 +156,7 @@ hammerdb-scale results --json # Machine-readable output ### `hammerdb-scale report` -Generates a self-contained HTML scorecard with charts and tables. The file can be opened in any browser, shared, or viewed offline — all CSS and JavaScript are embedded. +Generates a self-contained HTML scorecard with charts and tables. All CSS and JavaScript are embedded, so the file can be opened in any browser, shared, or viewed offline. ```bash hammerdb-scale report # Default: results/{test-id}/scorecard.html @@ -176,7 +169,7 @@ hammerdb-scale report -o report.html # Custom output path Removes benchmark resources. ```bash -# Remove K8s resources (Helm releases, jobs) +# Remove workers: containers on the container backend, Helm releases and jobs on Kubernetes hammerdb-scale clean --resources --id hammerdb-scale clean --resources --everything @@ -211,9 +204,9 @@ hammerdb-scale clean --resources --everything --force ### What to Expect -- TPM/NOPM scale roughly linearly with the number of database targets when storage is not the bottleneck -- When storage saturates, adding more databases will show diminishing returns — this is the point of scale testing -- Per-target metrics should be roughly equal. Large variance suggests a configuration or resource imbalance +- TPM/NOPM scale roughly linearly with the number of database targets when storage is not the bottleneck. +- When storage saturates, adding more databases shows diminishing returns. That is the point of scale testing: finding where the curve bends. +- Per-target metrics should be roughly equal. Large variance suggests a configuration or resource imbalance. ### Output Directory @@ -229,25 +222,18 @@ results/my-benchmark-20250304-1200/ └── scorecard.html # HTML report (after running `report`) ``` -- **summary.json** — Machine-readable results. Contains per-target metrics, aggregate totals, and the config snapshot used for the test. -- **Target logs** — Raw HammerDB output. Useful for debugging failed targets or verifying benchmark parameters. -- **pure_metrics.json** — Time-series storage metrics from Pure Storage FlashArray (IOPS, latency, bandwidth). -- **scorecard.html** — Self-contained HTML report. Can be opened in any browser without a web server. +- **summary.json**: machine-readable results, containing per-target metrics, aggregate totals, and the config snapshot used for the test. +- **Target logs**: raw HammerDB output, useful for debugging failed targets or verifying benchmark parameters. +- **pure_metrics.json**: time-series storage metrics from the Pure Storage FlashArray (IOPS, latency, bandwidth). +- **scorecard.html**: self-contained HTML report that opens in any browser without a web server. ### Reading the Scorecard -The HTML scorecard contains: +The TPC-C scorecard opens with summary cards for total TPM, total NOPM, and the average TPM and NOPM per target, followed by a per-target table showing status, duration, TPM, and NOPM for each database. Distribution charts show TPM and NOPM per target as horizontal bars: even bars indicate balanced load, uneven bars suggest an issue with specific targets. -**TPC-C Scorecard:** -- **Summary cards** — Total TPM, Total NOPM, Average TPM/target, Average NOPM/target -- **Per-target table** — Status, duration, TPM, and NOPM for each database target -- **Distribution charts** — Horizontal bar charts showing TPM and NOPM per target. Even bars indicate balanced load; uneven bars suggest an issue with specific targets. -- **Storage performance** (if Pure Storage metrics enabled) — Latency, IOPS, and bandwidth time-series charts showing how the storage array performed during the benchmark +With `time_profile` enabled, which is the default, the scorecard also includes a transaction response time section: p50, p95, and p99 latency per transaction type, drawn from HammerDB's own reservoir sampling rather than bucketed estimates. If Pure Storage metrics are enabled, a storage performance section shows latency, IOPS, and bandwidth as time series over the course of the benchmark. -**TPC-H Scorecard:** -- **QphH summary** — Composite query throughput metric -- **Per-query timing table** — Execution time for each of the 22 TPC-H queries (avg, min, max across targets) -- **Query distribution chart** — Visual comparison of query execution times +The TPC-H scorecard leads with the QphH summary, the composite query throughput metric, followed by a per-query timing table with average, minimum, and maximum execution time for each of the 22 TPC-H queries across targets, and a chart comparing query execution times. ## Pure Storage Metrics @@ -271,19 +257,13 @@ storage_metrics: ### What's Collected -- **Read/Write IOPS** — I/O operations per second -- **Read/Write Latency** — Response time in microseconds (average, P95, P99) -- **Read/Write Bandwidth** — Data throughput in bytes per second -- **Queue Depth** — Outstanding I/O requests +It collects read and write IOPS, read and write latency (average, P95, P99, in microseconds), read and write bandwidth (bytes per second), and queue depth (outstanding I/O requests). -Metrics are collected from the first target pod only (to avoid duplicate API calls) and run as a background process for the duration of the benchmark. +Metrics come from the first target's worker only, to avoid duplicate API calls, and run as a background process for the duration of the benchmark. ### In the Report -The storage performance section of the scorecard shows: -- Summary cards with peak and average values -- Time-series line charts for latency, IOPS, and bandwidth over the duration of the test -- These charts help identify storage bottlenecks — look for latency spikes or IOPS plateaus that correlate with benchmark load +The storage performance section of the scorecard shows summary cards with peak and average values, plus time-series line charts for latency, IOPS, and bandwidth over the duration of the test. Look for latency spikes or IOPS plateaus that correlate with benchmark load; those are where storage is the bottleneck rather than the database or the driver. ## Exit Codes @@ -304,9 +284,17 @@ The storage performance section of the scorecard shows: ### `helm not found` or `kubectl not found` -Install [Helm](https://helm.sh/docs/intro/install/) and [kubectl](https://kubernetes.io/docs/tasks/tools/), then ensure they're on your `PATH`. Run `hammerdb-scale version` to verify. +These only matter on the Kubernetes backend. If you meant to use the container backend, check that `backend` in your config is set to `podman`, `docker`, or `container`, not left as the `kubernetes` default. Otherwise, install [Helm](https://helm.sh/docs/intro/install/) and [kubectl](https://kubernetes.io/docs/tasks/tools/), then ensure they're on your `PATH`. Run `hammerdb-scale version` to verify. + +### No container runtime found + +This is the container-backend equivalent of the error above. Install podman or docker, or switch `backend` to `kubernetes` if that's what you meant to use. + +### SELinux denies the script mount (container backend) + +On a host running SELinux, podman needs a `:z` relabel on the read-only script mount, which the CLI applies automatically when it detects SELinux is active. If detection is wrong for your setup, the container fails immediately with a permission-denied mount error. Confirm SELinux mode with `getenforce`, and file an issue if the automatic handling did not match it. -### Namespace doesn't exist +### Namespace doesn't exist (Kubernetes backend) Create the namespace before running: @@ -318,12 +306,12 @@ Or change the namespace in your config (`kubernetes.namespace`). ### Database connectivity failures during `validate` -- Verify the database host is reachable from your workstation: `telnet ` -- Check credentials are correct -- For Oracle: ensure the listener is running and the service name matches your config -- For MSSQL: if using `encrypt_connection: true`, the server must support TLS +- Verify the database host is reachable from where the workers actually run. From your workstation, `telnet ` is a quick check, but on Kubernetes the workers may sit on a different network path; use `hammerdb-scale validate --from-cluster` to check from there instead. +- Check credentials are correct. +- For Oracle, ensure the listener is running and the service name matches your config. +- For MSSQL, if using `encrypt_connection: true`, the server must support TLS. -### Jobs stuck in Pending +### Jobs stuck in Pending (Kubernetes backend) Check pod events: @@ -331,9 +319,7 @@ Check pod events: kubectl describe pod -n hammerdb -l hammerdb.io/test-id= ``` -Common causes: -- **ImagePullBackOff** — Container image can't be pulled. Check image name and registry access. -- **Insufficient resources** — Reduce `resources.requests` in your config or ensure your cluster has available capacity. +Common causes: `ImagePullBackOff`, meaning the container image can't be pulled, so check the image name and registry access; or insufficient resources, in which case reduce `resources.requests` in your config or free up capacity on the cluster. ### Build jobs fail @@ -341,21 +327,17 @@ Common causes: hammerdb-scale logs --id ``` -Common causes: -- Wrong credentials (check `targets.defaults.username`/`password`) -- Database not reachable from inside the Kubernetes cluster (different from your workstation) -- For Oracle: tablespace doesn't exist — create it before building -- For MSSQL: database name conflict — use `clean --database` first +Common causes: wrong credentials, so check `targets.defaults.username` and `password`; the database not reachable from where the worker actually runs, which on Kubernetes can differ from your workstation; for Oracle, a tablespace that doesn't exist, which needs creating before building; for MSSQL, a database name conflict, which `clean --database` resolves. ### Results show 0 TPM -- Ensure `driver: timed` (not `test`) in your TPC-C config -- Ensure `duration` is long enough (at least 5 minutes recommended) -- Check logs for errors: `hammerdb-scale logs --target ` +- Ensure `driver: timed`, not `test`, in your TPC-C config. +- Ensure `duration` is long enough. Five minutes or more is a reasonable minimum. +- Check logs for errors: `hammerdb-scale logs --target `. ### Partial failures (exit code 2) -Some targets completed but others failed. Run `hammerdb-scale results` to see which targets succeeded. Check logs for failed targets individually: +Some targets completed but others failed. Run `hammerdb-scale results` to see which targets succeeded, then check logs for the failed ones individually: ```bash hammerdb-scale logs --target diff --git a/docs/oracle-scale-environment.md b/docs/oracle-scale-environment.md deleted file mode 100644 index a592619..0000000 --- a/docs/oracle-scale-environment.md +++ /dev/null @@ -1,167 +0,0 @@ -# Oracle Scale Test Environment - -Hi Ganesh — this document describes the 8-node Oracle environment set up for HammerDB-Scale testing. Everything is pre-built and ready to go. You have 8 identical Oracle 26ai databases, each loaded with a 1TB TPC-C dataset, RMAN backups on a shared NFS mount, and YAML configs for running scale tests from 1 to 8 targets. - - -## Environment at a Glance - -- **8x Oracle 26ai** (23.26.1.0.0) single-instance databases -- **10,000 TPC-C warehouses** per database (~1TB each) -- **RMAN cold backups** on NFS (~804GB per host, ~6.4TB total) -- **Kubernetes namespace:** `hammerdb-xl190` -- **FlashArray metrics:** enabled (10.21.158.130) - - -## Connecting to the Databases - -All 8 databases are identical in layout. SSH in as the `oracle` user, or connect from HammerDB via the service name. - -| Host | IP Address | Listener Port | -|------|------------|---------------| -| oracle-01 | 10.21.227.45 | 1521 | -| oracle-02 | 10.21.227.46 | 1521 | -| oracle-03 | 10.21.227.47 | 1521 | -| oracle-04 | 10.21.227.48 | 1521 | -| oracle-05 | 10.21.227.49 | 1521 | -| oracle-06 | 10.21.227.52 | 1521 | -| oracle-07 | 10.21.227.53 | 1521 | -| oracle-08 | 10.21.227.54 | 1521 | - -**Credentials:** - -| User | Password | Where | -|------|----------|-------| -| oracle (OS) | Osmium76 | SSH to any host | -| system | Osmium76 | CDB admin (sqlplus) | -| TPCC | Osmium76 | TPC-C schema owner (TPCC PDB) | - -**Database structure on each host:** - -``` -CDB: orcl - └── PDB: TPCC (service: tpcc.puretec.purestorage.com) - └── Schema: TPCC (9 tables, 10K warehouses, ~1TB) -``` - -**Storage:** - -| Diskgroup | Size | Contents | -|-----------|------|----------| -| +DATA01 | 1.6 TB | Datafiles, controlfiles | -| +REDO01 | 400 GB | 12 redo groups x 3 members x 4GB | -| +DATA02 | 1.6 TB | Unused (available) | -| +REDO02 | 400 GB | Unused (available) | - -All databases are in **NOARCHIVELOG** mode. - - -## YAML Config Files - -There are 8 config files, one for each scale point. They progressively add targets: - -| Config File | Targets | -|-------------|---------| -| `oracle-scale-1.yaml` | oracle-01 | -| `oracle-scale-2.yaml` | oracle-01, 02 | -| `oracle-scale-3.yaml` | oracle-01, 02, 03 | -| `oracle-scale-4.yaml` | oracle-01, 02, 03, 04 | -| `oracle-scale-5.yaml` | oracle-01 through 05 | -| `oracle-scale-6.yaml` | oracle-01 through 06 | -| `oracle-scale-7.yaml` | oracle-01 through 07 | -| `oracle-scale-8.yaml` | oracle-01 through 08 | - -All configs use the same benchmark parameters: 10,000 warehouses, 200 virtual users, timed driver with 5-minute rampup and 10-minute duration. Pure FlashArray storage metrics collection is enabled. - - -## Running a Scale Test - -The schemas are already built on all 8 hosts — you do **not** need `--build` unless you want to rebuild from scratch. - -```bash -# Step 1: Validate connectivity -hammerdb-scale validate -c oracle-scale-1.yaml - -# Step 2: Run the benchmark -hammerdb-scale run -c oracle-scale-1.yaml --wait - -# Step 3: Collect results -hammerdb-scale results -c oracle-scale-1.yaml - -# Step 4: Generate HTML scorecard -hammerdb-scale report -c oracle-scale-1.yaml --open -``` - -Then move on to the next scale point (`oracle-scale-2.yaml`, etc.). - - -## Backup and Restore - -RMAN cold backups already exist for all 8 hosts on a shared NFS mount at `/oracle-backup`. Use the scripts in `scripts/oracle/` to manage them. - -### Taking a Backup - -The backup script shuts down each database, backs up in MOUNT mode, then reopens it. By default it runs all hosts in parallel and takes about 12 minutes. - -```bash -# Backup all 8 hosts in parallel -./scripts/oracle/rman_backup.sh - -# Backup only specific hosts -./scripts/oracle/rman_backup.sh 01 04 - -# Backup one at a time instead of parallel -./scripts/oracle/rman_backup.sh --sequential -``` - -### Restoring from Backup - -```bash -# Restore a host from its own backup -./scripts/oracle/rman_restore.sh 04 - -# Restore multiple hosts -./scripts/oracle/rman_restore.sh 03 04 05 - -# Restore oracle-04 using oracle-01's backup (redirected restore) -./scripts/oracle/rman_restore.sh 04 --from 01 -``` - -The restore script shuts down the database, restores from the NFS backup, recovers, and opens with RESETLOGS. It verifies the CDB and PDB are open when done. - -### When Should You Restore? - -- **Between benchmark runs** if you need the data in a clean, pre-test state -- **After a crash or issue** to get a host back to a known good state -- **To clone a host** — use `--from` to restore one host's backup onto another - -### When Should You Re-backup? - -- After rebuilding schemas with `hammerdb-scale run --build` -- The backup overwrites the previous one, so just run `./scripts/oracle/rman_backup.sh` - - -## Suggested Workflow: Full Scale Run (1 through 8) - -``` -For each scale point (1, 2, 3, ... 8): - 1. Restore the databases that will be used → ./scripts/oracle/rman_restore.sh 01 02 ... - 2. Validate → hammerdb-scale validate -c oracle-scale-N.yaml - 3. Run → hammerdb-scale run -c oracle-scale-N.yaml --wait - 4. Collect results → hammerdb-scale results -c oracle-scale-N.yaml - 5. Generate report → hammerdb-scale report -c oracle-scale-N.yaml -``` - -If you don't need a clean restore between runs, you can skip step 1. - - -## Things to Know - -**Service name must be the FQDN.** The databases have `db_domain=puretec.purestorage.com`, so the listener registers the service as `tpcc.puretec.purestorage.com`. All YAML configs already have this set correctly. If you create new configs, make sure to use the full name — bare `tpcc` will fail with ORA-12514. - -**Listeners don't auto-start after reboot.** They're not managed by CRS. If a host gets rebooted, SSH in as `oracle` and run `lsnrctl start`. - -**NFS mount for RMAN.** All hosts mount the backup NFS share at `/oracle-backup` with options `noac,nconnect=8`. Oracle 26ai has a strict NFS validation check — event 10298 level 32 is set in the SPFILE on all hosts to bypass it. Don't remove this event or RMAN writes to NFS will fail with ORA-27054. - -**Redo log layout.** Each host has 12 redo groups x 3 members x 4GB, all on +REDO01. If you ever need to rebuild this layout, use `sqlplus / as sysdba @scripts/oracle/setup_redo.sql` on the host. - -**Oracle-04 had a crash.** It was terminated by LMHB (ORA-484, hung process) during an earlier schema build. It has been rebuilt and is now healthy with a complete dataset and backup. If it acts up again, restore it: `./scripts/oracle/rman_restore.sh 04`