Files
biztalk-checkmk-pulse/src/BizTalkCheckmkPulse.Setup/InstallerEngine.cs
T

558 lines
25 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using System.Xml;
namespace BizTalkCheckmkPulse.Setup
{
internal sealed class InstallerEngine
{
internal const string TaskName = "BizTalk Checkmk Pulse Provider";
private static readonly TimeSpan PostInstallValidationTimeout = TimeSpan.FromMinutes(4);
private readonly string packageDirectory;
private readonly string installDirectory;
private readonly string runtimeDirectory;
private readonly string checkmkLocalDirectory;
public InstallerEngine(string packageDirectory)
{
this.packageDirectory = Path.GetFullPath(packageDirectory);
installDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "BizTalkCheckmkPulse");
runtimeDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "BizTalkCheckmkPulse");
checkmkLocalDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "checkmk", "agent", "local");
}
public void Install(
string account,
string password,
bool isGmsa,
string environmentName,
bool allowServiceNameChange,
Action<string> report)
{
Validate(account, password, isGmsa, environmentName);
report = report ?? delegate { };
var accountSid = (SecurityIdentifier)new NTAccount(account).Translate(typeof(SecurityIdentifier));
report("Konto aufgeloest: " + account + " (" + accountSid.Value + ")");
var sourceApplication = Path.Combine(packageDirectory, "application");
var sourceExe = Path.Combine(sourceApplication, "BizTalkCheckmkPulse.exe");
var sourceConfig = sourceExe + ".config";
var sourceWrapper = Path.Combine(packageDirectory, "biztalk_checkmk_pulse.cmd");
RequireFile(sourceExe);
RequireFile(sourceConfig);
RequireFile(sourceWrapper);
var targetExe = Path.Combine(installDirectory, "BizTalkCheckmkPulse.exe");
var targetConfig = targetExe + ".config";
var stagingDirectory = installDirectory + ".staging." + Guid.NewGuid().ToString("N");
var backupDirectory = installDirectory + ".backup." + Guid.NewGuid().ToString("N");
var targetWrapper = Path.Combine(checkmkLocalDirectory, "biztalk_checkmk_pulse.cmd");
var previousWrapper = File.Exists(targetWrapper) ? File.ReadAllBytes(targetWrapper) : null;
var hadExistingInstallation = Directory.Exists(installDirectory);
var hadExistingExecutable = File.Exists(targetExe);
var mutationStarted = false;
var backupCreated = false;
var filesActivated = false;
var scheduler = new TaskSchedulerService();
try
{
RunSelfTest(sourceExe);
report("Paket-Vorpruefung erfolgreich: neun Checkmk-Services.");
if (!isGmsa)
{
ValidateBatchLogon(account, password);
report("Collector-Anmeldung und 'Log on as a batch job' vor dem Update bestaetigt.");
}
Directory.CreateDirectory(stagingDirectory);
var stagedExe = Path.Combine(stagingDirectory, "BizTalkCheckmkPulse.exe");
var stagedConfig = stagedExe + ".config";
File.Copy(sourceExe, stagedExe, false);
var effectiveEnvironment = PrepareConfig(
sourceConfig,
stagedConfig,
File.Exists(targetConfig) ? targetConfig : null,
environmentName);
var stagedServiceNames = RunSelfTest(stagedExe);
report("Update-Staging validiert. Umgebung=" + (effectiveEnvironment.Length == 0 ? "(keine)" : effectiveEnvironment) + ".");
if (File.Exists(targetExe))
{
var installedServiceNames = RunSelfTest(targetExe);
var serviceNameChange = EnsureServiceNameCompatibility(
installedServiceNames,
stagedServiceNames,
allowServiceNameChange);
report(serviceNameChange.Length == 0
? "Checkmk-Servicevertrag unveraendert: keine Service Discovery erforderlich."
: "Checkmk-Service-Rename ausdruecklich bestaetigt; Service Discovery erforderlich. " + serviceNameChange);
}
// Erst nach vollstaendiger Staging-Pruefung wird der laufende Provider angehalten.
mutationStarted = true;
scheduler.DeleteIfExists(TaskName);
report("Vorhandener Scheduled Task angehalten und fuer das Update entfernt.");
if (hadExistingInstallation)
{
Directory.Move(installDirectory, backupDirectory);
backupCreated = true;
}
Directory.Move(stagingDirectory, installDirectory);
filesActivated = true;
report(hadExistingInstallation
? "Programmdateien atomar auf die neue Version umgestellt."
: "Programmdateien installiert: " + installDirectory);
var dataDirectory = Path.Combine(runtimeDirectory, "data");
var logDirectory = Path.Combine(runtimeDirectory, "logs");
Directory.CreateDirectory(runtimeDirectory);
Directory.CreateDirectory(dataDirectory);
Directory.CreateDirectory(logDirectory);
ApplyDirectoryAcl(installDirectory, accountSid, FileSystemRights.ReadAndExecute, FileSystemRights.ReadAndExecute);
ApplyDirectoryAcl(runtimeDirectory, accountSid, FileSystemRights.ReadAndExecute, FileSystemRights.ReadAndExecute);
ApplyDirectoryAcl(dataDirectory, accountSid, FileSystemRights.Modify, FileSystemRights.ReadAndExecute);
ApplyDirectoryAcl(logDirectory, accountSid, FileSystemRights.Modify, FileSystemRights.Modify);
report("Least-Privilege-Verzeichnisrechte gesetzt; vorhandene Runtime-Daten bleiben erhalten.");
Directory.CreateDirectory(checkmkLocalDirectory);
File.Copy(sourceWrapper, targetWrapper, true);
report("Checkmk Local Check installiert: " + checkmkLocalDirectory);
RunSelfTest(targetExe);
report("Installierter Self-Test erfolgreich: neun Checkmk-Services.");
var runtimeValidationStartedUtc = DateTime.UtcNow;
scheduler.RegisterValidationAndStart(
TaskName,
targetExe,
installDirectory,
account,
isGmsa ? null : password,
isGmsa);
report("Einmaliger Provider-Abnahmelauf mit erzwungenem Endpoint-Katalogabgleich gestartet.");
var completedRun = scheduler.WaitForSuccessfulRun(
TaskName,
runtimeValidationStartedUtc,
PostInstallValidationTimeout,
report);
report("Provider-Abnahmelauf erfolgreich: LastTaskResult=" + completedRun.ExitCode + ".");
var runtimeValidation = RunRuntimeValidation(
targetExe,
runtimeValidationStartedUtc,
account);
report("Post-Install-Runtime-Abnahme erfolgreich: " + runtimeValidation);
scheduler.RegisterRecurring(
TaskName,
targetExe,
installDirectory,
account,
isGmsa ? null : password,
isGmsa);
report("Validierter minuetlicher Scheduled Task registriert: " + TaskName);
TryDeleteDirectory(backupDirectory, report);
}
catch (Exception installException)
{
var rollbackFailures = new List<string>();
if (mutationStarted)
{
try
{
scheduler.DeleteIfExists(TaskName);
}
catch (Exception ex)
{
rollbackFailures.Add("Task stoppen: " + ex.Message);
}
}
if (filesActivated || backupCreated)
{
try
{
if (Directory.Exists(installDirectory)) Directory.Delete(installDirectory, true);
if (backupCreated && Directory.Exists(backupDirectory)) Directory.Move(backupDirectory, installDirectory);
report("Vorherige Programmversion wiederhergestellt.");
}
catch (Exception ex)
{
rollbackFailures.Add("Programmdateien wiederherstellen: " + ex.Message);
}
}
if (mutationStarted)
{
try
{
RestoreWrapper(targetWrapper, previousWrapper);
}
catch (Exception ex)
{
rollbackFailures.Add("Checkmk-Wrapper wiederherstellen: " + ex.Message);
}
}
if (mutationStarted && hadExistingExecutable && File.Exists(targetExe))
{
try
{
var rollbackRunStartedUtc = DateTime.UtcNow;
scheduler.RegisterRecurringAndStart(
TaskName,
targetExe,
installDirectory,
account,
isGmsa ? null : password,
isGmsa);
scheduler.WaitForSuccessfulRun(
TaskName,
rollbackRunStartedUtc,
PostInstallValidationTimeout,
report);
report("Scheduled Task und frischer Snapshot der vorherigen Version wiederhergestellt.");
}
catch (Exception ex)
{
rollbackFailures.Add("Scheduled Task wiederherstellen: " + ex.Message);
}
}
var rollback = rollbackFailures.Count == 0
? "Rollback erfolgreich."
: "Rollback unvollstaendig: " + string.Join(" | ", rollbackFailures);
throw new InvalidOperationException(
"Installation/Update fehlgeschlagen. " + rollback + " Ursache: " + installException.Message,
installException);
}
finally
{
TryDeleteDirectory(stagingDirectory, null);
}
}
public void Uninstall(bool keepRuntimeData, Action<string> report)
{
new TaskSchedulerService().DeleteIfExists(TaskName);
report("Scheduled Task entfernt.");
var wrapper = Path.Combine(checkmkLocalDirectory, "biztalk_checkmk_pulse.cmd");
if (File.Exists(wrapper)) File.Delete(wrapper);
if (Directory.Exists(installDirectory)) Directory.Delete(installDirectory, true);
if (!keepRuntimeData && Directory.Exists(runtimeDirectory)) Directory.Delete(runtimeDirectory, true);
}
private static void Validate(string account, string password, bool isGmsa, string environmentName)
{
if (string.IsNullOrWhiteSpace(account)) throw new ArgumentException("Collector-Konto fehlt.");
if (account.IndexOf('\\') <= 0 || account.EndsWith("\\", StringComparison.Ordinal))
throw new ArgumentException(@"Collector-Konto im Format DOMAIN\Benutzer eingeben, z.B. BEW\t231bizmon.");
if (isGmsa && !account.EndsWith("$", StringComparison.Ordinal))
throw new ArgumentException(@"Ein gMSA-Konto muss mit '$' enden, z.B. BEW\svc_biztalk_cmk$.");
if (!isGmsa && string.IsNullOrEmpty(password)) throw new ArgumentException("Kennwort fehlt.");
if (!new[] { "", "ACC", "DEV", "TST", "PRD" }.Contains(environmentName))
throw new ArgumentException("Ungueltige Umgebung.");
}
private static void RequireFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException("Installationspaket ist unvollstaendig. Datei fehlt: " + path, path);
}
internal string GetInstalledEnvironment()
{
var config = Path.Combine(installDirectory, "BizTalkCheckmkPulse.exe.config");
if (!File.Exists(config)) return string.Empty;
try
{
var document = LoadXml(config);
var setting = FindAppSetting(document, "EnvironmentName");
return setting == null ? string.Empty : setting.GetAttribute("value").Trim();
}
catch
{
return string.Empty;
}
}
internal bool IsInstalled
{
get { return File.Exists(Path.Combine(installDirectory, "BizTalkCheckmkPulse.exe")); }
}
internal static string PrepareConfig(
string sourceConfig,
string stagedConfig,
string existingConfig,
string requestedEnvironment)
{
var document = LoadXml(sourceConfig);
if (!string.IsNullOrWhiteSpace(existingConfig) && File.Exists(existingConfig))
{
var existing = LoadXml(existingConfig);
var existingSettings = existing.SelectNodes("/configuration/appSettings/add[@key]");
if (existingSettings != null)
{
foreach (XmlNode node in existingSettings)
{
var element = node as XmlElement;
if (element == null) continue;
var key = element.GetAttribute("key");
var value = element.GetAttribute("value");
if (IsSupersededDefault(key, value)) continue;
var target = FindAppSetting(document, key);
if (target != null) target.SetAttribute("value", value);
}
}
}
var environmentSetting = FindAppSetting(document, "EnvironmentName");
if (environmentSetting == null)
throw new InvalidDataException("EnvironmentName fehlt in " + sourceConfig + ".");
if (!string.IsNullOrWhiteSpace(requestedEnvironment))
environmentSetting.SetAttribute("value", requestedEnvironment.Trim());
document.Save(stagedConfig);
return environmentSetting.GetAttribute("value").Trim();
}
private static XmlDocument LoadXml(string path)
{
var document = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
using (var reader = XmlReader.Create(path, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
}))
{
document.Load(reader);
}
return document;
}
private static XmlElement FindAppSetting(XmlDocument document, string key)
{
var nodes = document.SelectNodes("/configuration/appSettings/add[@key]");
if (nodes == null) return null;
foreach (XmlNode node in nodes)
{
var element = node as XmlElement;
if (element != null && string.Equals(element.GetAttribute("key"), key, StringComparison.Ordinal))
return element;
}
return null;
}
private static bool IsSupersededDefault(string key, string value)
{
return string.Equals(key, "EndpointProbeMaxConcurrency", StringComparison.Ordinal)
&& string.Equals(value, "12", StringComparison.Ordinal)
|| string.Equals(key, "EndpointMaxCount", StringComparison.Ordinal)
&& string.Equals(value, "500", StringComparison.Ordinal);
}
private static void ValidateBatchLogon(string account, string password)
{
var separator = account.IndexOf('\\');
var domain = account.Substring(0, separator);
var user = account.Substring(separator + 1);
IntPtr token;
if (!LogonUser(user, domain, password, 4, 0, out token))
{
throw new InvalidOperationException(
"Collector-Anmeldung als Batch fehlgeschlagen: "
+ new Win32Exception(Marshal.GetLastWin32Error()).Message
+ ". Kennwort und lokales Recht 'Log on as a batch job' pruefen.");
}
CloseHandle(token);
}
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool LogonUser(
string userName,
string domain,
string password,
int logonType,
int logonProvider,
out IntPtr token);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
private static void RestoreWrapper(string path, byte[] previousContent)
{
if (previousContent == null)
{
if (File.Exists(path)) File.Delete(path);
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllBytes(path, previousContent);
}
private static void TryDeleteDirectory(string path, Action<string> report)
{
if (!Directory.Exists(path)) return;
try
{
Directory.Delete(path, true);
}
catch (Exception ex)
{
if (report != null) report("Hinweis: temporaeres Verzeichnis konnte nicht entfernt werden: " + path + " (" + ex.Message + ")");
}
}
private static void ApplyDirectoryAcl(
string path,
SecurityIdentifier collectorSid,
FileSystemRights collectorRights,
FileSystemRights systemRights)
{
var inheritance = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
var security = new DirectorySecurity();
security.SetAccessRuleProtection(true, false);
security.AddAccessRule(new FileSystemAccessRule(
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
FileSystemRights.FullControl,
inheritance,
PropagationFlags.None,
AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(
new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null),
systemRights,
inheritance,
PropagationFlags.None,
AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(
collectorSid,
collectorRights,
inheritance,
PropagationFlags.None,
AccessControlType.Allow));
new DirectoryInfo(path).SetAccessControl(security);
}
private static IReadOnlyList<string> RunSelfTest(string executable)
{
var start = new ProcessStartInfo(executable, "--self-test")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
using (var process = Process.Start(start))
{
if (process == null) throw new InvalidOperationException("Self-Test konnte nicht gestartet werden.");
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
if (!process.WaitForExit(30000))
{
process.Kill();
throw new InvalidOperationException("Self-Test hat das Zeitlimit ueberschritten.");
}
var lines = output.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
if (process.ExitCode != 0 || lines.Length != 9 || lines.Any(x => !x.StartsWith("0 ", StringComparison.Ordinal)))
throw new InvalidOperationException("Self-Test fehlgeschlagen. Exitcode=" + process.ExitCode + ", Zeilen=" + lines.Length + ". " + error);
var serviceNames = lines.Select(ExtractServiceName).ToArray();
if (serviceNames.Distinct(StringComparer.Ordinal).Count() != serviceNames.Length)
throw new InvalidOperationException("Self-Test fehlgeschlagen: Checkmk-Servicenamen sind nicht eindeutig.");
return serviceNames;
}
}
private static string RunRuntimeValidation(
string executable,
DateTime notBeforeUtc,
string expectedIdentity)
{
var identityBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(expectedIdentity));
var arguments = "--validate-runtime --validation-not-before-utc "
+ notBeforeUtc.ToUniversalTime().ToString("o")
+ " --expected-identity-base64 " + identityBase64;
var start = new ProcessStartInfo(executable, arguments)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
using (var process = Process.Start(start))
{
if (process == null) throw new InvalidOperationException("Runtime-Abnahme konnte nicht gestartet werden.");
var output = process.StandardOutput.ReadToEnd().Trim();
var error = process.StandardError.ReadToEnd().Trim();
if (!process.WaitForExit(30000))
{
process.Kill();
throw new TimeoutException("Runtime-Abnahme hat das Zeitlimit ueberschritten.");
}
if (process.ExitCode != 0
|| !output.StartsWith("RUNTIME_VALIDATION_V1 ", StringComparison.Ordinal))
{
throw new InvalidOperationException(
"Runtime-Abnahme fehlgeschlagen. Exitcode=" + process.ExitCode
+ ". " + (error.Length == 0 ? output : error));
}
return output;
}
}
private static string ExtractServiceName(string line)
{
var firstQuote = line.IndexOf('"');
var secondQuote = firstQuote < 0 ? -1 : line.IndexOf('"', firstQuote + 1);
if (firstQuote < 0 || secondQuote <= firstQuote + 1)
throw new InvalidOperationException("Self-Test fehlgeschlagen: Checkmk-Servicename kann nicht gelesen werden: " + line);
return line.Substring(firstQuote + 1, secondQuote - firstQuote - 1);
}
internal static string EnsureServiceNameCompatibility(
IEnumerable<string> installedServiceNames,
IEnumerable<string> stagedServiceNames,
bool allowServiceNameChange)
{
var installed = new HashSet<string>(installedServiceNames ?? Enumerable.Empty<string>(), StringComparer.Ordinal);
var staged = new HashSet<string>(stagedServiceNames ?? Enumerable.Empty<string>(), StringComparer.Ordinal);
if (installed.SetEquals(staged)) return string.Empty;
var removed = installed.Except(staged, StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray();
var added = staged.Except(installed, StringComparer.Ordinal).OrderBy(x => x, StringComparer.Ordinal).ToArray();
var description = "Entfernt=[" + string.Join(", ", removed) + "]; Neu=[" + string.Join(", ", added) + "].";
if (!allowServiceNameChange)
{
throw new InvalidOperationException(
"Sicherheitsstopp: Das Update wuerde Checkmk-Servicenamen aendern. "
+ description
+ " Ohne Service Discovery entstehen verwaiste bzw. fehlende Services. "
+ "Nur wenn die Aenderung beabsichtigt ist, im Setup 'Service Discovery ist eingeplant' bestaetigen und anschliessend die Discovery ausfuehren.");
}
return description;
}
}
}