Guarantee durable visible runtime logging

This commit is contained in:
2026-08-26 10:40:29 +02:00
parent 999f69bcb9
commit fe3c84a27f
20 changed files with 520 additions and 72 deletions
@@ -23,7 +23,7 @@ namespace BizTalkPlatformManagementTool.Setup
private const string ProductName = "BizTalk Platform Management Tool";
/// <summary>Aktuelle Produktversion des Installers und Uninstall-Eintrags.</summary>
private const string ProductVersion = "2.3.0";
private const string ProductVersion = "2.3.1";
/// <summary>
/// Wartezeiten zwischen Wiederholungen atomarer Verzeichnisverschiebungen.
@@ -65,7 +65,7 @@ namespace BizTalkPlatformManagementTool.Setup
{
AutoSize = true,
Font = new Font(Font.FontFamily, 14, FontStyle.Bold),
Text = "BizTalk Platform Management Tool 2.3.0"
Text = "BizTalk Platform Management Tool 2.3.1"
});
root.Controls.Add(new Label
{
@@ -8,6 +8,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyProduct("BizTalk Platform Management Tool")]
[assembly: ComVisible(false)]
[assembly: Guid("675b68a9-bd80-46a5-b8c5-3b11b0b374e2")]
[assembly: AssemblyVersion("2.3.0.0")]
[assembly: AssemblyFileVersion("2.3.0.0")]
[assembly: AssemblyVersion("2.3.1.0")]
[assembly: AssemblyFileVersion("2.3.1.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.3.0.0" name="BizTalkPlatformManagementTool.Setup" />
<assemblyIdentity version="2.3.1.0" name="BizTalkPlatformManagementTool.Setup" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security><requestedPrivileges><requestedExecutionLevel level="requireAdministrator" uiAccess="false" /></requestedPrivileges></security>
</trustInfo>
@@ -9,6 +9,6 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCopyright("Copyright © 2026")]
[assembly: ComVisible(false)]
[assembly: Guid("2c5b2c0a-f407-46c2-9e3b-1fa09fa8445a")]
[assembly: AssemblyVersion("2.3.0.0")]
[assembly: AssemblyFileVersion("2.3.0.0")]
[assembly: AssemblyVersion("2.3.1.0")]
[assembly: AssemblyFileVersion("2.3.1.0")]
[assembly: InternalsVisibleTo("BizTalkPlatformManagementTool.Tests")]
@@ -37,6 +37,16 @@ namespace BizTalkPlatformManagementTool
SnapshotStore.SaveSnapshotSet(Path.Combine(directory, "before.json"), loaded);
SnapshotStore.SaveDiffSet(Path.Combine(directory, "diff.json"), diff);
var runtimeLogDirectory = Path.Combine(directory, "runtime-logs");
var runtimeLogger = new OperationLogger(null, runtimeLogDirectory);
runtimeLogger.Info("runtime log self-test marker");
if (!runtimeLogger.IsFileLoggingAvailable
|| !File.Exists(runtimeLogger.LogFilePath)
|| !runtimeLogger.ReadRecentEntries(10).Any(x => x.Message == "runtime log self-test marker"))
{
throw new InvalidOperationException("Runtime log write/read self-test failed.");
}
var operationService = new BizTalkOperationService(null);
var emergencyPlan = operationService.CreateEmergencyRestorePlan(loaded, loaded.Server);
if (emergencyPlan.Steps.Count == 0 || emergencyPlan.Steps[0].Kind != "WindowsService" || emergencyPlan.Steps[0].Name != "ENTSSO")
@@ -16,7 +16,7 @@ namespace BizTalkPlatformManagementTool.Services
/// <summary>
/// Current tool version written into generated snapshots.
/// </summary>
public const string Version = "2.3.0-net461";
public const string Version = "2.3.1-net461";
/// <summary>
/// Fallback application name used when WMI does not expose an application property.
@@ -89,16 +89,28 @@ namespace BizTalkPlatformManagementTool.Services
private readonly Action<LogEntry> _sink;
/// <summary>
/// Directory where daily log files are written.
/// Ordered local directories available for primary and fallback logging.
/// </summary>
private readonly string _logDirectory;
private readonly List<string> _candidateDirectories;
/// <summary>Index of the currently active writable directory, or minus one.</summary>
private int _activeDirectoryIndex;
/// <summary>Directory where daily log files are currently written.</summary>
private string _logDirectory;
/// <summary>Latest actionable storage warning for the operator.</summary>
private string _storageWarning;
/// <summary>Last storage notice already forwarded to the UI sink.</summary>
private string _reportedStorageNotice;
/// <summary>
/// Initializes a new logger that writes beside the executable.
/// Initializes a logger that verifies ProgramData and ordered local fallback directories.
/// </summary>
/// <param name="sink">Optional callback that receives entries for display.</param>
public OperationLogger(Action<LogEntry> sink)
: this(sink, ResolveLogDirectory())
: this(sink, BuildLogDirectoryCandidates())
{
}
@@ -106,15 +118,42 @@ namespace BizTalkPlatformManagementTool.Services
/// <param name="sink">Optional callback that receives new entries.</param>
/// <param name="logDirectory">Directory used for plain and compressed logs.</param>
internal OperationLogger(Action<LogEntry> sink, string logDirectory)
: this(sink, new[] { logDirectory })
{
}
/// <summary>Initializes a logger with ordered primary and fallback directories.</summary>
/// <param name="sink">Optional callback that receives new entries.</param>
/// <param name="candidateDirectories">Ordered local directories to verify.</param>
internal OperationLogger(Action<LogEntry> sink, IEnumerable<string> candidateDirectories)
{
_sink = sink;
_logDirectory = Path.GetFullPath(logDirectory);
Directory.CreateDirectory(_logDirectory);
MaintainLogs(DateTime.Now.Date);
_candidateDirectories = NormalizeDirectories(candidateDirectories).ToList();
_activeDirectoryIndex = -1;
SelectInitialLogDirectory();
if (_logDirectory != null)
{
MaintainLogs(DateTime.Now.Date);
}
}
/// <summary>Gets the directory containing active and compressed runtime logs.</summary>
public string LogDirectory { get { return _logDirectory; } }
public string LogDirectory
{
get { lock (FileLock) { return _logDirectory ?? string.Empty; } }
}
/// <summary>Gets whether a daily runtime log can currently be written.</summary>
public bool IsFileLoggingAvailable
{
get { lock (FileLock) { return _activeDirectoryIndex >= 0 && !string.IsNullOrWhiteSpace(_logDirectory); } }
}
/// <summary>Gets the latest fallback or total-storage-failure diagnostic.</summary>
public string StorageWarning
{
get { lock (FileLock) { return _storageWarning; } }
}
/// <summary>
/// Gets the path of the daily log file for the current date.
@@ -123,7 +162,12 @@ namespace BizTalkPlatformManagementTool.Services
{
get
{
return Path.Combine(_logDirectory, LogFilePrefix + DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + LogFileExtension);
lock (FileLock)
{
return string.IsNullOrWhiteSpace(_logDirectory)
? string.Empty
: BuildLogFilePath(_logDirectory, DateTime.Now);
}
}
}
@@ -141,9 +185,14 @@ namespace BizTalkPlatformManagementTool.Services
}
var entries = new List<LogEntry>();
var directory = LogDirectory;
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
{
return entries;
}
try
{
var files = Directory.GetFiles(_logDirectory, LogFilePrefix + "*" + LogFileExtension + "*")
var files = Directory.GetFiles(directory, LogFilePrefix + "*" + LogFileExtension + "*")
.Where(IsSupportedLogFile)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToArray();
@@ -213,13 +262,30 @@ namespace BizTalkPlatformManagementTool.Services
Message = message
};
WriteToFile(entry);
var storageNotice = WriteToFile(entry);
DeliverToSink(entry);
if (!string.IsNullOrWhiteSpace(storageNotice))
{
var warning = new LogEntry
{
Timestamp = DateTime.Now,
Level = LogLevel.Warning,
Message = storageNotice
};
WriteToFile(warning);
DeliverToSink(warning);
}
}
/// <summary>Forwards one record to the optional GUI without affecting runtime work.</summary>
/// <param name="entry">The record to display.</param>
private void DeliverToSink(LogEntry entry)
{
if (_sink == null)
{
return;
}
try
{
_sink(entry);
@@ -242,39 +308,79 @@ namespace BizTalkPlatformManagementTool.Services
/// Appends one entry to the current daily log file.
/// </summary>
/// <param name="entry">The entry to write.</param>
private void WriteToFile(LogEntry entry)
/// <returns>A new operator-visible storage notice, or null.</returns>
private string WriteToFile(LogEntry entry)
{
try
{
var line = string.Format(
CultureInfo.InvariantCulture,
"[{0:yyyy-MM-dd HH:mm:ss}][{1}] {2}{3}",
entry.Timestamp,
entry.Level.ToString().ToUpperInvariant(),
(entry.Message ?? string.Empty).Replace("\r", "\\r").Replace("\n", "\\n"),
Environment.NewLine);
var line = string.Format(
CultureInfo.InvariantCulture,
"[{0:yyyy-MM-dd HH:mm:ss}][{1}] {2}{3}",
entry.Timestamp,
entry.Level.ToString().ToUpperInvariant(),
(entry.Message ?? string.Empty).Replace("\r", "\\r").Replace("\n", "\\n"),
Environment.NewLine);
lock (FileLock)
{
File.AppendAllText(LogFilePath, line);
}
}
catch
lock (FileLock)
{
// Ein Logfehler darf niemals eine fachliche BizTalk-Operation abbrechen.
var failures = new List<string>();
var previousDirectory = _logDirectory;
var startIndex = _activeDirectoryIndex >= 0 ? _activeDirectoryIndex : 0;
for (var index = startIndex; index < _candidateDirectories.Count; index++)
{
var candidate = _candidateDirectories[index];
string error;
if (!TryAppend(candidate, line, out error))
{
failures.Add(candidate + " => " + error);
continue;
}
_activeDirectoryIndex = index;
_logDirectory = candidate;
if (!string.Equals(previousDirectory, candidate, StringComparison.OrdinalIgnoreCase))
{
_storageWarning = "Runtime log path switched to writable fallback '" + candidate
+ "'. Failed path(s): " + string.Join(" | ", failures.ToArray());
return TakeUnreportedStorageNotice(_storageWarning);
}
return null;
}
_activeDirectoryIndex = -1;
_logDirectory = null;
_storageWarning = "RUNTIME FILE LOGGING UNAVAILABLE. No candidate directory accepted an append. Attempted: "
+ string.Join(" | ", failures.ToArray()) + ". Operations continue and remain visible in the grid, but no durable runtime log is being written.";
return TakeUnreportedStorageNotice(_storageWarning);
}
}
/// <summary>Returns a storage notice only once per distinct failure state.</summary>
/// <param name="notice">The current diagnostic.</param>
/// <returns>The notice when it has not been reported before; otherwise null.</returns>
private string TakeUnreportedStorageNotice(string notice)
{
if (string.Equals(_reportedStorageNotice, notice, StringComparison.Ordinal))
{
return null;
}
_reportedStorageNotice = notice;
return notice;
}
/// <summary>
/// Compresses completed daily logs and removes all records outside retention.
/// </summary>
/// <param name="today">The current local date.</param>
internal void MaintainLogs(DateTime today)
{
var directory = LogDirectory;
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
{
return;
}
try
{
var cutoff = today.Date.AddDays(-(RetentionDays - 1));
foreach (var file in Directory.GetFiles(_logDirectory, LogFilePrefix + "*" + LogFileExtension + "*"))
foreach (var file in Directory.GetFiles(directory, LogFilePrefix + "*" + LogFileExtension + "*"))
{
DateTime fileDate;
if (!TryGetLogDate(file, out fileDate))
@@ -409,23 +515,144 @@ namespace BizTalkPlatformManagementTool.Services
return DateTime.TryParseExact(value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out date);
}
/// <summary>
/// Ermittelt das bevorzugte maschinenweite Logverzeichnis mit Rückfall auf das EXE-Verzeichnis.
/// </summary>
/// <returns>Ein verwendbares Verzeichnis für die täglichen Laufzeitlogs.</returns>
private static string ResolveLogDirectory()
/// <summary>Selects the first directory that passes a real create/write/delete probe.</summary>
private void SelectInitialLogDirectory()
{
var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
var preferred = Path.Combine(commonData, "BizTalkPlatformManagementTool", "Logs");
var failures = new List<string>();
for (var index = 0; index < _candidateDirectories.Count; index++)
{
string error;
if (!TryVerifyWritable(_candidateDirectories[index], out error))
{
failures.Add(_candidateDirectories[index] + " => " + error);
continue;
}
_activeDirectoryIndex = index;
_logDirectory = _candidateDirectories[index];
if (index > 0)
{
_storageWarning = "Primary runtime log path is not writable. Using verified fallback '"
+ _logDirectory + "'. Failed path(s): " + string.Join(" | ", failures.ToArray());
}
return;
}
_storageWarning = "RUNTIME FILE LOGGING UNAVAILABLE. No candidate directory passed the startup write probe. Attempted: "
+ (failures.Count == 0 ? "(none)" : string.Join(" | ", failures.ToArray()))
+ ". Operations remain visible in the grid, but no durable runtime log is being written.";
}
/// <summary>Builds primary and local fallback directories in deterministic order.</summary>
/// <returns>ProgramData, LocalAppData, executable-local and Temp candidates.</returns>
private static IEnumerable<string> BuildLogDirectoryCandidates()
{
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"BizTalkPlatformManagementTool",
"Logs");
yield return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"BizTalkPlatformManagementTool",
"Logs");
yield return Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
yield return Path.Combine(Path.GetTempPath(), "BizTalkPlatformManagementTool", "Logs");
}
/// <summary>Normalizes and de-duplicates candidate paths without requiring existence.</summary>
/// <param name="directories">Raw ordered paths.</param>
/// <returns>Safe absolute unique paths.</returns>
private static IEnumerable<string> NormalizeDirectories(IEnumerable<string> directories)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var directory in directories ?? Enumerable.Empty<string>())
{
if (string.IsNullOrWhiteSpace(directory))
{
continue;
}
string fullPath;
try
{
fullPath = Path.GetFullPath(directory.Trim()).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
catch
{
continue;
}
if (seen.Add(fullPath))
{
yield return fullPath;
}
}
}
/// <summary>Performs a real write probe and removes the temporary probe file.</summary>
/// <param name="directory">Candidate directory.</param>
/// <param name="error">Detailed failure when the probe fails.</param>
/// <returns>True only after bytes were created and flushed successfully.</returns>
private static bool TryVerifyWritable(string directory, out string error)
{
var probe = Path.Combine(directory, ".runtime-log-write-probe-" + Guid.NewGuid().ToString("N") + ".tmp");
try
{
Directory.CreateDirectory(preferred);
return preferred;
Directory.CreateDirectory(directory);
using (var stream = new FileStream(probe, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
stream.WriteByte(0x42);
stream.Flush();
}
File.Delete(probe);
error = null;
return true;
}
catch
catch (Exception ex)
{
return AppDomain.CurrentDomain.BaseDirectory;
error = ex.GetType().Name + ": " + ex.Message;
try
{
if (File.Exists(probe))
{
File.Delete(probe);
}
}
catch
{
// Probe cleanup is best-effort and the original write error remains authoritative.
}
return false;
}
}
/// <summary>Appends one physical line to a candidate daily log.</summary>
/// <param name="directory">Candidate directory.</param>
/// <param name="line">Serialized log line.</param>
/// <param name="error">Detailed append failure.</param>
/// <returns>True after the append completed.</returns>
private static bool TryAppend(string directory, string line, out string error)
{
try
{
Directory.CreateDirectory(directory);
File.AppendAllText(BuildLogFilePath(directory, DateTime.Now), line, new UTF8Encoding(false));
error = null;
return true;
}
catch (Exception ex)
{
error = ex.GetType().Name + ": " + ex.Message;
return false;
}
}
/// <summary>Builds the stable daily file path for a directory and local date.</summary>
/// <param name="directory">Active directory.</param>
/// <param name="timestamp">Timestamp whose local date selects the file.</param>
/// <returns>Full daily log path.</returns>
private static string BuildLogFilePath(string directory, DateTime timestamp)
{
return Path.Combine(
directory,
LogFilePrefix + timestamp.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + LogFileExtension);
}
}
}
@@ -155,7 +155,18 @@ namespace BizTalkPlatformManagementTool.Ui
BuildUi();
FormClosing += MainFormClosing;
LoadLogHistory();
_logger.Info("Log file: " + _logger.LogFilePath);
if (!string.IsNullOrWhiteSpace(_logger.StorageWarning))
{
_logger.Warning(_logger.StorageWarning);
}
if (_logger.IsFileLoggingAvailable)
{
_logger.Success("Runtime log storage verified by startup append. Active file: " + _logger.LogFilePath);
}
else
{
_logger.Error("Runtime file logging is unavailable. Use the Operation Log grid and resolve the storage warning before a real maintenance operation.");
}
}
/// <summary>
@@ -591,6 +602,10 @@ namespace BizTalkPlatformManagementTool.Ui
{
try
{
if (!_logger.IsFileLoggingAvailable || string.IsNullOrWhiteSpace(_logger.LogDirectory))
{
throw new InvalidOperationException(_logger.StorageWarning ?? "No writable runtime log directory is active.");
}
Directory.CreateDirectory(_logger.LogDirectory);
Process.Start("explorer.exe", _logger.LogDirectory);
}
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="2.3.0.0" name="BizTalkPlatformManagementTool" />
<assemblyIdentity version="2.3.1.0" name="BizTalkPlatformManagementTool" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>