first commit
This commit is contained in:
129
Services/AuroraService.cs
Normal file
129
Services/AuroraService.cs
Normal 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
232
Services/BackupService.cs
Normal 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 <sid>adm via IUserSwitcher.
|
||||
/// Compression (tar/pigz) runs as <sid>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);
|
||||
}
|
||||
68
Services/CleanerService.cs
Normal file
68
Services/CleanerService.cs
Normal 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}'.");
|
||||
}
|
||||
}
|
||||
38
Services/FileMonitorStateService.cs
Normal file
38
Services/FileMonitorStateService.cs
Normal 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/<key>.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
101
Services/FirewallService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
42
Services/HdbClientLocator.cs
Normal file
42
Services/HdbClientLocator.cs
Normal 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/<SID>/HDB<instance>/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.");
|
||||
}
|
||||
}
|
||||
8
Services/Interfaces/IAuroraService.cs
Normal file
8
Services/Interfaces/IAuroraService.cs
Normal 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);
|
||||
}
|
||||
8
Services/Interfaces/IBackupService.cs
Normal file
8
Services/Interfaces/IBackupService.cs
Normal 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);
|
||||
}
|
||||
8
Services/Interfaces/ICleanerService.cs
Normal file
8
Services/Interfaces/ICleanerService.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
using HanaToolbox.Config;
|
||||
|
||||
namespace HanaToolbox.Services.Interfaces;
|
||||
|
||||
public interface ICleanerService
|
||||
{
|
||||
Task RunAsync(CleanerConfig config, CancellationToken ct = default);
|
||||
}
|
||||
9
Services/Interfaces/IFirewallService.cs
Normal file
9
Services/Interfaces/IFirewallService.cs
Normal 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);
|
||||
}
|
||||
10
Services/Interfaces/IHdbClientLocator.cs
Normal file
10
Services/Interfaces/IHdbClientLocator.cs
Normal 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);
|
||||
}
|
||||
15
Services/Interfaces/IKeyManagerService.cs
Normal file
15
Services/Interfaces/IKeyManagerService.cs
Normal 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);
|
||||
}
|
||||
8
Services/Interfaces/IMonitorService.cs
Normal file
8
Services/Interfaces/IMonitorService.cs
Normal 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);
|
||||
}
|
||||
7
Services/Interfaces/IMonitorStateService.cs
Normal file
7
Services/Interfaces/IMonitorStateService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace HanaToolbox.Services.Interfaces;
|
||||
|
||||
public interface IMonitorStateService
|
||||
{
|
||||
string? GetState(string key);
|
||||
void SetState(string key, string value);
|
||||
}
|
||||
6
Services/Interfaces/INotificationService.cs
Normal file
6
Services/Interfaces/INotificationService.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace HanaToolbox.Services.Interfaces;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
Task SendAsync(string title, string message, CancellationToken ct = default);
|
||||
}
|
||||
13
Services/Interfaces/IProcessRunner.cs
Normal file
13
Services/Interfaces/IProcessRunner.cs
Normal 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);
|
||||
}
|
||||
14
Services/Interfaces/IUserSwitcher.cs
Normal file
14
Services/Interfaces/IUserSwitcher.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using HanaToolbox.Services.Interfaces;
|
||||
|
||||
namespace HanaToolbox.Services.Interfaces;
|
||||
|
||||
public interface IUserSwitcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes a shell command string as <sid>adm using `su - <sid>adm -c`.
|
||||
/// If already running as <sid>adm, runs the command directly.
|
||||
/// </summary>
|
||||
Task<ProcessResult> RunAsAsync(
|
||||
string sid, string shellCommand,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
74
Services/KeyManagerService.cs
Normal file
74
Services/KeyManagerService.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using HanaToolbox.Logging;
|
||||
using HanaToolbox.Services.Interfaces;
|
||||
|
||||
namespace HanaToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps hdbuserstore operations using SuUserSwitcher to run as <sid>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
229
Services/MonitorService.cs
Normal 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}]");
|
||||
}
|
||||
}
|
||||
39
Services/NtfyNotificationService.cs
Normal file
39
Services/NtfyNotificationService.cs
Normal 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
43
Services/ProcessRunner.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
74
Services/ServiceFactory.cs
Normal file
74
Services/ServiceFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
26
Services/SuUserSwitcher.cs
Normal file
26
Services/SuUserSwitcher.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using HanaToolbox.Services.Interfaces;
|
||||
|
||||
namespace HanaToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a shell command to run as <sid>adm using `su - <sid>adm -c '...'`.
|
||||
/// If the current OS user is already <sid>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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user