Compare commits
9 Commits
main
..
8b7f7dcf09
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b7f7dcf09 | |||
| a688be0f47 | |||
| 6c4ae73566 | |||
| 40c944adfe | |||
| ac5f3a3a2a | |||
| 9cc772f10f | |||
| 24bbb1175b | |||
| d3d7307076 | |||
| 05bd96e8b2 |
@@ -1,3 +0,0 @@
|
||||
bin
|
||||
obj
|
||||
hanatui.dbg
|
||||
@@ -1,82 +1,119 @@
|
||||
# 🚀 SAP HANA Automation Scripts
|
||||
# SAP HANA Automation Scripts
|
||||
|
||||
A collection of powerful Bash scripts designed to automate and simplify SAP HANA administration, monitoring, and management tasks.
|
||||
This repository contains a collection of Bash and Batch scripts designed to automate various administrative and monitoring tasks for SAP HANA databases.
|
||||
|
||||
## ✨ Key Features
|
||||
## Installation
|
||||
|
||||
* **Automate Everything**: Schedule routine backups, file cleanups, and schema refreshes.
|
||||
* **Monitor Proactively**: Keep an eye on system health, disk space, and backup status with automated alerts.
|
||||
* **Simplify Management**: Use powerful command-line tools and interactive menus for common tasks.
|
||||
* **Secure**: Integrates with SAP's secure user store (`hdbuserstore`) for credential management.
|
||||
* **Get Notified**: Receive completion and failure alerts via `ntfy.sh`.
|
||||
|
||||
## ⚙️ Quick Install
|
||||
|
||||
Get started in seconds. The interactive installer will guide you through selecting the tools you need.
|
||||
To get started, you can use the `install.sh` script to download and set up the tools:
|
||||
|
||||
```sh
|
||||
bash -c "$(curl -sSL https://install.technopunk.space)"
|
||||
```
|
||||
|
||||
## 🛠️ Tools Overview
|
||||
This command will download and execute the `install.sh` script, which provides an interactive menu to select and install individual tools.
|
||||
|
||||
The following scripts and suites are included. Suites are configured via a `.conf` file in their respective directories.
|
||||
## Tools Overview
|
||||
|
||||
| Tool | Purpose & Core Function |
|
||||
| :------------- | :------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`cleaner`** 🧹 | **File Cleaner**: Deletes files older than a specified retention period. Ideal for managing logs and temporary files. |
|
||||
| **`hanatool`** 🗄️ | **HANA Management**: A powerful CLI tool to export/import schemas, perform full tenant backups, and compress artifacts. |
|
||||
| **`keymanager`** 🔑 | **Key Manager**: An interactive menu to easily create, delete, and test `hdbuserstore` keys with an automatic rollback safety feature. |
|
||||
| **`aurora`** 🌅 | **Schema Refresh Suite**: Automates refreshing a non-production schema from a production source. |
|
||||
| **`backup`** 💾 | **Backup Suite**: A complete, cron-friendly solution for scheduling schema exports and/or full tenant backups with configurable compression. |
|
||||
| **`monitor`** 📊 | **Monitoring Suite**: Continuously checks HANA process status, disk usage, log segments, and backup age, sending alerts when thresholds are breached. |
|
||||
Here's a breakdown of the scripts included in this repository:
|
||||
|
||||
## 📖 Tool Details
|
||||
### 1. `install.sh` (Script Downloader)
|
||||
|
||||
### 1\. `cleaner.sh` (File Cleaner) 🧹
|
||||
|
||||
* **Purpose**: Deletes files older than a specified retention period from given directories to help manage disk space.
|
||||
|
||||
### 2\. `hanatool.sh` (SAP HANA Schema & Tenant Management) 🗄️
|
||||
|
||||
* **Purpose**: A versatile command-line utility for SAP HANA, enabling quick exports and imports of schemas, as well as full tenant backups.
|
||||
* **Purpose**: An interactive script to download and manage other scripts from a remote `packages.conf` file.
|
||||
* **Features**:
|
||||
* Export/Import schemas (with optional renaming).
|
||||
* Perform full tenant backups.
|
||||
* Presents a menu of available tools.
|
||||
* Checks for updates to installed scripts.
|
||||
* Shows a `diff` for configuration files before overwriting.
|
||||
* Automatically makes downloaded shell scripts executable.
|
||||
* **Usage**: Run the script and follow the on-screen prompts.
|
||||
|
||||
### 2. `packages.conf` (Configuration for `install.sh`)
|
||||
|
||||
* **Purpose**: Defines the list of available scripts, their versions, and their download URLs for `install.sh`.
|
||||
* **Format**: An associative array `SCRIPT_PACKAGES` where keys are package names and values are `version|URL1 URL2...`.
|
||||
|
||||
### 3. `clean.sh` (File Cleaner)
|
||||
|
||||
* **Purpose**: Deletes files older than a specified retention period in given directories.
|
||||
* **Usage**: `./clean.sh <retention_days>:<path> [<retention_days>:<path> ...]`
|
||||
* **Example**: `./clean.sh 30:/var/log 7:/tmp/downloads`
|
||||
|
||||
### 4. `hanatool.sh` (SAP HANA Schema and Tenant Management Tool)
|
||||
|
||||
* **Purpose**: A versatile tool for exporting/importing HANA schemas and performing tenant backups.
|
||||
* **Features**:
|
||||
* Export a schema.
|
||||
* Import a schema (with optional renaming).
|
||||
* Perform a full tenant backup.
|
||||
* Supports compression (`tar.gz`).
|
||||
* Dry-run mode to preview commands.
|
||||
* `ntfy.sh` notifications for task completion/failure.
|
||||
* **Options**: `-t, --threads N`, `-c, --compress`, `-n, --dry-run`, `--ntfy <token>`, `--replace`, `--hdbsql <path>`, `-h, --help`
|
||||
* Ntfy.sh notifications for completion/failure.
|
||||
* Custom `hdbsql` path.
|
||||
* **Usage**:
|
||||
* Schema: `./hanatool.sh [USER_KEY] export|import [SCHEMA_NAME] [PATH] [OPTIONS]`
|
||||
* Schema Rename: `./hanatool.sh [USER_KEY] import-rename [SCHEMA_NAME] [NEW_SCHEMA_NAME] [PATH] [OPTIONS]`
|
||||
* Tenant: `./hanatool.sh [USER_KEY] backup [PATH] [OPTIONS]`
|
||||
* **Options**: `-t, --threads N`, `-c, --compress`, `-n, --dry-run`, `--ntfy <token>`, `--hdbsql <path>`, `-h, --help`
|
||||
|
||||
### 3\. `keymanager.sh` (Secure User Store Key Manager) 🔑
|
||||
### 5. `hdb_keymanager.sh` (SAP HANA Secure User Store Key Manager)
|
||||
|
||||
* **Purpose**: An interactive script to simplify the creation, deletion, and testing of SAP HANA `hdbuserstore` keys.
|
||||
* **Purpose**: An interactive script to manage SAP HANA `hdbuserstore` keys.
|
||||
* **Features**:
|
||||
* Interactive menu for easy key management.
|
||||
* Connection testing for existing keys.
|
||||
* Automatic rollback of a newly created key if its connection test fails.
|
||||
* Create new secure keys with interactive prompts.
|
||||
* Delete existing keys from a selection list.
|
||||
* Test connections for existing keys.
|
||||
* Includes rollback functionality if a newly created key fails to connect.
|
||||
* **Usage**: `./hdb_keymanager.sh` (runs an interactive menu)
|
||||
|
||||
### 4\. `aurora.sh` (HANA Aurora Refresh Suite) 🌅
|
||||
### 6. `update.bat` (Git Update Script for Windows)
|
||||
|
||||
* **Purpose**: Automates the refresh of a "copy" schema from a production source, ensuring non-production environments stay up-to-date.
|
||||
* **Process**:
|
||||
1. Drops the existing target schema (optional).
|
||||
2. Exports the source schema from production.
|
||||
3. Imports and renames the data to the target schema.
|
||||
4. Runs post-import configurations and grants privileges.
|
||||
* **Purpose**: A simple Windows Batch script to stage all changes, commit with a user-provided message, and push to a Git repository.
|
||||
* **Usage**: `update.bat`
|
||||
|
||||
### 5\. `backup.sh` (SAP HANA Automated Backup Suite) 💾
|
||||
### 7. `aurora/aurora.sh` (HANA Aurora Refresh Script)
|
||||
|
||||
* **Purpose**: Provides automated, scheduled backups for SAP HANA databases.
|
||||
* **Purpose**: Automates the refresh of a "Aurora" schema (a copy of a production schema) for testing or development.
|
||||
* **Configuration**: Uses `aurora/aurora.conf`.
|
||||
* **Features**:
|
||||
* Supports schema exports, full tenant data backups, or both.
|
||||
* Configurable compression to save disk space.
|
||||
* Uses secure `hdbuserstore` keys for connections.
|
||||
* Exports a source schema.
|
||||
* Imports and renames it to an Aurora schema.
|
||||
* Updates company name fields in the imported schema.
|
||||
* Can drop existing Aurora schema before refresh.
|
||||
* Grants privileges to a specified user.
|
||||
* Runs post-import SQL scripts.
|
||||
* **Usage**: The script runs automatically based on the settings in `aurora/aurora.conf`. It is typically scheduled via cron.
|
||||
|
||||
### 6\. `monitor.sh` (SAP HANA Monitoring Suite) 📊
|
||||
### 8. `aurora/aurora.conf` (Configuration for `aurora.sh`)
|
||||
|
||||
* **Purpose**: Continuously monitors critical aspects of SAP HANA and sends proactive alerts via `ntfy.sh` when predefined thresholds are exceeded.
|
||||
* **Checks Performed**:
|
||||
* Verifies all HANA processes have a 'GREEN' status.
|
||||
* Monitors disk usage against a set threshold.
|
||||
* Analyzes log segment state.
|
||||
* Checks the age of the last successful data backup.
|
||||
* **Purpose**: Configures the `aurora.sh` script, including source schema, target user, backup directory, `hdbsql` path, and post-import SQL scripts.
|
||||
|
||||
### 9. `backup/backup.sh` (SAP HANA Automated Backup Script)
|
||||
|
||||
* **Purpose**: Performs automated schema exports and/or tenant backups for SAP HANA databases, typically via cronjobs.
|
||||
* **Configuration**: Uses `backup/backup.conf`.
|
||||
* **Features**:
|
||||
* Supports schema exports for multiple schemas.
|
||||
* Performs full tenant data backups.
|
||||
* Optionally backs up the SYSTEMDB.
|
||||
* Supports compression for both schema exports and tenant backups.
|
||||
* Configurable backup types (`schema`, `tenant`, `all`).
|
||||
* **Usage**: `./backup/backup.sh` (all settings are read from `backup.conf`)
|
||||
|
||||
### 10. `backup/backup.conf` (Configuration for `backup.sh`)
|
||||
|
||||
* **Purpose**: Configures the `backup.sh` script, including `hdbsql` path, user keys, base backup directory, backup type, compression settings, and schema names to export.
|
||||
|
||||
### 11. `monitor/monitor.sh` (SAP HANA Monitoring Script)
|
||||
|
||||
* **Purpose**: Monitors SAP HANA processes, disk usage, and log segment states, sending ntfy.sh notifications for alerts.
|
||||
* **Configuration**: Uses `monitor/monitor.conf`.
|
||||
* **Features**:
|
||||
* Checks if all HANA processes are running (GREEN status).
|
||||
* Monitors disk usage for specified directories against a threshold.
|
||||
* Analyzes HANA log segments for 'Truncated' and 'Free' percentages against thresholds.
|
||||
* Sends detailed notifications via ntfy.sh.
|
||||
* Uses a lock file to prevent multiple instances from running.
|
||||
* **Usage**: `./monitor/monitor.sh` (all settings are read from `monitor.conf`)
|
||||
|
||||
### 12. `monitor/monitor.conf` (Configuration for `monitor.sh`)
|
||||
|
||||
* **Purpose**: Configures the `monitor.sh` script, including company name, ntfy.sh settings, HANA connection details, monitoring thresholds (disk usage, log segments), and directories to monitor.
|
||||
@@ -1,6 +1,5 @@
|
||||
# Configuration for the Aurora Refresh Script (aurora_refresh.sh)
|
||||
# Place this file in the same directory as the script.
|
||||
# Author: Tomi Eckert
|
||||
|
||||
# --- Main Settings ---
|
||||
|
||||
@@ -31,9 +30,6 @@ HDBSQL="/usr/sap/NDB/HDB00/exe/hdbsql"
|
||||
# The root directory where post-import SQL scripts are located.
|
||||
SQL_SCRIPTS_ROOT="/usr/sap/NDB/home/tools/sql"
|
||||
|
||||
# Number of threads to use for schema export and import.
|
||||
THREADS=1
|
||||
|
||||
# --- Post-Import Scripts (Optional) ---
|
||||
|
||||
# A space-separated list of SQL script filenames to run after the import is complete.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Author: Tomi Eckert
|
||||
# This script helps to configure aurora.conf
|
||||
|
||||
# Source the aurora.conf to get current values
|
||||
source aurora.conf
|
||||
|
||||
HDBSQL_PATH_INPUT=$(which hdbsql)
|
||||
|
||||
# Default values if not found
|
||||
HDBSQL_PATH_INPUT=${HDBSQL_PATH_INPUT:-"/usr/sap/hdbclient/hdbsql"}
|
||||
|
||||
# Calculate default threads (half of available)
|
||||
TOTAL_THREADS=$(nproc --all)
|
||||
THREADS_DEFAULT=$((TOTAL_THREADS / 2))
|
||||
|
||||
# Update aurora.conf
|
||||
sed -i "s#^HDBSQL_PATH=\".*\"#HDBSQL_PATH=\"$HDBSQL_PATH_INPUT\"#" aurora.conf
|
||||
sed -i "s#^THREADS=.\"*\"#THREADS=\"$THREADS_DEFAULT\"#" aurora.conf
|
||||
|
||||
echo "aurora.conf updated successfully!"
|
||||
+46
-41
@@ -1,41 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Version: 2.5.1
|
||||
# Author: Tomi Eckert
|
||||
# ==============================================================================
|
||||
# Aurora Refresh Script
|
||||
#!/bin/sh
|
||||
# Version: 2.1.0
|
||||
#
|
||||
# Performs an automated refresh of a SAP HANA schema using hanatool.sh.
|
||||
# It exports a production schema and re-imports it under a new name ("Aurora")
|
||||
# Purpose: Performs an automated refresh of a SAP HANA schema. It exports a
|
||||
# production schema and re-imports it under a new name ("Aurora")
|
||||
# to create an up-to-date, non-production environment for testing.
|
||||
# ==============================================================================
|
||||
# Designed to be run via cron, typically in the early morning.
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# --- Configuration and Setup ---
|
||||
# --- Basic Setup ---
|
||||
# Exit immediately if any command fails or if an unset variable is used.
|
||||
set -eu
|
||||
|
||||
# Find the script's own directory to locate the config file and hanatool.sh
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)
|
||||
# --- Configuration ---
|
||||
# Load the configuration file located in the same directory as the script.
|
||||
SCRIPT_DIR=$(dirname "$0")
|
||||
CONFIG_FILE="${SCRIPT_DIR}/aurora.conf"
|
||||
HANATOOL_PATH="${SCRIPT_DIR}/hanatool.sh"
|
||||
|
||||
# Check for config file and source it
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
source "$CONFIG_FILE"
|
||||
else
|
||||
echo "❌ Error: Configuration file not found at '${CONFIG_FILE}'"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "❌ FATAL: Configuration file not found at '${CONFIG_FILE}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=aurora.conf
|
||||
. "$CONFIG_FILE"
|
||||
|
||||
# --- Validate Configuration ---
|
||||
if [ ! -x "$HDBSQL" ]; then
|
||||
echo "❌ FATAL: hdbsql is not found or not executable at '${HDBSQL}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if hanatool.sh executable exists
|
||||
if [[ ! -x "$HANATOOL_PATH" ]]; then
|
||||
echo "❌ Error: hanatool.sh not found or not executable at '${HANATOOL_PATH}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Derived Variables ---
|
||||
# --- Derived Variables (Do Not Edit) ---
|
||||
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
AURORA_SCHEMA="${SOURCE_SCHEMA}_AURORA"
|
||||
EXPORT_DIR="${BACKUP_BASE_DIR}/${AURORA_SCHEMA}_TEMP_EXPORT"
|
||||
COMPANY_NAME_BASE=$(echo "${SOURCE_SCHEMA}" | sed 's/^SBO_//' | sed 's/_PROD$//')
|
||||
|
||||
# --- Main Execution ---
|
||||
echo "🚀 Starting Aurora Refresh for '${SOURCE_SCHEMA}' using hanatool.sh..."
|
||||
echo
|
||||
echo "🚀 [$(date "+%T")] Starting Aurora Refresh for '${SOURCE_SCHEMA}'"
|
||||
echo "--------------------------------------------------------"
|
||||
echo " Source Schema: ${SOURCE_SCHEMA}"
|
||||
echo " Target Aurora Schema: ${AURORA_SCHEMA}"
|
||||
echo " Temp Export Path: ${EXPORT_DIR}"
|
||||
echo "--------------------------------------------------------"
|
||||
|
||||
# 1. Drop the old Aurora schema if it exists.
|
||||
echo "🗑️ Dropping old schema '${AURORA_SCHEMA}' (if it exists)..."
|
||||
@@ -46,26 +54,21 @@ echo "📁 Preparing temporary export directory..."
|
||||
rm -rf "$EXPORT_DIR"
|
||||
mkdir -p "$EXPORT_DIR"
|
||||
|
||||
# 3. Export the source schema using hanatool.sh
|
||||
echo "⬇️ Exporting source schema '${SOURCE_SCHEMA}'..."
|
||||
"$HANATOOL_PATH" "$DB_ADMIN_KEY" export "$SOURCE_SCHEMA" "$EXPORT_DIR" -t "$THREADS"
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "❌ Error: Export failed."
|
||||
exit 1
|
||||
fi
|
||||
# 3. Export the source schema.
|
||||
echo "⬇️ Exporting source schema '${SOURCE_SCHEMA}' to binary files..."
|
||||
"$HDBSQL" -U "$DB_ADMIN_KEY" "EXPORT \"${SOURCE_SCHEMA}\".\"*\" AS BINARY INTO '${EXPORT_DIR}' WITH REPLACE;" >/dev/null
|
||||
echo " -> Export complete."
|
||||
|
||||
# 4. Import the data into the new Aurora schema using hanatool.sh
|
||||
# 4. Import the data into the new Aurora schema.
|
||||
echo "⬆️ Importing data and renaming schema to '${AURORA_SCHEMA}'..."
|
||||
"$HANATOOL_PATH" "$DB_ADMIN_KEY" import-rename "$SOURCE_SCHEMA" "$AURORA_SCHEMA" "$EXPORT_DIR" -t "$THREADS"
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "❌ Error: Import failed."
|
||||
exit 1
|
||||
fi
|
||||
"$HDBSQL" -U "$DB_ADMIN_KEY" "IMPORT \"${SOURCE_SCHEMA}\".\"*\" FROM '${EXPORT_DIR}' WITH IGNORE EXISTING RENAME SCHEMA \"${SOURCE_SCHEMA}\" TO \"${AURORA_SCHEMA}\";" >/dev/null
|
||||
echo " -> Import complete."
|
||||
|
||||
# 5. Update company name in CINF and OADM tables.
|
||||
echo "✍️ Updating company name fields in the new schema..."
|
||||
|
||||
# First, get the original company name from the source schema.
|
||||
# The query returns a header and the name in quotes. sed gets the second line, tr removes the quotes, xargs trims whitespace.
|
||||
echo " -> Fetching original company name from '${SOURCE_SCHEMA}'..."
|
||||
ORIGINAL_COMPNY_NAME=$("$HDBSQL" -U "$DB_ADMIN_KEY" "SELECT \"CompnyName\" FROM \"${SOURCE_SCHEMA}\".\"CINF\"" | sed -n '2p' | tr -d '"' | xargs)
|
||||
|
||||
@@ -87,11 +90,13 @@ echo "🔑 Granting ALL privileges on '${AURORA_SCHEMA}' to '${AURORA_USER}'..."
|
||||
echo " -> Privileges granted."
|
||||
|
||||
# 7. Run post-import SQL scripts, if any are defined.
|
||||
if [[ -n "$POST_IMPORT_SQL" ]]; then
|
||||
if [ -n "$POST_IMPORT_SQL" ]; then
|
||||
echo "⚙️ Running post-import SQL scripts..."
|
||||
# Use word splitting intentionally here
|
||||
# shellcheck disable=SC2086
|
||||
for sql_file in $POST_IMPORT_SQL; do
|
||||
full_path="${SQL_SCRIPTS_ROOT}/${sql_file}"
|
||||
if [[ -f "$full_path" ]]; then
|
||||
if [ -f "$full_path" ]; then
|
||||
echo " -> Executing: ${sql_file}"
|
||||
"$HDBSQL" -U "$DB_ADMIN_KEY" -I "$full_path"
|
||||
else
|
||||
@@ -108,7 +113,7 @@ rm -rf "$EXPORT_DIR"
|
||||
echo " -> Cleanup complete."
|
||||
|
||||
echo "--------------------------------------------------------"
|
||||
echo "✅ Aurora Refresh finished successfully!"
|
||||
echo "✅ [$(date "+%T")] Aurora Refresh finished successfully!"
|
||||
echo
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Author: Tomi Eckert
|
||||
# ==============================================================================
|
||||
# SAP Business One for HANA Silent Installation Configurator
|
||||
# ==============================================================================
|
||||
# This script interactively collects necessary details to customize the
|
||||
# silent installation properties file for SAP Business One on HANA.
|
||||
# It provides sensible defaults and generates the final 'install.properties'.
|
||||
# ==============================================================================
|
||||
|
||||
# --- Function to display a welcome header ---
|
||||
print_header() {
|
||||
echo "======================================================"
|
||||
echo " SAP Business One for HANA Installation Configurator "
|
||||
echo "======================================================"
|
||||
echo "Please provide the following details. Defaults are in [brackets]."
|
||||
echo ""
|
||||
}
|
||||
|
||||
# --- Function to read password securely (single entry) ---
|
||||
read_password() {
|
||||
local prompt_text=$1
|
||||
local -n pass_var=$2 # Use a nameref to pass the variable name
|
||||
|
||||
# Loop until the entered password is not empty
|
||||
while true; do
|
||||
read -s -p "$prompt_text: " pass_var
|
||||
echo
|
||||
if [ -z "$pass_var" ]; then
|
||||
echo "Password cannot be empty. Please try again."
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- Function to read and verify password securely ---
|
||||
read_password_verify() {
|
||||
local prompt_text=$1
|
||||
local -n pass_var=$2 # Use a nameref to pass the variable name
|
||||
local pass_verify
|
||||
|
||||
# Loop until the entered passwords match and are not empty
|
||||
while true; do
|
||||
read -s -p "$prompt_text: " pass_var
|
||||
echo
|
||||
if [ -z "$pass_var" ]; then
|
||||
echo "Password cannot be empty. Please try again."
|
||||
continue
|
||||
fi
|
||||
|
||||
read -s -p "Confirm password: " pass_verify
|
||||
echo
|
||||
|
||||
if [ "$pass_var" == "$pass_verify" ]; then
|
||||
break
|
||||
else
|
||||
echo "Passwords do not match. Please try again."
|
||||
echo ""
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- Main configuration logic ---
|
||||
print_header
|
||||
|
||||
# --- Installation Type ---
|
||||
echo "--- Installation Type ---"
|
||||
read -p "Is this a new installation or are you reconfiguring an existing instance? (new/reconfigure) [new]: " install_type
|
||||
install_type=${install_type:-new}
|
||||
|
||||
if [[ "$install_type" == "reconfigure" ]]; then
|
||||
LANDSCAPE_INSTALL_ACTION="connect"
|
||||
B1S_SHARED_FOLDER_OVERWRITE="false"
|
||||
else
|
||||
LANDSCAPE_INSTALL_ACTION="create"
|
||||
B1S_SHARED_FOLDER_OVERWRITE="true"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
|
||||
# 1. Get Hostname/IP Details
|
||||
# Default to the current machine's hostname.
|
||||
DEFAULT_HOSTNAME=$(hostname)
|
||||
read -p "Enter HANA Database Server Hostname or IP [${DEFAULT_HOSTNAME}]: " HANA_DATABASE_SERVERS
|
||||
HANA_DATABASE_SERVERS=${HANA_DATABASE_SERVERS:-$DEFAULT_HOSTNAME}
|
||||
|
||||
# 2. Get HANA Instance Details
|
||||
read -p "Enter HANA Database Instance Number [00]: " HANA_DATABASE_INSTANCE
|
||||
HANA_DATABASE_INSTANCE=${HANA_DATABASE_INSTANCE:-00}
|
||||
|
||||
# 3. Get HANA SID to construct the admin user
|
||||
read -p "Enter HANA SID (Tenant Name) [NDB]: " HANA_SID
|
||||
HANA_SID=${HANA_SID:-NDB}
|
||||
# Convert SID to lowercase and append 'adm'
|
||||
HANA_DATABASE_ADMIN_ID=$(echo "${HANA_SID}" | tr '[:upper:]' '[:lower:]')adm
|
||||
|
||||
# 4. Get Passwords
|
||||
echo ""
|
||||
echo "--- Secure Password Entry ---"
|
||||
read_password "Enter password for HANA Admin ('${HANA_DATABASE_ADMIN_ID}')" HANA_DATABASE_ADMIN_PASSWD
|
||||
|
||||
# 5. Get HANA Database User
|
||||
read -p "Enter HANA Database User ID [SYSTEM]: " HANA_DATABASE_USER_ID
|
||||
HANA_DATABASE_USER_ID=${HANA_DATABASE_USER_ID:-SYSTEM}
|
||||
|
||||
# 6. Get HANA User Password
|
||||
read_password "Enter password for HANA User ('${HANA_DATABASE_USER_ID}')" HANA_DATABASE_USER_PASSWORD
|
||||
|
||||
# 7. Get SLD and Site User Details
|
||||
echo ""
|
||||
echo "--- System Landscape Directory (SLD) ---"
|
||||
read -p "Enter SLD Service Port [40000]: " SERVICE_PORT
|
||||
SERVICE_PORT=${SERVICE_PORT:-40000}
|
||||
|
||||
read -p "Enter SLD Site User ID [B1SiteUser]: " SITE_USER_ID
|
||||
SITE_USER_ID=${SITE_USER_ID:-B1SiteUser}
|
||||
|
||||
read_password_verify "Enter password for Site User ('${SITE_USER_ID}')" SITE_USER_PASSWORD
|
||||
|
||||
# --- SLD Single Sign-On (SSO) Settings ---
|
||||
echo ""
|
||||
echo "--- SLD Single Sign-On (SSO) Settings ---"
|
||||
read -p "Do you want to configure Active Directory SSO? [y/N]: " configure_sso
|
||||
|
||||
if [[ "$configure_sso" =~ ^[yY]$ ]]; then
|
||||
SLD_WINDOWS_DOMAIN_ACTION="use"
|
||||
read -p "Enter AD Domain Controller: " SLD_WINDOWS_DOMAIN_CONTROLLER
|
||||
read -p "Enter AD Domain Name: " SLD_WINDOWS_DOMAIN_NAME
|
||||
read -p "Enter AD Domain User ID: " SLD_WINDOWS_DOMAIN_USER_ID
|
||||
read_password "Enter password for AD Domain User ('${SLD_WINDOWS_DOMAIN_USER_ID}')" SLD_WINDOWS_DOMAIN_USER_PASSWORD
|
||||
else
|
||||
SLD_WINDOWS_DOMAIN_ACTION="skip"
|
||||
SLD_WINDOWS_DOMAIN_CONTROLLER=""
|
||||
SLD_WINDOWS_DOMAIN_NAME=""
|
||||
SLD_WINDOWS_DOMAIN_USER_ID=""
|
||||
SLD_WINDOWS_DOMAIN_USER_PASSWORD=""
|
||||
fi
|
||||
|
||||
# 10. & 11. Get Service Layer Load Balancer Details
|
||||
echo ""
|
||||
echo "--- Service Layer ---"
|
||||
read -p "Enter Service Layer Load Balancer Port [50000]: " SL_LB_PORT
|
||||
SL_LB_PORT=${SL_LB_PORT:-50000}
|
||||
|
||||
read -p "How many Service Layer member nodes should be configured? [2]: " SL_MEMBER_COUNT
|
||||
SL_MEMBER_COUNT=${SL_MEMBER_COUNT:-2}
|
||||
|
||||
# Generate the SL_LB_MEMBERS string
|
||||
SL_LB_MEMBERS=""
|
||||
for (( i=1; i<=SL_MEMBER_COUNT; i++ )); do
|
||||
port=$((50000 + i))
|
||||
member="${HANA_DATABASE_SERVERS}:${port}"
|
||||
if [ -z "$SL_LB_MEMBERS" ]; then
|
||||
SL_LB_MEMBERS="$member"
|
||||
else
|
||||
SL_LB_MEMBERS="$SL_LB_MEMBERS,$member"
|
||||
fi
|
||||
done
|
||||
|
||||
# 12. Display Summary and Ask for Confirmation
|
||||
clear
|
||||
echo "======================================================"
|
||||
echo " Configuration Summary"
|
||||
echo "======================================================"
|
||||
echo ""
|
||||
echo " --- Installation & System Details ---"
|
||||
echo " INSTALLATION_FOLDER=/usr/sap/SAPBusinessOne"
|
||||
echo " LANDSCAPE_INSTALL_ACTION=${LANDSCAPE_INSTALL_ACTION}"
|
||||
echo " B1S_SHARED_FOLDER_OVERWRITE=${B1S_SHARED_FOLDER_OVERWRITE}"
|
||||
echo ""
|
||||
echo " --- SAP HANA Database Server Details ---"
|
||||
echo " HANA_DATABASE_SERVERS=${HANA_DATABASE_SERVERS}"
|
||||
echo " HANA_DATABASE_INSTANCE=${HANA_DATABASE_INSTANCE}"
|
||||
echo " HANA_DATABASE_ADMIN_ID=${HANA_DATABASE_ADMIN_ID}"
|
||||
echo " HANA_DATABASE_ADMIN_PASSWD=[hidden]"
|
||||
echo ""
|
||||
echo " --- SAP HANA Database User ---"
|
||||
echo " HANA_DATABASE_USER_ID=${HANA_DATABASE_USER_ID}"
|
||||
echo " HANA_DATABASE_USER_PASSWORD=[hidden]"
|
||||
echo ""
|
||||
echo " --- System Landscape Directory (SLD) Details ---"
|
||||
echo " SERVICE_PORT=${SERVICE_PORT}"
|
||||
echo " SITE_USER_ID=${SITE_USER_ID}"
|
||||
echo " SITE_USER_PASSWORD=[hidden]"
|
||||
echo ""
|
||||
echo " --- SLD Single Sign-On (SSO) ---"
|
||||
echo " SLD_WINDOWS_DOMAIN_ACTION=${SLD_WINDOWS_DOMAIN_ACTION}"
|
||||
if [ "$SLD_WINDOWS_DOMAIN_ACTION" == "use" ]; then
|
||||
echo " SLD_WINDOWS_DOMAIN_CONTROLLER=${SLD_WINDOWS_DOMAIN_CONTROLLER}"
|
||||
echo " SLD_WINDOWS_DOMAIN_NAME=${SLD_WINDOWS_DOMAIN_NAME}"
|
||||
echo " SLD_WINDOWS_DOMAIN_USER_ID=${SLD_WINDOWS_DOMAIN_USER_ID}"
|
||||
echo " SLD_WINDOWS_DOMAIN_USER_PASSWORD=[hidden]"
|
||||
fi
|
||||
echo ""
|
||||
echo " --- Service Layer ---"
|
||||
echo " SL_LB_PORT=${SL_LB_PORT}"
|
||||
echo " SL_LB_MEMBERS=${SL_LB_MEMBERS}"
|
||||
echo ""
|
||||
echo "======================================================"
|
||||
read -p "Save this configuration to 'install.properties'? [y/N]: " confirm
|
||||
echo ""
|
||||
|
||||
if [[ ! "$confirm" =~ ^[yY]$ ]]; then
|
||||
echo "Configuration cancelled by user."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Write the final install.properties file ---
|
||||
# Using a HEREDOC to write the configuration file with the variables collected.
|
||||
cat > install.properties << EOL
|
||||
# SAP Business One for HANA Silent Installation Properties
|
||||
# Generated by configuration script on $(date)
|
||||
|
||||
INSTALLATION_FOLDER=/usr/sap/SAPBusinessOne
|
||||
|
||||
HANA_DATABASE_SERVERS=${HANA_DATABASE_SERVERS}
|
||||
HANA_DATABASE_INSTANCE=${HANA_DATABASE_INSTANCE}
|
||||
HANA_DATABASE_ADMIN_ID=${HANA_DATABASE_ADMIN_ID}
|
||||
HANA_DATABASE_ADMIN_PASSWD=${HANA_DATABASE_ADMIN_PASSWD}
|
||||
|
||||
HANA_DATABASE_USER_ID=${HANA_DATABASE_USER_ID}
|
||||
HANA_DATABASE_USER_PASSWORD=${HANA_DATABASE_USER_PASSWORD}
|
||||
|
||||
SERVICE_PORT=${SERVICE_PORT}
|
||||
SLD_DATABASE_NAME=SLDDATA
|
||||
SLD_CERTIFICATE_ACTION=self
|
||||
CONNECTION_SSL_CERTIFICATE_VERIFICATION=false
|
||||
SLD_DATABASE_ACTION=create
|
||||
SLD_SERVER_PROTOCOL=https
|
||||
SITE_USER_ID=${SITE_USER_ID}
|
||||
SITE_USER_PASSWORD=${SITE_USER_PASSWORD}
|
||||
|
||||
# --- SLD Single Sign-On (SSO) Settings ---
|
||||
SLD_WINDOWS_DOMAIN_ACTION=${SLD_WINDOWS_DOMAIN_ACTION}
|
||||
SLD_WINDOWS_DOMAIN_CONTROLLER=${SLD_WINDOWS_DOMAIN_CONTROLLER}
|
||||
SLD_WINDOWS_DOMAIN_NAME=${SLD_WINDOWS_DOMAIN_NAME}
|
||||
SLD_WINDOWS_DOMAIN_USER_ID=${SLD_WINDOWS_DOMAIN_USER_ID}
|
||||
SLD_WINDOWS_DOMAIN_USER_PASSWORD=${SLD_WINDOWS_DOMAIN_USER_PASSWORD}
|
||||
|
||||
SL_LB_MEMBER_ONLY=false
|
||||
SL_LB_PORT=${SL_LB_PORT}
|
||||
SL_LB_MEMBERS=${SL_LB_MEMBERS}
|
||||
SL_THREAD_PER_SERVER=10
|
||||
|
||||
SELECTED_FEATURES=B1ServerTools,B1ServerToolsLandscape,B1ServerToolsSLD,B1ServerToolsLicense,B1ServerToolsJobService,B1ServerToolsXApp,B1SLDAgent,B1BackupService,B1Server,B1ServerSHR,B1ServerHelp,B1AnalyticsPlatform,B1ServerCommonDB,B1ServiceLayerComponent,B1WebClient
|
||||
|
||||
B1S_SAMBA_AUTOSTART=true
|
||||
B1S_SHARED_FOLDER_OVERWRITE=${B1S_SHARED_FOLDER_OVERWRITE}
|
||||
LANDSCAPE_INSTALL_ACTION=${LANDSCAPE_INSTALL_ACTION}
|
||||
EOL
|
||||
|
||||
echo "Success! The configuration file 'install.properties' has been created in the current directory."
|
||||
exit 0
|
||||
|
||||
+6
-7
@@ -1,30 +1,32 @@
|
||||
# ==============================================================================
|
||||
# Configuration for HANA Backup Script (backup.sh)
|
||||
# ==============================================================================
|
||||
# Author: Tomi Eckert
|
||||
|
||||
# --- Connection Settings ---
|
||||
|
||||
# Full path to the SAP HANA hdbsql executable.
|
||||
HDBSQL_PATH="/usr/sap/hdbclient/hdbsql"
|
||||
|
||||
# User key name from the hdbuserstore.
|
||||
# This key should be configured to connect to the target tenant database.
|
||||
USER_KEY="CRONKEY"
|
||||
|
||||
# hdbuserstore key for the SYSTEMDB user
|
||||
SYSTEMDB_USER_KEY="SYSTEMKEY"
|
||||
SYSTEMDB_USER_KEY="SYSTEMDB_KEY"
|
||||
|
||||
# --- Backup Settings ---
|
||||
|
||||
# The base directory where all backup files and directories will be stored.
|
||||
# Ensure this directory exists and that the OS user running the script has
|
||||
# write permissions to it.
|
||||
BACKUP_BASE_DIR="/hana/shared/backup"
|
||||
BACKUP_BASE_DIR="/hana/backups/automated"
|
||||
|
||||
# Specify the type of backup to perform on script execution.
|
||||
# Options are:
|
||||
# 'schema' - Performs only the schema export.
|
||||
# 'tenant' - Performs only the tenant data backup.
|
||||
# 'all' - Performs both the schema export and the tenant backup.
|
||||
BACKUP_TYPE="tenant"
|
||||
BACKUP_TYPE="all"
|
||||
|
||||
# Set to 'true' to also perform a backup of the SYSTEMDB
|
||||
BACKUP_SYSTEMDB=true
|
||||
@@ -37,9 +39,6 @@ COMPRESS_SCHEMA=true
|
||||
# for a 40GB tenant.
|
||||
COMPRESS_TENANT=true
|
||||
|
||||
# Number of threads to use for schema export.
|
||||
THREADS=1
|
||||
|
||||
# --- Target Identifiers ---
|
||||
|
||||
# The name of the schema to be exported when BACKUP_TYPE is 'schema' or 'all'.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Author: Tomi Eckert
|
||||
# This script helps to configure backup.conf
|
||||
|
||||
# Source the backup.conf to get current values
|
||||
source backup.conf
|
||||
|
||||
HDBSQL_PATH_INPUT=$(which hdbsql)
|
||||
|
||||
# Default values if not found
|
||||
HDBSQL_PATH_INPUT=${HDBSQL_PATH_INPUT:-"/usr/sap/hdbclient/hdbsql"}
|
||||
|
||||
# Calculate default threads (half of available)
|
||||
TOTAL_THREADS=$(nproc --all)
|
||||
THREADS_DEFAULT=$((TOTAL_THREADS / 2))
|
||||
|
||||
# Update backup.conf
|
||||
sed -i "s#^HDBSQL_PATH=\".*\"#HDBSQL_PATH=\"$HDBSQL_PATH_INPUT\"#" backup.conf
|
||||
sed -i "s#^THREADS=.\"*\"#THREADS=\"$THREADS_DEFAULT\"#" backup.conf
|
||||
|
||||
echo "backup.conf updated successfully!"
|
||||
+147
-80
@@ -1,20 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Version: 1.1.0
|
||||
# Author: Tomi Eckert
|
||||
# Version: 1.0.5
|
||||
# ==============================================================================
|
||||
# SAP HANA Backup Script
|
||||
#
|
||||
# Performs schema exports for one or more schemas and/or tenant backups for a
|
||||
# SAP HANA database using hanatool.sh. Designed to be executed via a cronjob.
|
||||
# SAP HANA database. Designed to be executed via a cronjob.
|
||||
# Reads all settings from the backup.conf file in the same directory.
|
||||
# ==============================================================================
|
||||
|
||||
# --- Configuration and Setup ---
|
||||
|
||||
# Find the script's own directory to locate the config file and hanatool.sh
|
||||
# Find the script's own directory to locate the config file
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)
|
||||
CONFIG_FILE="${SCRIPT_DIR}/backup.conf"
|
||||
HANATOOL_PATH="${SCRIPT_DIR}/hanatool.sh" # Assuming hanatool.sh is in the parent directory
|
||||
|
||||
# Check for config file and source it
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
@@ -24,107 +22,176 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if hanatool.sh executable exists
|
||||
if [[ ! -x "$HANATOOL_PATH" ]]; then
|
||||
echo "❌ Error: hanatool.sh not found or not executable at '${HANATOOL_PATH}'"
|
||||
# Check if hdbsql executable exists
|
||||
if [[ ! -x "$HDBSQL_PATH" ]]; then
|
||||
echo "❌ Error: hdbsql not found or not executable at '${HDBSQL_PATH}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate threads to use (half of the available cores, but at least 1)
|
||||
TOTAL_THREADS=$(nproc --all)
|
||||
THREADS=$((TOTAL_THREADS / 2))
|
||||
if [[ "$THREADS" -eq 0 ]]; then
|
||||
THREADS=1
|
||||
fi
|
||||
|
||||
# --- Functions ---
|
||||
|
||||
# Performs a binary export of a specific schema.
|
||||
# Accepts the schema name as its first argument.
|
||||
perform_schema_export() {
|
||||
local schema_name="$1"
|
||||
if [[ -z "$schema_name" ]]; then
|
||||
echo " ❌ Error: No schema name provided to perform_schema_export function."
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "⬇️ Starting schema export for '${schema_name}'..."
|
||||
|
||||
local timestamp
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local export_base_dir="${BACKUP_BASE_DIR}/schema"
|
||||
local export_path="${export_base_dir}/${schema_name}_${timestamp}"
|
||||
local query_export_path="$export_path"
|
||||
|
||||
if [[ "$COMPRESS_SCHEMA" == "true" ]]; then
|
||||
export_path="${export_base_dir}/tmp/${schema_name}_${timestamp}"
|
||||
query_export_path="$export_path"
|
||||
echo " ℹ️ Compression enabled. Using temporary export path: ${export_path}"
|
||||
fi
|
||||
|
||||
local archive_file="${export_base_dir}/${schema_name}_${timestamp}.tar.gz"
|
||||
|
||||
mkdir -p "$(dirname "$export_path")"
|
||||
|
||||
local query="EXPORT \"${schema_name}\".\"*\" AS BINARY INTO '${query_export_path}' WITH REPLACE THREADS ${THREADS};"
|
||||
|
||||
"$HDBSQL_PATH" -U "$USER_KEY" "$query" > /dev/null 2>&1
|
||||
local exit_code=$?
|
||||
|
||||
if [[ "$exit_code" -eq 0 ]]; then
|
||||
echo " ✅ Successfully exported schema '${schema_name}'."
|
||||
|
||||
if [[ "$COMPRESS_SCHEMA" == "true" ]]; then
|
||||
echo " 🗜️ Compressing exported files..."
|
||||
tar -czf "$archive_file" -C "$(dirname "$export_path")" "$(basename "$export_path")"
|
||||
local tar_exit_code=$?
|
||||
|
||||
if [[ "$tar_exit_code" -eq 0 ]]; then
|
||||
echo " ✅ Successfully created archive '${archive_file}'."
|
||||
echo " 🧹 Cleaning up temporary directory..."
|
||||
rm -rf "$export_path"
|
||||
rmdir --ignore-fail-on-non-empty "$(dirname "$export_path")"
|
||||
echo " ✨ Cleanup complete."
|
||||
else
|
||||
echo " ❌ Error: Failed to compress '${export_path}'."
|
||||
fi
|
||||
else
|
||||
echo " ℹ️ Compression disabled. Raw export files are located at '${export_path}'."
|
||||
fi
|
||||
else
|
||||
echo " ❌ Error: Failed to export schema '${schema_name}' (hdbsql exit code: ${exit_code})."
|
||||
fi
|
||||
}
|
||||
|
||||
# Loops through the schemas in the config file and runs an export for each.
|
||||
run_all_schema_exports() {
|
||||
if [[ -z "$SCHEMA_NAMES" ]]; then
|
||||
echo " ⚠️ Warning: SCHEMA_NAMES variable is not set in config. Skipping schema export."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "🔎 Found schemas to export: ${SCHEMA_NAMES}"
|
||||
for schema in $SCHEMA_NAMES; do
|
||||
perform_schema_export "$schema"
|
||||
echo "--------------------------------------------------"
|
||||
done
|
||||
}
|
||||
|
||||
# REFACTORED: Generic function to back up any database (Tenant or SYSTEMDB).
|
||||
# Arguments: 1:Backup Name (for logging), 2:User Key, 3:Base Directory, 4:Compression Flag
|
||||
perform_database_backup() {
|
||||
local backup_name="$1"
|
||||
local user_key="$2"
|
||||
local backup_base_dir="$3"
|
||||
local compress_enabled="$4"
|
||||
|
||||
echo "⬇️ Starting ${backup_name} backup..."
|
||||
|
||||
local timestamp
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_path_prefix
|
||||
local backup_target_dir
|
||||
|
||||
if [[ "$compress_enabled" == "true" ]]; then
|
||||
backup_target_dir="${backup_base_dir}/tmp"
|
||||
backup_path_prefix="${backup_target_dir}/backup_${timestamp}"
|
||||
echo " ℹ️ Compression enabled. Using temporary backup path: ${backup_path_prefix}"
|
||||
else
|
||||
backup_target_dir="$backup_base_dir"
|
||||
backup_path_prefix="${backup_target_dir}/backup_${timestamp}"
|
||||
fi
|
||||
|
||||
mkdir -p "$backup_target_dir"
|
||||
|
||||
local query="BACKUP DATA USING FILE ('${backup_path_prefix}')"
|
||||
|
||||
"$HDBSQL_PATH" -U "$user_key" "$query" > /dev/null 2>&1
|
||||
local exit_code=$?
|
||||
|
||||
if [[ "$exit_code" -eq 0 ]]; then
|
||||
echo " ✅ Successfully initiated ${backup_name} backup with prefix '${backup_path_prefix}'."
|
||||
|
||||
if [[ "$compress_enabled" == "true" ]]; then
|
||||
local archive_file="${backup_base_dir}/backup_${timestamp}.tar.gz"
|
||||
echo " 🗜️ Compressing backup files..."
|
||||
tar -czf "$archive_file" -C "$backup_target_dir" .
|
||||
local tar_exit_code=$?
|
||||
|
||||
if [[ "$tar_exit_code" -eq 0 ]]; then
|
||||
echo " ✅ Successfully created archive '${archive_file}'."
|
||||
echo " 🧹 Cleaning up temporary directory..."
|
||||
rm -rf "$backup_target_dir"
|
||||
echo " ✨ Cleanup complete."
|
||||
else
|
||||
echo " ❌ Error: Failed to compress backup files in '${backup_target_dir}'."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " ❌ Error: Failed to initiate ${backup_name} backup (hdbsql exit code: ${exit_code})."
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Main Execution ---
|
||||
|
||||
echo "⚙️ Starting HANA backup process using hanatool.sh..."
|
||||
echo "⚙️ Starting HANA backup process..."
|
||||
|
||||
mkdir -p "$BACKUP_BASE_DIR"
|
||||
|
||||
SCHEMA_EXPORT_OPTIONS=""
|
||||
|
||||
case "$BACKUP_TYPE" in
|
||||
schema)
|
||||
if [[ -z "$SCHEMA_NAMES" ]]; then
|
||||
echo " ⚠️ Warning: SCHEMA_NAMES variable is not set in config. Skipping schema export."
|
||||
else
|
||||
echo "🔎 Found schemas to export: ${SCHEMA_NAMES}"
|
||||
for schema in $SCHEMA_NAMES; do
|
||||
echo "⬇️ Starting schema export for '${schema}'..."
|
||||
SCHEMA_EXPORT_OPTIONS="$COMMON_OPTIONS"
|
||||
if [[ -n "$THREADS" ]]; then
|
||||
SCHEMA_EXPORT_OPTIONS+=" -t $THREADS"
|
||||
fi
|
||||
if [[ "$COMPRESS_SCHEMA" == "true" ]]; then
|
||||
SCHEMA_EXPORT_OPTIONS+=" --compress"
|
||||
fi
|
||||
"$HANATOOL_PATH" "$USER_KEY" export "$schema" "${BACKUP_BASE_DIR}/schema" $SCHEMA_EXPORT_OPTIONS
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "❌ Error: Schema export for '${schema}' failed."
|
||||
fi
|
||||
echo "--------------------------------------------------"
|
||||
done
|
||||
fi
|
||||
run_all_schema_exports
|
||||
;;
|
||||
tenant)
|
||||
echo "⬇️ Starting Tenant backup..."
|
||||
TENANT_BACKUP_OPTIONS="$COMMON_OPTIONS"
|
||||
if [[ "$COMPRESS_TENANT" == "true" ]]; then
|
||||
TENANT_BACKUP_OPTIONS+=" --compress"
|
||||
fi
|
||||
"$HANATOOL_PATH" "$USER_KEY" backup "${BACKUP_BASE_DIR}/tenant" $TENANT_BACKUP_OPTIONS
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "❌ Error: Tenant backup failed."
|
||||
fi
|
||||
perform_database_backup "Tenant" "$USER_KEY" "${BACKUP_BASE_DIR}/tenant" "$COMPRESS_TENANT"
|
||||
;;
|
||||
all)
|
||||
if [[ -z "$SCHEMA_NAMES" ]]; then
|
||||
echo " ⚠️ Warning: SCHEMA_NAMES variable is not set in config. Skipping schema export."
|
||||
else
|
||||
echo "🔎 Found schemas to export: ${SCHEMA_NAMES}"
|
||||
for schema in $SCHEMA_NAMES; do
|
||||
echo "⬇️ Starting schema export for '${schema}'..."
|
||||
SCHEMA_EXPORT_OPTIONS="$COMMON_OPTIONS"
|
||||
if [[ -n "$THREADS" ]]; then
|
||||
SCHEMA_EXPORT_OPTIONS+=" -t $THREADS"
|
||||
fi
|
||||
if [[ "$COMPRESS_SCHEMA" == "true" ]]; then
|
||||
SCHEMA_EXPORT_OPTIONS+=" --compress"
|
||||
fi
|
||||
"$HANATOOL_PATH" "$USER_KEY" export "$schema" "${BACKUP_BASE_DIR}/schema" $SCHEMA_EXPORT_OPTIONS
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "❌ Error: Schema export for '${schema}' failed."
|
||||
fi
|
||||
echo "--------------------------------------------------"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "⬇️ Starting Tenant backup..."
|
||||
TENANT_BACKUP_OPTIONS="$COMMON_OPTIONS"
|
||||
if [[ "$COMPRESS_TENANT" == "true" ]]; then
|
||||
TENANT_BACKUP_OPTIONS+=" --compress"
|
||||
fi
|
||||
"$HANATOOL_PATH" "$USER_KEY" backup "${BACKUP_BASE_DIR}/tenant" $TENANT_BACKUP_OPTIONS
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "❌ Error: Tenant backup failed."
|
||||
fi
|
||||
run_all_schema_exports
|
||||
perform_database_backup "Tenant" "$USER_KEY" "${BACKUP_BASE_DIR}/tenant" "$COMPRESS_TENANT"
|
||||
;;
|
||||
*)
|
||||
echo " ❌ Error: Invalid BACKUP_TYPE '${BACKUP_TYPE}' in config. Use 'schema', 'tenant', or 'all'."
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check if SYSTEMDB backup is enabled, regardless of BACKUP_TYPE (as long as it's not 'schema' only)
|
||||
# NEW: Check if SYSTEMDB backup is enabled, regardless of BACKUP_TYPE (as long as it's not 'schema' only)
|
||||
if [[ "$BACKUP_TYPE" == "tenant" || "$BACKUP_TYPE" == "all" ]]; then
|
||||
if [[ "$BACKUP_SYSTEMDB" == "true" ]]; then
|
||||
echo "--------------------------------------------------"
|
||||
if [[ -z "$SYSTEMDB_USER_KEY" ]]; then
|
||||
echo " ❌ Error: BACKUP_SYSTEMDB is true, but SYSTEMDB_USER_KEY is not set in config."
|
||||
else
|
||||
echo "⬇️ Starting SYSTEMDB backup..."
|
||||
SYSTEMDB_BACKUP_OPTIONS="$COMMON_OPTIONS"
|
||||
if [[ "$COMPRESS_TENANT" == "true" ]]; then # SYSTEMDB compression uses COMPRESS_TENANT setting
|
||||
SYSTEMDB_BACKUP_OPTIONS+=" --compress"
|
||||
fi
|
||||
"$HANATOOL_PATH" "$SYSTEMDB_USER_KEY" backup "${BACKUP_BASE_DIR}/tenant" $SYSTEMDB_BACKUP_OPTIONS
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "❌ Error: SYSTEMDB backup failed."
|
||||
fi
|
||||
perform_database_backup "SYSTEMDB" "$SYSTEMDB_USER_KEY" "${BACKUP_BASE_DIR}/systemdb" "$COMPRESS_TENANT"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Version: 1.1.0
|
||||
# Author: Tomi Eckert
|
||||
|
||||
# Check if any arguments were provided
|
||||
if [ "$#" -eq 0 ]; then
|
||||
|
||||
-354
@@ -1,354 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# INTERACTIVE FIREWALL CONFIGURATOR FOR SAP B1
|
||||
# (With Save/Load State)
|
||||
# ==========================================
|
||||
|
||||
# Configuration File
|
||||
CONFIG_FILE="./firewall_state.conf"
|
||||
|
||||
# Colors for formatting
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# ==========================================
|
||||
# SERVICE DEFINITIONS
|
||||
# ==========================================
|
||||
declare -a SVC_NAMES
|
||||
declare -a SVC_PORTS
|
||||
|
||||
SVC_NAMES[0]="SAP Web Client"
|
||||
SVC_PORTS[0]="443"
|
||||
|
||||
SVC_NAMES[1]="SAP HANA Database (System & Company DB)"
|
||||
SVC_PORTS[1]="30013 30015"
|
||||
|
||||
SVC_NAMES[2]="SAP Business One SLD"
|
||||
SVC_PORTS[2]="40000 40001 40002 40003 40004 40005"
|
||||
|
||||
SVC_NAMES[3]="SAP Business One Auth"
|
||||
SVC_PORTS[3]="40020"
|
||||
|
||||
SVC_NAMES[4]="SAP Business One Service Layer, Cockpit"
|
||||
SVC_PORTS[4]="50000 4300"
|
||||
|
||||
SVC_NAMES[5]="SAP Host Agent"
|
||||
SVC_PORTS[5]="1128 1129"
|
||||
|
||||
SVC_NAMES[6]="SSH Remote Access"
|
||||
SVC_PORTS[6]="22"
|
||||
|
||||
SVC_NAMES[7]="SMB / B1_SHR (File Sharing)"
|
||||
SVC_PORTS[7]="139 445"
|
||||
|
||||
# Arrays to store user decisions
|
||||
declare -a CONFIG_DECISION # "ALL", "IP", "SKIP"
|
||||
declare -a CONFIG_IPS # Stores the IP string if "IP" is chosen
|
||||
DO_FLUSH=false
|
||||
|
||||
# ==========================================
|
||||
# HELPER FUNCTIONS
|
||||
# ==========================================
|
||||
|
||||
print_header() {
|
||||
clear
|
||||
echo -e "${CYAN}==========================================${NC}"
|
||||
echo -e "${CYAN} SAP B1 Interactive Firewall Setup ${NC}"
|
||||
echo -e "${CYAN}==========================================${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
save_config() {
|
||||
echo "# SAP B1 Firewall State - Do not edit manually unless you know what you are doing" > "$CONFIG_FILE"
|
||||
for i in "${!SVC_NAMES[@]}"; do
|
||||
# Use simple variable assignment format
|
||||
echo "SAVED_DECISION[$i]=\"${CONFIG_DECISION[$i]}\"" >> "$CONFIG_FILE"
|
||||
echo "SAVED_IPS[$i]=\"${CONFIG_IPS[$i]}\"" >> "$CONFIG_FILE"
|
||||
done
|
||||
echo "Config state saved to $CONFIG_FILE"
|
||||
}
|
||||
|
||||
load_config() {
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
echo -e "${GREEN}Found saved configuration file.${NC}"
|
||||
source "$CONFIG_FILE"
|
||||
# Map SAVED variables to current CONFIG variables
|
||||
for i in "${!SVC_NAMES[@]}"; do
|
||||
CONFIG_DECISION[$i]="${SAVED_DECISION[$i]}"
|
||||
CONFIG_IPS[$i]="${SAVED_IPS[$i]}"
|
||||
done
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# INITIAL SETUP
|
||||
# ==========================================
|
||||
print_header
|
||||
|
||||
# 1. Flush Question (First)
|
||||
echo -e "${YELLOW}Existing Configuration:${NC}"
|
||||
echo "Do you want to FLUSH (remove) all currently active firewall rules before starting?"
|
||||
echo "This ensures a clean slate. (Only affects Runtime, not Permanent config)"
|
||||
read -p "Flush all current rules? [Y/n]: " flush_choice
|
||||
flush_choice=${flush_choice:-Y} # Default to Yes
|
||||
|
||||
if [[ "$flush_choice" =~ ^[Yy]$ ]]; then
|
||||
DO_FLUSH=true
|
||||
echo -e "-> ${RED}Will flush all rules (Clean Slate).${NC}"
|
||||
else
|
||||
echo -e "-> Keeping existing rules (Appending new ones)."
|
||||
fi
|
||||
echo ""
|
||||
sleep 1
|
||||
|
||||
# 2. Load Configuration (Second)
|
||||
HAS_CONFIG=false
|
||||
if load_config; then
|
||||
HAS_CONFIG=true
|
||||
echo "Previous settings loaded. You can press ENTER to accept defaults during selection."
|
||||
else
|
||||
echo "No previous configuration found. Starting fresh."
|
||||
fi
|
||||
echo ""
|
||||
sleep 1
|
||||
|
||||
# ==========================================
|
||||
# CONFIGURATION LOOP
|
||||
# ==========================================
|
||||
|
||||
print_header
|
||||
echo -e "This script will help you configure access rules for each service."
|
||||
echo -e "${YELLOW}Note: Configuration applies to RUNTIME only.${NC}"
|
||||
echo ""
|
||||
|
||||
for i in "${!SVC_NAMES[@]}"; do
|
||||
NAME="${SVC_NAMES[$i]}"
|
||||
PORTS="${SVC_PORTS[$i]}"
|
||||
|
||||
# Get previous setting if available
|
||||
PREV_DECISION="${CONFIG_DECISION[$i]}"
|
||||
PREV_IPS="${CONFIG_IPS[$i]}"
|
||||
|
||||
# Determine default option number for UI
|
||||
DEFAULT_OPT=""
|
||||
DEFAULT_TXT=""
|
||||
if [[ "$PREV_DECISION" == "ALL" ]]; then DEFAULT_OPT="1"; DEFAULT_TXT="[Default: 1 - Public]"; fi
|
||||
if [[ "$PREV_DECISION" == "IP" ]]; then DEFAULT_OPT="2"; DEFAULT_TXT="[Default: 2 - Restricted]"; fi
|
||||
if [[ "$PREV_DECISION" == "SKIP" ]]; then DEFAULT_OPT="3"; DEFAULT_TXT="[Default: 3 - Skip]"; fi
|
||||
|
||||
echo -e "--------------------------------------------------"
|
||||
echo -e "Configuring Service: ${GREEN}$NAME${NC}"
|
||||
echo -e "Ports: ${YELLOW}$PORTS${NC}"
|
||||
echo "--------------------------------------------------"
|
||||
echo "1) Allow from ANYWHERE (Public)"
|
||||
echo "2) Restrict to SPECIFIC IPs"
|
||||
echo "3) Skip / Block (Do not open)"
|
||||
|
||||
while true; do
|
||||
read -p "Select option (1-3) $DEFAULT_TXT: " choice
|
||||
|
||||
# Handle Enter key (Default)
|
||||
if [[ -z "$choice" && -n "$DEFAULT_OPT" ]]; then
|
||||
choice=$DEFAULT_OPT
|
||||
fi
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
CONFIG_DECISION[$i]="ALL"
|
||||
CONFIG_IPS[$i]=""
|
||||
echo -e "-> Selected: ${RED}Public Access${NC}"
|
||||
break
|
||||
;;
|
||||
2)
|
||||
CONFIG_DECISION[$i]="IP"
|
||||
echo ""
|
||||
echo -e "Enter IPs or Subnets separated by spaces or commas."
|
||||
|
||||
# Show previous IPs as default if they exist
|
||||
if [[ -n "$PREV_IPS" ]]; then
|
||||
echo -e "Current Saved IPs: ${CYAN}$PREV_IPS${NC}"
|
||||
read -p "IPs [Press Enter to keep current]: " ip_input
|
||||
if [[ -z "$ip_input" ]]; then
|
||||
ip_input="$PREV_IPS"
|
||||
fi
|
||||
else
|
||||
echo -e "Example: ${CYAN}192.168.1.10, 192.168.1.20${NC}"
|
||||
read -p "IPs: " ip_input
|
||||
fi
|
||||
|
||||
# Replace commas with spaces to sanitize
|
||||
CONFIG_IPS[$i]="${ip_input//,/ }"
|
||||
echo -e "-> Selected: ${GREEN}Restricted to ${CONFIG_IPS[$i]}${NC}"
|
||||
break
|
||||
;;
|
||||
3)
|
||||
CONFIG_DECISION[$i]="SKIP"
|
||||
CONFIG_IPS[$i]=""
|
||||
echo -e "-> Selected: ${YELLOW}Skipping${NC}"
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Invalid option."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ==========================================
|
||||
# SUMMARY & CONFIRMATION
|
||||
# ==========================================
|
||||
|
||||
print_header
|
||||
echo -e "${YELLOW}SUMMARY OF PENDING CHANGES:${NC}"
|
||||
echo ""
|
||||
printf "%-25s | %-15s | %-30s\n" "Service" "Action" "Details"
|
||||
echo "-------------------------------------------------------------------------------"
|
||||
|
||||
for i in "${!SVC_NAMES[@]}"; do
|
||||
NAME="${SVC_NAMES[$i]}"
|
||||
ACTION="${CONFIG_DECISION[$i]}"
|
||||
DETAILS="${CONFIG_IPS[$i]}"
|
||||
|
||||
# Shorten name for table
|
||||
SHORT_NAME=${NAME:0:24}
|
||||
|
||||
if [ "$ACTION" == "ALL" ]; then
|
||||
printf "%-25s | ${RED}%-15s${NC} | %-30s\n" "$SHORT_NAME" "Open Public" "0.0.0.0/0"
|
||||
elif [ "$ACTION" == "IP" ]; then
|
||||
printf "%-25s | ${GREEN}%-15s${NC} | %-30s\n" "$SHORT_NAME" "Restricted" "$DETAILS"
|
||||
else
|
||||
printf "%-25s | ${YELLOW}%-15s${NC} | %-30s\n" "$SHORT_NAME" "Blocked/Skip" "-"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}Global Actions:${NC}"
|
||||
if [ "$DO_FLUSH" = true ]; then
|
||||
echo "1. FLUSH ALL current rules (Clean Slate)."
|
||||
else
|
||||
echo "1. Remove specific insecure rules (0-65535) and standard SSH service."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
read -p "Do you want to SAVE config and APPLY changes now? [Y/n]: " confirm
|
||||
confirm=${confirm:-Y} # Default to Yes
|
||||
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ==========================================
|
||||
# EXECUTION
|
||||
# ==========================================
|
||||
echo ""
|
||||
echo "Saving configuration to $CONFIG_FILE..."
|
||||
save_config
|
||||
|
||||
echo "Applying configurations (RUNTIME ONLY)..."
|
||||
|
||||
# 1. Flush or Safety Cleanup
|
||||
if [ "$DO_FLUSH" = true ]; then
|
||||
echo "-> Flushing active rules..."
|
||||
# Flush Services
|
||||
for service in $(firewall-cmd --list-services); do
|
||||
firewall-cmd --remove-service="$service" >/dev/null 2>&1
|
||||
done
|
||||
# Flush Ports
|
||||
for port in $(firewall-cmd --list-ports); do
|
||||
firewall-cmd --remove-port="$port" >/dev/null 2>&1
|
||||
done
|
||||
# Flush Rich Rules
|
||||
firewall-cmd --list-rich-rules | while read -r rule; do
|
||||
if [ -n "$rule" ]; then
|
||||
firewall-cmd --remove-rich-rule="$rule" >/dev/null 2>&1
|
||||
fi
|
||||
done
|
||||
else
|
||||
# Only remove specific conflicting rules if not flushing everything
|
||||
echo "-> Cleaning up insecure rules..."
|
||||
firewall-cmd --remove-port=0-65535/tcp >/dev/null 2>&1
|
||||
firewall-cmd --remove-service=ssh >/dev/null 2>&1
|
||||
firewall-cmd --remove-port=22/tcp >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
# 2. Loop and Apply
|
||||
for i in "${!SVC_NAMES[@]}"; do
|
||||
PORTS="${SVC_PORTS[$i]}"
|
||||
DECISION="${CONFIG_DECISION[$i]}"
|
||||
|
||||
# Convert space-separated ports to array for inner loop
|
||||
read -ra PORT_ARR <<< "$PORTS"
|
||||
|
||||
if [ "$DECISION" == "ALL" ]; then
|
||||
# Open ports globally
|
||||
for port in "${PORT_ARR[@]}"; do
|
||||
echo " Opening $port globally..."
|
||||
firewall-cmd --add-port=${port}/tcp >/dev/null
|
||||
done
|
||||
|
||||
elif [ "$DECISION" == "IP" ]; then
|
||||
# Add rich rules for specific IPs
|
||||
read -ra IP_ARR <<< "${CONFIG_IPS[$i]}"
|
||||
|
||||
for ip in "${IP_ARR[@]}"; do
|
||||
if [[ -z "$ip" ]]; then continue; fi
|
||||
for port in "${PORT_ARR[@]}"; do
|
||||
echo " Allowing $ip access to port $port..."
|
||||
firewall-cmd --add-rich-rule="rule family='ipv4' source address='$ip' port port='$port' protocol='tcp' accept" >/dev/null
|
||||
done
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}==========================================${NC}"
|
||||
echo -e "${YELLOW} TESTING CONNECTIVITY ${NC}"
|
||||
echo -e "${YELLOW}==========================================${NC}"
|
||||
echo "The new rules are active. If you are locked out, DO NOTHING."
|
||||
echo "The firewall will automatically REVERT in 15 seconds."
|
||||
echo ""
|
||||
echo -e "${GREEN}If you can read this and your connection works:${NC}"
|
||||
echo -e "Press ${CYAN}ENTER${NC} now to CONFIRM and KEEP the changes."
|
||||
echo "OR press Ctrl+C to keep changes without saving (script exits)."
|
||||
echo ""
|
||||
|
||||
if read -t 15 -p "Waiting for confirmation... "; then
|
||||
# User pressed Enter (Connection is good)
|
||||
echo ""
|
||||
echo -e "${GREEN}Connection confirmed!${NC}"
|
||||
echo ""
|
||||
read -p "Do you want to save these rules PERMANENTLY now? [Y/n]: " save_choice
|
||||
save_choice=${save_choice:-Y} # Default to Yes
|
||||
|
||||
if [[ "$save_choice" =~ ^[Yy]$ ]]; then
|
||||
echo "Saving to permanent configuration..."
|
||||
firewall-cmd --runtime-to-permanent
|
||||
echo -e "${GREEN}Configuration Saved.${NC}"
|
||||
else
|
||||
echo "Rules kept in RUNTIME only. They will be lost on reboot."
|
||||
fi
|
||||
else
|
||||
# Timeout occurred (User likely locked out)
|
||||
echo ""
|
||||
echo -e "${RED}Timeout reached! Reverting changes...${NC}"
|
||||
|
||||
# Revert Logic
|
||||
echo "1. Reloading permanent configuration..."
|
||||
firewall-cmd --reload >/dev/null 2>&1
|
||||
|
||||
echo "2. Ensuring SSH is accessible (Failsafe)..."
|
||||
firewall-cmd --add-service=ssh >/dev/null 2>&1
|
||||
|
||||
echo -e "${YELLOW}Firewall reverted to previous state (plus global SSH allow).${NC}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
-510
@@ -1,510 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Version: 1.6.0
|
||||
# Author: Tomi Eckert
|
||||
# ==============================================================================
|
||||
# HANA Database Manager Menu (hanamgr.sh)
|
||||
#
|
||||
# An interactive command-line menu for managing SAP HANA schemas.
|
||||
# Provides functionality to Export, Import, Import-Rename, Drop schemas,
|
||||
# and Rename the B1 company name within a database.
|
||||
# ==============================================================================
|
||||
|
||||
# --- Configuration ---
|
||||
# Assuming hdbsql is in the environment PATH
|
||||
HDBSQL_CMD="hdbsql"
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
# Function to print a separator
|
||||
print_separator() {
|
||||
echo "--------------------------------------------------------"
|
||||
}
|
||||
|
||||
# Function to check if hdbsql is available
|
||||
check_hdbsql() {
|
||||
if ! command -v "$HDBSQL_CMD" &> /dev/null; then
|
||||
echo -e "\033[31m[ERROR] '$HDBSQL_CMD' command not found. Ensure it is in your PATH.\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to execute a SQL query and capture the exit code
|
||||
execute_sql() {
|
||||
local key="$1"
|
||||
local query="$2"
|
||||
local output
|
||||
|
||||
output=$("$HDBSQL_CMD" -U "$key" "$query" 2>&1)
|
||||
local exit_code=$?
|
||||
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
return 0
|
||||
else
|
||||
echo "$output"
|
||||
return $exit_code
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to interactively select a schema from the database
|
||||
# Sets the global variable SELECTED_SCHEMA
|
||||
select_schema() {
|
||||
local user_key="$1"
|
||||
SELECTED_SCHEMA=""
|
||||
|
||||
echo "[🔎] Fetching available schemas..."
|
||||
local query="SELECT SCHEMA_NAME FROM SCHEMAS WHERE SCHEMA_OWNER = 'SYSTEM' AND SCHEMA_NAME NOT IN ('_SYS_SECURITY', 'IFSERV', 'B1if', 'SYSTEM', 'RSP');"
|
||||
|
||||
local raw_output
|
||||
raw_output=$("$HDBSQL_CMD" -U "$user_key" "$query" 2>/dev/null)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "\033[31m[ERROR] Failed to fetch schemas. Check your HDBUSERSTORE key.\033[0m"
|
||||
# Fallback to manual entry
|
||||
read -p "Enter Schema Name manually: " SELECTED_SCHEMA
|
||||
return 1
|
||||
fi
|
||||
|
||||
local schemas=()
|
||||
while IFS= read -r line; do
|
||||
if [[ -z "$line" || "$line" == SCHEMA_NAME* || "$line" == *"rows selected"* || "$line" == *"row selected"* || "$line" == *"overall time"* || "$line" == -* ]]; then
|
||||
continue
|
||||
fi
|
||||
local clean_name
|
||||
clean_name=$(echo "$line" | tr -d '"' | xargs)
|
||||
if [[ -n "$clean_name" ]]; then
|
||||
schemas+=("$clean_name")
|
||||
fi
|
||||
done <<< "$raw_output"
|
||||
|
||||
if [ ${#schemas[@]} -eq 0 ]; then
|
||||
echo -e "\033[33m[WARN] No eligible schemas found in the database.\033[0m"
|
||||
# Fallback to manual
|
||||
read -p "Enter Schema Name manually: " SELECTED_SCHEMA
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo -e "\n\033[1mAvailable Schemas:\033[0m"
|
||||
for i in "${!schemas[@]}"; do
|
||||
echo "$((i+1))) ${schemas[$i]}"
|
||||
done
|
||||
echo "0) Enter manually"
|
||||
echo
|
||||
|
||||
local choice
|
||||
while true; do
|
||||
read -p "Select a schema (0-${#schemas[@]}): " choice
|
||||
if [[ "$choice" == "0" ]]; then
|
||||
read -p "Enter Schema Name manually: " SELECTED_SCHEMA
|
||||
break
|
||||
elif [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#schemas[@]}" ]; then
|
||||
SELECTED_SCHEMA="${schemas[$((choice-1))]}"
|
||||
break
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Invalid selection. Please try again.\033[0m"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# --- Operation Functions ---
|
||||
|
||||
do_export() {
|
||||
echo -e "\n\033[1m=== Export Schema ===\033[0m"
|
||||
select_schema "$GLOBAL_USER_KEY"
|
||||
local schema_name="$SELECTED_SCHEMA"
|
||||
read -p "Enter Target Directory or File Path (.tar.gz): " target_path
|
||||
|
||||
local max_threads=$(nproc --all 2>/dev/null || echo 1)
|
||||
local half_threads=$((max_threads / 2))
|
||||
[[ $half_threads -lt 1 ]] && half_threads=1
|
||||
|
||||
read -p "Number of threads (default: $half_threads, max: $max_threads): " threads
|
||||
threads=${threads:-$half_threads}
|
||||
|
||||
read -p "Compress output as tar.gz? (y/N): " compress
|
||||
|
||||
if [[ -z "$GLOBAL_USER_KEY" || -z "$schema_name" || -z "$target_path" ]]; then
|
||||
echo -e "\033[31m[ERROR] Key, Schema, and Path are required.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "\n- Starting export of schema '${schema_name}'..."
|
||||
local export_dir="$target_path"
|
||||
local archive_file=""
|
||||
|
||||
if [[ "$compress" =~ ^[Yy]$ ]]; then
|
||||
# If the target path ends with .tar.gz, treat it as the final filename
|
||||
if [[ "$target_path" == *.tar.gz ]]; then
|
||||
archive_file="$target_path"
|
||||
local target_dir=$(dirname "$target_path")
|
||||
mkdir -p "$target_dir"
|
||||
export_dir=$(mktemp -d "${target_dir}/export_${schema_name}_XXXXXXXX")
|
||||
else
|
||||
archive_file="${target_path}/${schema_name}_export_$(date +%Y%m%d_%H%M%S).tar.gz"
|
||||
mkdir -p "$target_path"
|
||||
export_dir=$(mktemp -d "${target_path}/export_${schema_name}_XXXXXXXX")
|
||||
fi
|
||||
echo "- Using temporary export directory: ${export_dir}"
|
||||
else
|
||||
mkdir -p "$export_dir"
|
||||
fi
|
||||
|
||||
local query="EXPORT \"${schema_name}\".\"*\" AS BINARY INTO '${export_dir}' WITH REPLACE THREADS ${threads} NO DEPENDENCIES;"
|
||||
|
||||
if execute_sql "$GLOBAL_USER_KEY" "$query"; then
|
||||
echo -e "\033[32m[DONE] Successfully exported schema '${schema_name}'.\033[0m"
|
||||
|
||||
if [[ "$compress" =~ ^[Yy]$ ]]; then
|
||||
echo "- Compressing export to ${archive_file}..."
|
||||
|
||||
if command -v pigz &> /dev/null; then
|
||||
tar -I "pigz -p $threads" -cf "$archive_file" -C "$(dirname "$export_dir")" "$(basename "$export_dir")"
|
||||
else
|
||||
tar -czf "$archive_file" -C "$(dirname "$export_dir")" "$(basename "$export_dir")"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "\033[32m[DONE] Compression successful.\033[0m"
|
||||
rm -rf "$export_dir"
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Compression failed.\033[0m"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Export failed.\033[0m"
|
||||
if [[ "$compress" =~ ^[Yy]$ ]]; then rm -rf "$export_dir"; fi
|
||||
fi
|
||||
}
|
||||
|
||||
do_import() {
|
||||
local rename_mode="$1"
|
||||
|
||||
if [[ "$rename_mode" == "true" ]]; then
|
||||
echo -e "\n\033[1m=== Import & Rename Schema ===\033[0m"
|
||||
else
|
||||
echo -e "\n\033[1m=== Import Schema ===\033[0m"
|
||||
fi
|
||||
|
||||
read -p "Enter Source Schema Name (as it was exported): " schema_name
|
||||
|
||||
local new_schema_name=""
|
||||
if [[ "$rename_mode" == "true" ]]; then
|
||||
read -p "Enter NEW Target Schema Name: " new_schema_name
|
||||
if [[ -z "$new_schema_name" ]]; then
|
||||
echo -e "\033[31m[ERROR] New Schema Name is required for renaming.\033[0m"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
read -p "Enter Source Path (Directory or .tar.gz): " source_path
|
||||
|
||||
local max_threads=$(nproc --all 2>/dev/null || echo 1)
|
||||
local half_threads=$((max_threads / 2))
|
||||
[[ $half_threads -lt 1 ]] && half_threads=1
|
||||
|
||||
read -p "Number of threads (default: $half_threads, max: $max_threads): " threads
|
||||
threads=${threads:-$half_threads}
|
||||
|
||||
read -p "Replace existing objects? (y/N): " replace_opt
|
||||
|
||||
if [[ -z "$GLOBAL_USER_KEY" || -z "$schema_name" || -z "$source_path" ]]; then
|
||||
echo -e "\033[31m[ERROR] Key, Schema, and Path are required.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "\n- Starting schema import..."
|
||||
local import_dir="$source_path"
|
||||
local cleanup_temp=false
|
||||
|
||||
# Handle compressed archives
|
||||
if [[ -f "$source_path" && "$source_path" == *.tar.gz ]]; then
|
||||
import_dir=$(mktemp -d "/tmp/import_${schema_name}_XXXXXXXX")
|
||||
cleanup_temp=true
|
||||
echo "- Decompressing archive to ${import_dir}..."
|
||||
|
||||
if command -v pigz &> /dev/null; then
|
||||
tar -I "pigz -p $threads" -xf "$source_path" -C "$import_dir" --strip-components=1
|
||||
else
|
||||
tar -xzf "$source_path" -C "$import_dir" --strip-components=1
|
||||
fi
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "\033[31m[ERROR] Failed to decompress archive.\033[0m"
|
||||
rm -rf "$import_dir"
|
||||
return
|
||||
fi
|
||||
elif [[ ! -d "$import_dir" ]]; then
|
||||
echo -e "\033[31m[ERROR] Path is neither a directory nor a valid .tar.gz archive.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
local import_options="IGNORE EXISTING"
|
||||
if [[ "$replace_opt" =~ ^[Yy]$ ]]; then
|
||||
import_options="REPLACE"
|
||||
fi
|
||||
|
||||
if [[ "$rename_mode" == "true" ]]; then
|
||||
import_options="${import_options} RENAME SCHEMA \"${schema_name}\" TO \"${new_schema_name}\""
|
||||
fi
|
||||
|
||||
local query="IMPORT \"${schema_name}\".\"*\" AS BINARY FROM '${import_dir}' WITH ${import_options} THREADS ${threads};"
|
||||
|
||||
if execute_sql "$GLOBAL_USER_KEY" "$query"; then
|
||||
echo -e "\033[32m[DONE] Successfully imported schema.\033[0m"
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Import failed.\033[0m"
|
||||
fi
|
||||
|
||||
if [[ "$cleanup_temp" == "true" ]]; then
|
||||
echo "- Cleaning up temporary files..."
|
||||
rm -rf "$import_dir"
|
||||
fi
|
||||
}
|
||||
|
||||
do_drop() {
|
||||
echo -e "\n\033[1m=== Drop Schema ===\033[0m"
|
||||
select_schema "$GLOBAL_USER_KEY"
|
||||
local schema_name="$SELECTED_SCHEMA"
|
||||
|
||||
if [[ -z "$GLOBAL_USER_KEY" || -z "$schema_name" ]]; then
|
||||
echo -e "\033[31m[ERROR] Key and Schema Name are required.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "\033[31m[WARN] You are about to completely drop the schema '${schema_name}'.\033[0m"
|
||||
read -p "Are you absolutely sure? Type 'YES' to confirm: " confirm
|
||||
|
||||
if [[ "$confirm" == "YES" ]]; then
|
||||
echo "- Dropping schema '${schema_name}'..."
|
||||
local query="DROP SCHEMA \"${schema_name}\" CASCADE"
|
||||
if execute_sql "$GLOBAL_USER_KEY" "$query"; then
|
||||
echo -e "\033[32m[DONE] Schema successfully dropped.\033[0m"
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Failed to drop schema.\033[0m"
|
||||
fi
|
||||
else
|
||||
echo "- Operation cancelled."
|
||||
fi
|
||||
}
|
||||
|
||||
do_copy() {
|
||||
echo -e "\n\033[1m=== Copy Schema ===\033[0m"
|
||||
select_schema "$GLOBAL_USER_KEY"
|
||||
local source_schema="$SELECTED_SCHEMA"
|
||||
|
||||
if [[ -z "$source_schema" ]]; then
|
||||
echo -e "\033[31m[ERROR] Source Schema is required.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
read -p "Enter Target Schema Name: " target_schema
|
||||
read -p "Enter Temporary Export Directory Path: " export_path
|
||||
|
||||
local max_threads=$(nproc --all 2>/dev/null || echo 1)
|
||||
local half_threads=$((max_threads / 2))
|
||||
[[ $half_threads -lt 1 ]] && half_threads=1
|
||||
|
||||
read -p "Number of threads (default: $half_threads, max: $max_threads): " threads
|
||||
threads=${threads:-$half_threads}
|
||||
|
||||
read -p "Replace existing objects in target schema? (y/N): " replace_opt
|
||||
|
||||
if [[ -z "$GLOBAL_USER_KEY" || -z "$target_schema" || -z "$export_path" ]]; then
|
||||
echo -e "\033[31m[ERROR] Key, Target Schema, and Path are required.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
# 1. Export
|
||||
echo -e "\n-Step 1/2: Exporting source schema '${source_schema}' to '${export_path}'..."
|
||||
mkdir -p "$export_path"
|
||||
local temp_export_dir=$(mktemp -d "${export_path}/copy_export_${source_schema}_XXXXXXXX")
|
||||
echo "-Using temporary export directory: ${temp_export_dir}"
|
||||
|
||||
local export_query="EXPORT \"${source_schema}\".\"*\" AS BINARY INTO '${temp_export_dir}' WITH REPLACE THREADS ${threads} NO DEPENDENCIES;"
|
||||
|
||||
if ! execute_sql "$GLOBAL_USER_KEY" "$export_query"; then
|
||||
echo -e "\033[31m[ERROR] Export phase failed. Aborting copy.\033[0m"
|
||||
rm -rf "$temp_export_dir"
|
||||
return
|
||||
fi
|
||||
echo -e "\033[32m[DONE] Export phase completed successfully.\033[0m"
|
||||
|
||||
# 2. Import-Rename
|
||||
echo -e "\n-Step 2/2: Importing and renaming to '${target_schema}'..."
|
||||
local import_options="IGNORE EXISTING"
|
||||
if [[ "$replace_opt" =~ ^[Yy]$ ]]; then
|
||||
import_options="REPLACE"
|
||||
fi
|
||||
import_options="${import_options} RENAME SCHEMA \"${source_schema}\" TO \"${target_schema}\""
|
||||
|
||||
local import_query="IMPORT \"${source_schema}\".\"*\" AS BINARY FROM '${temp_export_dir}' WITH ${import_options} THREADS ${threads};"
|
||||
|
||||
if execute_sql "$GLOBAL_USER_KEY" "$import_query"; then
|
||||
echo -e "\033[32m[DONE] Successfully copied schema '${source_schema}' to '${target_schema}'.\033[0m"
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Import phase failed.\033[0m"
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
echo "- Cleaning up temporary files..."
|
||||
rm -rf "$temp_export_dir"
|
||||
}
|
||||
|
||||
do_backup() {
|
||||
echo -e "\n\033[1m=== Backup Tenant ===\033[0m"
|
||||
read -p "Enter Target Directory Path (parent directory): " target_path
|
||||
|
||||
local max_threads=$(nproc --all 2>/dev/null || echo 1)
|
||||
local half_threads=$((max_threads / 2))
|
||||
[[ $half_threads -lt 1 ]] && half_threads=1
|
||||
|
||||
read -p "Number of threads for compression (default: $half_threads, max: $max_threads): " threads
|
||||
threads=${threads:-$half_threads}
|
||||
|
||||
read -p "Compress output as tar.gz? (y/N): " compress
|
||||
|
||||
if [[ -z "$GLOBAL_USER_KEY" || -z "$target_path" ]]; then
|
||||
echo -e "\033[31m[ERROR] Key and Path are required.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
# Get tenant name
|
||||
local tenant_query="SELECT DATABASE_NAME FROM SYS.M_DATABASES;"
|
||||
local tenant_name
|
||||
tenant_name=$("$HDBSQL_CMD" -U "$GLOBAL_USER_KEY" "$tenant_query" 2>/dev/null | tail -n +2 | head -n 1 | tr -d '[:space:]' | tr -d '"')
|
||||
|
||||
if [[ -z "$tenant_name" ]]; then
|
||||
echo -e "\033[31m[ERROR] Could not retrieve HANA tenant name.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_target_dir="$target_path"
|
||||
local archive_file=""
|
||||
|
||||
if [[ "$compress" =~ ^[Yy]$ ]]; then
|
||||
backup_target_dir=$(mktemp -d "${target_path}/${tenant_name}_backup_${timestamp}_XXXXXXXX")
|
||||
archive_file="${target_path}/${tenant_name}_backup_${timestamp}.tar.gz"
|
||||
echo "- Using temporary backup directory: ${backup_target_dir}"
|
||||
fi
|
||||
|
||||
mkdir -p "$backup_target_dir"
|
||||
local backup_path_prefix="${backup_target_dir}/backup_${tenant_name}_${timestamp}"
|
||||
|
||||
echo -e "\n- Starting backup of tenant '${tenant_name}'..."
|
||||
local query="BACKUP DATA USING FILE ('${backup_path_prefix}')"
|
||||
|
||||
if execute_sql "$GLOBAL_USER_KEY" "$query"; then
|
||||
echo -e "\033[32m[DONE] Successfully initiated tenant backup.\033[0m"
|
||||
|
||||
if [[ "$compress" =~ ^[Yy]$ ]]; then
|
||||
echo "- Compressing backup to ${archive_file}..."
|
||||
|
||||
if command -v pigz &> /dev/null; then
|
||||
tar -I "pigz -p $threads" -cf "$archive_file" -C "$backup_target_dir" .
|
||||
else
|
||||
tar -czf "$archive_file" -C "$backup_target_dir" .
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "\033[32m[DONE] Compression successful.\033[0m"
|
||||
rm -rf "$backup_target_dir"
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Compression failed.\033[0m"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "\033[31m[ERROR] Backup failed.\033[0m"
|
||||
if [[ "$compress" =~ ^[Yy]$ ]]; then rm -rf "$backup_target_dir"; fi
|
||||
fi
|
||||
}
|
||||
|
||||
do_rename_db() {
|
||||
echo -e "\n\033[1m=== Rename Database (Company Name) ===\033[0m"
|
||||
select_schema "$GLOBAL_USER_KEY"
|
||||
local schema_name="$SELECTED_SCHEMA"
|
||||
read -p "Enter NEW Company Name: " new_compny_name
|
||||
|
||||
if [[ -z "$GLOBAL_USER_KEY" || -z "$schema_name" || -z "$new_compny_name" ]]; then
|
||||
echo -e "\033[31m[ERROR] Key, Schema Name, and New Company Name are required.\033[0m"
|
||||
return
|
||||
fi
|
||||
|
||||
echo -e "\n- Updating company name for '${schema_name}' to '${new_compny_name}'..."
|
||||
|
||||
# Update CINF
|
||||
echo " -> Updating CINF table..."
|
||||
local q_cinf="UPDATE \"${schema_name}\".CINF SET \"CompnyName\" = '${new_compny_name}';"
|
||||
execute_sql "$GLOBAL_USER_KEY" "$q_cinf" > /dev/null
|
||||
|
||||
# Update OADM
|
||||
echo " -> Updating OADM table..."
|
||||
local q_oadm="UPDATE \"${schema_name}\".OADM SET \"CompnyName\" = '${new_compny_name}', \"PrintHeadr\" = '${new_compny_name}';"
|
||||
execute_sql "$GLOBAL_USER_KEY" "$q_oadm" > /dev/null
|
||||
|
||||
echo -e "\033[32m[DONE] Database renamed successfully.\033[0m"
|
||||
}
|
||||
|
||||
# --- Main Menu Loop ---
|
||||
|
||||
check_hdbsql
|
||||
|
||||
echo
|
||||
echo "========================================================"
|
||||
echo " HANA Database Manager Init "
|
||||
echo "========================================================"
|
||||
read -p "Enter HDBUSERSTORE Key: " GLOBAL_USER_KEY
|
||||
if [[ -z "$GLOBAL_USER_KEY" ]]; then
|
||||
echo -e "\033[31m[ERROR] Key is required.\033[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo
|
||||
echo "========================================================"
|
||||
echo " HANA Database Manager Menu "
|
||||
echo "========================================================"
|
||||
echo "1) Export Schema"
|
||||
echo "2) Import Schema"
|
||||
echo "3) Import-Rename Schema"
|
||||
echo "4) Copy Schema"
|
||||
echo "5) Drop Schema"
|
||||
echo "6) Rename Database (Update Company Name)"
|
||||
echo "7) Backup Tenant"
|
||||
echo "8) Quit"
|
||||
echo
|
||||
read -p "Please select an option (1-8): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
do_export
|
||||
;;
|
||||
2)
|
||||
do_import "false"
|
||||
;;
|
||||
3)
|
||||
do_import "true"
|
||||
;;
|
||||
4)
|
||||
do_copy
|
||||
;;
|
||||
5)
|
||||
do_drop
|
||||
;;
|
||||
6)
|
||||
do_rename_db
|
||||
;;
|
||||
7)
|
||||
do_backup
|
||||
;;
|
||||
8)
|
||||
echo "Exiting."
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo -e "\033[31m[WARN] Invalid option. Please try again.\033[0m"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo
|
||||
read -n 1 -s -r -p "Press any key to continue..."
|
||||
done
|
||||
+15
-85
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Version: 1.6.0
|
||||
# Author: Tomi Eckert
|
||||
# Version: 1.5.0
|
||||
# ==============================================================================
|
||||
# SAP HANA Schema and Tenant Management Tool (hanatool.sh)
|
||||
#
|
||||
@@ -8,35 +7,13 @@
|
||||
# ==============================================================================
|
||||
|
||||
# --- Default Settings ---
|
||||
# Define potential HDB client paths
|
||||
HDB_CLIENT_PATH_1="/usr/sap/hdbclient"
|
||||
HDB_CLIENT_PATH_2="/usr/sap/NDB/HDB00/exe"
|
||||
|
||||
# Determine the correct HDB_CLIENT_PATH
|
||||
if [ -d "$HDB_CLIENT_PATH_1" ]; then
|
||||
HDB_CLIENT_PATH="$HDB_CLIENT_PATH_1"
|
||||
elif [ -d "$HDB_CLIENT_PATH_2" ]; then
|
||||
HDB_CLIENT_PATH="$HDB_CLIENT_PATH_2"
|
||||
else
|
||||
echo "❌ Error: Neither '$HDB_CLIENT_PATH_1' nor '$HDB_CLIENT_PATH_2' found."
|
||||
echo "Please install the SAP HANA client or adjust the paths in the script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HDBSQL_PATH="${HDB_CLIENT_PATH}/hdbsql"
|
||||
HDBSQL_PATH="/usr/sap/hdbclient/hdbsql"
|
||||
COMPRESS=false
|
||||
THREADS=0 # 0 means auto-calculate later
|
||||
DRY_RUN=false
|
||||
NTFY_TOKEN=""
|
||||
IMPORT_REPLACE=false
|
||||
|
||||
# Detect pigz for parallel compression
|
||||
if command -v pigz &>/dev/null; then
|
||||
USE_PIGZ=true
|
||||
else
|
||||
USE_PIGZ=false
|
||||
fi
|
||||
|
||||
# --- Help/Usage Function ---
|
||||
usage() {
|
||||
echo "SAP HANA Schema and Tenant Management Tool"
|
||||
@@ -88,28 +65,6 @@ send_notification() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Function to get HANA tenant name ---
|
||||
get_hana_tenant_name() {
|
||||
local user_key="$1"
|
||||
local hdbsql_path="$2"
|
||||
local dry_run="$3"
|
||||
|
||||
local query="SELECT DATABASE_NAME FROM SYS.M_DATABASES;"
|
||||
local tenant_name=""
|
||||
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
echo "[DRY RUN] Would execute hdbsql to get tenant name: \"$hdbsql_path\" -U \"$user_key\" \"$query\""
|
||||
tenant_name="DRYRUN_TENANT"
|
||||
else
|
||||
tenant_name=$("$hdbsql_path" -U "$user_key" "$query" | tail -n +2 | head -n 1 | tr -d '[:space:]' | tr -d '"')
|
||||
if [[ -z "$tenant_name" ]]; then
|
||||
echo "❌ Error: Could not retrieve HANA tenant name using user key '${user_key}'."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "$tenant_name"
|
||||
}
|
||||
|
||||
# --- Argument Parsing ---
|
||||
POSITIONAL_ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -169,7 +124,7 @@ if [[ ! -x "$HDBSQL_PATH" ]]; then
|
||||
fi
|
||||
|
||||
# Calculate default threads if not specified and action is not backup
|
||||
if [[ "$THREADS" -eq 0 ]]; then
|
||||
if [[ "$THREADS" -eq 0 && "$ACTION" != "backup" ]]; then
|
||||
TOTAL_THREADS=$(nproc --all)
|
||||
THREADS=$((TOTAL_THREADS / 2))
|
||||
if [[ "$THREADS" -eq 0 ]]; then
|
||||
@@ -193,29 +148,28 @@ case "$ACTION" in
|
||||
echo " - Path: ${TARGET_PATH}"
|
||||
echo " - Compress: ${COMPRESS}"
|
||||
|
||||
TENANT_NAME=$(get_hana_tenant_name "$USER_KEY" "$HDBSQL_PATH" "$DRY_RUN")
|
||||
echo " - Tenant Name: ${TENANT_NAME}"
|
||||
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
backup_target_dir="$TARGET_PATH" # Initialize with TARGET_PATH
|
||||
backup_target_dir=""
|
||||
backup_path_prefix=""
|
||||
|
||||
if [[ "$COMPRESS" == "true" ]]; then
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
backup_target_dir="${TARGET_PATH}/${TENANT_NAME}_backup_DRYRUN_TEMP" # Use TARGET_PATH
|
||||
backup_target_dir="/tmp/tenant_backup_DRYRUN_TEMP"
|
||||
else
|
||||
backup_target_dir=$(mktemp -d "${TARGET_PATH}/${TENANT_NAME}_backup_${timestamp}_XXXXXXXX") # Use TARGET_PATH
|
||||
backup_target_dir=$(mktemp -d "/tmp/tenant_backup_${timestamp}_XXXXXXXX")
|
||||
fi
|
||||
echo "ℹ️ Using temporary backup directory: ${backup_target_dir}"
|
||||
else
|
||||
backup_target_dir="$TARGET_PATH"
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
if [[ "$DRY_RUN" == "true" && "$COMPRESS" == "false" ]]; then
|
||||
echo "[DRY RUN] Would create directory: mkdir -p \"$backup_target_dir\""
|
||||
else
|
||||
mkdir -p "$backup_target_dir"
|
||||
fi
|
||||
|
||||
backup_path_prefix="${backup_target_dir}/backup_${TENANT_NAME}_${timestamp}"
|
||||
backup_path_prefix="${backup_target_dir}/backup_${timestamp}"
|
||||
|
||||
QUERY="BACKUP DATA USING FILE ('${backup_path_prefix}')"
|
||||
|
||||
@@ -230,22 +184,14 @@ case "$ACTION" in
|
||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
||||
echo "✅ Successfully initiated tenant backup with prefix '${backup_path_prefix}'."
|
||||
if [[ "$COMPRESS" == "true" ]]; then
|
||||
ARCHIVE_FILE="${TARGET_PATH}/${TENANT_NAME}_backup_${timestamp}.tar.gz"
|
||||
ARCHIVE_FILE="${TARGET_PATH}/tenant_backup_${timestamp}.tar.gz"
|
||||
echo "🗜️ Compressing backup files to '${ARCHIVE_FILE}'..."
|
||||
|
||||
TAR_EXIT_CODE=0
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
if [[ "$USE_PIGZ" == "true" ]]; then
|
||||
echo "[DRY RUN] Would execute tar (pigz): tar -I \"pigz -p $THREADS\" -cf \"$ARCHIVE_FILE\" -C \"$backup_target_dir\" ."
|
||||
else
|
||||
echo "[DRY RUN] Would execute tar: tar -czf \"$ARCHIVE_FILE\" -C \"$backup_target_dir\" ."
|
||||
fi
|
||||
else
|
||||
if [[ "$USE_PIGZ" == "true" ]]; then
|
||||
tar -I "pigz -p $THREADS" -cf "$ARCHIVE_FILE" -C "$backup_target_dir" .
|
||||
else
|
||||
tar -czf "$ARCHIVE_FILE" -C "$backup_target_dir" .
|
||||
fi
|
||||
TAR_EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
@@ -261,10 +207,10 @@ case "$ACTION" in
|
||||
echo "❌ Error: Failed to create archive from '${backup_target_dir}'."
|
||||
fi
|
||||
fi
|
||||
send_notification "✅ HANA tenant '${TENANT_NAME}' backup completed successfully."
|
||||
send_notification "✅ Tenant backup for user key '${USER_KEY}' completed successfully."
|
||||
else
|
||||
echo "❌ Error: Failed to initiate tenant backup (hdbsql exit code: ${EXIT_CODE})."
|
||||
send_notification "❌ HANA tenant '${TENANT_NAME}' backup FAILED."
|
||||
send_notification "❌ Tenant backup for user key '${USER_KEY}' FAILED."
|
||||
if [[ "$COMPRESS" == "true" && "$DRY_RUN" == "false" ]]; then rm -rf "$backup_target_dir"; fi
|
||||
fi
|
||||
;;
|
||||
@@ -301,7 +247,7 @@ case "$ACTION" in
|
||||
mkdir -p "$EXPORT_DIR"
|
||||
fi
|
||||
|
||||
QUERY="EXPORT \"${SCHEMA_NAME}\".\"*\" AS BINARY INTO '${EXPORT_DIR}' WITH REPLACE THREADS ${THREADS} NO DEPENDENCIES;"
|
||||
QUERY="EXPORT \"${SCHEMA_NAME}\".\"*\" AS BINARY INTO '${EXPORT_DIR}' WITH REPLACE THREADS ${THREADS};"
|
||||
|
||||
EXIT_CODE=0
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
@@ -319,17 +265,9 @@ case "$ACTION" in
|
||||
|
||||
TAR_EXIT_CODE=0
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
if [[ "$USE_PIGZ" == "true" ]]; then
|
||||
echo "[DRY RUN] Would execute tar (pigz): tar -I \"pigz -p $THREADS\" -cf \"$ARCHIVE_FILE\" -C \"$(dirname "$EXPORT_DIR")\" \"$(basename "$EXPORT_DIR")\""
|
||||
else
|
||||
echo "[DRY RUN] Would execute tar: tar -czf \"$ARCHIVE_FILE\" -C \"$(dirname "$EXPORT_DIR")\" \"$(basename "$EXPORT_DIR")\""
|
||||
fi
|
||||
else
|
||||
if [[ "$USE_PIGZ" == "true" ]]; then
|
||||
tar -I "pigz -p $THREADS" -cf "$ARCHIVE_FILE" -C "$(dirname "$EXPORT_DIR")" "$(basename "$EXPORT_DIR")"
|
||||
else
|
||||
tar -czf "$ARCHIVE_FILE" -C "$(dirname "$EXPORT_DIR")" "$(basename "$EXPORT_DIR")"
|
||||
fi
|
||||
TAR_EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
@@ -400,17 +338,9 @@ case "$ACTION" in
|
||||
|
||||
TAR_EXIT_CODE=0
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
if [[ "$USE_PIGZ" == "true" ]]; then
|
||||
echo "[DRY RUN] Would decompress archive (pigz): tar -I \"pigz -p $THREADS\" -xf \"$SOURCE_PATH\" -C \"$IMPORT_DIR\" --strip-components=1"
|
||||
else
|
||||
echo "[DRY RUN] Would decompress archive: tar -xzf \"$SOURCE_PATH\" -C \"$IMPORT_DIR\" --strip-components=1"
|
||||
fi
|
||||
else
|
||||
if [[ "$USE_PIGZ" == "true" ]]; then
|
||||
tar -I "pigz -p $THREADS" -xf "$SOURCE_PATH" -C "$IMPORT_DIR" --strip-components=1
|
||||
else
|
||||
tar -xzf "$SOURCE_PATH" -C "$IMPORT_DIR" --strip-components=1
|
||||
fi
|
||||
TAR_EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
@@ -426,7 +356,7 @@ case "$ACTION" in
|
||||
exit 1
|
||||
fi
|
||||
|
||||
import_options=""
|
||||
local import_options
|
||||
if [[ "$IMPORT_REPLACE" == "true" ]]; then
|
||||
import_options="REPLACE"
|
||||
echo " - Mode: REPLACE"
|
||||
|
||||
-215
@@ -1,215 +0,0 @@
|
||||
# HANA TUI - Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
A single-binary AOT-compiled C# TUI for managing SAP HANA schemas. Built on **.NET 10**
|
||||
with **Spectre.Console** for rich UI, running against the existing `hdbsql`/`hdbuserstore`
|
||||
tools already installed on the system.
|
||||
|
||||
---
|
||||
|
||||
## Project Setup
|
||||
|
||||
Update `hanatui.csproj`:
|
||||
- `<PublishAot>true</PublishAot>`
|
||||
- `<AllowUnsafeBlocks>true</AllowUnsafeBlocks>`
|
||||
- `<InvariantGlobalization>true</InvariantGlobalization>`
|
||||
- `<StripSymbols>true</StripSymbols>`
|
||||
- NuGet: `Spectre.Console` (core only, no `.Cli`)
|
||||
|
||||
Build command:
|
||||
```bash
|
||||
dotnet publish -r linux-x64 -c Release -o bin/publish
|
||||
```
|
||||
Output: `bin/publish/hanatui` (~15-20 MB single native binary, no runtime dependency)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
hanatui/
|
||||
├── hanatui.csproj
|
||||
├── PLAN.md
|
||||
├── Program.cs
|
||||
└── src/
|
||||
├── Hana/
|
||||
│ ├── HdbClientLocator.cs # Finds hdbclient path (/usr/sap/hdbclient etc.)
|
||||
│ ├── HdbCliRunner.cs # Spawns hdbsql/hdbuserstore, streams output
|
||||
│ ├── HdbUserstoreKey.cs # Model: key name, host, port, tenant, user
|
||||
│ ├── SchemaService.cs # Lists schemas, builds/runs SQL queries
|
||||
│ └── SqlQueryBuilder.cs # Builds EXPORT/IMPORT/DROP/BACKUP SQL strings
|
||||
├── System/
|
||||
│ ├── SystemStats.cs # Reads /proc/stat + /proc/meminfo, computes deltas
|
||||
│ └── CpuSample.cs # Struct for CPU snapshot (used for delta calc)
|
||||
└── Tui/
|
||||
├── KeySelectionScreen.cs # Startup: arrow-key key picker
|
||||
├── MainMenuScreen.cs # Main menu with current key shown in header
|
||||
├── OperationForms.cs # Per-op guided input forms (Export/Import/etc.)
|
||||
├── TaskRunnerScreen.cs # Live split-panel: stats left, log right
|
||||
└── Components/
|
||||
├── StatsPanel.cs # CPU bars per core + total, RAM bar, Swap bar
|
||||
└── LogPanel.cs # Scrolling timestamped log lines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen Flow
|
||||
|
||||
```
|
||||
[Launch]
|
||||
└─> KeySelectionScreen
|
||||
hdbuserstore list → parse keys → arrow-key picker (or manual entry)
|
||||
└─> MainMenuScreen (shows active key + host in header)
|
||||
└─> OperationForms (per operation)
|
||||
1. Fetch schemas (spinner while loading)
|
||||
2. Schema picker (arrow-key selectable list)
|
||||
3. Additional inputs (path, threads, flags)
|
||||
4. Confirmation summary before running
|
||||
└─> TaskRunnerScreen
|
||||
┌──────────────────┬───────────────────┐
|
||||
│ SYSTEM STATS │ OPERATION LOG │
|
||||
│ CPU bars │ streaming lines │
|
||||
│ Total [████░░] │ with timestamps │
|
||||
│ Core0 [███░░░] │ │
|
||||
│ Core1 [█████░] │ │
|
||||
│ ... │ │
|
||||
│ RAM bar │ │
|
||||
│ [██████░░] 58% │ │
|
||||
│ 12.4 / 21.3 GB │ │
|
||||
│ Swap bar │ │
|
||||
│ [██░░░░░░] 18% │ │
|
||||
│ Elapsed: 4m 32s │ │
|
||||
└──────────────────┴───────────────────┘
|
||||
[q] → "Press Q again to abort"
|
||||
[q again] → SIGTERM → wait 5s → SIGKILL
|
||||
|
||||
On complete/abort:
|
||||
- Stats stop refreshing, log stays visible
|
||||
- "Returning in 10s... [any key to stay]"
|
||||
- If key pressed → cancel countdown, stay on result screen
|
||||
- Enter/Esc → back to main menu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
| # | Operation | Key inputs | Notes |
|
||||
|---|--------------------|-------------------------------------------------|------------------------------|
|
||||
| 1 | Export Schema | Schema (picker), target path, threads, compress | Shells pigz if available |
|
||||
| 2 | Import Schema | Source schema name, source path, threads, replace | Decompresses .tar.gz if needed |
|
||||
| 3 | Import & Rename | Source name, new name, source path, threads, replace | Adds RENAME clause |
|
||||
| 4 | Copy Schema | Schema (picker), target name, temp path, threads, replace | Export then Import-Rename |
|
||||
| 5 | Drop Schema | Schema (picker), type YES to confirm | Destructive — extra confirm |
|
||||
| 6 | Rename DB | Schema (picker), new company name | Updates CINF + OADM tables |
|
||||
| 7 | Backup Tenant | Target path, threads, compress | BACKUP DATA USING FILE |
|
||||
|
||||
---
|
||||
|
||||
## System Stats Details
|
||||
|
||||
| Metric | Source | Method |
|
||||
|--------------|------------------|-----------------------------------------------|
|
||||
| CPU % total | `/proc/stat` | Delta between two reads 500ms apart |
|
||||
| CPU % core N | `/proc/stat` | Per-core lines (`cpu0`, `cpu1`, ...) |
|
||||
| RAM total | `/proc/meminfo` | `MemTotal` |
|
||||
| RAM used | `/proc/meminfo` | `MemTotal - MemAvailable` |
|
||||
| Swap total | `/proc/meminfo` | `SwapTotal` |
|
||||
| Swap used | `/proc/meminfo` | `SwapTotal - SwapFree` |
|
||||
|
||||
- Refresh interval: 800ms via `System.Threading.Timer`
|
||||
- Bar width adapts to terminal width
|
||||
- Bar format: `[████████░░] 82%`
|
||||
|
||||
---
|
||||
|
||||
## Abort Behavior
|
||||
|
||||
1. First `q` press during running task:
|
||||
- Shows warning in log panel: `[WARN] Press Q again to abort the operation`
|
||||
2. Second `q` press within ~3 seconds:
|
||||
- Sends SIGTERM to `hdbsql` child process
|
||||
- Waits up to 5 seconds for graceful exit
|
||||
- If still running after 5s: sends SIGKILL
|
||||
- Logs each step to the log panel
|
||||
|
||||
---
|
||||
|
||||
## Post-Operation Return Behavior
|
||||
|
||||
- After success or abort: stats panel freezes, log remains
|
||||
- Footer shows: `Returning to menu in 10s... [any key to stay]`
|
||||
- If any key pressed: countdown cancels, footer changes to `[Enter/Esc] Return to menu`
|
||||
- User reads the result at their own pace
|
||||
|
||||
---
|
||||
|
||||
## AOT Compatibility
|
||||
|
||||
| Concern | Mitigation |
|
||||
|-------------------------------|------------------------------------------------------|
|
||||
| No runtime reflection | All code uses concrete types, no Activator |
|
||||
| Spectre.Console prompts | SelectionPrompt<T>, TextPrompt<T> — AOT-safe |
|
||||
| Spectre.Console Live/Layout | AnsiConsole.Live() — AOT-compatible |
|
||||
| Process spawning | System.Diagnostics.Process — fully AOT-safe |
|
||||
| /proc file reading | File.ReadAllText — no issues |
|
||||
| Compression | Process (pigz/tar) — no native .NET compression |
|
||||
| No JsonSerializer | Not needed |
|
||||
| No dynamic/Activator | Enforced throughout |
|
||||
|
||||
---
|
||||
|
||||
## Key Discovery (hdbuserstore)
|
||||
|
||||
Command: `hdbuserstore list`
|
||||
|
||||
Sample output:
|
||||
```
|
||||
DATA FILE : /home/user/.hdb/hostname/SSFS_HDB.DAT
|
||||
KEY FILE : /home/user/.hdb/hostname/SSFS_HDB.KEY
|
||||
|
||||
KEY CRONKEY
|
||||
ENV : hostname:30015@NDB
|
||||
USER: SYSTEM
|
||||
|
||||
KEY DEVKEY
|
||||
ENV : devhost:30015@DEV
|
||||
USER: SYSTEM
|
||||
```
|
||||
|
||||
Parse: lines starting with `KEY ` → key name. Following `ENV :` and `USER:` lines → details.
|
||||
|
||||
---
|
||||
|
||||
## SQL Queries Used
|
||||
|
||||
```sql
|
||||
-- List schemas
|
||||
SELECT SCHEMA_NAME FROM SCHEMAS
|
||||
WHERE SCHEMA_OWNER = 'SYSTEM'
|
||||
AND SCHEMA_NAME NOT IN ('_SYS_SECURITY', 'IFSERV', 'B1if', 'SYSTEM', 'RSP');
|
||||
|
||||
-- Export
|
||||
EXPORT "SCHEMA"."*" AS BINARY INTO '/path' WITH REPLACE THREADS N NO DEPENDENCIES;
|
||||
|
||||
-- Import
|
||||
IMPORT "SCHEMA"."*" AS BINARY FROM '/path' WITH IGNORE EXISTING THREADS N;
|
||||
|
||||
-- Import-Rename
|
||||
IMPORT "SCHEMA"."*" AS BINARY FROM '/path'
|
||||
WITH IGNORE EXISTING RENAME SCHEMA "OLD" TO "NEW" THREADS N;
|
||||
|
||||
-- Drop
|
||||
DROP SCHEMA "SCHEMA" CASCADE;
|
||||
|
||||
-- Rename company
|
||||
UPDATE "SCHEMA".CINF SET "CompnyName" = 'NAME';
|
||||
UPDATE "SCHEMA".OADM SET "CompnyName" = 'NAME', "PrintHeadr" = 'NAME';
|
||||
|
||||
-- Backup tenant
|
||||
BACKUP DATA USING FILE ('/path/prefix');
|
||||
|
||||
-- Get tenant name
|
||||
SELECT DATABASE_NAME FROM SYS.M_DATABASES;
|
||||
```
|
||||
@@ -1,145 +0,0 @@
|
||||
using HanaTui.Hana;
|
||||
using HanaTui.Tui;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Entry point
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// Enable UTF-8 output for block characters in bars
|
||||
Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
|
||||
// Key selection loop — allows returning to key picker via "Change Key"
|
||||
string? keyName = null;
|
||||
HdbUserstoreKey? keyObj = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Step 1: Select or re-select a key
|
||||
keyName = KeySelectionScreen.Run();
|
||||
if (keyName is null)
|
||||
{
|
||||
// User chose Exit
|
||||
break;
|
||||
}
|
||||
|
||||
// Resolve key metadata if available
|
||||
keyObj = HdbCliRunner.ListKeys().Find(k => k.Name.Equals(keyName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Step 2: Main menu loop for this key
|
||||
var exitToKeySelection = false;
|
||||
while (!exitToKeySelection)
|
||||
{
|
||||
var operation = MainMenuScreen.Run(keyObj, keyName);
|
||||
|
||||
switch (operation)
|
||||
{
|
||||
case MainMenuScreen.Operation.Quit:
|
||||
Environment.Exit(0);
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.ChangeKey:
|
||||
exitToKeySelection = true;
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.Export:
|
||||
await HandleExportAsync(keyName);
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.Import:
|
||||
await HandleImportAsync(keyName, renameMode: false);
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.ImportRename:
|
||||
await HandleImportAsync(keyName, renameMode: true);
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.Copy:
|
||||
await HandleCopyAsync(keyName);
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.Drop:
|
||||
await HandleDropAsync(keyName);
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.RenameDb:
|
||||
await HandleRenameDbAsync(keyName);
|
||||
break;
|
||||
|
||||
case MainMenuScreen.Operation.Backup:
|
||||
await HandleBackupAsync(keyName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Operation handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static async Task HandleExportAsync(string key)
|
||||
{
|
||||
var p = OperationForms.ExportForm(key);
|
||||
if (p is null) return;
|
||||
|
||||
var svc = new SchemaService(key);
|
||||
await TaskRunnerScreen.RunAsync(
|
||||
$"Export Schema '{p.Schema}'",
|
||||
(log, ct) => svc.ExportAsync(p, log, ct));
|
||||
}
|
||||
|
||||
static async Task HandleImportAsync(string key, bool renameMode)
|
||||
{
|
||||
var p = OperationForms.ImportForm(key, renameMode);
|
||||
if (p is null) return;
|
||||
|
||||
var svc = new SchemaService(key);
|
||||
await TaskRunnerScreen.RunAsync(
|
||||
renameMode ? $"Import & Rename '{p.SourceSchema}'" : $"Import '{p.SourceSchema}'",
|
||||
(log, ct) => svc.ImportAsync(p, log, ct));
|
||||
}
|
||||
|
||||
static async Task HandleCopyAsync(string key)
|
||||
{
|
||||
var p = OperationForms.CopyForm(key);
|
||||
if (p is null) return;
|
||||
|
||||
var svc = new SchemaService(key);
|
||||
await TaskRunnerScreen.RunAsync(
|
||||
$"Copy '{p.SourceSchema}' -> '{p.TargetSchema}'",
|
||||
(log, ct) => svc.CopyAsync(p, log, ct));
|
||||
}
|
||||
|
||||
static async Task HandleDropAsync(string key)
|
||||
{
|
||||
var p = OperationForms.DropForm(key);
|
||||
if (p is null) return;
|
||||
|
||||
var svc = new SchemaService(key);
|
||||
await TaskRunnerScreen.RunAsync(
|
||||
$"Drop Schema '{p.Schema}'",
|
||||
(log, ct) => svc.DropAsync(p, log, ct));
|
||||
}
|
||||
|
||||
static async Task HandleRenameDbAsync(string key)
|
||||
{
|
||||
var p = OperationForms.RenameDbForm(key);
|
||||
if (p is null) return;
|
||||
|
||||
var svc = new SchemaService(key);
|
||||
await TaskRunnerScreen.RunAsync(
|
||||
$"Rename DB '{p.Schema}'",
|
||||
(log, ct) => svc.RenameDbAsync(p, log, ct));
|
||||
}
|
||||
|
||||
static async Task HandleBackupAsync(string key)
|
||||
{
|
||||
var p = OperationForms.BackupForm(key);
|
||||
if (p is null) return;
|
||||
|
||||
var svc = new SchemaService(key);
|
||||
await TaskRunnerScreen.RunAsync(
|
||||
"Backup Tenant",
|
||||
(log, ct) => svc.BackupAsync(p, log, ct));
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#! /bin/bash
|
||||
|
||||
dotnet publish -r linux-x64 -c Release -o ./publish
|
||||
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PublishAot>true</PublishAot>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<StripSymbols>true</StripSymbols>
|
||||
<RootNamespace>HanaTui</RootNamespace>
|
||||
<AssemblyName>hanatui</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Binary file not shown.
@@ -1,201 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace HanaTui.Hana;
|
||||
|
||||
/// <summary>
|
||||
/// Low-level wrapper for spawning hdbsql and hdbuserstore child processes.
|
||||
/// All output is streamed line-by-line via callbacks for live log display.
|
||||
/// </summary>
|
||||
public sealed class HdbCliRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists all hdbuserstore keys. Returns empty list on failure.
|
||||
/// </summary>
|
||||
public static List<HdbUserstoreKey> ListKeys()
|
||||
{
|
||||
var result = RunAndCapture(HdbClientLocator.HdbUserstore, ["list"]);
|
||||
if (!result.Success)
|
||||
return [];
|
||||
return HdbUserstoreKey.ParseFrom(result.Output);
|
||||
}
|
||||
|
||||
public static bool TestKey(string key)
|
||||
{
|
||||
var result = RunAndCapture(HdbClientLocator.HdbSql,
|
||||
["-U", key, "SELECT 'ok' FROM DUMMY"]);
|
||||
return result.Success && result.Output.Contains("ok");
|
||||
}
|
||||
|
||||
public static (bool Success, List<string> Schemas, string Error) ListSchemas(string key)
|
||||
{
|
||||
const string query =
|
||||
"SELECT SCHEMA_NAME FROM SCHEMAS " +
|
||||
"WHERE SCHEMA_OWNER = 'SYSTEM' " +
|
||||
"AND SCHEMA_NAME NOT IN ('_SYS_SECURITY', 'IFSERV', 'B1if', 'SYSTEM', 'RSP');";
|
||||
|
||||
var result = RunAndCapture(HdbClientLocator.HdbSql, ["-U", key, query]);
|
||||
if (!result.Success)
|
||||
return (false, [], result.Output);
|
||||
|
||||
var schemas = new List<string>();
|
||||
foreach (var line in result.Output.Split('\n'))
|
||||
{
|
||||
var clean = line.Trim().Trim('"');
|
||||
if (string.IsNullOrWhiteSpace(clean)) continue;
|
||||
if (clean.StartsWith("SCHEMA_NAME", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (clean.Contains("rows selected", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (clean.Contains("row selected", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (clean.Contains("overall time", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (clean.StartsWith('-')) continue;
|
||||
schemas.Add(clean);
|
||||
}
|
||||
|
||||
return (true, schemas, "");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs hdbsql with the given key and SQL query, streaming output lines
|
||||
/// to the provided callback. Returns the process exit code.
|
||||
/// The CancellationToken is checked — if cancelled, the process is terminated.
|
||||
/// </summary>
|
||||
public static async Task<int> RunSqlStreamingAsync(
|
||||
string key,
|
||||
string sql,
|
||||
Action<string> onOutputLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Use ArgumentList (not Arguments) so args are passed directly to the process
|
||||
// without shell quoting — double quotes inside SQL won't break the argument boundary.
|
||||
return await RunStreamingAsync(
|
||||
HdbClientLocator.HdbSql,
|
||||
["-U", key, sql],
|
||||
onOutputLine,
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs an arbitrary shell command, streaming output lines to the callback.
|
||||
/// Used for compression steps (tar/pigz).
|
||||
/// </summary>
|
||||
public static async Task<int> RunCommandStreamingAsync(
|
||||
string executable,
|
||||
string arguments,
|
||||
Action<string> onOutputLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Shell commands (tar, pigz) need shell parsing for things like -I "pigz -p N",
|
||||
// so we use a bash -c wrapper to ensure correct tokenisation.
|
||||
return await RunStreamingAsync(
|
||||
"/bin/bash",
|
||||
["-c", arguments],
|
||||
onOutputLine,
|
||||
ct);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static (bool Success, string Output) RunAndCapture(string exe, string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo(exe)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
using var proc = Process.Start(psi);
|
||||
if (proc is null)
|
||||
return (false, "Failed to start process");
|
||||
|
||||
var stdout = proc.StandardOutput.ReadToEnd();
|
||||
var stderr = proc.StandardError.ReadToEnd();
|
||||
proc.WaitForExit();
|
||||
|
||||
return proc.ExitCode == 0
|
||||
? (true, stdout)
|
||||
: (false, string.IsNullOrWhiteSpace(stderr) ? stdout : stderr);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> RunStreamingAsync(
|
||||
string exe,
|
||||
string[] args,
|
||||
Action<string> onOutputLine,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo(exe)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
Process? proc;
|
||||
try
|
||||
{
|
||||
proc = Process.Start(psi);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
onOutputLine($"[ERROR] Failed to start process: {ex.Message}");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (proc is null)
|
||||
{
|
||||
onOutputLine("[ERROR] Process could not be started.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Register cancellation: SIGTERM first, then SIGKILL
|
||||
using var ctr = ct.Register(() =>
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
onOutputLine("[WARN] Sending SIGTERM to process...");
|
||||
proc.Kill(entireProcessTree: false); // sends SIGTERM on Linux
|
||||
await Task.Delay(5000, CancellationToken.None);
|
||||
if (!proc.HasExited)
|
||||
{
|
||||
onOutputLine("[WARN] Process did not exit, sending SIGKILL...");
|
||||
proc.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch { /* process may already be gone */ }
|
||||
}, CancellationToken.None);
|
||||
});
|
||||
|
||||
// Stream stdout
|
||||
var stdoutTask = Task.Run(async () =>
|
||||
{
|
||||
string? line;
|
||||
while ((line = await proc.StandardOutput.ReadLineAsync(CancellationToken.None)) is not null)
|
||||
onOutputLine(line);
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Stream stderr as well (SAP HANA outputs progress to stderr)
|
||||
var stderrTask = Task.Run(async () =>
|
||||
{
|
||||
string? line;
|
||||
while ((line = await proc.StandardError.ReadLineAsync(CancellationToken.None)) is not null)
|
||||
onOutputLine(line);
|
||||
}, CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(stdoutTask, stderrTask);
|
||||
await proc.WaitForExitAsync(CancellationToken.None);
|
||||
|
||||
return proc.ExitCode;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
namespace HanaTui.Hana;
|
||||
|
||||
/// <summary>
|
||||
/// Locates the SAP HANA client binaries (hdbsql, hdbuserstore) on the system.
|
||||
/// Mirrors the path detection logic from keymanager.sh.
|
||||
/// </summary>
|
||||
public static class HdbClientLocator
|
||||
{
|
||||
private static readonly string[] KnownPaths =
|
||||
[
|
||||
"/usr/sap/hdbclient",
|
||||
"/usr/sap/NDB/HDB00/exe",
|
||||
];
|
||||
|
||||
private static string? _resolvedPath;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full path to hdbsql, or just "hdbsql" if it's on PATH.
|
||||
/// Throws if the client cannot be found anywhere.
|
||||
/// </summary>
|
||||
public static string HdbSql => Resolve("hdbsql");
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full path to hdbuserstore, or just "hdbuserstore" if it's on PATH.
|
||||
/// </summary>
|
||||
public static string HdbUserstore => Resolve("hdbuserstore");
|
||||
|
||||
/// <summary>
|
||||
/// Returns the resolved client directory, or null if only found via PATH.
|
||||
/// </summary>
|
||||
public static string? ClientDirectory => GetClientDirectory();
|
||||
|
||||
private static string Resolve(string binary)
|
||||
{
|
||||
var dir = GetClientDirectory();
|
||||
if (dir is not null)
|
||||
return Path.Combine(dir, binary);
|
||||
|
||||
// Fall back to PATH — let the OS resolve it
|
||||
return binary;
|
||||
}
|
||||
|
||||
private static string? GetClientDirectory()
|
||||
{
|
||||
if (_resolvedPath is not null)
|
||||
return _resolvedPath;
|
||||
|
||||
foreach (var path in KnownPaths)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
_resolvedPath = path;
|
||||
return _resolvedPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Not found in known paths — return null (caller will use PATH)
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether hdbsql is available either in a known path or on PATH.
|
||||
/// </summary>
|
||||
public static bool IsAvailable()
|
||||
{
|
||||
if (GetClientDirectory() is not null)
|
||||
return true;
|
||||
|
||||
// Check if it's available on PATH by attempting to locate it
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathEnv.Split(':'))
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "hdbsql")))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
namespace HanaTui.Hana;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single parsed entry from hdbuserstore list output.
|
||||
/// </summary>
|
||||
public sealed class HdbUserstoreKey
|
||||
{
|
||||
public string Name { get; init; } = "";
|
||||
public string Environment { get; init; } = ""; // raw ENV value e.g. host:30015@NDB
|
||||
public string User { get; init; } = "";
|
||||
|
||||
// Parsed from Environment
|
||||
public string Host { get; init; } = "";
|
||||
public string Port { get; init; } = "";
|
||||
public string Tenant { get; init; } = "";
|
||||
|
||||
public override string ToString() => Name;
|
||||
|
||||
/// <summary>
|
||||
/// Parse a list of keys from the raw stdout of "hdbuserstore list".
|
||||
/// </summary>
|
||||
public static List<HdbUserstoreKey> ParseFrom(string output)
|
||||
{
|
||||
var keys = new List<HdbUserstoreKey>();
|
||||
var lines = output.Split('\n', StringSplitOptions.None);
|
||||
|
||||
string currentName = "";
|
||||
string currentEnv = "";
|
||||
string currentUser = "";
|
||||
|
||||
foreach (var rawLine in lines)
|
||||
{
|
||||
var line = rawLine.TrimEnd();
|
||||
|
||||
if (line.StartsWith("KEY ", StringComparison.Ordinal))
|
||||
{
|
||||
// Flush previous key
|
||||
if (!string.IsNullOrEmpty(currentName))
|
||||
keys.Add(Build(currentName, currentEnv, currentUser));
|
||||
|
||||
currentName = line[4..].Trim();
|
||||
currentEnv = "";
|
||||
currentUser = "";
|
||||
}
|
||||
else if (line.TrimStart().StartsWith("ENV :", StringComparison.Ordinal))
|
||||
{
|
||||
currentEnv = line.TrimStart()[5..].Trim();
|
||||
}
|
||||
else if (line.TrimStart().StartsWith("USER:", StringComparison.Ordinal))
|
||||
{
|
||||
currentUser = line.TrimStart()[5..].Trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Flush last key
|
||||
if (!string.IsNullOrEmpty(currentName))
|
||||
keys.Add(Build(currentName, currentEnv, currentUser));
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static HdbUserstoreKey Build(string name, string env, string user)
|
||||
{
|
||||
// ENV format: host:port@TENANT or host:port
|
||||
string host = "", port = "", tenant = "";
|
||||
|
||||
var atIdx = env.IndexOf('@');
|
||||
var hostPort = atIdx >= 0 ? env[..atIdx] : env;
|
||||
if (atIdx >= 0)
|
||||
tenant = env[(atIdx + 1)..];
|
||||
|
||||
var colonIdx = hostPort.IndexOf(':');
|
||||
if (colonIdx >= 0)
|
||||
{
|
||||
host = hostPort[..colonIdx];
|
||||
port = hostPort[(colonIdx + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
host = hostPort;
|
||||
}
|
||||
|
||||
return new HdbUserstoreKey
|
||||
{
|
||||
Name = name,
|
||||
Environment = env,
|
||||
User = user,
|
||||
Host = host,
|
||||
Port = port,
|
||||
Tenant = tenant,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
using SysDiag = System.Diagnostics;
|
||||
|
||||
namespace HanaTui.Hana;
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for an Export operation.
|
||||
/// </summary>
|
||||
public sealed class ExportParams
|
||||
{
|
||||
public string Schema { get; init; } = "";
|
||||
public string TargetPath { get; init; } = "";
|
||||
public int Threads { get; init; } = 1;
|
||||
public bool Compress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for an Import operation.
|
||||
/// </summary>
|
||||
public sealed class ImportParams
|
||||
{
|
||||
public string SourceSchema { get; init; } = "";
|
||||
public string? NewSchemaName { get; init; } // non-null = rename mode
|
||||
public string SourcePath { get; init; } = "";
|
||||
public int Threads { get; init; } = 1;
|
||||
public bool Replace { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for a Copy operation.
|
||||
/// </summary>
|
||||
public sealed class CopyParams
|
||||
{
|
||||
public string SourceSchema { get; init; } = "";
|
||||
public string TargetSchema { get; init; } = "";
|
||||
public string TempPath { get; init; } = "";
|
||||
public int Threads { get; init; } = 1;
|
||||
public bool Replace { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for Drop Schema.
|
||||
/// </summary>
|
||||
public sealed class DropParams
|
||||
{
|
||||
public string Schema { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for Rename Database (company name).
|
||||
/// </summary>
|
||||
public sealed class RenameDbParams
|
||||
{
|
||||
public string Schema { get; init; } = "";
|
||||
public string NewCompanyName { get; init; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for Backup Tenant.
|
||||
/// </summary>
|
||||
public sealed class BackupParams
|
||||
{
|
||||
public string TargetPath { get; init; } = "";
|
||||
public int Threads { get; init; } = 1;
|
||||
public bool Compress { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// High-level service that runs HANA operations using HdbCliRunner.
|
||||
/// Each method accepts an operation params object and a log callback.
|
||||
/// The CancellationToken is forwarded so the TaskRunner can abort.
|
||||
/// </summary>
|
||||
public sealed class SchemaService(string userKey)
|
||||
{
|
||||
private readonly string _key = userKey;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Export
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<bool> ExportAsync(
|
||||
ExportParams p,
|
||||
Action<string> log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
log($"[INFO] Starting export of schema '{p.Schema}'...");
|
||||
|
||||
string exportDir = p.TargetPath;
|
||||
string archiveFile = "";
|
||||
|
||||
if (p.Compress)
|
||||
{
|
||||
string targetDir;
|
||||
if (p.TargetPath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
archiveFile = p.TargetPath;
|
||||
targetDir = Path.GetDirectoryName(p.TargetPath) ?? ".";
|
||||
}
|
||||
else
|
||||
{
|
||||
targetDir = p.TargetPath;
|
||||
archiveFile = Path.Combine(targetDir,
|
||||
$"{p.Schema}_export_{DateTime.Now:yyyyMMdd_HHmmss}.tar.gz");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(targetDir);
|
||||
exportDir = CreateTempDir(targetDir, $"export_{p.Schema}");
|
||||
log($"[INFO] Using temp export directory: {exportDir}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(exportDir);
|
||||
}
|
||||
|
||||
var sql = SqlQueryBuilder.ExportSchema(p.Schema, exportDir, p.Threads);
|
||||
log($"[SQL ] {sql}");
|
||||
|
||||
var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
log("[ERROR] Export failed.");
|
||||
if (p.Compress) SafeDelete(exportDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
log("[DONE] Export completed successfully.");
|
||||
|
||||
if (p.Compress && !ct.IsCancellationRequested)
|
||||
{
|
||||
var ok = await CompressAsync(exportDir, archiveFile, p.Threads, log, ct);
|
||||
if (ok) SafeDelete(exportDir);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Import
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<bool> ImportAsync(
|
||||
ImportParams p,
|
||||
Action<string> log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var mode = p.NewSchemaName is not null ? "Import & Rename" : "Import";
|
||||
log($"[INFO] Starting {mode} of schema '{p.SourceSchema}'...");
|
||||
|
||||
string importDir = p.SourcePath;
|
||||
bool cleanupTemp = false;
|
||||
|
||||
if (File.Exists(p.SourcePath) &&
|
||||
p.SourcePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
importDir = CreateTempDir("/tmp", $"import_{p.SourceSchema}");
|
||||
cleanupTemp = true;
|
||||
log($"[INFO] Decompressing archive to {importDir}...");
|
||||
|
||||
var ok = await DecompressAsync(p.SourcePath, importDir, p.Threads, log, ct);
|
||||
if (!ok)
|
||||
{
|
||||
SafeDelete(importDir);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!Directory.Exists(importDir))
|
||||
{
|
||||
log("[ERROR] Source path is neither a directory nor a valid .tar.gz archive.");
|
||||
return false;
|
||||
}
|
||||
|
||||
string sql = p.NewSchemaName is not null
|
||||
? SqlQueryBuilder.ImportSchemaWithRename(
|
||||
p.SourceSchema, p.NewSchemaName, importDir, p.Threads, p.Replace)
|
||||
: SqlQueryBuilder.ImportSchema(p.SourceSchema, importDir, p.Threads, p.Replace);
|
||||
|
||||
log($"[SQL ] {sql}");
|
||||
|
||||
var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct);
|
||||
|
||||
if (cleanupTemp)
|
||||
{
|
||||
log("[INFO] Cleaning up temporary files...");
|
||||
SafeDelete(importDir);
|
||||
}
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
log("[ERROR] Import failed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
log("[DONE] Import completed successfully.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Copy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<bool> CopyAsync(
|
||||
CopyParams p,
|
||||
Action<string> log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
log($"[INFO] Starting copy: '{p.SourceSchema}' -> '{p.TargetSchema}'");
|
||||
|
||||
// Step 1: Export
|
||||
log($"[INFO] Step 1/2: Exporting '{p.SourceSchema}'...");
|
||||
Directory.CreateDirectory(p.TempPath);
|
||||
var tempExportDir = CreateTempDir(p.TempPath, $"copy_export_{p.SourceSchema}");
|
||||
log($"[INFO] Temp export dir: {tempExportDir}");
|
||||
|
||||
var exportSql = SqlQueryBuilder.ExportSchema(p.SourceSchema, tempExportDir, p.Threads);
|
||||
log($"[SQL ] {exportSql}");
|
||||
|
||||
var exportCode = await HdbCliRunner.RunSqlStreamingAsync(_key, exportSql, log, ct);
|
||||
if (exportCode != 0)
|
||||
{
|
||||
log("[ERROR] Export phase failed. Aborting copy.");
|
||||
SafeDelete(tempExportDir);
|
||||
return false;
|
||||
}
|
||||
log("[DONE] Export phase completed.");
|
||||
|
||||
if (ct.IsCancellationRequested) { SafeDelete(tempExportDir); return false; }
|
||||
|
||||
// Step 2: Import with rename
|
||||
log($"[INFO] Step 2/2: Importing as '{p.TargetSchema}'...");
|
||||
var importSql = SqlQueryBuilder.ImportSchemaWithRename(
|
||||
p.SourceSchema, p.TargetSchema, tempExportDir, p.Threads, p.Replace);
|
||||
log($"[SQL ] {importSql}");
|
||||
|
||||
var importCode = await HdbCliRunner.RunSqlStreamingAsync(_key, importSql, log, ct);
|
||||
|
||||
log("[INFO] Cleaning up temporary files...");
|
||||
SafeDelete(tempExportDir);
|
||||
|
||||
if (importCode != 0)
|
||||
{
|
||||
log("[ERROR] Import phase failed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
log($"[DONE] Successfully copied schema '{p.SourceSchema}' to '{p.TargetSchema}'.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Drop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<bool> DropAsync(
|
||||
DropParams p,
|
||||
Action<string> log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
log($"[INFO] Dropping schema '{p.Schema}'...");
|
||||
var sql = SqlQueryBuilder.DropSchema(p.Schema);
|
||||
log($"[SQL ] {sql}");
|
||||
|
||||
var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
log("[ERROR] Failed to drop schema.");
|
||||
return false;
|
||||
}
|
||||
|
||||
log("[DONE] Schema successfully dropped.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rename DB (company name)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<bool> RenameDbAsync(
|
||||
RenameDbParams p,
|
||||
Action<string> log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
log($"[INFO] Renaming company in schema '{p.Schema}' to '{p.NewCompanyName}'...");
|
||||
|
||||
var q1 = SqlQueryBuilder.RenameCompanyInCinf(p.Schema, p.NewCompanyName);
|
||||
log($"[SQL ] {q1}");
|
||||
var r1 = await HdbCliRunner.RunSqlStreamingAsync(_key, q1, log, ct);
|
||||
if (r1 != 0) { log("[WARN] CINF update may have failed (table may not exist)."); }
|
||||
|
||||
if (ct.IsCancellationRequested) return false;
|
||||
|
||||
var q2 = SqlQueryBuilder.RenameCompanyInOadm(p.Schema, p.NewCompanyName);
|
||||
log($"[SQL ] {q2}");
|
||||
var r2 = await HdbCliRunner.RunSqlStreamingAsync(_key, q2, log, ct);
|
||||
if (r2 != 0) { log("[WARN] OADM update may have failed (table may not exist)."); }
|
||||
|
||||
log("[DONE] Database rename completed.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Backup Tenant
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public async Task<bool> BackupAsync(
|
||||
BackupParams p,
|
||||
Action<string> log,
|
||||
CancellationToken ct)
|
||||
{
|
||||
log("[INFO] Fetching tenant name...");
|
||||
|
||||
var tenantResult = await GetTenantNameAsync();
|
||||
if (string.IsNullOrEmpty(tenantResult))
|
||||
{
|
||||
log("[ERROR] Could not retrieve HANA tenant name.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
string backupDir = p.TargetPath;
|
||||
string archiveFile = "";
|
||||
|
||||
if (p.Compress)
|
||||
{
|
||||
backupDir = CreateTempDir(p.TargetPath, $"{tenantResult}_backup_{timestamp}");
|
||||
archiveFile = Path.Combine(p.TargetPath, $"{tenantResult}_backup_{timestamp}.tar.gz");
|
||||
log($"[INFO] Using temp backup directory: {backupDir}");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(backupDir);
|
||||
var backupPrefix = Path.Combine(backupDir, $"backup_{tenantResult}_{timestamp}");
|
||||
|
||||
log($"[INFO] Starting backup of tenant '{tenantResult}'...");
|
||||
var sql = SqlQueryBuilder.BackupTenant(backupPrefix);
|
||||
log($"[SQL ] {sql}");
|
||||
|
||||
var exitCode = await HdbCliRunner.RunSqlStreamingAsync(_key, sql, log, ct);
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
log("[ERROR] Backup failed.");
|
||||
if (p.Compress) SafeDelete(backupDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
log("[DONE] Backup completed successfully.");
|
||||
|
||||
if (p.Compress && !ct.IsCancellationRequested)
|
||||
{
|
||||
var ok = await CompressDir(backupDir, archiveFile, p.Threads, log, ct);
|
||||
if (ok) SafeDelete(backupDir);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string> GetTenantNameAsync()
|
||||
{
|
||||
var sql = SqlQueryBuilder.GetTenantName();
|
||||
var (success, output) = RunCapture(HdbClientLocator.HdbSql, ["-U", _key, sql]);
|
||||
if (!success) return "";
|
||||
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var clean = line.Trim().Trim('"').Trim();
|
||||
if (string.IsNullOrWhiteSpace(clean)) continue;
|
||||
if (clean.StartsWith("DATABASE_NAME", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (clean.StartsWith('-')) continue;
|
||||
if (clean.Contains("row", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
return clean;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static (bool, string) RunCapture(string exe, string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new SysDiag.ProcessStartInfo(exe)
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
using var proc = SysDiag.Process.Start(psi);
|
||||
if (proc is null) return (false, "");
|
||||
var stdout = proc.StandardOutput.ReadToEnd();
|
||||
var stderr = proc.StandardError.ReadToEnd();
|
||||
proc.WaitForExit();
|
||||
return (proc.ExitCode == 0, proc.ExitCode == 0 ? stdout : stderr);
|
||||
}
|
||||
catch (Exception ex) { return (false, ex.Message); }
|
||||
}
|
||||
|
||||
private static async Task<bool> CompressAsync(
|
||||
string sourceDir, string archiveFile, int threads, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
log($"[INFO] Compressing to {archiveFile}...");
|
||||
return await CompressDir(sourceDir, archiveFile, threads, log, ct);
|
||||
}
|
||||
|
||||
private static async Task<bool> CompressDir(
|
||||
string sourceDir, string archiveFile, int threads, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(archiveFile) ?? ".");
|
||||
|
||||
bool hasPigz = IsOnPath("pigz");
|
||||
string exe, args;
|
||||
|
||||
if (hasPigz)
|
||||
{
|
||||
exe = "tar";
|
||||
args = $"-I \"pigz -p {threads}\" -cf \"{archiveFile}\" -C \"{Path.GetDirectoryName(sourceDir)}\" \"{Path.GetFileName(sourceDir)}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
exe = "tar";
|
||||
args = $"-czf \"{archiveFile}\" -C \"{Path.GetDirectoryName(sourceDir)}\" \"{Path.GetFileName(sourceDir)}\"";
|
||||
}
|
||||
|
||||
log($"[INFO] Using: {exe} {args}");
|
||||
var code = await HdbCliRunner.RunCommandStreamingAsync(exe, args, log, ct);
|
||||
|
||||
if (code == 0)
|
||||
{
|
||||
log("[DONE] Compression successful.");
|
||||
return true;
|
||||
}
|
||||
|
||||
log("[ERROR] Compression failed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task<bool> DecompressAsync(
|
||||
string archiveFile, string targetDir, int threads, Action<string> log, CancellationToken ct)
|
||||
{
|
||||
bool hasPigz = IsOnPath("pigz");
|
||||
string exe = "tar";
|
||||
string args;
|
||||
|
||||
if (hasPigz)
|
||||
args = $"-I \"pigz -p {threads}\" -xf \"{archiveFile}\" -C \"{targetDir}\" --strip-components=1";
|
||||
else
|
||||
args = $"-xzf \"{archiveFile}\" -C \"{targetDir}\" --strip-components=1";
|
||||
|
||||
var code = await HdbCliRunner.RunCommandStreamingAsync(exe, args, log, ct);
|
||||
|
||||
if (code == 0) { log("[DONE] Decompression successful."); return true; }
|
||||
log("[ERROR] Decompression failed.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string CreateTempDir(string parent, string prefix)
|
||||
{
|
||||
// Generate a unique temp dir similar to mktemp -d
|
||||
string path;
|
||||
do
|
||||
{
|
||||
path = Path.Combine(parent, $"{prefix}_{Path.GetRandomFileName()[..6]}");
|
||||
} while (Directory.Exists(path));
|
||||
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void SafeDelete(string path)
|
||||
{
|
||||
try { if (Directory.Exists(path)) Directory.Delete(path, true); }
|
||||
catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private static bool IsOnPath(string binary)
|
||||
{
|
||||
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
|
||||
foreach (var dir in pathEnv.Split(':'))
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, binary)))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
namespace HanaTui.Hana;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the SQL / shell command strings for each operation.
|
||||
/// All SQL is built here; nothing else constructs query strings.
|
||||
/// </summary>
|
||||
public static class SqlQueryBuilder
|
||||
{
|
||||
public static string ExportSchema(string schema, string exportDir, int threads)
|
||||
=> $"EXPORT \"{schema}\".\"*\" AS BINARY INTO '{exportDir}' WITH REPLACE THREADS {threads} NO DEPENDENCIES;";
|
||||
|
||||
public static string ImportSchema(string schema, string importDir, int threads, bool replace)
|
||||
{
|
||||
var opts = replace ? "REPLACE" : "IGNORE EXISTING";
|
||||
return $"IMPORT \"{schema}\".\"*\" AS BINARY FROM '{importDir}' WITH {opts} THREADS {threads};";
|
||||
}
|
||||
|
||||
public static string ImportSchemaWithRename(
|
||||
string sourceSchema, string targetSchema, string importDir, int threads, bool replace)
|
||||
{
|
||||
var opts = replace ? "REPLACE" : "IGNORE EXISTING";
|
||||
return $"IMPORT \"{sourceSchema}\".\"*\" AS BINARY FROM '{importDir}' " +
|
||||
$"WITH {opts} RENAME SCHEMA \"{sourceSchema}\" TO \"{targetSchema}\" THREADS {threads};";
|
||||
}
|
||||
|
||||
public static string DropSchema(string schema)
|
||||
=> $"DROP SCHEMA \"{schema}\" CASCADE";
|
||||
|
||||
public static string RenameCompanyInCinf(string schema, string newName)
|
||||
=> $"UPDATE \"{schema}\".CINF SET \"CompnyName\" = '{Escape(newName)}';";
|
||||
|
||||
public static string RenameCompanyInOadm(string schema, string newName)
|
||||
=> $"UPDATE \"{schema}\".OADM SET \"CompnyName\" = '{Escape(newName)}', \"PrintHeadr\" = '{Escape(newName)}';";
|
||||
|
||||
public static string GetTenantName()
|
||||
=> "SELECT DATABASE_NAME FROM SYS.M_DATABASES;";
|
||||
|
||||
public static string BackupTenant(string backupPrefix)
|
||||
=> $"BACKUP DATA USING FILE ('{backupPrefix}')";
|
||||
|
||||
// SQL single-quote escape
|
||||
private static string Escape(string value) => value.Replace("'", "''");
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
namespace HanaTui.System;
|
||||
|
||||
/// <summary>
|
||||
/// A single snapshot of /proc/stat CPU counters for one CPU line.
|
||||
/// Used to compute usage % by diffing two samples.
|
||||
/// </summary>
|
||||
public readonly struct CpuSample
|
||||
{
|
||||
public readonly string Label; // "cpu", "cpu0", "cpu1", ...
|
||||
public readonly long User;
|
||||
public readonly long Nice;
|
||||
public readonly long System;
|
||||
public readonly long Idle;
|
||||
public readonly long IoWait;
|
||||
public readonly long Irq;
|
||||
public readonly long SoftIrq;
|
||||
public readonly long Steal;
|
||||
|
||||
public long Total => User + Nice + System + Idle + IoWait + Irq + SoftIrq + Steal;
|
||||
public long Active => Total - Idle - IoWait;
|
||||
|
||||
public CpuSample(string label, long user, long nice, long system,
|
||||
long idle, long ioWait, long irq, long softIrq, long steal)
|
||||
{
|
||||
Label = label;
|
||||
User = user;
|
||||
Nice = nice;
|
||||
System = system;
|
||||
Idle = idle;
|
||||
IoWait = ioWait;
|
||||
Irq = irq;
|
||||
SoftIrq = softIrq;
|
||||
Steal = steal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute usage % between two consecutive samples (0.0 - 100.0).
|
||||
/// </summary>
|
||||
public static double ComputePercent(CpuSample prev, CpuSample curr)
|
||||
{
|
||||
var totalDelta = curr.Total - prev.Total;
|
||||
var activeDelta = curr.Active - prev.Active;
|
||||
if (totalDelta <= 0) return 0.0;
|
||||
return Math.Clamp(activeDelta * 100.0 / totalDelta, 0.0, 100.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse all CPU lines from /proc/stat content.
|
||||
/// Returns only lines that start with "cpu".
|
||||
/// </summary>
|
||||
public static List<CpuSample> ParseProcStat(string content)
|
||||
{
|
||||
var result = new List<CpuSample>();
|
||||
foreach (var line in content.Split('\n'))
|
||||
{
|
||||
if (!line.StartsWith("cpu", StringComparison.Ordinal)) continue;
|
||||
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 8) continue;
|
||||
|
||||
result.Add(new CpuSample(
|
||||
label: parts[0],
|
||||
user: long.TryParse(parts[1], out var u) ? u : 0,
|
||||
nice: long.TryParse(parts[2], out var n) ? n : 0,
|
||||
system: long.TryParse(parts[3], out var s) ? s : 0,
|
||||
idle: long.TryParse(parts[4], out var id) ? id : 0,
|
||||
ioWait: long.TryParse(parts[5], out var io) ? io : 0,
|
||||
irq: long.TryParse(parts[6], out var irq) ? irq : 0,
|
||||
softIrq: long.TryParse(parts[7], out var si) ? si : 0,
|
||||
steal: parts.Length > 8 && long.TryParse(parts[8], out var st) ? st : 0
|
||||
));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
using HanaTui.System;
|
||||
|
||||
namespace HanaTui.System;
|
||||
|
||||
/// <summary>
|
||||
/// A snapshot of all system metrics at a point in time.
|
||||
/// </summary>
|
||||
public sealed class SystemSnapshot
|
||||
{
|
||||
// CPU: index 0 = total ("cpu"), 1+ = per-core ("cpu0", "cpu1", ...)
|
||||
public double[] CpuPercents { get; init; } = [];
|
||||
public string[] CpuLabels { get; init; } = [];
|
||||
|
||||
public long MemTotalKb { get; init; }
|
||||
public long MemAvailableKb { get; init; }
|
||||
public long MemUsedKb => MemTotalKb - MemAvailableKb;
|
||||
|
||||
public long SwapTotalKb { get; init; }
|
||||
public long SwapFreeKb { get; init; }
|
||||
public long SwapUsedKb => SwapTotalKb - SwapFreeKb;
|
||||
|
||||
public double MemUsedPercent =>
|
||||
MemTotalKb > 0 ? MemUsedKb * 100.0 / MemTotalKb : 0;
|
||||
|
||||
public double SwapUsedPercent =>
|
||||
SwapTotalKb > 0 ? SwapUsedKb * 100.0 / SwapTotalKb : 0;
|
||||
|
||||
public static string FormatGb(long kb) =>
|
||||
$"{kb / 1024.0 / 1024.0:F1} GB";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls /proc/stat and /proc/meminfo on a background timer.
|
||||
/// Thread-safe snapshot available via CurrentSnapshot.
|
||||
/// </summary>
|
||||
public sealed class SystemStats : IDisposable
|
||||
{
|
||||
private const int RefreshMs = 800;
|
||||
|
||||
private List<CpuSample> _prevSamples = [];
|
||||
private SystemSnapshot _snapshot = new();
|
||||
private readonly object _lock = new();
|
||||
private readonly Timer _timer;
|
||||
private bool _disposed;
|
||||
|
||||
public SystemStats()
|
||||
{
|
||||
// Take initial sample immediately (no % yet, need two samples)
|
||||
_prevSamples = ReadCpuSamples();
|
||||
_timer = new Timer(Tick, null, RefreshMs, RefreshMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the most recent system snapshot. Safe to call from any thread.
|
||||
/// </summary>
|
||||
public SystemSnapshot CurrentSnapshot
|
||||
{
|
||||
get { lock (_lock) { return _snapshot; } }
|
||||
}
|
||||
|
||||
private void Tick(object? _)
|
||||
{
|
||||
try
|
||||
{
|
||||
var currSamples = ReadCpuSamples();
|
||||
var mem = ReadMemInfo();
|
||||
|
||||
// Compute CPU % by diffing prev vs curr
|
||||
var percents = new double[currSamples.Count];
|
||||
var labels = new string[currSamples.Count];
|
||||
|
||||
for (int i = 0; i < currSamples.Count; i++)
|
||||
{
|
||||
labels[i] = currSamples[i].Label;
|
||||
// Find matching prev sample by label
|
||||
var prev = _prevSamples.Find(s => s.Label == currSamples[i].Label);
|
||||
percents[i] = CpuSample.ComputePercent(prev, currSamples[i]);
|
||||
}
|
||||
|
||||
var snap = new SystemSnapshot
|
||||
{
|
||||
CpuPercents = percents,
|
||||
CpuLabels = labels,
|
||||
MemTotalKb = mem.MemTotal,
|
||||
MemAvailableKb = mem.MemAvailable,
|
||||
SwapTotalKb = mem.SwapTotal,
|
||||
SwapFreeKb = mem.SwapFree,
|
||||
};
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_prevSamples = currSamples;
|
||||
_snapshot = snap;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently swallow errors in background stats thread
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CpuSample> ReadCpuSamples()
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = File.ReadAllText("/proc/stat");
|
||||
return CpuSample.ParseProcStat(content);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static (long MemTotal, long MemAvailable, long SwapTotal, long SwapFree) ReadMemInfo()
|
||||
{
|
||||
long memTotal = 0, memAvailable = 0, swapTotal = 0, swapFree = 0;
|
||||
try
|
||||
{
|
||||
foreach (var line in File.ReadLines("/proc/meminfo"))
|
||||
{
|
||||
if (line.StartsWith("MemTotal:", StringComparison.Ordinal))
|
||||
memTotal = ParseKb(line);
|
||||
else if (line.StartsWith("MemAvailable:", StringComparison.Ordinal))
|
||||
memAvailable = ParseKb(line);
|
||||
else if (line.StartsWith("SwapTotal:", StringComparison.Ordinal))
|
||||
swapTotal = ParseKb(line);
|
||||
else if (line.StartsWith("SwapFree:", StringComparison.Ordinal))
|
||||
swapFree = ParseKb(line);
|
||||
}
|
||||
}
|
||||
catch { /* no /proc/meminfo — probably not Linux */ }
|
||||
|
||||
return (memTotal, memAvailable, swapTotal, swapFree);
|
||||
}
|
||||
|
||||
private static long ParseKb(string line)
|
||||
{
|
||||
// Format: "MemTotal: 32768 kB"
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return parts.Length >= 2 && long.TryParse(parts[1], out var v) ? v : 0;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_timer.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace HanaTui.Tui.Components;
|
||||
|
||||
public sealed class LogEntry
|
||||
{
|
||||
public DateTime Time { get; init; } = DateTime.Now;
|
||||
public string Text { get; init; } = "";
|
||||
public LogLevel Level { get; init; } = LogLevel.Info;
|
||||
}
|
||||
|
||||
public enum LogLevel { Info, Sql, Done, Warn, Error }
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe bounded log. Build() returns an IRenderable panel.
|
||||
/// Dynamic content is rendered via Text() objects — never interpolated into markup strings.
|
||||
/// </summary>
|
||||
public sealed class LogPanel
|
||||
{
|
||||
private const int MaxEntries = 500;
|
||||
private const int VisibleLines = 20;
|
||||
|
||||
private readonly List<LogEntry> _entries = new(MaxEntries);
|
||||
private readonly object _lock = new();
|
||||
|
||||
public void Add(string text)
|
||||
{
|
||||
var level = DetectLevel(text);
|
||||
lock (_lock)
|
||||
{
|
||||
if (_entries.Count >= MaxEntries) _entries.RemoveAt(0);
|
||||
_entries.Add(new LogEntry { Time = DateTime.Now, Text = text, Level = level });
|
||||
}
|
||||
}
|
||||
|
||||
public IRenderable Build()
|
||||
{
|
||||
List<LogEntry> snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
var start = Math.Max(0, _entries.Count - VisibleLines);
|
||||
snapshot = _entries.GetRange(start, _entries.Count - start);
|
||||
}
|
||||
|
||||
var rows = new Grid();
|
||||
rows.AddColumn(new GridColumn().NoWrap());
|
||||
|
||||
// Pad so the panel height stays stable
|
||||
for (int i = 0; i < VisibleLines - snapshot.Count; i++)
|
||||
rows.AddRow(new Text(""));
|
||||
|
||||
foreach (var entry in snapshot)
|
||||
{
|
||||
var (tagLabel, tagColor, textColor) = entry.Level switch
|
||||
{
|
||||
LogLevel.Sql => ("SQL ", Color.Blue, Color.White),
|
||||
LogLevel.Done => ("DONE", Color.Green, Color.White),
|
||||
LogLevel.Warn => ("WARN", Color.Yellow, Color.White),
|
||||
LogLevel.Error => ("ERR ", Color.Red, Color.White),
|
||||
_ => ("INFO", Color.Grey, Color.Silver),
|
||||
};
|
||||
|
||||
// Strip the [TAG] prefix the service already prepends — we re-render it with color
|
||||
var bodyText = StripKnownPrefix(entry.Text);
|
||||
|
||||
// Compose the row from Text objects — zero markup parsing of dynamic content
|
||||
var lineGrid = new Grid();
|
||||
lineGrid.AddColumn(new GridColumn().NoWrap().Width(10)); // time
|
||||
lineGrid.AddColumn(new GridColumn().NoWrap().Width(8)); // tag
|
||||
lineGrid.AddColumn(new GridColumn().NoWrap()); // body
|
||||
|
||||
lineGrid.AddRow(
|
||||
new Text(entry.Time.ToString("HH:mm:ss"), new Style(Color.Grey, decoration: Decoration.Dim)),
|
||||
new Text($"[{tagLabel}]", new Style(tagColor)),
|
||||
new Text(" " + bodyText, new Style(textColor))
|
||||
);
|
||||
|
||||
rows.AddRow(lineGrid);
|
||||
}
|
||||
|
||||
return new Panel(rows)
|
||||
{
|
||||
Header = new PanelHeader("[bold] OPERATION LOG [/]"),
|
||||
Border = BoxBorder.Rounded,
|
||||
Padding = new Padding(1, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static LogLevel DetectLevel(string text)
|
||||
{
|
||||
if (text.Contains("[SQL ]") || text.Contains("[SQL]")) return LogLevel.Sql;
|
||||
if (text.Contains("[DONE]")) return LogLevel.Done;
|
||||
if (text.Contains("[WARN]")) return LogLevel.Warn;
|
||||
if (text.Contains("[ERROR]") || text.Contains("[ERR ]") || text.Contains("[ERR]"))
|
||||
return LogLevel.Error;
|
||||
return LogLevel.Info;
|
||||
}
|
||||
|
||||
private static readonly string[] KnownPrefixes =
|
||||
["[INFO] ", "[SQL ] ", "[SQL] ", "[DONE] ", "[WARN] ", "[ERROR] ", "[ERR ] ", "[ERR] "];
|
||||
|
||||
private static string StripKnownPrefix(string text)
|
||||
{
|
||||
foreach (var p in KnownPrefixes)
|
||||
if (text.StartsWith(p, StringComparison.Ordinal))
|
||||
return text[p.Length..];
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
using HanaTui.System;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace HanaTui.Tui.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Renders the system stats panel (CPU bars, RAM bar, Swap bar, elapsed time).
|
||||
/// Uses only hardcoded markup tags — no dynamic data ever enters a markup string.
|
||||
/// </summary>
|
||||
public static class StatsPanel
|
||||
{
|
||||
public static IRenderable Build(SystemSnapshot snap, TimeSpan elapsed, int panelWidth)
|
||||
{
|
||||
var barWidth = Math.Max(10, panelWidth - 16);
|
||||
|
||||
var grid = new Grid();
|
||||
grid.AddColumn(new GridColumn().NoWrap());
|
||||
|
||||
// --- CPU ---
|
||||
grid.AddRow(new Markup("[bold yellow]CPU[/]"));
|
||||
|
||||
if (snap.CpuPercents.Length == 0)
|
||||
{
|
||||
grid.AddRow(new Markup("[grey]No data[/]"));
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < snap.CpuPercents.Length; i++)
|
||||
{
|
||||
var label = snap.CpuLabels.Length > i ? snap.CpuLabels[i] : $"cpu{i}";
|
||||
var displayLabel = label == "cpu" ? "Total" : label.Replace("cpu", "Core");
|
||||
var pct = snap.CpuPercents[i];
|
||||
// Build as Columns: label | bar | pct — no interpolation of dynamic values into markup
|
||||
grid.AddRow(BuildBarRow(displayLabel, pct, barWidth));
|
||||
}
|
||||
}
|
||||
|
||||
grid.AddRow(new Text(""));
|
||||
|
||||
// --- Memory ---
|
||||
grid.AddRow(new Markup("[bold cyan]MEMORY[/]"));
|
||||
if (snap.MemTotalKb > 0)
|
||||
{
|
||||
var memPct = snap.MemUsedPercent;
|
||||
grid.AddRow(BuildBarRow("Used", memPct, barWidth));
|
||||
grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.MemUsedKb)} / {SystemSnapshot.FormatGb(snap.MemTotalKb)}",
|
||||
new Style(Color.Grey)));
|
||||
}
|
||||
else
|
||||
{
|
||||
grid.AddRow(new Markup("[grey]No data[/]"));
|
||||
}
|
||||
|
||||
grid.AddRow(new Text(""));
|
||||
|
||||
// --- Swap ---
|
||||
grid.AddRow(new Markup("[bold cyan]SWAP[/]"));
|
||||
if (snap.SwapTotalKb > 0)
|
||||
{
|
||||
var swapPct = snap.SwapUsedPercent;
|
||||
grid.AddRow(BuildBarRow("Used", swapPct, barWidth));
|
||||
grid.AddRow(new Text($" {SystemSnapshot.FormatGb(snap.SwapUsedKb)} / {SystemSnapshot.FormatGb(snap.SwapTotalKb)}",
|
||||
new Style(Color.Grey)));
|
||||
}
|
||||
else
|
||||
{
|
||||
grid.AddRow(new Markup("[grey]No swap[/]"));
|
||||
}
|
||||
|
||||
grid.AddRow(new Text(""));
|
||||
|
||||
// --- Elapsed ---
|
||||
// Two separate renderables composed — no interpolation of elapsed into markup
|
||||
var elapsedGrid = new Grid();
|
||||
elapsedGrid.AddColumn(new GridColumn().NoWrap());
|
||||
elapsedGrid.AddColumn(new GridColumn().NoWrap());
|
||||
elapsedGrid.AddRow(
|
||||
new Markup("[bold]Elapsed:[/]"),
|
||||
new Text(" " + FormatElapsed(elapsed), new Style(Color.Yellow)));
|
||||
grid.AddRow(elapsedGrid);
|
||||
|
||||
return new Panel(grid)
|
||||
{
|
||||
Header = new PanelHeader("[bold] SYSTEM STATS [/]"),
|
||||
Border = BoxBorder.Rounded,
|
||||
Padding = new Padding(1, 0),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Builds a single bar row as a Grid with three columns:
|
||||
/// label (dim) | bar (block chars, plain Text with color) | pct (bold)
|
||||
/// Nothing here goes through markup parsing with dynamic content.
|
||||
/// </summary>
|
||||
private static IRenderable BuildBarRow(string label, double pct, int barWidth)
|
||||
{
|
||||
var filled = (int)Math.Round(pct / 100.0 * barWidth);
|
||||
filled = Math.Clamp(filled, 0, barWidth);
|
||||
var empty = barWidth - filled;
|
||||
|
||||
var filledColor = CpuColor(pct);
|
||||
var filledStr = new string('\u2588', filled);
|
||||
var emptyStr = new string('\u2591', empty);
|
||||
|
||||
var row = new Grid();
|
||||
|
||||
// FIX: Add all three columns back!
|
||||
row.AddColumn(new GridColumn().NoWrap().Width(10)); // Column 1: Label
|
||||
row.AddColumn(new GridColumn().NoWrap()); // Column 2: Bar
|
||||
row.AddColumn(new GridColumn().NoWrap().Width(8)); // Column 3: Pct
|
||||
|
||||
var colorMarkup = filledColor.ToMarkup();
|
||||
|
||||
// Construct a seamless markup string
|
||||
var barMarkup = $"[grey][[[/][{colorMarkup}]{filledStr}[/][grey dim]{emptyStr}[/][grey]]][/]";
|
||||
|
||||
row.AddRow(
|
||||
new Text($" {label,-5}", new Style(Color.Grey, decoration: Decoration.Dim)),
|
||||
new Markup(barMarkup),
|
||||
new Text($" {pct,5:F1}%", new Style(Color.White, decoration: Decoration.Bold))
|
||||
);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private static Color CpuColor(double pct) => pct switch
|
||||
{
|
||||
> 90 => Color.Red,
|
||||
> 70 => Color.Yellow,
|
||||
> 40 => Color.Green,
|
||||
_ => Color.Blue,
|
||||
};
|
||||
|
||||
private static string FormatElapsed(TimeSpan t)
|
||||
{
|
||||
if (t.TotalHours >= 1)
|
||||
return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s";
|
||||
if (t.TotalMinutes >= 1)
|
||||
return $"{t.Minutes}m {t.Seconds:D2}s";
|
||||
return $"{t.Seconds}s";
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
using HanaTui.Hana;
|
||||
using Spectre.Console;
|
||||
using SysText = System.Text;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// Startup screen: lists hdbuserstore keys and lets the user pick one.
|
||||
/// Returns the selected key name, or null if the user chose to exit.
|
||||
/// </summary>
|
||||
public static class KeySelectionScreen
|
||||
{
|
||||
private sealed class KeyChoice
|
||||
{
|
||||
public string Display { get; init; } = "";
|
||||
public string? KeyName { get; init; } // null = exit, "" = manual entry
|
||||
|
||||
public static readonly KeyChoice Manual = new() { Display = "[ Enter key name manually ]", KeyName = "" };
|
||||
public static readonly KeyChoice Exit = new() { Display = "[ Exit ]", KeyName = null };
|
||||
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
|
||||
public static string? Run()
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
AnsiConsole.Write(new FigletText("HANA TUI").Color(Color.DodgerBlue1));
|
||||
AnsiConsole.MarkupLine("[dim]SAP HANA Database Manager[/]\n");
|
||||
|
||||
if (!HdbClientLocator.IsAvailable())
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red][[ERROR]][/] hdbsql not found. " +
|
||||
"Ensure the SAP HANA client is installed and on your PATH, " +
|
||||
"or present in [dim]/usr/sap/hdbclient[/] or [dim]/usr/sap/NDB/HDB00/exe[/].");
|
||||
AnsiConsole.MarkupLine("\nPress any key to exit.");
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
var clientDir = HdbClientLocator.ClientDirectory;
|
||||
if (clientDir is not null)
|
||||
AnsiConsole.MarkupLine($"[dim]HDB client: {Markup.Escape(clientDir)}[/]\n");
|
||||
|
||||
List<HdbUserstoreKey> keys = [];
|
||||
AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("blue"))
|
||||
.Start("[blue]Loading hdbuserstore keys...[/]", _ =>
|
||||
{
|
||||
keys = HdbCliRunner.ListKeys();
|
||||
});
|
||||
|
||||
if (keys.Count == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow][[WARN]][/] No hdbuserstore keys found.");
|
||||
AnsiConsole.MarkupLine("You can still enter a key name manually.\n");
|
||||
}
|
||||
|
||||
// Build typed choices. Display is plain text — never parsed as markup.
|
||||
var choices = keys
|
||||
.Select(k => new KeyChoice { Display = BuildKeyDisplay(k), KeyName = k.Name })
|
||||
.ToList();
|
||||
choices.Add(KeyChoice.Manual);
|
||||
choices.Add(KeyChoice.Exit);
|
||||
|
||||
var prompt = new SelectionPrompt<KeyChoice>()
|
||||
.Title("[bold]Select HDBUSERSTORE key:[/]")
|
||||
.PageSize(15)
|
||||
.HighlightStyle(Style.Parse("bold dodgerblue1"))
|
||||
.UseConverter(c => Markup.Escape(c.Display))
|
||||
.AddChoices(choices);
|
||||
|
||||
var selected = AnsiConsole.Prompt(prompt);
|
||||
|
||||
if (selected.KeyName is null)
|
||||
return null; // Exit
|
||||
|
||||
if (selected.KeyName == "")
|
||||
{
|
||||
var manual = AnsiConsole.Ask<string>("[bold]Enter HDBUSERSTORE key name:[/]").Trim();
|
||||
return string.IsNullOrWhiteSpace(manual) ? null : manual;
|
||||
}
|
||||
|
||||
return selected.KeyName;
|
||||
}
|
||||
|
||||
private static string BuildKeyDisplay(HdbUserstoreKey k)
|
||||
{
|
||||
var sb = new SysText.StringBuilder(k.Name);
|
||||
|
||||
if (!string.IsNullOrEmpty(k.Host))
|
||||
{
|
||||
sb.Append(" ");
|
||||
sb.Append(k.Host);
|
||||
if (!string.IsNullOrEmpty(k.Port))
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(k.Port);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(k.Tenant))
|
||||
{
|
||||
sb.Append('@');
|
||||
sb.Append(k.Tenant);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(k.User))
|
||||
{
|
||||
sb.Append(" user=");
|
||||
sb.Append(k.User);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using HanaTui.Hana;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// The main operation menu. Returns the selected operation, or null to exit.
|
||||
/// </summary>
|
||||
public static class MainMenuScreen
|
||||
{
|
||||
public enum Operation
|
||||
{
|
||||
Export,
|
||||
Import,
|
||||
ImportRename,
|
||||
Copy,
|
||||
Drop,
|
||||
RenameDb,
|
||||
Backup,
|
||||
ChangeKey,
|
||||
Quit,
|
||||
}
|
||||
|
||||
public static Operation Run(HdbUserstoreKey? key, string keyName)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
|
||||
// Header with key info
|
||||
var rule = new Rule("[bold dodgerblue1]HANA Database Manager[/]")
|
||||
.RuleStyle(Style.Parse("dodgerblue1"));
|
||||
AnsiConsole.Write(rule);
|
||||
|
||||
if (key is not null)
|
||||
{
|
||||
var conn = key.Host + ":" + key.Port +
|
||||
(string.IsNullOrEmpty(key.Tenant) ? "" : "@" + key.Tenant) +
|
||||
" user=" + key.User;
|
||||
AnsiConsole.MarkupLine(
|
||||
$" Key: [bold yellow]{Markup.Escape(key.Name)}[/] [dim]{Markup.Escape(conn)}[/]");
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.MarkupLine($" Key: [bold yellow]{Markup.Escape(keyName)}[/]");
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
var choices = new Dictionary<string, Operation>
|
||||
{
|
||||
["1 Export Schema"] = Operation.Export,
|
||||
["2 Import Schema"] = Operation.Import,
|
||||
["3 Import & Rename Schema"] = Operation.ImportRename,
|
||||
["4 Copy Schema"] = Operation.Copy,
|
||||
["5 Drop Schema"] = Operation.Drop,
|
||||
["6 Rename Database (Company Name)"] = Operation.RenameDb,
|
||||
["7 Backup Tenant"] = Operation.Backup,
|
||||
["----------------------------------"] = Operation.Quit, // separator placeholder
|
||||
["k Change Key"] = Operation.ChangeKey,
|
||||
["q Quit"] = Operation.Quit,
|
||||
};
|
||||
|
||||
// Build SelectionPrompt without the separator entry
|
||||
var prompt = new SelectionPrompt<string>()
|
||||
.Title("[bold]Select operation:[/]")
|
||||
.PageSize(12)
|
||||
.HighlightStyle(Style.Parse("bold dodgerblue1"));
|
||||
|
||||
foreach (var key2 in choices.Keys)
|
||||
{
|
||||
if (key2.StartsWith("--")) continue;
|
||||
prompt.AddChoice(key2);
|
||||
}
|
||||
|
||||
var selected = AnsiConsole.Prompt(prompt);
|
||||
return choices.TryGetValue(selected, out var op) ? op : Operation.Quit;
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
using HanaTui.Hana;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// Guided input forms for each operation.
|
||||
/// Each method collects all needed parameters, then returns a typed params object.
|
||||
/// Returns null if the user cancelled.
|
||||
/// </summary>
|
||||
public static class OperationForms
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// A choice item that keeps the real value separate from the display label.
|
||||
/// The display label is pre-escaped plain text — never interpreted as markup.
|
||||
/// </summary>
|
||||
private sealed class SchemaChoice
|
||||
{
|
||||
public string Display { get; init; } = "";
|
||||
public string? Value { get; init; } // null = cancel, "" = manual entry
|
||||
|
||||
public static readonly SchemaChoice Manual = new() { Display = "[ Enter manually ]", Value = "" };
|
||||
public static readonly SchemaChoice Cancel = new() { Display = "[ Cancel ]", Value = null };
|
||||
|
||||
public override string ToString() => Display;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the schema list with a spinner, then shows an arrow-key picker.
|
||||
/// Returns the selected schema, or null if cancelled.
|
||||
/// </summary>
|
||||
private static string? PickSchema(string userKey, string title)
|
||||
{
|
||||
List<string> schemas = [];
|
||||
string? error = null;
|
||||
|
||||
AnsiConsole.Status()
|
||||
.Spinner(Spinner.Known.Dots)
|
||||
.SpinnerStyle(Style.Parse("blue"))
|
||||
.Start("[blue]Fetching schemas...[/]", _ =>
|
||||
{
|
||||
var (success, list, err) = HdbCliRunner.ListSchemas(userKey);
|
||||
if (success)
|
||||
schemas = list;
|
||||
else
|
||||
error = err;
|
||||
});
|
||||
|
||||
if (error is not null)
|
||||
AnsiConsole.MarkupLine($"[yellow][[WARN]] Could not fetch schemas: {Markup.Escape(error)}[/]");
|
||||
|
||||
// Build typed choices. Display is plain text; ToString() is what SelectionPrompt shows.
|
||||
var choices = schemas
|
||||
.Select(s => new SchemaChoice { Display = s, Value = s })
|
||||
.ToList();
|
||||
choices.Add(SchemaChoice.Manual);
|
||||
choices.Add(SchemaChoice.Cancel);
|
||||
|
||||
// UseConverter returns Display which is already plain text.
|
||||
// We also set the prompt to NOT interpret converter output as markup by
|
||||
// escaping it — belt-and-suspenders.
|
||||
var prompt = new SelectionPrompt<SchemaChoice>()
|
||||
.Title($"[bold]{Markup.Escape(title)}[/]")
|
||||
.PageSize(15)
|
||||
.HighlightStyle(Style.Parse("bold dodgerblue1"))
|
||||
.UseConverter(c => Markup.Escape(c.Display))
|
||||
.AddChoices(choices);
|
||||
|
||||
var selected = AnsiConsole.Prompt(prompt);
|
||||
|
||||
if (selected.Value is null)
|
||||
return null; // Cancel
|
||||
|
||||
if (selected.Value == "")
|
||||
{
|
||||
var manual = AnsiConsole.Ask<string>("Enter schema name:").Trim();
|
||||
return string.IsNullOrWhiteSpace(manual) ? null : manual;
|
||||
}
|
||||
|
||||
return selected.Value;
|
||||
}
|
||||
|
||||
private static int PickThreads(string label = "Number of threads")
|
||||
{
|
||||
var maxThreads = Environment.ProcessorCount;
|
||||
var defaultThreads = Math.Max(1, maxThreads / 2);
|
||||
return AnsiConsole.Ask($"{label} [dim](default={defaultThreads}, max={maxThreads})[/]:",
|
||||
defaultThreads);
|
||||
}
|
||||
|
||||
private static bool ConfirmYesNo(string question, bool defaultYes = false)
|
||||
{
|
||||
var defaultLabel = defaultYes ? "Y/n" : "y/N";
|
||||
var answer = AnsiConsole.Ask<string>($"{question} [dim]({defaultLabel})[/]:", defaultYes ? "y" : "n");
|
||||
return answer.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) ||
|
||||
answer.Trim().Equals("yes", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void PrintOperationHeader(string title)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
var rule = new Rule($"[bold dodgerblue1]{Markup.Escape(title)}[/]").RuleStyle(Style.Parse("dodgerblue1"));
|
||||
AnsiConsole.Write(rule);
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
private static bool ShowSummaryAndConfirm(string title, Dictionary<string, string> fields)
|
||||
{
|
||||
AnsiConsole.WriteLine();
|
||||
var table = new Table().BorderColor(Color.DodgerBlue1).Border(TableBorder.Rounded);
|
||||
table.AddColumn("[bold]Parameter[/]");
|
||||
table.AddColumn("[bold]Value[/]");
|
||||
foreach (var (k, v) in fields)
|
||||
table.AddRow($"[dim]{Markup.Escape(k)}[/]", $"[yellow]{Markup.Escape(v)}[/]");
|
||||
AnsiConsole.MarkupLine($"[bold]{Markup.Escape(title)}[/]");
|
||||
AnsiConsole.Write(table);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
return ConfirmYesNo("Proceed with this operation?", defaultYes: true);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Export
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static ExportParams? ExportForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Export Schema");
|
||||
|
||||
var schema = PickSchema(userKey, "Select schema to export:");
|
||||
if (schema is null) return null;
|
||||
|
||||
var targetPath = AnsiConsole.Ask<string>("Target directory or file path [dim](.tar.gz for archive)[/]:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetPath)) return null;
|
||||
|
||||
var threads = PickThreads();
|
||||
var compress = ConfirmYesNo("Compress output as .tar.gz?");
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Export Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Schema"] = schema,
|
||||
["Target path"] = targetPath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Compress"] = compress ? "Yes" : "No",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new ExportParams
|
||||
{
|
||||
Schema = schema,
|
||||
TargetPath = targetPath,
|
||||
Threads = threads,
|
||||
Compress = compress,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Import
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static ImportParams? ImportForm(string userKey, bool renameMode)
|
||||
{
|
||||
var title = renameMode ? "Import & Rename Schema" : "Import Schema";
|
||||
PrintOperationHeader(title);
|
||||
|
||||
var sourceSchema = AnsiConsole.Ask<string>("Source schema name [dim](as it was exported)[/]:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourceSchema)) return null;
|
||||
|
||||
string? newSchemaName = null;
|
||||
if (renameMode)
|
||||
{
|
||||
newSchemaName = AnsiConsole.Ask<string>("New target schema name:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(newSchemaName)) return null;
|
||||
}
|
||||
|
||||
var sourcePath = AnsiConsole.Ask<string>("Source path [dim](directory or .tar.gz)[/]:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourcePath)) return null;
|
||||
|
||||
var threads = PickThreads();
|
||||
var replace = ConfirmYesNo("Replace existing objects?");
|
||||
|
||||
var summary = new Dictionary<string, string>
|
||||
{
|
||||
["Source schema"] = sourceSchema,
|
||||
["Source path"] = sourcePath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Replace"] = replace ? "Yes" : "No (IGNORE EXISTING)",
|
||||
};
|
||||
if (renameMode && newSchemaName is not null)
|
||||
summary["New schema name"] = newSchemaName;
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm($"{title} Summary", summary);
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new ImportParams
|
||||
{
|
||||
SourceSchema = sourceSchema,
|
||||
NewSchemaName = newSchemaName,
|
||||
SourcePath = sourcePath,
|
||||
Threads = threads,
|
||||
Replace = replace,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Copy
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static CopyParams? CopyForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Copy Schema");
|
||||
|
||||
var sourceSchema = PickSchema(userKey, "Select source schema:");
|
||||
if (sourceSchema is null) return null;
|
||||
|
||||
var targetSchema = AnsiConsole.Ask<string>("Target schema name:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetSchema)) return null;
|
||||
|
||||
var tempPath = AnsiConsole.Ask<string>("Temporary export directory path:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(tempPath)) return null;
|
||||
|
||||
var threads = PickThreads();
|
||||
var replace = ConfirmYesNo("Replace existing objects in target schema?");
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Copy Schema Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Source schema"] = sourceSchema,
|
||||
["Target schema"] = targetSchema,
|
||||
["Temp path"] = tempPath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Replace"] = replace ? "Yes" : "No",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new CopyParams
|
||||
{
|
||||
SourceSchema = sourceSchema,
|
||||
TargetSchema = targetSchema,
|
||||
TempPath = tempPath,
|
||||
Threads = threads,
|
||||
Replace = replace,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Drop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static DropParams? DropForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Drop Schema");
|
||||
|
||||
var schema = PickSchema(userKey, "Select schema to drop:");
|
||||
if (schema is null) return null;
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine($"[bold red]WARNING:[/] You are about to [bold red]permanently drop[/] schema [bold yellow]{Markup.Escape(schema)}[/].");
|
||||
AnsiConsole.MarkupLine("[red]This cannot be undone.[/]\n");
|
||||
|
||||
var confirm = AnsiConsole.Ask<string>($"Type [bold red]YES[/] to confirm dropping [yellow]{Markup.Escape(schema)}[/]:").Trim();
|
||||
if (confirm != "YES")
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Operation cancelled.[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[dim]Press any key to return...[/]");
|
||||
Console.ReadKey(intercept: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new DropParams { Schema = schema };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rename DB
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static RenameDbParams? RenameDbForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Rename Database (Company Name)");
|
||||
|
||||
var schema = PickSchema(userKey, "Select schema:");
|
||||
if (schema is null) return null;
|
||||
|
||||
var newName = AnsiConsole.Ask<string>("New company name:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(newName)) return null;
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Rename DB Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Schema"] = schema,
|
||||
["New company name"] = newName,
|
||||
["Tables updated"] = "CINF, OADM",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new RenameDbParams { Schema = schema, NewCompanyName = newName };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Backup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public static BackupParams? BackupForm(string userKey)
|
||||
{
|
||||
PrintOperationHeader("Backup Tenant");
|
||||
|
||||
var targetPath = AnsiConsole.Ask<string>("Target directory path:").Trim();
|
||||
if (string.IsNullOrWhiteSpace(targetPath)) return null;
|
||||
|
||||
var threads = PickThreads("Number of compression threads");
|
||||
var compress = ConfirmYesNo("Compress backup as .tar.gz?");
|
||||
|
||||
var confirmed = ShowSummaryAndConfirm("Backup Summary", new Dictionary<string, string>
|
||||
{
|
||||
["Target path"] = targetPath,
|
||||
["Threads"] = threads.ToString(),
|
||||
["Compress"] = compress ? "Yes" : "No",
|
||||
});
|
||||
|
||||
if (!confirmed) return null;
|
||||
|
||||
return new BackupParams
|
||||
{
|
||||
TargetPath = targetPath,
|
||||
Threads = threads,
|
||||
Compress = compress,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
using HanaTui.System;
|
||||
using HanaTui.Tui.Components;
|
||||
using Spectre.Console;
|
||||
using Spectre.Console.Rendering;
|
||||
|
||||
namespace HanaTui.Tui;
|
||||
|
||||
/// <summary>
|
||||
/// Runs an operation while showing a live split-panel:
|
||||
/// Left: System stats (CPU, RAM, Swap, elapsed)
|
||||
/// Right: Streaming operation log
|
||||
///
|
||||
/// Abort: first Q warns, second Q within 3s sends SIGTERM then SIGKILL.
|
||||
/// Post-completion: 10s countdown, any key cancels and holds; Enter/Esc returns.
|
||||
/// </summary>
|
||||
public static class TaskRunnerScreen
|
||||
{
|
||||
private const int CountdownSeconds = 10;
|
||||
private const double AbortWindowSeconds = 3.0;
|
||||
|
||||
public static async Task RunAsync(
|
||||
string operationTitle,
|
||||
Func<Action<string>, CancellationToken, Task<bool>> operation)
|
||||
{
|
||||
AnsiConsole.Clear();
|
||||
|
||||
var logPanel = new LogPanel();
|
||||
var stats = new SystemStats();
|
||||
var cts = new CancellationTokenSource();
|
||||
var startTime = DateTime.Now;
|
||||
|
||||
var operationDone = false;
|
||||
var operationSuccess = false;
|
||||
DateTime? firstQTime = null;
|
||||
|
||||
// Key listener
|
||||
var keyTask = Task.Run(async () =>
|
||||
{
|
||||
while (!operationDone)
|
||||
{
|
||||
if (Console.KeyAvailable)
|
||||
{
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
if (key.Key == ConsoleKey.Q || key.KeyChar is 'q' or 'Q')
|
||||
{
|
||||
if (cts.IsCancellationRequested) break;
|
||||
|
||||
if (firstQTime is null)
|
||||
{
|
||||
firstQTime = DateTime.Now;
|
||||
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
|
||||
}
|
||||
else if ((DateTime.Now - firstQTime.Value).TotalSeconds <= AbortWindowSeconds)
|
||||
{
|
||||
logPanel.Add("[WARN] Aborting operation...");
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
firstQTime = DateTime.Now;
|
||||
logPanel.Add("[WARN] Press Q again within 3 seconds to abort.");
|
||||
}
|
||||
}
|
||||
}
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Operation task
|
||||
var operationTask = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
operationSuccess = await operation(line => logPanel.Add(line), cts.Token);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logPanel.Add($"[ERROR] Unexpected exception: {ex.Message}");
|
||||
operationSuccess = false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
operationDone = true;
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
// Live render loop
|
||||
await AnsiConsole.Live(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running))
|
||||
.AutoClear(false)
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
while (!operationDone)
|
||||
{
|
||||
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, RunState.Running));
|
||||
await Task.Delay(800, CancellationToken.None);
|
||||
}
|
||||
|
||||
var elapsed = DateTime.Now - startTime;
|
||||
RunState finalState;
|
||||
if (cts.IsCancellationRequested)
|
||||
{
|
||||
finalState = RunState.Aborted;
|
||||
logPanel.Add($"[WARN] Operation aborted after {FormatElapsed(elapsed)}.");
|
||||
}
|
||||
else if (operationSuccess)
|
||||
{
|
||||
finalState = RunState.Success;
|
||||
logPanel.Add($"[DONE] Operation completed in {FormatElapsed(elapsed)}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
finalState = RunState.Failed;
|
||||
logPanel.Add($"[ERR ] Operation failed after {FormatElapsed(elapsed)}.");
|
||||
}
|
||||
|
||||
ctx.UpdateTarget(BuildLayout(operationTitle, logPanel, stats, startTime, finalState));
|
||||
});
|
||||
|
||||
stats.Dispose();
|
||||
await operationTask;
|
||||
operationDone = true;
|
||||
await keyTask;
|
||||
|
||||
await PostCompletionWaitAsync(operationTitle, logPanel, stats, startTime,
|
||||
cts.IsCancellationRequested ? RunState.Aborted :
|
||||
operationSuccess ? RunState.Success : RunState.Failed);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private enum RunState { Running, Success, Failed, Aborted }
|
||||
|
||||
private static async Task PostCompletionWaitAsync(
|
||||
string title, LogPanel logPanel, SystemStats stats, DateTime startTime, RunState state)
|
||||
{
|
||||
var countdownCancelled = false;
|
||||
var returnNow = false;
|
||||
var staying = false;
|
||||
var startCountdown = DateTime.Now;
|
||||
|
||||
var countdownTask = Task.Run(async () =>
|
||||
{
|
||||
for (int i = CountdownSeconds; i > 0 && !countdownCancelled; i--)
|
||||
await Task.Delay(1000, CancellationToken.None);
|
||||
if (!countdownCancelled) returnNow = true;
|
||||
}, CancellationToken.None);
|
||||
|
||||
await AnsiConsole.Live(BuildLayout(title, logPanel, stats, startTime, state))
|
||||
.AutoClear(false)
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
while (!returnNow)
|
||||
{
|
||||
if (Console.KeyAvailable)
|
||||
{
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
countdownCancelled = true;
|
||||
|
||||
if (key.Key is ConsoleKey.Enter or ConsoleKey.Escape)
|
||||
{
|
||||
returnNow = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!staying)
|
||||
{
|
||||
staying = true;
|
||||
logPanel.Add("[INFO] Countdown cancelled. Press Enter or Esc to return to menu.");
|
||||
}
|
||||
}
|
||||
|
||||
string footerText;
|
||||
if (!staying)
|
||||
{
|
||||
var secsLeft = Math.Max(0, CountdownSeconds -
|
||||
(int)(DateTime.Now - startCountdown).TotalSeconds);
|
||||
footerText = $"Returning to menu in {secsLeft}s... (any key to stay)";
|
||||
}
|
||||
else
|
||||
{
|
||||
footerText = "Press Enter or Esc to return to menu.";
|
||||
}
|
||||
|
||||
ctx.UpdateTarget(BuildLayout(title, logPanel, stats, startTime, state, footerText));
|
||||
await Task.Delay(200, CancellationToken.None);
|
||||
}
|
||||
});
|
||||
|
||||
await countdownTask;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Layout
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static IRenderable BuildLayout(
|
||||
string title,
|
||||
LogPanel logPanel,
|
||||
SystemStats stats,
|
||||
DateTime startTime,
|
||||
RunState state,
|
||||
string? footerText = null)
|
||||
{
|
||||
var elapsed = DateTime.Now - startTime;
|
||||
var snap = stats.CurrentSnapshot;
|
||||
|
||||
const int statsPanelWidth = 40;
|
||||
var statsRenderable = StatsPanel.Build(snap, elapsed, statsPanelWidth - 4);
|
||||
var logRenderable = logPanel.Build();
|
||||
|
||||
var layout = new Layout("root").SplitColumns(
|
||||
new Layout("stats").Size(statsPanelWidth),
|
||||
new Layout("log"));
|
||||
layout["stats"].Update(statsRenderable);
|
||||
layout["log"].Update(logRenderable);
|
||||
|
||||
var outerGrid = new Grid();
|
||||
outerGrid.AddColumn(new GridColumn());
|
||||
|
||||
// Title row — state label uses Text objects, not markup interpolation
|
||||
var titleGrid = new Grid();
|
||||
titleGrid.AddColumn(new GridColumn().NoWrap());
|
||||
titleGrid.AddColumn(new GridColumn().NoWrap());
|
||||
titleGrid.AddColumn(new GridColumn().NoWrap());
|
||||
titleGrid.AddColumn(new GridColumn().NoWrap());
|
||||
titleGrid.AddRow(
|
||||
new Markup("[bold dodgerblue1] Operation:[/]"),
|
||||
new Text(" " + title, new Style(Color.Yellow)),
|
||||
new Markup(" [bold dodgerblue1]Status:[/]"),
|
||||
StateLabel(state)
|
||||
);
|
||||
outerGrid.AddRow(titleGrid);
|
||||
outerGrid.AddRow(layout);
|
||||
|
||||
// Footer row — plain Text, no markup parsing of dynamic content
|
||||
var footerGrid = new Grid();
|
||||
footerGrid.AddColumn(new GridColumn().NoWrap());
|
||||
footerGrid.AddColumn(new GridColumn().NoWrap());
|
||||
|
||||
var footerLeft = footerText is not null
|
||||
? new Text(" " + footerText, new Style(Color.Grey, decoration: Decoration.Dim))
|
||||
: (IRenderable)new Text(" Press Q to abort (twice within 3s)", new Style(Color.Grey, decoration: Decoration.Dim));
|
||||
|
||||
var footerRight = new Text(" [Q] abort", new Style(Color.Grey, decoration: Decoration.Dim));
|
||||
|
||||
footerGrid.AddRow(footerLeft, footerRight);
|
||||
outerGrid.AddRow(footerGrid);
|
||||
|
||||
return outerGrid;
|
||||
}
|
||||
|
||||
private static IRenderable StateLabel(RunState state) => state switch
|
||||
{
|
||||
RunState.Running => new Markup(" [yellow]Running...[/]"),
|
||||
RunState.Success => new Markup(" [green]Completed[/]"),
|
||||
RunState.Failed => new Markup(" [red]Failed[/]"),
|
||||
RunState.Aborted => new Markup(" [red]Aborted[/]"),
|
||||
_ => new Text(""),
|
||||
};
|
||||
|
||||
private static string FormatElapsed(TimeSpan t)
|
||||
{
|
||||
if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes:D2}m {t.Seconds:D2}s";
|
||||
if (t.TotalMinutes >= 1) return $"{t.Minutes}m {t.Seconds:D2}s";
|
||||
return $"{t.Seconds}s";
|
||||
}
|
||||
}
|
||||
+2
-16
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Author: Tomi Eckert
|
||||
# --- Main Script ---
|
||||
|
||||
# This script presents a menu of software packages, or installs them
|
||||
@@ -48,8 +47,7 @@ process_package() {
|
||||
display_name=$(echo "${config_value}" | cut -d'|' -f1)
|
||||
remote_version=$(echo "${config_value}" | cut -d'|' -f2)
|
||||
description=$(echo "${config_value}" | cut -d'|' -f3)
|
||||
urls_to_download=$(echo "${config_value}" | cut -d'|' -f4)
|
||||
install_script=$(echo "${config_value}" | cut -d'|' -f5) # Optional install script
|
||||
urls_to_download=$(echo "${config_value}" | cut -d'|' -f4-)
|
||||
|
||||
read -r -a urls_to_download_array <<< "$urls_to_download"
|
||||
|
||||
@@ -103,17 +101,6 @@ process_package() {
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${install_script}" ]]; then
|
||||
echo "[⚙️] Running install script for '${choice_key}'..."
|
||||
#eval "${install_script}"
|
||||
bash -c "$(curl -sSL $install_script)"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "[✅] Install script completed successfully."
|
||||
else
|
||||
echo "[❌] Install script failed with exit code $?."
|
||||
fi
|
||||
fi
|
||||
echo "[📦] Package processing complete for '${choice_key}'."
|
||||
}
|
||||
|
||||
@@ -186,8 +173,7 @@ for i in "${!ordered_keys[@]}"; do
|
||||
display_name=$(echo "${config_value}" | cut -d'|' -f1)
|
||||
remote_version=$(echo "${config_value}" | cut -d'|' -f2)
|
||||
description=$(echo "${config_value}" | cut -d'|' -f3)
|
||||
urls=$(echo "${config_value}" | cut -d'|' -f4)
|
||||
# install_script=$(echo "${config_value}" | cut -d'|' -f5) # Not used for display in menu
|
||||
urls=$(echo "${config_value}" | cut -d'|' -f4-)
|
||||
read -r -a url_array <<< "$urls"
|
||||
main_script_filename=$(basename "${url_array[0]}")
|
||||
local_version=$(get_local_version "${main_script_filename}")
|
||||
|
||||
+3
-17
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Version: 1.2.3
|
||||
# Author: Tomi Eckert
|
||||
# Version: 1.2.1
|
||||
|
||||
# A script to interactively manage SAP HANA hdbuserstore keys, with testing.
|
||||
|
||||
@@ -13,20 +12,7 @@ COLOR_NC='\033[0m' # No Color
|
||||
|
||||
# --- Configuration ---
|
||||
# Adjust these paths if your HANA client is installed elsewhere.
|
||||
# Define potential HDB client paths
|
||||
HDB_CLIENT_PATH_1="/usr/sap/hdbclient"
|
||||
HDB_CLIENT_PATH_2="/usr/sap/NDB/HDB00/exe"
|
||||
|
||||
# Check which path exists and set HDB_CLIENT_PATH accordingly
|
||||
if [ -d "$HDB_CLIENT_PATH_1" ]; then
|
||||
HDB_CLIENT_PATH="$HDB_CLIENT_PATH_1"
|
||||
elif [ -d "$HDB_CLIENT_PATH_2" ]; then
|
||||
HDB_CLIENT_PATH="$HDB_CLIENT_PATH_2"
|
||||
else
|
||||
echo -e "${COLOR_RED}❌ Error: Neither '$HDB_CLIENT_PATH_1' nor '$HDB_CLIENT_PATH_2' found.${COLOR_NC}"
|
||||
echo -e "${COLOR_RED}Please install the SAP HANA client or adjust the paths in the script.${COLOR_NC}"
|
||||
exit 1
|
||||
fi
|
||||
HDB_CLIENT_PATH="/usr/sap/hdbclient"
|
||||
HDB_USERSTORE_EXEC="${HDB_CLIENT_PATH}/hdbuserstore"
|
||||
HDB_SQL_EXEC="${HDB_CLIENT_PATH}/hdbsql"
|
||||
|
||||
@@ -79,7 +65,7 @@ create_new_key() {
|
||||
|
||||
# Conditionally build the connection string
|
||||
if [[ "$is_systemdb" =~ ^[Yy]$ ]]; then
|
||||
CONNECTION_STRING="${hdb_host}:3${hdb_instance}13"
|
||||
CONNECTION_STRING="${hdb_host}:3${hdb_instance}15"
|
||||
echo -e "${COLOR_YELLOW}💡 Connecting to SYSTEMDB. Tenant name will be omitted from the connection string.${COLOR_NC}"
|
||||
else
|
||||
read -p "Enter the Tenant DB [NDB]: " hdb_tenant
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Configuration for SAP HANA Monitoring Script
|
||||
# Author: Tomi Eckert
|
||||
|
||||
# --- Company Information ---
|
||||
# Used to identify which company the alert is for.
|
||||
@@ -13,9 +12,9 @@ NTFY_TOKEN="tk_xxxxx"
|
||||
|
||||
# --- HANA Connection Settings ---
|
||||
# Full path to the sapcontrol executable
|
||||
SAPCONTROL_PATH="<sapcontrol_path>"
|
||||
SAPCONTROL_PATH="/usr/sap/NDB/HDB00/exe/sapcontrol"
|
||||
# Full path to the hdbsql executable
|
||||
HDBSQL_PATH="<hdbsql_path>"
|
||||
HDBSQL_PATH="/usr/sap/hdbclient/hdbsql"
|
||||
# HANA user key for authentication
|
||||
HANA_USER_KEY="CRONKEY"
|
||||
# HANA Instance Number for sapcontrol
|
||||
@@ -30,11 +29,8 @@ TRUNCATED_PERCENTAGE_THRESHOLD=50
|
||||
FREE_PERCENTAGE_THRESHOLD=25
|
||||
# Maximum age of the last successful full data backup in hours.
|
||||
BACKUP_THRESHOLD_HOURS=25
|
||||
# Statement queue length that triggers a check
|
||||
STATEMENT_QUEUE_THRESHOLD=100
|
||||
# Number of consecutive runs the queue must be over threshold to trigger an alert
|
||||
STATEMENT_QUEUE_CONSECUTIVE_RUNS=3
|
||||
|
||||
# --- Monitored Directories ---
|
||||
# List of directories to check for disk usage (space-separated)
|
||||
DIRECTORIES_TO_MONITOR=("/hana/log" "/hana/shared" "/hana/data" "/usr/sap")
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Author: Tomi Eckert
|
||||
# This script helps to configure monitor.conf
|
||||
|
||||
# Source the monitor.conf to get current values
|
||||
source monitor.conf
|
||||
|
||||
# Check if COMPANY_NAME or NTFY_TOKEN are still default
|
||||
if [ "$COMPANY_NAME" = "Company" ] || [ "$NTFY_TOKEN" = "tk_xxxxx" ]; then
|
||||
echo "Default COMPANY_NAME or NTFY_TOKEN detected. Running configuration..."
|
||||
else
|
||||
echo "COMPANY_NAME and NTFY_TOKEN are already configured. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prompt for COMPANY_NAME
|
||||
read -p "Enter Company Name (e.g., MyCompany): " COMPANY_NAME_INPUT
|
||||
COMPANY_NAME_INPUT=${COMPANY_NAME_INPUT:-"$COMPANY_NAME"} # Default to current value if not provided
|
||||
|
||||
# Prompt for NTFY_TOKEN
|
||||
read -p "Enter ntfy.sh token (e.g., tk_xxxxx): " NTFY_TOKEN_INPUT
|
||||
NTFY_TOKEN_INPUT=${NTFY_TOKEN_INPUT:-"$NTFY_TOKEN"} # Default to current value if not provided
|
||||
|
||||
# Define HANA client paths
|
||||
HDB_CLIENT_PATH="/usr/sap/hdbclient"
|
||||
HDB_USERSTORE_EXEC="${HDB_CLIENT_PATH}/hdbuserstore"
|
||||
|
||||
# List HANA user keys and prompt for selection
|
||||
echo "Available HANA User Keys:"
|
||||
HANA_KEYS=$("$HDB_USERSTORE_EXEC" list 2>/dev/null | tail -n +3 | grep '^KEY ' | awk '{print $2}')
|
||||
if [ -z "$HANA_KEYS" ]; then
|
||||
echo "No HANA user keys found. Please create one using keymanager.sh or enter manually."
|
||||
read -p "Enter HANA User Key (e.g., CRONKEY): " HANA_USER_KEY_INPUT
|
||||
else
|
||||
echo "$HANA_KEYS"
|
||||
read -p "Enter HANA User Key from the list above (e.g., CRONKEY): " HANA_USER_KEY_INPUT
|
||||
fi
|
||||
HANA_USER_KEY_INPUT=${HANA_USER_KEY_INPUT:-"CRONKEY"} # Default value
|
||||
|
||||
# Find paths for sapcontrol and hdbsql
|
||||
SAPCONTROL_PATH_INPUT=$(which sapcontrol)
|
||||
HDBSQL_PATH_INPUT=$(which hdbsql)
|
||||
|
||||
# Default values if not found
|
||||
SAPCONTROL_PATH_INPUT=${SAPCONTROL_PATH_INPUT:-"/usr/sap/NDB/HDB00/exe/sapcontrol"}
|
||||
HDBSQL_PATH_INPUT=${HDBSQL_PATH_INPUT:-"/usr/sap/hdbclient/hdbsql"}
|
||||
|
||||
# Update monitor.conf
|
||||
sed -i "s/^COMPANY_NAME=\".*\"/COMPANY_NAME=\"$COMPANY_NAME_INPUT\"/" monitor.conf
|
||||
sed -i "s/^NTFY_TOKEN=\".*\"/NTFY_TOKEN=\"$NTFY_TOKEN_INPUT\"/" monitor.conf
|
||||
sed -i "s#^SAPCONTROL_PATH=\".*\"#SAPCONTROL_PATH=\"$SAPCONTROL_PATH_INPUT\"#" monitor.conf
|
||||
sed -i "s#^HDBSQL_PATH=\".*\"#HDBSQL_PATH=\"$HDBSQL_PATH_INPUT\"#" monitor.conf
|
||||
sed -i "s/^HANA_USER_KEY=\".*\"/HANA_USER_KEY=\"$HANA_USER_KEY_INPUT\"/" monitor.conf
|
||||
|
||||
echo "monitor.conf updated successfully!"
|
||||
+21
-44
@@ -1,10 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Version: 1.3.1
|
||||
# Author: Tomi Eckert
|
||||
# Version: 1.2.0
|
||||
# =============================================================================
|
||||
# SAP HANA Monitoring Script
|
||||
#
|
||||
# Checks HANA processes, disk usage, log segments, and statement queue.
|
||||
# Checks HANA processes, disk usage, and log segment state.
|
||||
# Sends ntfy.sh notifications if thresholds are exceeded.
|
||||
# =============================================================================
|
||||
|
||||
@@ -75,6 +74,7 @@ send_notification_if_changed() {
|
||||
else
|
||||
# No alert, and no previous alert to resolve, so just update state silently
|
||||
set_state "${alert_key}" "$current_value"
|
||||
echo "ℹ️ State for ${alert_key} updated to ${current_value}. No notification sent."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
@@ -83,6 +83,8 @@ send_notification_if_changed() {
|
||||
curl -H "Authorization: Bearer ${NTFY_TOKEN}" -H "Title: ${full_title}" -d "${final_message}" "${NTFY_TOPIC_URL}" > /dev/null 2>&1
|
||||
set_state "${alert_key}" "$current_value"
|
||||
echo "🔔 Notification sent for ${alert_key}: ${full_message}"
|
||||
else
|
||||
echo "ℹ️ State for ${alert_key} unchanged. No notification sent."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -162,8 +164,11 @@ echo "ℹ️ Free Segments: ${free_segments}"
|
||||
if [ $total_segments -eq 0 ]; then
|
||||
echo "⚠️ Warning: No log segments found. Skipping percentage checks." >&2
|
||||
send_notification_if_changed "hana_log_segments_total" "HANA Log Segment Warning" "No log segments found. Skipping percentage checks." "true" "NO_LOG_SEGMENTS"
|
||||
exit 0
|
||||
else
|
||||
send_notification_if_changed "hana_log_segments_total" "HANA Log Segment" "Log segments found." "false" "OK"
|
||||
fi
|
||||
|
||||
truncated_percentage=$((truncated_segments * 100 / total_segments))
|
||||
if (( $(echo "$truncated_percentage > $TRUNCATED_PERCENTAGE_THRESHOLD" | bc -l) )); then
|
||||
echo "🚨 Alert: ${truncated_percentage}% of log segments are 'Truncated'." >&2
|
||||
@@ -179,66 +184,38 @@ else
|
||||
else
|
||||
send_notification_if_changed "hana_log_free" "HANA Log Segment" "Only ${free_percentage}% of HANA log segments are in 'Free' state (above threshold)." "false" "OK"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- HANA Statement Queue Monitoring ---
|
||||
echo "⚙️ Checking HANA statement queue..."
|
||||
STATEMENT_QUEUE_SQL="SELECT COUNT(*) FROM M_SERVICE_THREADS WHERE THREAD_TYPE = 'SqlExecutor' AND THREAD_STATE = 'Queueing';"
|
||||
queue_count=$("$HDBSQL_PATH" -U "$HANA_USER_KEY" -j -a -x "$STATEMENT_QUEUE_SQL" 2>/dev/null | tr -d '"')
|
||||
|
||||
if ! [[ "$queue_count" =~ ^[0-9]+$ ]]; then
|
||||
echo "⚠️ Warning: Could not retrieve HANA statement queue count. Skipping check." >&2
|
||||
send_notification_if_changed "hana_statement_queue_check_fail" "HANA Monitor Warning" "Could not retrieve statement queue count." "true" "QUEUE_CHECK_FAIL"
|
||||
else
|
||||
send_notification_if_changed "hana_statement_queue_check_fail" "HANA Monitor Warning" "Statement queue check is working." "false" "OK"
|
||||
echo "ℹ️ Current statement queue length: ${queue_count}"
|
||||
|
||||
breach_count=$(get_state "statement_queue_breach_count")
|
||||
breach_count=${breach_count:-0}
|
||||
|
||||
if (( queue_count > STATEMENT_QUEUE_THRESHOLD )); then
|
||||
breach_count=$((breach_count + 1))
|
||||
echo "📈 Statement queue is above threshold. Consecutive breach count: ${breach_count}/${STATEMENT_QUEUE_CONSECUTIVE_RUNS}."
|
||||
else
|
||||
breach_count=0
|
||||
fi
|
||||
set_state "statement_queue_breach_count" "$breach_count"
|
||||
|
||||
if (( breach_count >= STATEMENT_QUEUE_CONSECUTIVE_RUNS )); then
|
||||
message="Statement queue has been over ${STATEMENT_QUEUE_THRESHOLD} for ${breach_count} checks. Current count: ${queue_count}."
|
||||
send_notification_if_changed "hana_statement_queue_status" "HANA Statement Queue" "${message}" "true" "ALERT:${queue_count}"
|
||||
else
|
||||
message="Statement queue is normal. Current count: ${queue_count}."
|
||||
send_notification_if_changed "hana_statement_queue_status" "HANA Statement Queue" "${message}" "false" "OK"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# --- HANA Backup Status Monitoring ---
|
||||
echo "ℹ️ Checking last successful data backup status..."
|
||||
|
||||
# Query to get the start time of the most recent successful complete data backup
|
||||
last_backup_date=$("$HDBSQL_PATH" -U "$HANA_USER_KEY" -j -a -x \
|
||||
"SELECT TOP 1 SYS_START_TIME FROM M_BACKUP_CATALOG WHERE ENTRY_TYPE_NAME = 'complete data backup' AND STATE_NAME = 'successful' ORDER BY SYS_START_TIME DESC" 2>/dev/null | tr -d "\"" | sed 's/\..*//')
|
||||
"SELECT TOP 1 SYS_START_TIME FROM M_BACKUP_CATALOG WHERE ENTRY_TYPE_NAME = 'complete data backup' AND STATE_NAME = 'successful' ORDER BY SYS_START_TIME DESC" 2>/dev/null | tr -d "\"" | sed 's/\..*//') # sed removes fractional seconds
|
||||
|
||||
if [[ -z "$last_backup_date" ]]; then
|
||||
message="No successful complete data backup found for ${COMPANY_NAME} HANA."
|
||||
# No successful backup found at all
|
||||
local message="No successful complete data backup found for ${COMPANY_NAME} HANA."
|
||||
echo "🚨 Critical: ${message}"
|
||||
send_notification_if_changed "hana_backup_status" "HANA Backup" "${message}" "true" "NO_BACKUP"
|
||||
else
|
||||
return
|
||||
fi
|
||||
|
||||
# Convert dates to epoch seconds for comparison
|
||||
last_backup_epoch=$(date -d "$last_backup_date" +%s)
|
||||
current_epoch=$(date +%s)
|
||||
threshold_seconds=$((BACKUP_THRESHOLD_HOURS * 3600))
|
||||
|
||||
age_seconds=$((current_epoch - last_backup_epoch))
|
||||
age_hours=$((age_seconds / 3600))
|
||||
|
||||
if (( age_seconds > threshold_seconds )); then
|
||||
message="Last successful HANA backup for ${COMPANY_NAME} is ${age_hours} hours old, which exceeds the threshold of ${BACKUP_THRESHOLD_HOURS} hours. Last backup was on: ${last_backup_date}."
|
||||
local message="Last successful HANA backup for ${COMPANY_NAME} is ${age_hours} hours old, which exceeds the threshold of ${BACKUP_THRESHOLD_HOURS} hours. Last backup was on: ${last_backup_date}."
|
||||
echo "🚨 Critical: ${message}"
|
||||
send_notification_if_changed "hana_backup_status" "HANA Backup" "${message}" "true" "${age_hours}h"
|
||||
else
|
||||
message="Last successful backup is ${age_hours} hours old (Threshold: ${BACKUP_THRESHOLD_HOURS} hours)."
|
||||
local message="Last successful backup is ${age_hours} hours old (Threshold: ${BACKUP_THRESHOLD_HOURS} hours)."
|
||||
echo "✅ Success! ${message}"
|
||||
send_notification_if_changed "hana_backup_status" "HANA Backup" "${message}" "false" "OK"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Success! HANA monitoring check complete."
|
||||
|
||||
|
||||
+6
-9
@@ -1,19 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Author: Tomi Eckert
|
||||
#
|
||||
# This file contains the configuration for the script downloader.
|
||||
# The `SCRIPT_PACKAGES` associative array maps a short package name
|
||||
# to a pipe-separated string with the following format:
|
||||
# "<Display Name>|<Version>|<Description>|<Space-separated list of URLs>|[Install Script (optional)]"
|
||||
# The Install Script will be executed after all files for the package are downloaded.
|
||||
# "<Display Name>|<Version>|<Description>|<Space-separated list of URLs>"
|
||||
|
||||
declare -A SCRIPT_PACKAGES
|
||||
|
||||
# Format: short_name="Display Name|Version|Description|URL1 URL2..."
|
||||
SCRIPT_PACKAGES["aurora"]="Aurora Suite|2.5.1|A collection of scripts for managing Aurora database instances.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/aurora/aurora.sh https://git.technopunk.space/tomi/Scripts/raw/branch/main/aurora/aurora.conf"
|
||||
SCRIPT_PACKAGES["backup"]="Backup Suite|1.1.0|A comprehensive script for backing up system files and databases.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/backup/backup.sh https://git.technopunk.space/tomi/Scripts/raw/branch/main/backup/backup.conf"
|
||||
SCRIPT_PACKAGES["monitor"]="Monitor Suite|1.3.1|Scripts for monitoring system health and performance metrics.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/monitor/monitor.sh https://git.technopunk.space/tomi/Scripts/raw/branch/main/monitor/monitor.conf|https://git.technopunk.space/tomi/Scripts/raw/branch/main/monitor/monitor.hook.sh"
|
||||
SCRIPT_PACKAGES["keymanager"]="Key Manager|1.2.3|A utility for managing HDB user keys for SAP HANA.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/keymanager.sh"
|
||||
SCRIPT_PACKAGES["aurora"]="Aurora Suite|2.1.0|A collection of scripts for managing Aurora database instances.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/aurora/aurora.sh https://git.technopunk.space/tomi/Scripts/raw/branch/main/aurora/aurora.conf"
|
||||
SCRIPT_PACKAGES["backup"]="Backup Suite|1.0.5|A comprehensive script for backing up system files and databases.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/backup/backup.sh https://git.technopunk.space/tomi/Scripts/raw/branch/main/backup/backup.conf"
|
||||
SCRIPT_PACKAGES["monitor"]="Monitor Suite|1.2.0|Scripts for monitoring system health and performance metrics.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/monitor/monitor.sh https://git.technopunk.space/tomi/Scripts/raw/branch/main/monitor/monitor.conf"
|
||||
SCRIPT_PACKAGES["keymanager"]="Key Manager|1.2.1|A utility for managing HDB user keys for SAP HANA.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/keymanager.sh"
|
||||
SCRIPT_PACKAGES["cleaner"]="File Cleaner|1.1.0|A simple script to clean up temporary files and logs.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/cleaner.sh"
|
||||
SCRIPT_PACKAGES["hanatool"]="HANA Tool|1.6.0|A command-line tool for various SAP HANA administration tasks.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/hanatool.sh"
|
||||
SCRIPT_PACKAGES["hanamgr"]="HANA Manager UI|1.6.0|An interactive command-line menu for managing SAP HANA schemas and databases.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/hanamgr.sh"
|
||||
SCRIPT_PACKAGES["hanatool"]="HANA Tool|1.5.0|A command-line tool for various SAP HANA administration tasks.|https://git.technopunk.space/tomi/Scripts/raw/branch/main/hanatool.sh"
|
||||
|
||||
Reference in New Issue
Block a user