first commit

This commit is contained in:
2026-03-02 20:53:28 +01:00
commit d27c205106
63 changed files with 4593 additions and 0 deletions

129
Services/AuroraService.cs Normal file
View File

@@ -0,0 +1,129 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Performs the Aurora schema refresh:
/// Drop old _AURORA schema → Export source → Import-rename → Update company name → Grant privileges.
/// </summary>
public sealed class AuroraService(
IUserSwitcher switcher,
IHdbClientLocator locator,
INotificationService ntfy,
AppLogger logger) : IAuroraService
{
public async Task RunAsync(
AuroraConfig config, HanaConfig hana, string sid,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(config.SourceSchema))
{
logger.Error("Aurora: SourceSchema is not configured.");
return;
}
var hdbsql = locator.LocateHdbsql(hana.HdbsqlPath, sid, hana.InstanceNumber);
var threads = config.Threads > 0 ? config.Threads : Math.Max(1, Environment.ProcessorCount / 2);
var aurora = $"{config.SourceSchema}_AURORA";
var tmpDir = Path.Combine(config.BackupBasePath, $"{aurora}_TEMP_{DateTime.Now:yyyyMMdd_HHmmss}");
logger.Step($"Starting Aurora refresh: '{config.SourceSchema}' → '{aurora}'");
// 1. Drop old Aurora schema (ignore errors if it doesn't exist)
logger.Step($"Dropping old schema '{aurora}' (if exists)...");
await RunSql(hdbsql, config.AdminUserKey,
$"DROP SCHEMA \"{aurora}\" CASCADE;", sid, ct);
// 2. Prepare temp export directory
await RunAs(sid, $"mkdir -p \"{tmpDir}\"", ct);
// 3. Export source schema
logger.Step($"Exporting '{config.SourceSchema}' to temp dir...");
var exportResult = await RunSql(hdbsql, config.AdminUserKey,
$"EXPORT \"{config.SourceSchema}\".\"*\" AS BINARY INTO '{tmpDir}' WITH REPLACE THREADS {threads} NO DEPENDENCIES;",
sid, ct);
if (!exportResult.Success)
{
logger.Error($"Aurora export failed: {exportResult.StdErr}");
await ntfy.SendAsync("Aurora Failed", $"Export of '{config.SourceSchema}' FAILED.", ct);
await Cleanup(tmpDir, sid, ct);
return;
}
// 4. Import → rename
logger.Step($"Importing as '{aurora}'...");
var importResult = await RunSql(hdbsql, config.AdminUserKey,
$"IMPORT \"{config.SourceSchema}\".\"*\" AS BINARY FROM '{tmpDir}' WITH REPLACE RENAME SCHEMA \"{config.SourceSchema}\" TO \"{aurora}\" THREADS {threads};",
sid, ct);
if (!importResult.Success)
{
logger.Error($"Aurora import failed: {importResult.StdErr}");
await ntfy.SendAsync("Aurora Failed", $"Import-rename to '{aurora}' FAILED.", ct);
await Cleanup(tmpDir, sid, ct);
return;
}
// 5. Get original company name from CINF
logger.Step("Fetching company name from source schema...");
var nameResult = await RunSqlScalar(hdbsql, config.AdminUserKey,
$"SELECT \"CompnyName\" FROM \"{config.SourceSchema}\".\"CINF\";", sid, ct);
var companyName = string.IsNullOrWhiteSpace(nameResult) ? config.SourceSchema : nameResult;
var newName = $"AURORA - {companyName} - {DateTime.Now:yyyy-MM-dd}";
// 6. Update company name in CINF and OADM
logger.Step($"Setting company name to '{newName}'...");
await RunSql(hdbsql, config.AdminUserKey,
$"UPDATE \"{aurora}\".\"CINF\" SET \"CompnyName\" = '{newName}';", sid, ct);
await RunSql(hdbsql, config.AdminUserKey,
$"UPDATE \"{aurora}\".\"OADM\" SET \"CompnyName\" = '{newName}', \"PrintHeadr\" = '{newName}';", sid, ct);
// 7. Grant privileges
if (!string.IsNullOrWhiteSpace(config.AuroraUser))
{
logger.Step($"Granting ALL PRIVILEGES on '{aurora}' to '{config.AuroraUser}'...");
await RunSql(hdbsql, config.AdminUserKey,
$"GRANT ALL PRIVILEGES ON SCHEMA \"{aurora}\" TO \"{config.AuroraUser}\";", sid, ct);
}
// 8. Cleanup temp directory
await Cleanup(tmpDir, sid, ct);
logger.Success($"Aurora refresh of '{aurora}' completed!");
await ntfy.SendAsync("Aurora Complete", $"Aurora refresh of '{aurora}' completed successfully.", ct);
}
private async Task<ProcessResult> RunSql(
string hdbsql, string userKey, string sql, string sid, CancellationToken ct)
{
var tmpFile = Path.Combine("/tmp", $"ht_{Guid.NewGuid():N}.sql");
await File.WriteAllTextAsync(tmpFile, sql, ct);
var result = await switcher.RunAsAsync(sid,
$"\"{hdbsql}\" -U {userKey} -I \"{tmpFile}\" 2>&1", ct);
File.Delete(tmpFile);
return result;
}
private async Task<string> RunSqlScalar(
string hdbsql, string userKey, string sql, string sid, CancellationToken ct)
{
var tmpFile = Path.Combine("/tmp", $"ht_{Guid.NewGuid():N}.sql");
await File.WriteAllTextAsync(tmpFile, sql, ct);
var result = await switcher.RunAsAsync(sid,
$"\"{hdbsql}\" -U {userKey} -a -x -I \"{tmpFile}\" 2>&1", ct);
File.Delete(tmpFile);
return result.StdOut.Replace("\"", "").Trim();
}
private Task<ProcessResult> RunAs(string sid, string cmd, CancellationToken ct) =>
switcher.RunAsAsync(sid, cmd, ct);
private async Task Cleanup(string dir, string sid, CancellationToken ct)
{
logger.Step($"Cleaning up temp dir '{dir}'...");
await RunAs(sid, $"rm -rf \"{dir}\"", ct);
}
}

232
Services/BackupService.cs Normal file
View File

@@ -0,0 +1,232 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Handles tenant backups and schema exports/imports.
/// All hdbsql and file-creation operations run as &lt;sid&gt;adm via IUserSwitcher.
/// Compression (tar/pigz) runs as &lt;sid&gt;adm so the archive is owned by the HANA user.
/// </summary>
public sealed class BackupService(
IUserSwitcher switcher,
IHdbClientLocator locator,
INotificationService ntfy,
AppLogger logger) : IBackupService
{
public async Task RunAsync(
BackupConfig config, HanaConfig hana, string sid,
CancellationToken ct = default)
{
var hdbsql = locator.LocateHdbsql(hana.HdbsqlPath, sid, hana.InstanceNumber);
var threads = ResolveThreads(config.Threads);
switch (config.Type)
{
case BackupType.Schema:
await RunSchemaExportsAsync(config, hdbsql, sid, threads, ct);
break;
case BackupType.Tenant:
await RunTenantBackupAsync(config, hdbsql, sid, ct);
break;
case BackupType.All:
await RunSchemaExportsAsync(config, hdbsql, sid, threads, ct);
await RunTenantBackupAsync(config, hdbsql, sid, ct);
break;
}
}
// ── Public helpers used by ExportCommand / ImportCommand ─────────────────
public async Task ExportSchemaAsync(
string hdbsql, string userKey, string schema, string targetPath,
int threads, bool compress, string sid, CancellationToken ct)
{
logger.Step($"Exporting schema '{schema}' to '{targetPath}'...");
Directory.CreateDirectory(targetPath);
string exportDir = targetPath;
string? archivePath = null;
if (compress)
{
var tmpName = $"export_{schema}_{DateTime.Now:yyyyMMdd_HHmmss}";
exportDir = Path.Combine(targetPath, tmpName);
archivePath = Path.Combine(targetPath, $"{schema}_{DateTime.Now:yyyyMMdd_HHmmss}.tar.gz");
await RunAs(sid, $"mkdir -p \"{exportDir}\"", ct);
}
var sql = $"EXPORT \"{schema}\".\"*\" AS BINARY INTO '{exportDir}' WITH REPLACE THREADS {threads} NO DEPENDENCIES;";
var result = await RunHdbsqlAsync(hdbsql, userKey, sql, sid, ct);
if (!result.Success)
{
logger.Error($"Schema export failed for '{schema}': {result.StdErr}");
await ntfy.SendAsync("HANA Export Failed", $"Export of schema '{schema}' FAILED.", ct);
return;
}
if (compress && archivePath != null)
await CompressAsync(exportDir, archivePath, sid, threads, ct);
logger.Success($"Schema export of '{schema}' complete.");
await ntfy.SendAsync("HANA Export", $"Export of schema '{schema}' completed successfully.", ct);
}
public async Task ImportSchemaAsync(
string hdbsql, string userKey, string schema, string sourcePath,
int threads, bool compress, bool replace, string? newSchema, string sid, CancellationToken ct)
{
logger.Step($"Importing schema '{schema}'{(newSchema != null ? $" as '{newSchema}'" : "")}...");
string importDir = sourcePath;
string? tmpDir = null;
if (compress)
{
tmpDir = Path.Combine("/tmp", $"import_{schema}_{Path.GetRandomFileName()}");
await RunAs(sid, $"mkdir -p \"{tmpDir}\"", ct);
var decompResult = await RunAs(sid, $"tar -xzf \"{sourcePath}\" -C \"{tmpDir}\" --strip-components=1", ct);
if (!decompResult.Success)
{
logger.Error($"Decompression failed: {decompResult.StdErr}");
return;
}
importDir = tmpDir;
}
var mode = replace ? "REPLACE" : "IGNORE EXISTING";
var rename = newSchema != null ? $" RENAME SCHEMA \"{schema}\" TO \"{newSchema}\"" : string.Empty;
var sql = $"IMPORT \"{schema}\".\"*\" AS BINARY FROM '{importDir}' WITH {mode}{rename} THREADS {threads};";
var result = await RunHdbsqlAsync(hdbsql, userKey, sql, sid, ct);
var target = newSchema ?? schema;
if (!result.Success)
{
logger.Error($"Import failed: {result.StdErr}");
await ntfy.SendAsync("HANA Import Failed", $"Import of '{schema}' to '{target}' FAILED.", ct);
}
else
{
logger.Success("Import complete.");
await ntfy.SendAsync("HANA Import", $"Import of '{schema}' to '{target}' completed.", ct);
}
if (tmpDir != null)
await RunAs(sid, $"rm -rf \"{tmpDir}\"", ct);
}
// ── Private helpers ───────────────────────────────────────────────────────
private async Task RunSchemaExportsAsync(
BackupConfig config, string hdbsql, string sid, int threads, CancellationToken ct)
{
foreach (var schema in config.SchemaNames)
await ExportSchemaAsync(hdbsql, config.UserKey, schema,
config.SchemaBackupPath, threads, config.CompressSchema, sid, ct);
}
private async Task RunTenantBackupAsync(
BackupConfig config, string hdbsql, string sid, CancellationToken ct)
{
await BackupTenantAsync(hdbsql, config.UserKey, config.BackupBasePath,
config.Compress, sid, ct);
if (config.BackupSystemDb && !string.IsNullOrWhiteSpace(config.SystemDbUserKey))
await BackupTenantAsync(hdbsql, config.SystemDbUserKey, config.BackupBasePath,
config.Compress, sid, ct);
}
private async Task BackupTenantAsync(
string hdbsql, string userKey, string basePath,
bool compress, string sid, CancellationToken ct)
{
logger.Step("Starting tenant backup...");
var ts = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string backupDir = basePath;
string? archivePath = null;
if (compress)
{
backupDir = Path.Combine(basePath, $"backup_{ts}");
archivePath = Path.Combine(basePath, $"backup_{ts}.tar.gz");
await RunAs(sid, $"mkdir -p \"{backupDir}\"", ct);
}
else
{
await RunAs(sid, $"mkdir -p \"{backupDir}\"", ct);
}
var prefix = Path.Combine(backupDir, $"backup_{ts}");
var sql = $"BACKUP DATA USING FILE ('{prefix}');";
var result = await RunHdbsqlAsync(hdbsql, userKey, sql, sid, ct);
if (!result.Success)
{
logger.Error($"Tenant backup failed: {result.StdErr}");
await ntfy.SendAsync("HANA Backup Failed", "Tenant backup FAILED.", ct);
if (compress) await RunAs(sid, $"rm -rf \"{backupDir}\"", ct);
return;
}
if (compress && archivePath != null)
await CompressAsync(backupDir, archivePath, sid, 0, ct);
logger.Success("Tenant backup complete.");
await ntfy.SendAsync("HANA Backup", "Tenant backup completed successfully.", ct);
}
private async Task CompressAsync(
string sourceDir, string archivePath, string sid, int threads, CancellationToken ct)
{
logger.Step($"Compressing '{sourceDir}' → '{archivePath}'...");
// Check for pigz availability
var whichPigz = await RunAs(sid, "which pigz 2>/dev/null", ct);
string tarCmd;
if (whichPigz.Success && !string.IsNullOrWhiteSpace(whichPigz.StdOut))
{
var pigzThreads = threads > 0 ? threads : ResolveThreads(0);
tarCmd = $"tar -I \"pigz -p {pigzThreads}\" -cf \"{archivePath}\" -C \"{sourceDir}\" .";
}
else
{
tarCmd = $"tar -czf \"{archivePath}\" -C \"{sourceDir}\" .";
}
var result = await RunAs(sid, tarCmd, ct);
if (!result.Success)
{
logger.Error($"Compression failed: {result.StdErr}");
return;
}
await RunAs(sid, $"rm -rf \"{sourceDir}\"", ct);
logger.Success("Compression complete.");
}
private async Task<ProcessResult> RunHdbsqlAsync(
string hdbsql, string userKey, string sql, string sid, CancellationToken ct)
{
// Write SQL to a temp file so no shell-quoting complications arise
var tmpFile = Path.Combine("/tmp", $"ht_{Guid.NewGuid():N}.sql");
await File.WriteAllTextAsync(tmpFile, sql, ct);
// chmod so ndbadm can read it
await switcher.RunAsAsync(sid, $"chmod 644 \"{tmpFile}\" 2>/dev/null; true", ct);
var result = await switcher.RunAsAsync(sid,
$"\"{hdbsql}\" -U {userKey} -I \"{tmpFile}\" 2>&1", ct);
File.Delete(tmpFile);
return result;
}
private Task<ProcessResult> RunAs(string sid, string cmd, CancellationToken ct) =>
switcher.RunAsAsync(sid, cmd, ct);
private static int ResolveThreads(int configured) =>
configured > 0 ? configured : Math.Max(1, Environment.ProcessorCount / 2);
}

View File

@@ -0,0 +1,68 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Deletes backup and log files older than their configured retention period.
/// Runs as root (no user switching needed for file deletion).
/// </summary>
public sealed class CleanerService(AppLogger logger) : ICleanerService
{
public Task RunAsync(CleanerConfig config, CancellationToken ct = default)
{
logger.Step($"Cleaning tenant backup path: {config.TenantBackupPath} "
+ $"(retention: {config.TenantRetentionDays}d)");
CleanDirectory(config.TenantBackupPath, config.TenantRetentionDays);
foreach (var logPath in config.LogBackupPaths)
{
ct.ThrowIfCancellationRequested();
logger.Step($"Cleaning log backup path: {logPath} "
+ $"(retention: {config.LogRetentionDays}d)");
CleanDirectory(logPath, config.LogRetentionDays);
}
logger.Success("Cleanup complete.");
return Task.CompletedTask;
}
private void CleanDirectory(string directory, int retentionDays)
{
if (!Directory.Exists(directory))
{
logger.Warning($"Directory not found, skipping: {directory}");
return;
}
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
int deleted = 0;
try
{
foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly))
{
try
{
if (File.GetLastWriteTimeUtc(file) < cutoff)
{
File.Delete(file);
deleted++;
logger.Info($"Deleted: {file}");
}
}
catch (Exception ex)
{
logger.Warning($"Could not delete '{file}': {ex.Message}");
}
}
}
catch (Exception ex)
{
logger.Error($"Error enumerating '{directory}': {ex.Message}");
}
logger.Info($"Deleted {deleted} file(s) from '{directory}'.");
}
}

View File

@@ -0,0 +1,38 @@
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// File-based alert state persistence.
/// Each key maps to /etc/hanatoolbox/state/&lt;key&gt;.state containing a plain-text value.
/// </summary>
public sealed class FileMonitorStateService(AppLogger logger) : IMonitorStateService
{
private static readonly string StateDir = Config.ConfigService.StateDirectory;
public string? GetState(string key)
{
var path = StatePath(key);
if (!File.Exists(path)) return null;
try { return File.ReadAllText(path).Trim(); }
catch { return null; }
}
public void SetState(string key, string value)
{
try
{
Directory.CreateDirectory(StateDir);
File.WriteAllText(StatePath(key), value);
}
catch (Exception ex)
{
logger.Warning($"Could not write state for '{key}': {ex.Message}");
}
}
private static string StatePath(string key) =>
Path.Combine(StateDir, $"{key}.state");
}

101
Services/FirewallService.cs Normal file
View File

@@ -0,0 +1,101 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Applies firewall rules via firewall-cmd (firewalld).
/// Used by CronOrchestrator (non-interactive apply) and FirewallTui (interactive).
/// </summary>
public sealed class FirewallService(IProcessRunner runner, AppLogger logger) : IFirewallService
{
public async Task ApplyAsync(FirewallConfig config, CancellationToken ct = default)
{
logger.Step("Applying firewall rules (firewall-cmd)...");
if (config.FlushBeforeApply)
{
logger.Step("Flushing existing rules...");
await FlushAsync(ct);
}
else
{
// Remove insecure catch-all rules as a safety measure
await Cmd("--remove-port=0-65535/tcp", ct);
await Cmd("--remove-service=ssh", ct);
await Cmd("--remove-port=22/tcp", ct);
}
foreach (var svc in config.Services)
{
ct.ThrowIfCancellationRequested();
switch (svc.Decision)
{
case FirewallDecision.All:
foreach (var port in svc.Ports)
{
logger.Step($"Opening port {port}/tcp globally ({svc.Name})");
await Cmd($"--add-port={port}/tcp", ct);
}
break;
case FirewallDecision.Ip:
foreach (var ip in svc.AllowedIps)
{
foreach (var port in svc.Ports)
{
logger.Step($"Restricting port {port}/tcp to {ip} ({svc.Name})");
await Cmd(
$"--add-rich-rule=rule family='ipv4' source address='{ip}' port port='{port}' protocol='tcp' accept",
ct);
}
}
break;
case FirewallDecision.Skip:
logger.Info($"Skipping {svc.Name}");
break;
}
}
// Save runtime config to permanent
logger.Step("Saving rules permanently...");
await Cmd("--runtime-to-permanent", ct);
logger.Success("Firewall rules applied.");
}
private async Task FlushAsync(CancellationToken ct)
{
// list and remove all services
var services = (await RunCmd("--list-services", ct)).StdOut.Trim();
foreach (var s in services.Split(' ', StringSplitOptions.RemoveEmptyEntries))
await Cmd($"--remove-service={s}", ct);
// list and remove all ports
var ports = (await RunCmd("--list-ports", ct)).StdOut.Trim();
foreach (var p in ports.Split(' ', StringSplitOptions.RemoveEmptyEntries))
await Cmd($"--remove-port={p}", ct);
// list and remove rich rules
var richRules = (await RunCmd("--list-rich-rules", ct)).StdOut.Trim();
foreach (var rule in richRules.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (!string.IsNullOrWhiteSpace(rule))
await Cmd($"--remove-rich-rule={rule.Trim()}", ct);
}
}
private Task<ProcessResult> Cmd(string args, CancellationToken ct) =>
RunCmd(args, ct);
private async Task<ProcessResult> RunCmd(string args, CancellationToken ct)
{
// Split simple args by space (they won't contain spaces except rich-rules)
// Use shell to handle complex rich-rule strings
var result = await runner.RunAsync(
"/bin/bash", ["-c", $"firewall-cmd {args}"], ct);
return result;
}
}

View File

@@ -0,0 +1,42 @@
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Locates hdbsql and hdbuserstore binaries using:
/// 1. Value from config (if set)
/// 2. `which` lookup on PATH
/// 3. /usr/sap/hdbclient/
/// 4. /usr/sap/&lt;SID&gt;/HDB&lt;instance&gt;/exe/
/// </summary>
public sealed class HdbClientLocator(IProcessRunner runner) : IHdbClientLocator
{
public string LocateHdbsql(string? configuredPath, string sid, string instanceNumber) =>
Locate("hdbsql", configuredPath, sid, instanceNumber);
public string LocateHdbuserstore(string? configuredPath, string sid, string instanceNumber) =>
Locate("hdbuserstore", configuredPath, sid, instanceNumber);
private string Locate(string binary, string? configuredPath, string sid, string instanceNumber)
{
// 1. User-configured explicit path
if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath))
return configuredPath;
// 2. which <binary>
var whichResult = runner.RunAsync("/usr/bin/which", [binary]).GetAwaiter().GetResult();
if (whichResult.Success && !string.IsNullOrWhiteSpace(whichResult.StdOut))
return whichResult.StdOut.Split('\n')[0].Trim();
// 3. /usr/sap/hdbclient/
var path3 = $"/usr/sap/hdbclient/{binary}";
if (File.Exists(path3)) return path3;
// 4. /usr/sap/<SID>/HDB<instance>/exe/
var path4 = $"/usr/sap/{sid.ToUpperInvariant()}/HDB{instanceNumber}/exe/{binary}";
if (File.Exists(path4)) return path4;
throw new FileNotFoundException(
$"Could not locate '{binary}'. Set HdbsqlPath/HdbuserstorePath in hanatoolbox.json or ensure it is on PATH.");
}
}

View File

@@ -0,0 +1,8 @@
using HanaToolbox.Config;
namespace HanaToolbox.Services.Interfaces;
public interface IAuroraService
{
Task RunAsync(AuroraConfig config, HanaConfig hana, string sid, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using HanaToolbox.Config;
namespace HanaToolbox.Services.Interfaces;
public interface IBackupService
{
Task RunAsync(BackupConfig config, HanaConfig hana, string sid, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using HanaToolbox.Config;
namespace HanaToolbox.Services.Interfaces;
public interface ICleanerService
{
Task RunAsync(CleanerConfig config, CancellationToken ct = default);
}

View File

@@ -0,0 +1,9 @@
using HanaToolbox.Config;
namespace HanaToolbox.Services.Interfaces;
public interface IFirewallService
{
/// <summary>Applies the saved firewall config non-interactively (cron mode).</summary>
Task ApplyAsync(FirewallConfig config, CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
namespace HanaToolbox.Services.Interfaces;
public interface IHdbClientLocator
{
/// <summary>Returns the resolved path to hdbsql. Throws if not found.</summary>
string LocateHdbsql(string? configuredPath, string sid, string instanceNumber);
/// <summary>Returns the resolved path to hdbuserstore. Throws if not found.</summary>
string LocateHdbuserstore(string? configuredPath, string sid, string instanceNumber);
}

View File

@@ -0,0 +1,15 @@
namespace HanaToolbox.Services.Interfaces;
public interface IKeyManagerService
{
Task<bool> CreateKeyAsync(
string keyName, string connectionString,
string user, string password,
string sid, CancellationToken ct = default);
Task<bool> DeleteKeyAsync(string keyName, string sid, CancellationToken ct = default);
Task<IReadOnlyList<string>> ListKeysAsync(string sid, CancellationToken ct = default);
Task<bool> TestKeyAsync(string hdbsqlPath, string keyName, string sid, CancellationToken ct = default);
}

View File

@@ -0,0 +1,8 @@
using HanaToolbox.Config;
namespace HanaToolbox.Services.Interfaces;
public interface IMonitorService
{
Task RunAsync(MonitorConfig config, HanaConfig hana, string sid, CancellationToken ct = default);
}

View File

@@ -0,0 +1,7 @@
namespace HanaToolbox.Services.Interfaces;
public interface IMonitorStateService
{
string? GetState(string key);
void SetState(string key, string value);
}

View File

@@ -0,0 +1,6 @@
namespace HanaToolbox.Services.Interfaces;
public interface INotificationService
{
Task SendAsync(string title, string message, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
namespace HanaToolbox.Services.Interfaces;
public sealed record ProcessResult(int ExitCode, string StdOut, string StdErr)
{
public bool Success => ExitCode == 0;
}
public interface IProcessRunner
{
Task<ProcessResult> RunAsync(
string executable, string[] args,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,14 @@
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services.Interfaces;
public interface IUserSwitcher
{
/// <summary>
/// Executes a shell command string as &lt;sid&gt;adm using `su - &lt;sid&gt;adm -c`.
/// If already running as &lt;sid&gt;adm, runs the command directly.
/// </summary>
Task<ProcessResult> RunAsAsync(
string sid, string shellCommand,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,74 @@
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Wraps hdbuserstore operations using SuUserSwitcher to run as &lt;sid&gt;adm.
/// Keys stored in the OS user's home directory (~/.hdbusers/).
/// </summary>
public sealed class KeyManagerService(
IUserSwitcher switcher,
IHdbClientLocator locator,
AppLogger logger) : IKeyManagerService
{
public async Task<bool> CreateKeyAsync(
string keyName, string connectionString,
string user, string password,
string sid, CancellationToken ct = default)
{
var hdbus = locator.LocateHdbuserstore(null, sid, "00");
var result = await switcher.RunAsAsync(sid,
$"\"{hdbus}\" SET \"{keyName}\" \"{connectionString}\" \"{user}\" \"{password}\"", ct);
if (!result.Success)
{
logger.Error($"Failed to create key '{keyName}': {result.StdErr}");
return false;
}
logger.Success($"Key '{keyName}' created.");
return true;
}
public async Task<bool> DeleteKeyAsync(string keyName, string sid, CancellationToken ct = default)
{
var hdbus = locator.LocateHdbuserstore(null, sid, "00");
var result = await switcher.RunAsAsync(sid,
$"\"{hdbus}\" DELETE \"{keyName}\"", ct);
if (!result.Success)
{
logger.Error($"Failed to delete key '{keyName}': {result.StdErr}");
return false;
}
logger.Success($"Key '{keyName}' deleted.");
return true;
}
public async Task<IReadOnlyList<string>> ListKeysAsync(string sid, CancellationToken ct = default)
{
var hdbus = locator.LocateHdbuserstore(null, sid, "00");
var result = await switcher.RunAsAsync(sid, $"\"{hdbus}\" LIST", ct);
if (!result.Success) return [];
// Parse lines like: "KEY mykey" from hdbuserstore LIST output
return result.StdOut
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Where(l => l.TrimStart().StartsWith("KEY "))
.Select(l => l.Trim()[4..].Trim()) // strip "KEY "
.ToList();
}
public async Task<bool> TestKeyAsync(
string hdbsqlPath, string keyName, string sid, CancellationToken ct = default)
{
var result = await switcher.RunAsAsync(sid,
$"\"{hdbsqlPath}\" -U \"{keyName}\" \"SELECT 'Connection successful' FROM DUMMY\"", ct);
var ok = result.Success && result.StdOut.Contains("Connection successful");
if (ok) logger.Success($"Key '{keyName}' connection test passed.");
else logger.Error($"Key '{keyName}' connection test failed: {result.StdErr}");
return ok;
}
}

229
Services/MonitorService.cs Normal file
View File

@@ -0,0 +1,229 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Monitors HANA health: process status, disk usage, log segments, statement queue, backup age.
/// Sends state-change notifications via ntfy to avoid alert spam.
/// </summary>
public sealed class MonitorService(
IProcessRunner runner,
IUserSwitcher switcher,
IHdbClientLocator locator,
INotificationService ntfy,
IMonitorStateService state,
AppLogger logger) : IMonitorService
{
public async Task RunAsync(
MonitorConfig config, HanaConfig hana, string sid,
CancellationToken ct = default)
{
var hdbsql = locator.LocateHdbsql(hana.HdbsqlPath, sid, hana.InstanceNumber);
var host = System.Net.Dns.GetHostName();
var prefix = $"[{config.CompanyName} | {host}]";
// 1. HANA processes (sapcontrol runs as root)
logger.Step("Checking HANA processes...");
var sapResult = await runner.RunAsync(
config.SapcontrolPath,
["-nr", config.HanaInstanceNumber, "-function", "GetProcessList"], ct);
var nonGreen = sapResult.StdOut
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Skip(5) // skip header lines
.Where(l => !l.Contains("GREEN"))
.ToList();
if (nonGreen.Count > 0)
{
var msg = string.Join(", ", nonGreen.Select(l => l.Trim()));
await NotifyIfChanged("hana_processes", "HANA Process",
$"{prefix} One or more HANA processes are not GREEN: {msg}",
isAlert: true, currentVal: $"ALERT:{msg}", ct);
return; // Exit early — other checks may also fail
}
else
{
await NotifyIfChanged("hana_processes", "HANA Process",
$"{prefix} All HANA processes are GREEN.",
isAlert: false, currentVal: "OK", ct);
}
// 2. Disk usage
logger.Step("Checking disk usage...");
foreach (var dir in config.DirectoriesToMonitor)
{
ct.ThrowIfCancellationRequested();
var dfResult = await runner.RunAsync("/bin/df", ["-h", dir], ct);
var usageStr = dfResult.StdOut
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Skip(1).FirstOrDefault()
?.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.ElementAtOrDefault(4)
?.TrimEnd('%');
if (!int.TryParse(usageStr, out var usage))
{
logger.Warning($"Could not parse disk usage for '{dir}'.");
continue;
}
var key = $"disk_{dir.Replace('/', '_')}";
if (usage > config.DiskUsageThresholdPercent)
{
await NotifyIfChanged(key, "HANA Disk",
$"{prefix} Disk usage for '{dir}' is at {usage}% (threshold: {config.DiskUsageThresholdPercent}%).",
isAlert: true, currentVal: $"{usage}%", ct);
}
else
{
await NotifyIfChanged(key, "HANA Disk",
$"{prefix} Disk '{dir}' is at {usage}% (OK).",
isAlert: false, currentVal: "OK", ct);
}
}
// 3. Log segments
logger.Step("Checking HANA log segments...");
var segSql = "SELECT b.host, b.service_name, a.state, count(*) " +
"FROM PUBLIC.M_LOG_SEGMENTS a " +
"JOIN PUBLIC.M_SERVICES b ON (a.host = b.host AND a.port = b.port) " +
"GROUP BY b.host, b.service_name, a.state;";
var segResult = await RunSql(hdbsql, config.HanaUserKey, segSql, sid, false, ct);
int total = 0, truncated = 0, free = 0;
foreach (var line in segResult.StdOut.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (line.Contains("host") || line.Contains("HOST")) continue;
var parts = line.Replace("\"", "").Split(',');
if (parts.Length < 4) continue;
if (!int.TryParse(parts[3].Trim(), out var cnt)) continue;
total += cnt;
var seg = parts[2].Trim();
if (seg == "Truncated") truncated += cnt;
else if (seg == "Free") free += cnt;
}
if (total > 0)
{
var truncPct = truncated * 100 / total;
var freePct = free * 100 / total;
if (truncPct > config.TruncatedSegmentThresholdPercent)
await NotifyIfChanged("hana_log_truncated", "HANA Log Segment",
$"{prefix} {truncPct}% of log segments are 'Truncated' (threshold: {config.TruncatedSegmentThresholdPercent}%).",
isAlert: true, currentVal: $"{truncPct}%", ct);
else
await NotifyIfChanged("hana_log_truncated", "HANA Log Segment",
$"{prefix} Log segments OK ({truncPct}% truncated).",
isAlert: false, currentVal: "OK", ct);
if (freePct < config.FreeSegmentThresholdPercent)
await NotifyIfChanged("hana_log_free", "HANA Log Segment",
$"{prefix} Only {freePct}% of log segments are 'Free' (threshold: {config.FreeSegmentThresholdPercent}%).",
isAlert: true, currentVal: $"{freePct}%", ct);
else
await NotifyIfChanged("hana_log_free", "HANA Log Segment",
$"{prefix} Free log segments OK ({freePct}%).",
isAlert: false, currentVal: "OK", ct);
}
// 4. Statement queue
logger.Step("Checking HANA statement queue...");
var queueSql = "SELECT COUNT(*) FROM M_SERVICE_THREADS " +
"WHERE THREAD_TYPE = 'SqlExecutor' AND THREAD_STATE = 'Queueing';";
var queueResult = await RunSql(hdbsql, config.HanaUserKey, queueSql, sid, scalar: true, ct);
if (int.TryParse(queueResult.StdOut.Trim().Replace("\"", ""), out var queueCount))
{
var breachStr = state.GetState("statement_queue_breach_count") ?? "0";
var breachCount = int.TryParse(breachStr, out var b) ? b : 0;
if (queueCount > config.StatementQueueThreshold)
breachCount++;
else
breachCount = 0;
state.SetState("statement_queue_breach_count", breachCount.ToString());
if (breachCount >= config.StatementQueueConsecutiveRuns)
await NotifyIfChanged("hana_statement_queue", "HANA Statement Queue",
$"{prefix} Statement queue has been over {config.StatementQueueThreshold} for {breachCount} checks. Current: {queueCount}.",
isAlert: true, currentVal: $"ALERT:{queueCount}", ct);
else
await NotifyIfChanged("hana_statement_queue", "HANA Statement Queue",
$"{prefix} Statement queue is normal ({queueCount}).",
isAlert: false, currentVal: "OK", ct);
}
// 5. Backup age
logger.Step("Checking last successful backup age...");
var bakSql = "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;";
var bakResult = await RunSql(hdbsql, config.HanaUserKey, bakSql, sid, scalar: true, ct);
var bakDateStr = bakResult.StdOut.Trim().Replace("\"", "").Split('.')[0];
if (string.IsNullOrWhiteSpace(bakDateStr) || !DateTime.TryParse(bakDateStr, out var lastBak))
{
await NotifyIfChanged("hana_backup_status", "HANA Backup",
$"{prefix} No successful backup found.",
isAlert: true, currentVal: "NO_BACKUP", ct);
}
else
{
var ageHours = (int)(DateTime.UtcNow - lastBak.ToUniversalTime()).TotalHours;
if (ageHours > config.BackupThresholdHours)
await NotifyIfChanged("hana_backup_status", "HANA Backup",
$"{prefix} Last successful backup is {ageHours}h old (threshold: {config.BackupThresholdHours}h). Last backup: {lastBak:yyyy-MM-dd HH:mm}.",
isAlert: true, currentVal: $"{ageHours}h", ct);
else
await NotifyIfChanged("hana_backup_status", "HANA Backup",
$"{prefix} Backup age is {ageHours}h (OK).",
isAlert: false, currentVal: "OK", ct);
}
logger.Success("Monitor check complete.");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private async Task<ProcessResult> RunSql(
string hdbsql, string userKey, string sql, string sid,
bool scalar, CancellationToken ct)
{
var tmpFile = Path.Combine("/tmp", $"ht_{Guid.NewGuid():N}.sql");
await File.WriteAllTextAsync(tmpFile, sql, ct);
var flags = scalar ? $"-a -x" : string.Empty;
var result = await switcher.RunAsAsync(sid,
$"\"{hdbsql}\" -U {userKey} {flags} -I \"{tmpFile}\" 2>&1", ct);
File.Delete(tmpFile);
return result;
}
private async Task NotifyIfChanged(
string key, string titlePrefix, string message,
bool isAlert, string currentVal, CancellationToken ct)
{
var prev = state.GetState(key);
if (currentVal == prev) return; // No change — don't spam
string title;
if (isAlert)
title = $"{titlePrefix} Alert";
else if (!string.IsNullOrEmpty(prev) && prev != "OK")
title = $"{titlePrefix} Resolved";
else
{
state.SetState(key, currentVal);
return; // Transition OK→OK: update silently
}
await ntfy.SendAsync(title, message, ct);
state.SetState(key, currentVal);
logger.Info($"Notification sent: [{title}]");
}
}

View File

@@ -0,0 +1,39 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Sends notifications to a ntfy server.
/// URL and token are read from <see cref="NtfyConfig"/> (set during onboarding).
/// Failures are silently swallowed — notifications must never crash the main flow.
/// </summary>
public sealed class NtfyNotificationService(NtfyConfig config, AppLogger logger)
: INotificationService
{
private static readonly HttpClient Http = new();
public async Task SendAsync(string title, string message, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(config.Url) || string.IsNullOrWhiteSpace(config.Token))
{
logger.Info("Ntfy not configured — skipping notification.");
return;
}
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, config.Url);
request.Headers.Add("Authorization", $"Bearer {config.Token}");
request.Headers.Add("Title", title);
request.Content = new StringContent(message);
await Http.SendAsync(request, ct);
logger.Info($"Notification sent: [{title}] {message}");
}
catch (Exception ex)
{
logger.Warning($"Failed to send ntfy notification: {ex.Message}");
}
}
}

43
Services/ProcessRunner.cs Normal file
View File

@@ -0,0 +1,43 @@
using System.Diagnostics;
using HanaToolbox.Logging;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>Runs external processes and captures stdout/stderr.</summary>
public sealed class ProcessRunner(AppLogger logger) : IProcessRunner
{
public async Task<ProcessResult> RunAsync(
string executable, string[] args,
CancellationToken ct = default)
{
var psi = new ProcessStartInfo
{
FileName = executable,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var arg in args) psi.ArgumentList.Add(arg);
logger.Info($"Exec: {executable} {string.Join(' ', args)}");
using var process = new Process { StartInfo = psi };
process.Start();
var stdoutTask = process.StandardOutput.ReadToEndAsync(ct);
var stderrTask = process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
var stdout = await stdoutTask;
var stderr = await stderrTask;
logger.Info($"Exit code: {process.ExitCode}");
if (!string.IsNullOrWhiteSpace(stderr))
logger.Info($"Stderr: {stderr.Trim()}");
return new ProcessResult(process.ExitCode, stdout.Trim(), stderr.Trim());
}
}

View File

@@ -0,0 +1,74 @@
using HanaToolbox.Config;
using HanaToolbox.Logging;
using HanaToolbox.Scheduling;
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Simple factory for wiring service dependencies without a DI container.
/// AOT-safe — no reflection, no generic activators.
/// </summary>
public static class ServiceFactory
{
public static ProcessRunner CreateRunner(AppLogger log) => new(log);
public static SuUserSwitcher CreateSwitcher(AppLogger log) => new(CreateRunner(log));
public static HdbClientLocator CreateLocator(AppLogger log) => new(CreateRunner(log));
public static NtfyNotificationService CreateNtfy(AppLogger log, NtfyConfig ntfy) =>
new(ntfy, log);
public static FileMonitorStateService CreateState(AppLogger log) => new(log);
public static BackupService CreateBackupService(AppLogger log)
{
var config = ConfigService.Load();
var runner = CreateRunner(log);
var switcher = new SuUserSwitcher(runner);
var locator = new HdbClientLocator(runner);
var ntfy = CreateNtfy(log, config.Ntfy);
return new BackupService(switcher, locator, ntfy, log);
}
public static AuroraService CreateAuroraService(AppLogger log)
{
var config = ConfigService.Load();
var runner = CreateRunner(log);
var switcher = new SuUserSwitcher(runner);
var locator = new HdbClientLocator(runner);
var ntfy = CreateNtfy(log, config.Ntfy);
return new AuroraService(switcher, locator, ntfy, log);
}
public static MonitorService CreateMonitorService(AppLogger log)
{
var config = ConfigService.Load();
var runner = CreateRunner(log);
var switcher = new SuUserSwitcher(runner);
var locator = new HdbClientLocator(runner);
var ntfy = CreateNtfy(log, config.Ntfy);
var state = CreateState(log);
return new MonitorService(runner, switcher, locator, ntfy, state, log);
}
public static CronOrchestrator CreateCronOrchestrator(AppLogger log)
{
var config = ConfigService.Load();
var runner = CreateRunner(log);
var switcher = new SuUserSwitcher(runner);
var locator = new HdbClientLocator(runner);
var ntfy = CreateNtfy(log, config.Ntfy);
var state = CreateState(log);
return new CronOrchestrator(
monitor: new MonitorService(runner, switcher, locator, ntfy, state, log),
backup: new BackupService(switcher, locator, ntfy, log),
cleaner: new CleanerService(log),
aurora: new AuroraService(switcher, locator, ntfy, log),
firewall: new FirewallService(runner, log),
stateService: state,
logger: log);
}
}

View File

@@ -0,0 +1,26 @@
using HanaToolbox.Services.Interfaces;
namespace HanaToolbox.Services;
/// <summary>
/// Wraps a shell command to run as &lt;sid&gt;adm using `su - &lt;sid&gt;adm -c '...'`.
/// If the current OS user is already &lt;sid&gt;adm, the command is executed directly.
/// </summary>
public sealed class SuUserSwitcher(IProcessRunner runner) : IUserSwitcher
{
public async Task<ProcessResult> RunAsAsync(
string sid, string shellCommand,
CancellationToken ct = default)
{
var sidAdm = $"{sid.ToLowerInvariant()}adm";
if (string.Equals(Environment.UserName, sidAdm, StringComparison.OrdinalIgnoreCase))
{
// Already running as the correct user — run directly via bash
return await runner.RunAsync("/bin/bash", ["-c", shellCommand], ct);
}
// Use `su -` to inherit the user's environment (HOME, PATH, etc.)
return await runner.RunAsync("/bin/su", ["-", sidAdm, "-c", shellCommand], ct);
}
}