Initial commit: ESB Certificate Manager.
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
###############################################################################
|
||||||
|
# Set default behavior to automatically normalize line endings.
|
||||||
|
###############################################################################
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Set default behavior for command prompt diff.
|
||||||
|
#
|
||||||
|
# This is need for earlier builds of msysgit that does not have it on by
|
||||||
|
# default for csharp files.
|
||||||
|
# Note: This is only used by command line
|
||||||
|
###############################################################################
|
||||||
|
#*.cs diff=csharp
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Set the merge driver for project and solution files
|
||||||
|
#
|
||||||
|
# Merging from the command prompt will add diff markers to the files if there
|
||||||
|
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||||
|
# the diff markers are never inserted). Diff markers may cause the following
|
||||||
|
# file extensions to fail to load in VS. An alternative would be to treat
|
||||||
|
# these files as binary and thus will always conflict and require user
|
||||||
|
# intervention with every merge. To do so, just uncomment the entries below
|
||||||
|
###############################################################################
|
||||||
|
#*.sln merge=binary
|
||||||
|
#*.csproj merge=binary
|
||||||
|
#*.vbproj merge=binary
|
||||||
|
#*.vcxproj merge=binary
|
||||||
|
#*.vcproj merge=binary
|
||||||
|
#*.dbproj merge=binary
|
||||||
|
#*.fsproj merge=binary
|
||||||
|
#*.lsproj merge=binary
|
||||||
|
#*.wixproj merge=binary
|
||||||
|
#*.modelproj merge=binary
|
||||||
|
#*.sqlproj merge=binary
|
||||||
|
#*.wwaproj merge=binary
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# behavior for image files
|
||||||
|
#
|
||||||
|
# image files are treated as binary by default.
|
||||||
|
###############################################################################
|
||||||
|
#*.jpg binary
|
||||||
|
#*.png binary
|
||||||
|
#*.gif binary
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# diff behavior for common document formats
|
||||||
|
#
|
||||||
|
# Convert binary document formats to text before diffing them. This feature
|
||||||
|
# is only available from the command line. Turn it on by uncommenting the
|
||||||
|
# entries below.
|
||||||
|
###############################################################################
|
||||||
|
#*.doc diff=astextplain
|
||||||
|
#*.DOC diff=astextplain
|
||||||
|
#*.docx diff=astextplain
|
||||||
|
#*.DOCX diff=astextplain
|
||||||
|
#*.dot diff=astextplain
|
||||||
|
#*.DOT diff=astextplain
|
||||||
|
#*.pdf diff=astextplain
|
||||||
|
#*.PDF diff=astextplain
|
||||||
|
#*.rtf diff=astextplain
|
||||||
|
#*.RTF diff=astextplain
|
||||||
+367
@@ -0,0 +1,367 @@
|
|||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Oo]ut/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# Lokale Offline-Deploy-/Log-Artefakte der App
|
||||||
|
DeploySandbox/
|
||||||
|
**/DeploySandbox/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.10.35027.167
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ZA.CoreService.ESBCertificateManager", "ZA.CoreService.ESBCertificateManager\ZA.CoreService.ESBCertificateManager.csproj", "{4F3F9601-BBE8-4A11-8DF5-716911179223}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{4F3F9601-BBE8-4A11-8DF5-716911179223}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F3F9601-BBE8-4A11-8DF5-716911179223}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4F3F9601-BBE8-4A11-8DF5-716911179223}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4F3F9601-BBE8-4A11-8DF5-716911179223}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {AB798282-A87B-47F6-A9F1-F0CE0851167D}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Configuration;
|
||||||
|
|
||||||
|
public static class AppSettingsLoader
|
||||||
|
{
|
||||||
|
public static AppSettings Load()
|
||||||
|
{
|
||||||
|
string basePath = AppContext.BaseDirectory;
|
||||||
|
|
||||||
|
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(basePath)
|
||||||
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
AppSettings settings = new();
|
||||||
|
configuration.Bind(settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Data;
|
||||||
|
|
||||||
|
public interface ITargetRepository
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<DeploymentTarget>> GetActiveTargetsAsync(CancellationToken cancellationToken = default);
|
||||||
|
string SourceDescription { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Data;
|
||||||
|
|
||||||
|
public sealed class JsonTargetRepository : ITargetRepository
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly string _filePath;
|
||||||
|
|
||||||
|
public JsonTargetRepository(string filePath)
|
||||||
|
{
|
||||||
|
_filePath = filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SourceDescription => $"Offline-Sample ({Path.GetFileName(_filePath)})";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DeploymentTarget>> GetActiveTargetsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException(
|
||||||
|
$"Sample-Zieldatei wurde nicht gefunden: {_filePath}",
|
||||||
|
_filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using FileStream stream = File.OpenRead(_filePath);
|
||||||
|
List<DeploymentTarget>? targets = await JsonSerializer.DeserializeAsync<List<DeploymentTarget>>(
|
||||||
|
stream,
|
||||||
|
JsonOptions,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return (targets ?? [])
|
||||||
|
.Where(t => t.IsActive)
|
||||||
|
.OrderBy(t => t.SortOrder)
|
||||||
|
.ThenBy(t => t.Name)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schreibt Deployment-Lauf-Ergebnisse in die SQL-Datenbank.
|
||||||
|
/// Tabellen: dbo.DeploymentRuns (GUID-PK) + dbo.DeploymentTargetResults.
|
||||||
|
/// Ist kein ConnectionString konfiguriert, werden alle Operationen still übersprungen.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SqlRunLogger
|
||||||
|
{
|
||||||
|
private readonly string? _connectionString;
|
||||||
|
|
||||||
|
public SqlRunLogger(string? connectionString)
|
||||||
|
{
|
||||||
|
_connectionString = string.IsNullOrWhiteSpace(connectionString)
|
||||||
|
? null
|
||||||
|
: connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled => _connectionString is not null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legt einen neuen Lauf-Datensatz an.
|
||||||
|
/// </summary>
|
||||||
|
public async Task BeginRunAsync(
|
||||||
|
Guid runId,
|
||||||
|
DateTimeOffset startedAt,
|
||||||
|
string certificateFilePath,
|
||||||
|
string certificateFingerprint,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connectionString is null) return;
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.DeploymentRuns
|
||||||
|
(Id, StartedAtUtc, SourceFile, SourceFingerprint, StartedBy, MachineName, OverallStatus)
|
||||||
|
VALUES
|
||||||
|
(@Id, @StartedAtUtc, @SourceFile, @SourceFingerprint, @StartedBy, @MachineName, N'Running');
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using SqlConnection conn = new(_connectionString);
|
||||||
|
await conn.OpenAsync(cancellationToken);
|
||||||
|
await using SqlCommand cmd = new(sql, conn);
|
||||||
|
|
||||||
|
cmd.Parameters.AddWithValue("@Id", runId);
|
||||||
|
cmd.Parameters.AddWithValue("@StartedAtUtc", startedAt.UtcDateTime);
|
||||||
|
cmd.Parameters.AddWithValue("@SourceFile", Path.GetFileName(certificateFilePath));
|
||||||
|
cmd.Parameters.AddWithValue("@SourceFingerprint", certificateFingerprint);
|
||||||
|
cmd.Parameters.AddWithValue("@StartedBy", Environment.UserName);
|
||||||
|
cmd.Parameters.AddWithValue("@MachineName", Environment.MachineName);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aktualisiert den Lauf-Datensatz mit Endzeitpunkt und Gesamtstatus.
|
||||||
|
/// </summary>
|
||||||
|
public async Task CompleteRunAsync(
|
||||||
|
Guid runId,
|
||||||
|
DateTimeOffset finishedAt,
|
||||||
|
bool overallSuccess,
|
||||||
|
int successCount,
|
||||||
|
int totalCount,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connectionString is null) return;
|
||||||
|
|
||||||
|
string status = overallSuccess
|
||||||
|
? "Success"
|
||||||
|
: (successCount > 0 ? "PartialFailure" : "Failure");
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
UPDATE dbo.DeploymentRuns
|
||||||
|
SET FinishedAtUtc = @FinishedAtUtc,
|
||||||
|
OverallStatus = @OverallStatus
|
||||||
|
WHERE Id = @Id;
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using SqlConnection conn = new(_connectionString);
|
||||||
|
await conn.OpenAsync(cancellationToken);
|
||||||
|
await using SqlCommand cmd = new(sql, conn);
|
||||||
|
|
||||||
|
cmd.Parameters.AddWithValue("@Id", runId);
|
||||||
|
cmd.Parameters.AddWithValue("@FinishedAtUtc", finishedAt.UtcDateTime);
|
||||||
|
cmd.Parameters.AddWithValue("@OverallStatus", status);
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Schreibt das Ergebnis eines einzelnen Deployment-Ziels.
|
||||||
|
/// </summary>
|
||||||
|
public async Task WriteTargetResultAsync(
|
||||||
|
Guid runId,
|
||||||
|
TargetStepResult step,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connectionString is null) return;
|
||||||
|
|
||||||
|
string status = step.Success ? "Success" : "Failure";
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
INSERT INTO dbo.DeploymentTargetResults
|
||||||
|
(DeploymentRunId, TargetId, TargetName,
|
||||||
|
StartedAtUtc, FinishedAtUtc,
|
||||||
|
CopySucceeded, RestartSucceeded, TlsSucceeded,
|
||||||
|
ObservedFingerprint, Status, ErrorMessage)
|
||||||
|
VALUES
|
||||||
|
(@RunId, @TargetId, @TargetName,
|
||||||
|
@StartedAtUtc, @FinishedAtUtc,
|
||||||
|
@CopySucceeded, @RestartSucceeded, @TlsSucceeded,
|
||||||
|
@ObservedFingerprint, @Status, @ErrorMessage);
|
||||||
|
""";
|
||||||
|
|
||||||
|
await using SqlConnection conn = new(_connectionString);
|
||||||
|
await conn.OpenAsync(cancellationToken);
|
||||||
|
await using SqlCommand cmd = new(sql, conn);
|
||||||
|
|
||||||
|
cmd.Parameters.AddWithValue("@RunId", runId);
|
||||||
|
cmd.Parameters.AddWithValue("@TargetId", step.TargetId);
|
||||||
|
cmd.Parameters.AddWithValue("@TargetName", step.TargetName);
|
||||||
|
cmd.Parameters.AddWithValue("@StartedAtUtc", step.StartedAt.UtcDateTime);
|
||||||
|
cmd.Parameters.AddWithValue("@FinishedAtUtc", step.FinishedAt.UtcDateTime);
|
||||||
|
cmd.Parameters.AddWithValue("@CopySucceeded", step.CopySucceeded);
|
||||||
|
cmd.Parameters.AddWithValue("@RestartSucceeded", step.RestartSucceeded);
|
||||||
|
cmd.Parameters.AddWithValue("@TlsSucceeded", step.TlsSucceeded);
|
||||||
|
cmd.Parameters.AddWithValue("@ObservedFingerprint", (object?)step.ObservedFingerprint ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@Status", status);
|
||||||
|
cmd.Parameters.AddWithValue("@ErrorMessage", step.Success ? DBNull.Value : (object?)(step.Detail ?? step.StatusText));
|
||||||
|
|
||||||
|
await cmd.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persistiert ein vollständiges <see cref="DeploymentRunResult"/> in einem einzigen Aufruf.
|
||||||
|
/// </summary>
|
||||||
|
public async Task PersistRunResultAsync(
|
||||||
|
DeploymentRunResult runResult,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connectionString is null) return;
|
||||||
|
|
||||||
|
await BeginRunAsync(
|
||||||
|
runResult.RunId,
|
||||||
|
runResult.StartedAt,
|
||||||
|
runResult.CertificateFilePath,
|
||||||
|
runResult.CertificateFingerprint,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
foreach (TargetStepResult step in runResult.TargetResults)
|
||||||
|
{
|
||||||
|
await WriteTargetResultAsync(runResult.RunId, step, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
int successCount = runResult.TargetResults.Count(r => r.Success);
|
||||||
|
|
||||||
|
await CompleteRunAsync(
|
||||||
|
runResult.RunId,
|
||||||
|
runResult.FinishedAt,
|
||||||
|
runResult.OverallSuccess,
|
||||||
|
successCount,
|
||||||
|
runResult.TargetResults.Count,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Data;
|
||||||
|
|
||||||
|
public sealed class SqlTargetRepository : ITargetRepository
|
||||||
|
{
|
||||||
|
private readonly string _connectionString;
|
||||||
|
|
||||||
|
public SqlTargetRepository(string connectionString)
|
||||||
|
{
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SourceDescription => "SQL Server (dbo.DeploymentTargets)";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DeploymentTarget>> GetActiveTargetsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_connectionString))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Es ist keine SQL-Verbindungszeichenfolge konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
SELECT
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
Environment,
|
||||||
|
IsActive,
|
||||||
|
CertificateTargetPath,
|
||||||
|
CertificateFileName,
|
||||||
|
ContainerName,
|
||||||
|
RestartType,
|
||||||
|
RestartCommand,
|
||||||
|
RestartArguments,
|
||||||
|
RestartTimeoutSeconds,
|
||||||
|
SonicConnectionName,
|
||||||
|
XapiSourcePath,
|
||||||
|
TlsHost,
|
||||||
|
TlsPort,
|
||||||
|
TlsServerName,
|
||||||
|
ExpectedFingerprint,
|
||||||
|
SortOrder
|
||||||
|
FROM dbo.DeploymentTargets
|
||||||
|
WHERE IsActive = 1
|
||||||
|
ORDER BY SortOrder, Name;
|
||||||
|
""";
|
||||||
|
|
||||||
|
List<DeploymentTarget> targets = [];
|
||||||
|
|
||||||
|
await using SqlConnection connection = new(_connectionString);
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using SqlCommand command = new(sql, connection);
|
||||||
|
await using SqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
|
||||||
|
int id = reader.GetOrdinal("Id");
|
||||||
|
int name = reader.GetOrdinal("Name");
|
||||||
|
int environment = reader.GetOrdinal("Environment");
|
||||||
|
int isActive = reader.GetOrdinal("IsActive");
|
||||||
|
int certificateTargetPath = reader.GetOrdinal("CertificateTargetPath");
|
||||||
|
int certificateFileName = reader.GetOrdinal("CertificateFileName");
|
||||||
|
int containerName = reader.GetOrdinal("ContainerName");
|
||||||
|
int restartType = reader.GetOrdinal("RestartType");
|
||||||
|
int restartCommand = reader.GetOrdinal("RestartCommand");
|
||||||
|
int restartArguments = reader.GetOrdinal("RestartArguments");
|
||||||
|
int restartTimeoutSeconds = reader.GetOrdinal("RestartTimeoutSeconds");
|
||||||
|
int sonicConnectionName = reader.GetOrdinal("SonicConnectionName");
|
||||||
|
int xapiSourcePath = reader.GetOrdinal("XapiSourcePath");
|
||||||
|
int tlsHost = reader.GetOrdinal("TlsHost");
|
||||||
|
int tlsPort = reader.GetOrdinal("TlsPort");
|
||||||
|
int tlsServerName = reader.GetOrdinal("TlsServerName");
|
||||||
|
int expectedFingerprint = reader.GetOrdinal("ExpectedFingerprint");
|
||||||
|
int sortOrder = reader.GetOrdinal("SortOrder");
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
RestartType parsedRestartType = Enum.TryParse(
|
||||||
|
reader.GetString(restartType),
|
||||||
|
ignoreCase: true,
|
||||||
|
out RestartType parsed)
|
||||||
|
? parsed
|
||||||
|
: RestartType.None;
|
||||||
|
|
||||||
|
targets.Add(new DeploymentTarget
|
||||||
|
{
|
||||||
|
Id = reader.GetInt32(id),
|
||||||
|
Name = reader.GetString(name),
|
||||||
|
Environment = reader.GetString(environment),
|
||||||
|
IsActive = reader.GetBoolean(isActive),
|
||||||
|
TargetDirectory = reader.GetString(certificateTargetPath),
|
||||||
|
CertificateFileName = reader.GetString(certificateFileName),
|
||||||
|
ContainerName = reader.GetString(containerName),
|
||||||
|
RestartType = parsedRestartType,
|
||||||
|
RestartCommand = reader.IsDBNull(restartCommand) ? string.Empty : reader.GetString(restartCommand),
|
||||||
|
RestartArguments = reader.GetString(restartArguments),
|
||||||
|
RestartTimeoutSeconds = reader.GetInt32(restartTimeoutSeconds),
|
||||||
|
SonicConnectionName = reader.GetString(sonicConnectionName),
|
||||||
|
XapiSourcePath = reader.GetString(xapiSourcePath),
|
||||||
|
TlsHost = reader.GetString(tlsHost),
|
||||||
|
TlsPort = reader.IsDBNull(tlsPort) ? null : reader.GetInt32(tlsPort),
|
||||||
|
TlsServerName = reader.GetString(tlsServerName),
|
||||||
|
ExpectedFingerprint = reader.IsDBNull(expectedFingerprint) ? null : reader.GetString(expectedFingerprint),
|
||||||
|
SortOrder = reader.GetInt32(sortOrder)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Data;
|
||||||
|
|
||||||
|
public static class TargetRepositoryFactory
|
||||||
|
{
|
||||||
|
public static async Task<(ITargetRepository Repository, string LoadMessage)> CreateAsync(
|
||||||
|
AppSettings settings,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string samplePath = Path.GetFullPath(
|
||||||
|
Path.Combine(AppContext.BaseDirectory, settings.SampleTargetsPath));
|
||||||
|
|
||||||
|
if (settings.UseOfflineSampleData)
|
||||||
|
{
|
||||||
|
ITargetRepository jsonRepo = new JsonTargetRepository(samplePath);
|
||||||
|
_ = await jsonRepo.GetActiveTargetsAsync(cancellationToken);
|
||||||
|
return (jsonRepo, $"Ziele geladen aus {jsonRepo.SourceDescription}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.ConnectionString))
|
||||||
|
{
|
||||||
|
return OfflineSample(samplePath, "Keine SQL-Verbindung konfiguriert – Offline-Sample wird verwendet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SqlTargetRepository sqlRepo = new(settings.ConnectionString);
|
||||||
|
_ = await sqlRepo.GetActiveTargetsAsync(cancellationToken);
|
||||||
|
return (sqlRepo, $"Ziele geladen aus {sqlRepo.SourceDescription}.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return OfflineSample(
|
||||||
|
samplePath,
|
||||||
|
$"SQL nicht erreichbar ({ex.Message}). Offline-Sample wird verwendet.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ITargetRepository Repository, string LoadMessage) OfflineSample(
|
||||||
|
string samplePath,
|
||||||
|
string message)
|
||||||
|
=> (new JsonTargetRepository(samplePath), message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Name": "ESB Local A",
|
||||||
|
"Environment": "DEV",
|
||||||
|
"IsActive": true,
|
||||||
|
"TargetDirectory": "DeploySandbox/target-a",
|
||||||
|
"CertificateFileName": "esb-cert.cer",
|
||||||
|
"ContainerName": "sonic-container-a",
|
||||||
|
"RestartType": "None",
|
||||||
|
"RestartCommand": "",
|
||||||
|
"RestartArguments": "",
|
||||||
|
"RestartTimeoutSeconds": 30,
|
||||||
|
"SonicConnectionName": "",
|
||||||
|
"XapiSourcePath": "",
|
||||||
|
"TlsHost": "",
|
||||||
|
"TlsPort": null,
|
||||||
|
"TlsServerName": "",
|
||||||
|
"ExpectedFingerprint": null,
|
||||||
|
"SortOrder": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": 2,
|
||||||
|
"Name": "ESB Local B",
|
||||||
|
"Environment": "DEV",
|
||||||
|
"IsActive": true,
|
||||||
|
"TargetDirectory": "DeploySandbox/target-b",
|
||||||
|
"CertificateFileName": "esb-cert.cer",
|
||||||
|
"ContainerName": "sonic-container-b",
|
||||||
|
"RestartType": "Command",
|
||||||
|
"RestartCommand": "cmd.exe",
|
||||||
|
"RestartArguments": "/c echo Restart simulated for sonic-container-b",
|
||||||
|
"RestartTimeoutSeconds": 15,
|
||||||
|
"SonicConnectionName": "",
|
||||||
|
"XapiSourcePath": "",
|
||||||
|
"TlsHost": "",
|
||||||
|
"TlsPort": null,
|
||||||
|
"TlsServerName": "",
|
||||||
|
"ExpectedFingerprint": null,
|
||||||
|
"SortOrder": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": 3,
|
||||||
|
"Name": "DE-Test Container A (Sonic)",
|
||||||
|
"Environment": "TEST",
|
||||||
|
"IsActive": false,
|
||||||
|
"TargetDirectory": "DeploySandbox/target-c",
|
||||||
|
"CertificateFileName": "esb-cert.cer",
|
||||||
|
"ContainerName": "sonic-container-a",
|
||||||
|
"RestartType": "SonicContainer",
|
||||||
|
"RestartCommand": "",
|
||||||
|
"RestartArguments": "",
|
||||||
|
"RestartTimeoutSeconds": 60,
|
||||||
|
"SonicConnectionName": "DE-Test",
|
||||||
|
"XapiSourcePath": "",
|
||||||
|
"TlsHost": "dekun-painwbdet",
|
||||||
|
"TlsPort": 443,
|
||||||
|
"TlsServerName": "esb-test.firma.local",
|
||||||
|
"ExpectedFingerprint": null,
|
||||||
|
"SortOrder": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": 4,
|
||||||
|
"Name": "DE-Test Container B (Sonic + XApi)",
|
||||||
|
"Environment": "TEST",
|
||||||
|
"IsActive": false,
|
||||||
|
"TargetDirectory": "DeploySandbox/target-d",
|
||||||
|
"CertificateFileName": "esb-cert.cer",
|
||||||
|
"ContainerName": "sonic-container-b",
|
||||||
|
"RestartType": "SonicContainerWithXapi",
|
||||||
|
"RestartCommand": "",
|
||||||
|
"RestartArguments": "",
|
||||||
|
"RestartTimeoutSeconds": 60,
|
||||||
|
"SonicConnectionName": "DE-Test",
|
||||||
|
"XapiSourcePath": "Assets/xapi-resources.xml",
|
||||||
|
"TlsHost": "dekun-painwbdet",
|
||||||
|
"TlsPort": 443,
|
||||||
|
"TlsServerName": "esb-test.firma.local",
|
||||||
|
"ExpectedFingerprint": null,
|
||||||
|
"SortOrder": 40
|
||||||
|
}
|
||||||
|
]
|
||||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager
|
||||||
|
{
|
||||||
|
partial class Form1
|
||||||
|
{
|
||||||
|
private System.ComponentModel.IContainer components = null;
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing && (components != null))
|
||||||
|
{
|
||||||
|
components.Dispose();
|
||||||
|
}
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Windows Form Designer generated code
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Required method for Designer support - do not modify
|
||||||
|
/// the contents of this method with the code editor.
|
||||||
|
/// </summary>
|
||||||
|
private void InitializeComponent()
|
||||||
|
{
|
||||||
|
SuspendLayout();
|
||||||
|
//
|
||||||
|
// Form1
|
||||||
|
//
|
||||||
|
AutoScaleDimensions = new SizeF(7F, 15F);
|
||||||
|
AutoScaleMode = AutoScaleMode.Font;
|
||||||
|
ClientSize = new Size(800, 450);
|
||||||
|
Name = "Form1";
|
||||||
|
Text = "Form1";
|
||||||
|
ResumeLayout(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
Microsoft ResX Schema
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
The primary goals of this format is to allow a simple XML format
|
||||||
|
that is mostly human readable. The generation and parsing of the
|
||||||
|
various data types are done through the TypeConverter classes
|
||||||
|
associated with the data types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
... ado.net/XML headers & schema ...
|
||||||
|
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||||
|
<resheader name="version">2.0</resheader>
|
||||||
|
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||||
|
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||||
|
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||||
|
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||||
|
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||||
|
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||||
|
</data>
|
||||||
|
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
|
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||||
|
<comment>This is a comment</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
There are any number of "resheader" rows that contain simple
|
||||||
|
name/value pairs.
|
||||||
|
|
||||||
|
Each data row contains a name, and value. The row also contains a
|
||||||
|
type or mimetype. Type corresponds to a .NET class that support
|
||||||
|
text/value conversion through the TypeConverter architecture.
|
||||||
|
Classes that don't support this are serialized and stored with the
|
||||||
|
mimetype set.
|
||||||
|
|
||||||
|
The mimetype is used for serialized objects, and tells the
|
||||||
|
ResXResourceReader how to depersist the object. This is currently not
|
||||||
|
extensible. For a given mimetype the value must be set accordingly:
|
||||||
|
|
||||||
|
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||||
|
that the ResXResourceWriter will generate, however the reader can
|
||||||
|
read any of the formats listed below.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.binary.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.soap.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||||
|
value : The object must be serialized into a byte array
|
||||||
|
: using a System.ComponentModel.TypeConverter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
public sealed class AppSettings
|
||||||
|
{
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
public bool UseOfflineSampleData { get; set; } = true;
|
||||||
|
public string SampleTargetsPath { get; set; } = "Data/targets.sample.json";
|
||||||
|
public string LogDirectory { get; set; } = "Logs";
|
||||||
|
public int TlsTimeoutSeconds { get; set; } = 8;
|
||||||
|
public int TlsRetryCount { get; set; } = 2;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verbindungskonfigurationen für Progress Sonic ESB Management Instanzen.
|
||||||
|
/// Jeder Eintrag entspricht einer Sonic-Domain (z.B. einer Umgebung oder Tochtergesellschaft).
|
||||||
|
/// </summary>
|
||||||
|
public List<SonicConnection> SonicConnections { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager.Models
|
||||||
|
{
|
||||||
|
public sealed class CertificateInfo
|
||||||
|
{
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
public required string Issuer { get; init; }
|
||||||
|
public required string FingerprintSha256 { get; init; }
|
||||||
|
public DateTimeOffset ValidFrom { get; init; }
|
||||||
|
public DateTimeOffset ValidUntil { get; init; }
|
||||||
|
public bool IsCurrentlyValid { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
public sealed class DeploymentRunResult
|
||||||
|
{
|
||||||
|
public Guid RunId { get; init; } = Guid.NewGuid();
|
||||||
|
public required IReadOnlyList<TargetStepResult> TargetResults { get; init; }
|
||||||
|
public bool OverallSuccess => TargetResults.All(r => r.Success);
|
||||||
|
public DateTimeOffset StartedAt { get; init; }
|
||||||
|
public DateTimeOffset FinishedAt { get; init; }
|
||||||
|
public string CertificateFilePath { get; init; } = string.Empty;
|
||||||
|
public string CertificateFingerprint { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class TargetStepResult
|
||||||
|
{
|
||||||
|
public required int TargetId { get; init; }
|
||||||
|
public required string TargetName { get; init; }
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public required string StatusText { get; init; }
|
||||||
|
public string? Detail { get; init; }
|
||||||
|
public IReadOnlyList<string> Steps { get; init; } = [];
|
||||||
|
public DateTimeOffset StartedAt { get; init; }
|
||||||
|
public DateTimeOffset FinishedAt { get; init; }
|
||||||
|
public bool CopySucceeded { get; init; }
|
||||||
|
public bool RestartSucceeded { get; init; }
|
||||||
|
public bool TlsSucceeded { get; init; }
|
||||||
|
public string? ObservedFingerprint { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ValidationIssue
|
||||||
|
{
|
||||||
|
public required string Message { get; init; }
|
||||||
|
public int? TargetId { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PreflightValidationResult
|
||||||
|
{
|
||||||
|
public bool IsValid => Issues.Count == 0;
|
||||||
|
public List<ValidationIssue> Issues { get; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
public sealed class DeploymentTarget
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string Environment { get; init; }
|
||||||
|
public bool IsActive { get; init; } = true;
|
||||||
|
public required string TargetDirectory { get; init; }
|
||||||
|
public required string CertificateFileName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Name des Sonic-ESB-Containers (z.B. "sonic-container-a").</summary>
|
||||||
|
public string ContainerName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public RestartType RestartType { get; init; } = RestartType.None;
|
||||||
|
|
||||||
|
// --- Command-basierter Neustart ---
|
||||||
|
public string RestartCommand { get; init; } = string.Empty;
|
||||||
|
public string RestartArguments { get; init; } = string.Empty;
|
||||||
|
public int RestartTimeoutSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
// --- Sonic-ESB-Management-Neustart ---
|
||||||
|
/// <summary>
|
||||||
|
/// Referenz auf den Namen einer <see cref="SonicConnection"/> in AppSettings.
|
||||||
|
/// Pflichtfeld bei RestartType = SonicContainer oder SonicContainerWithXapi.
|
||||||
|
/// </summary>
|
||||||
|
public string SonicConnectionName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pfad zur XApi-Ressourcendatei (.xml/.zip), die vor dem Neustart importiert wird.
|
||||||
|
/// Pflichtfeld bei RestartType = SonicContainerWithXapi.
|
||||||
|
/// </summary>
|
||||||
|
public string XapiSourcePath { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// --- TLS-Probe nach Deployment ---
|
||||||
|
public string TlsHost { get; init; } = string.Empty;
|
||||||
|
public int? TlsPort { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hostname, der im TLS-Handshake als ServerName (SNI) verwendet wird.
|
||||||
|
/// Wichtig wenn TlsHost eine IP-Adresse ist, das Zertifikat aber einen DNS-Namen trägt.
|
||||||
|
/// Ist leer, wird TlsHost als ServerName verwendet.
|
||||||
|
/// </summary>
|
||||||
|
public string TlsServerName { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erwarteter SHA-256-Fingerprint des Zertifikats nach dem Deployment (ohne Trennzeichen).
|
||||||
|
/// Wenn gesetzt, schlägt der TLS-Probe fehl wenn der Fingerprint abweicht.
|
||||||
|
/// </summary>
|
||||||
|
public string? ExpectedFingerprint { get; init; }
|
||||||
|
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RestartType
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>Neustart über einen lokalen Betriebssystem-Prozess (RestartCommand).</summary>
|
||||||
|
Command = 1,
|
||||||
|
|
||||||
|
/// <summary>Container-Neustart über die Sonic ESB Management Console REST-API.</summary>
|
||||||
|
SonicContainer = 2,
|
||||||
|
|
||||||
|
/// <summary>XApi-Ressourcen importieren und danach Container neu starten (Sonic ESB).</summary>
|
||||||
|
SonicContainerWithXapi = 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verbindungskonfiguration für eine Progress Sonic ESB Management Instanz.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SonicConnection
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Eindeutiger Bezeichner; wird in <see cref="DeploymentTarget.SonicConnectionName"/> referenziert.
|
||||||
|
/// </summary>
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Sonic-Domain-Name, z.B. "proalpha-test".</summary>
|
||||||
|
public required string DomainName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sonic-Broker-/Management-URL im Sonic-Format, z.B. "tcp://dekun-painwbdet:13070".
|
||||||
|
/// Der Hostname wird daraus extrahiert (für HTTP-Modus: Basis-URL, für WinRM-Modus: Zielrechner).
|
||||||
|
/// </summary>
|
||||||
|
public required string ConnectionUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Benutzername für die Management-Konsole.</summary>
|
||||||
|
public required string Username { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Passwort für die Management-Konsole.</summary>
|
||||||
|
public required string Password { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Management-Modus: HttpApi (REST) oder WinRm (PowerShell Remoting).
|
||||||
|
/// Standard: WinRm – da Sonic 10.x kein HTTP REST API bereitstellt.
|
||||||
|
/// </summary>
|
||||||
|
public SonicManagementMode ManagementMode { get; init; } = SonicManagementMode.WinRm;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// HTTP REST API (ManagementMode = HttpApi)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>HTTP-Port der Sonic Management Console REST-API (Standard: 8080).</summary>
|
||||||
|
public int ManagementHttpPort { get; init; } = 8080;
|
||||||
|
|
||||||
|
/// <summary>Präfix für alle REST-API-Pfade (Standard: "/api/v1").</summary>
|
||||||
|
public string ApiBasePath { get; init; } = "/api/v1";
|
||||||
|
|
||||||
|
/// <summary>Konfigurierter Pfad zum Auflisten aller Container. Leer = automatische Erkennung.</summary>
|
||||||
|
public string ContainerListPath { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Konfigurierter Pfad zum Neustarten. Platzhalter: {domain}, {container}.</summary>
|
||||||
|
public string ContainerRestartPath { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Konfigurierter Pfad zum Stoppen. Platzhalter: {domain}, {container}.</summary>
|
||||||
|
public string ContainerStopPath { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Konfigurierter Pfad zum Starten. Platzhalter: {domain}, {container}.</summary>
|
||||||
|
public string ContainerStartPath { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// WinRM / PowerShell Remoting (ManagementMode = WinRm)
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WinRM-Port auf dem Zielrechner (Standard: 5985 = HTTP, 5986 = HTTPS).
|
||||||
|
/// </summary>
|
||||||
|
public int WinRmPort { get; init; } = 5985;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PowerShell-Scriptblock zum Neustarten eines Containers.
|
||||||
|
/// Platzhalter: {container} = Container-Name (nicht enkodiert), {domain} = Domain-Name.
|
||||||
|
/// Beispiel für Windows-Service: "Restart-Service -Name 'CT-ZADBService' -Force"
|
||||||
|
/// Beispiel für Sonic-Skript: "& 'C:\\Sonic\\bin\\stopContainer.bat' '{container}'; Start-Sleep 5; & 'C:\\Sonic\\bin\\startContainer.bat' '{container}'"
|
||||||
|
/// </summary>
|
||||||
|
public string WinRmRestartScript { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PowerShell-Scriptblock zum Auflisten aller Container der Domain.
|
||||||
|
/// Ausgabe: eine Zeile pro Container-Name.
|
||||||
|
/// Beispiel: "Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty Name"
|
||||||
|
/// </summary>
|
||||||
|
public string WinRmContainerListScript { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PowerShell-Scriptblock für XApi-Import.
|
||||||
|
/// Platzhalter: {container}, {xapiPath}.
|
||||||
|
/// </summary>
|
||||||
|
public string WinRmXapiImportScript { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Gemeinsame Einstellungen
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>Timeout in Sekunden für einzelne API-/Script-Aufrufe (Standard: 60).</summary>
|
||||||
|
public int TimeoutSeconds { get; init; } = 60;
|
||||||
|
|
||||||
|
/// <summary>Wartezeit in Sekunden nach einem Container-Neustart (Standard: 15).</summary>
|
||||||
|
public int PostRestartDelaySeconds { get; init; } = 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SonicManagementMode
|
||||||
|
{
|
||||||
|
/// <summary>HTTP REST API (wenn vom Sonic-Server bereitgestellt).</summary>
|
||||||
|
HttpApi,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PowerShell Remoting (WinRM) – Standard für Sonic 10.x auf Windows.
|
||||||
|
/// Führt konfigurierte Scriptblöcke via Invoke-Command auf dem Sonic-Server aus.
|
||||||
|
/// </summary>
|
||||||
|
WinRm
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager
|
||||||
|
{
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
|
||||||
|
[STAThread]
|
||||||
|
static void Main()
|
||||||
|
{
|
||||||
|
ApplicationConfiguration.Initialize();
|
||||||
|
Application.Run(new Form1());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
public sealed class CertificateDeployer
|
||||||
|
{
|
||||||
|
public Task<(bool Success, string Status, string? Detail, string? BackupPath)> DeployAsync(
|
||||||
|
string sourceCertificatePath,
|
||||||
|
DeploymentTarget target,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return Task.Run(() => Deploy(sourceCertificatePath, target, cancellationToken), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (bool Success, string Status, string? Detail, string? BackupPath) Deploy(
|
||||||
|
string sourceCertificatePath,
|
||||||
|
DeploymentTarget target,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!File.Exists(sourceCertificatePath))
|
||||||
|
{
|
||||||
|
return (false, "Quelle fehlt", $"Quelldatei nicht gefunden: {sourceCertificatePath}", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
string targetDirectory = PathResolver.ResolvePath(target.TargetDirectory);
|
||||||
|
Directory.CreateDirectory(targetDirectory);
|
||||||
|
|
||||||
|
string destinationPath = Path.Combine(targetDirectory, target.CertificateFileName);
|
||||||
|
string? backupPath = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
backupPath = $"{destinationPath}.bak-{DateTime.Now:yyyyMMddHHmmss}";
|
||||||
|
File.Copy(destinationPath, backupPath, overwrite: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
File.Copy(sourceCertificatePath, destinationPath, overwrite: true);
|
||||||
|
|
||||||
|
string sourceHash = ComputeSha256(sourceCertificatePath);
|
||||||
|
string destHash = ComputeSha256(destinationPath);
|
||||||
|
|
||||||
|
if (!string.Equals(sourceHash, destHash, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
RestoreFromBackupOrDelete(destinationPath, backupPath);
|
||||||
|
return (false, "Hash-Fehler", "SHA-256 von Quelle und Ziel stimmen nicht überein. Rollback ausgeführt.", backupPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, "Kopiert", $"Ziel: {destinationPath}", backupPath);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (backupPath is not null && File.Exists(backupPath) && File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
File.Copy(backupPath, destinationPath, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Rollback best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
return (false, "Kopierfehler", ex.Message, backupPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RestoreFromBackupOrDelete(string destinationPath, string? backupPath)
|
||||||
|
{
|
||||||
|
if (backupPath is not null && File.Exists(backupPath))
|
||||||
|
{
|
||||||
|
File.Copy(backupPath, destinationPath, overwrite: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File.Exists(destinationPath))
|
||||||
|
{
|
||||||
|
File.Delete(destinationPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ComputeSha256(string filePath)
|
||||||
|
{
|
||||||
|
using FileStream stream = File.OpenRead(filePath);
|
||||||
|
byte[] hash = SHA256.HashData(stream);
|
||||||
|
return Convert.ToHexString(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
using ZA.CoreService.ESBCertificateManager.Data;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
public sealed class DeploymentOrchestrator
|
||||||
|
{
|
||||||
|
private readonly CertificateDeployer _deployer = new();
|
||||||
|
private readonly RestartExecutor _restartExecutor;
|
||||||
|
private readonly TlsCertificateProbe _tlsProbe;
|
||||||
|
private readonly PreflightValidator _preflightValidator = new();
|
||||||
|
private readonly SqlRunLogger _sqlRunLogger;
|
||||||
|
private readonly AppSettings _settings;
|
||||||
|
|
||||||
|
public DeploymentOrchestrator(AppSettings settings)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_tlsProbe = new TlsCertificateProbe(settings.TlsTimeoutSeconds, settings.TlsRetryCount);
|
||||||
|
_restartExecutor = new RestartExecutor(settings.SonicConnections);
|
||||||
|
_sqlRunLogger = new SqlRunLogger(settings.ConnectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PreflightValidationResult ValidatePreflight(
|
||||||
|
string? certificatePath,
|
||||||
|
CertificateInfo? certificateInfo,
|
||||||
|
IReadOnlyList<DeploymentTarget> selectedTargets)
|
||||||
|
=> _preflightValidator.Validate(certificatePath, certificateInfo, selectedTargets);
|
||||||
|
|
||||||
|
public async Task<DeploymentRunResult> RunAsync(
|
||||||
|
string certificatePath,
|
||||||
|
CertificateInfo certificateInfo,
|
||||||
|
IReadOnlyList<DeploymentTarget> selectedTargets,
|
||||||
|
IProgress<TargetProgressUpdate>? progress,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
DateTimeOffset startedAt = DateTimeOffset.Now;
|
||||||
|
List<TargetStepResult> results = [];
|
||||||
|
|
||||||
|
using RunLogger logger = new(_settings.LogDirectory);
|
||||||
|
logger.Write($"Deployment gestartet für {selectedTargets.Count} Ziel(e). Zertifikat={Path.GetFileName(certificatePath)}");
|
||||||
|
|
||||||
|
foreach (DeploymentTarget target in selectedTargets)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
results.Add(await RunTargetAsync(target, certificatePath, certificateInfo, logger, progress, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset finishedAt = DateTimeOffset.Now;
|
||||||
|
DeploymentRunResult runResult = new()
|
||||||
|
{
|
||||||
|
TargetResults = results,
|
||||||
|
StartedAt = startedAt,
|
||||||
|
FinishedAt = finishedAt,
|
||||||
|
CertificateFilePath = certificatePath,
|
||||||
|
CertificateFingerprint = certificateInfo.FingerprintSha256
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.Write(
|
||||||
|
$"Deployment beendet. Erfolg={runResult.OverallSuccess}; Dauer={(finishedAt - startedAt).TotalSeconds:F1}s; Log={logger.LogFilePath}");
|
||||||
|
|
||||||
|
// Ergebnis in SQL-Datenbank protokollieren (wenn ConnectionString konfiguriert)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _sqlRunLogger.PersistRunResultAsync(runResult, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.Write($"SQL-Protokollierung fehlgeschlagen (nicht kritisch): {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return runResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<TargetStepResult> RunTargetAsync(
|
||||||
|
DeploymentTarget target,
|
||||||
|
string certificatePath,
|
||||||
|
CertificateInfo certificateInfo,
|
||||||
|
RunLogger logger,
|
||||||
|
IProgress<TargetProgressUpdate>? progress,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
List<string> steps = [];
|
||||||
|
DateTimeOffset targetStart = DateTimeOffset.Now;
|
||||||
|
progress?.Report(new TargetProgressUpdate(target.Id, "Läuft…", false));
|
||||||
|
|
||||||
|
(bool copyOk, string copyStatus, string? copyDetail, _) =
|
||||||
|
await _deployer.DeployAsync(certificatePath, target, cancellationToken);
|
||||||
|
|
||||||
|
RecordStep(steps, logger, target.Name, "Deploy", copyStatus, copyDetail);
|
||||||
|
if (!copyOk)
|
||||||
|
{
|
||||||
|
return Fail(target, copyStatus, copyDetail, steps, targetStart, progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new TargetProgressUpdate(target.Id, copyStatus, false));
|
||||||
|
|
||||||
|
(bool restartOk, string restartStatus, string? restartDetail) =
|
||||||
|
await _restartExecutor.ExecuteAsync(target, cancellationToken);
|
||||||
|
|
||||||
|
RecordStep(steps, logger, target.Name, "Restart", restartStatus, restartDetail);
|
||||||
|
if (!restartOk)
|
||||||
|
{
|
||||||
|
return Fail(target, restartStatus, restartDetail, steps, targetStart, progress,
|
||||||
|
copySucceeded: copyOk);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new TargetProgressUpdate(target.Id, restartStatus, false));
|
||||||
|
|
||||||
|
(bool tlsOk, string tlsStatus, string? tlsDetail, string? observedFingerprint) = await _tlsProbe.ProbeAsync(
|
||||||
|
target,
|
||||||
|
certificateInfo.FingerprintSha256,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
RecordStep(steps, logger, target.Name, "TLS", tlsStatus, tlsDetail);
|
||||||
|
|
||||||
|
bool success = tlsOk;
|
||||||
|
string finalStatus = success
|
||||||
|
? (string.IsNullOrWhiteSpace(target.TlsHost) ? "Erfolg" : tlsStatus)
|
||||||
|
: tlsStatus;
|
||||||
|
|
||||||
|
TargetStepResult targetResult = new()
|
||||||
|
{
|
||||||
|
TargetId = target.Id,
|
||||||
|
TargetName = target.Name,
|
||||||
|
Success = success,
|
||||||
|
StatusText = finalStatus,
|
||||||
|
Detail = tlsDetail,
|
||||||
|
Steps = steps,
|
||||||
|
StartedAt = targetStart,
|
||||||
|
FinishedAt = DateTimeOffset.Now,
|
||||||
|
CopySucceeded = copyOk,
|
||||||
|
RestartSucceeded = restartOk,
|
||||||
|
TlsSucceeded = tlsOk,
|
||||||
|
ObservedFingerprint = observedFingerprint
|
||||||
|
};
|
||||||
|
progress?.Report(new TargetProgressUpdate(target.Id, finalStatus, success));
|
||||||
|
return targetResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RecordStep(
|
||||||
|
List<string> steps,
|
||||||
|
RunLogger logger,
|
||||||
|
string targetName,
|
||||||
|
string phase,
|
||||||
|
string status,
|
||||||
|
string? detail)
|
||||||
|
{
|
||||||
|
steps.Add($"{status}: {detail}");
|
||||||
|
logger.Write($"[{targetName}] {phase}: {status} | {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TargetStepResult Fail(
|
||||||
|
DeploymentTarget target,
|
||||||
|
string status,
|
||||||
|
string? detail,
|
||||||
|
List<string> steps,
|
||||||
|
DateTimeOffset startedAt,
|
||||||
|
IProgress<TargetProgressUpdate>? progress,
|
||||||
|
bool copySucceeded = false,
|
||||||
|
bool restartSucceeded = false)
|
||||||
|
{
|
||||||
|
progress?.Report(new TargetProgressUpdate(target.Id, status, false));
|
||||||
|
return new TargetStepResult
|
||||||
|
{
|
||||||
|
TargetId = target.Id,
|
||||||
|
TargetName = target.Name,
|
||||||
|
Success = false,
|
||||||
|
StatusText = status,
|
||||||
|
Detail = detail,
|
||||||
|
Steps = steps,
|
||||||
|
StartedAt = startedAt,
|
||||||
|
FinishedAt = DateTimeOffset.Now,
|
||||||
|
CopySucceeded = copySucceeded,
|
||||||
|
RestartSucceeded = restartSucceeded,
|
||||||
|
TlsSucceeded = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct TargetProgressUpdate(int TargetId, string StatusText, bool? SuccessHint);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
public static class PathResolver
|
||||||
|
{
|
||||||
|
public static string ResolvePath(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
{
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Path.IsPathRooted(path))
|
||||||
|
{
|
||||||
|
return Path.GetFullPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
public sealed class PreflightValidator
|
||||||
|
{
|
||||||
|
public PreflightValidationResult Validate(
|
||||||
|
string? certificatePath,
|
||||||
|
CertificateInfo? certificateInfo,
|
||||||
|
IReadOnlyList<DeploymentTarget> selectedTargets)
|
||||||
|
{
|
||||||
|
PreflightValidationResult result = new();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(certificatePath) || !File.Exists(certificatePath))
|
||||||
|
{
|
||||||
|
AddIssue(result, "Es ist keine gültige Zertifikatsdatei ausgewählt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificateInfo is null)
|
||||||
|
{
|
||||||
|
AddIssue(result, "Zertifikatsmetadaten sind nicht geladen.");
|
||||||
|
}
|
||||||
|
else if (!certificateInfo.IsCurrentlyValid)
|
||||||
|
{
|
||||||
|
AddIssue(result, "Das geladene Zertifikat ist abgelaufen oder ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTargets.Count == 0)
|
||||||
|
{
|
||||||
|
AddIssue(result, "Bitte mindestens ein Ziel anhaken.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (DeploymentTarget target in selectedTargets)
|
||||||
|
{
|
||||||
|
ValidateTarget(result, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateTarget(PreflightValidationResult result, DeploymentTarget target)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(target.TargetDirectory))
|
||||||
|
{
|
||||||
|
AddIssue(result, $"Ziel '{target.Name}': TargetDirectory fehlt.", target.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(target.CertificateFileName))
|
||||||
|
{
|
||||||
|
AddIssue(result, $"Ziel '{target.Name}': CertificateFileName fehlt.", target.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.RestartType == RestartType.Command
|
||||||
|
&& string.IsNullOrWhiteSpace(target.RestartCommand))
|
||||||
|
{
|
||||||
|
AddIssue(result, $"Ziel '{target.Name}': RestartCommand fehlt bei RestartType=Command.", target.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.RestartType is RestartType.SonicContainer or RestartType.SonicContainerWithXapi)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(target.ContainerName))
|
||||||
|
{
|
||||||
|
AddIssue(result, $"Ziel '{target.Name}': ContainerName fehlt bei RestartType={target.RestartType}.", target.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(target.SonicConnectionName))
|
||||||
|
{
|
||||||
|
AddIssue(result, $"Ziel '{target.Name}': SonicConnectionName fehlt bei RestartType={target.RestartType}.", target.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.RestartType == RestartType.SonicContainerWithXapi
|
||||||
|
&& string.IsNullOrWhiteSpace(target.XapiSourcePath))
|
||||||
|
{
|
||||||
|
AddIssue(result, $"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.", target.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(target.TargetDirectory))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string directory = PathResolver.ResolvePath(target.TargetDirectory);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
string probeFile = Path.Combine(directory, $".write-probe-{Guid.NewGuid():N}");
|
||||||
|
File.WriteAllText(probeFile, "ok");
|
||||||
|
File.Delete(probeFile);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AddIssue(result, $"Ziel '{target.Name}': Verzeichnis nicht beschreibbar ({ex.Message}).", target.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddIssue(PreflightValidationResult result, string message, int? targetId = null)
|
||||||
|
{
|
||||||
|
result.Issues.Add(new ValidationIssue
|
||||||
|
{
|
||||||
|
TargetId = targetId,
|
||||||
|
Message = message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
public sealed class RestartExecutor
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<SonicConnection> _sonicConnections;
|
||||||
|
|
||||||
|
public RestartExecutor(IReadOnlyList<SonicConnection> sonicConnections)
|
||||||
|
{
|
||||||
|
_sonicConnections = sonicConnections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<(bool Success, string Status, string? Detail)> ExecuteAsync(
|
||||||
|
DeploymentTarget target,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> target.RestartType switch
|
||||||
|
{
|
||||||
|
RestartType.None => Task.FromResult<(bool, string, string?)>((true, "Neustart übersprungen", "RestartType=None")),
|
||||||
|
RestartType.Command => ExecuteCommandAsync(target, cancellationToken),
|
||||||
|
RestartType.SonicContainer => ExecuteSonicRestartAsync(target, importXapi: false, cancellationToken),
|
||||||
|
RestartType.SonicContainerWithXapi => ExecuteSonicRestartAsync(target, importXapi: true, cancellationToken),
|
||||||
|
_ => Task.FromResult<(bool, string, string?)>((false, "Unbekannter RestartType", $"RestartType={target.RestartType}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task<(bool Success, string Status, string? Detail)> ExecuteSonicRestartAsync(
|
||||||
|
DeploymentTarget target,
|
||||||
|
bool importXapi,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(target.ContainerName))
|
||||||
|
{
|
||||||
|
return (false, "Sonic-Neustart fehlgeschlagen", $"Ziel '{target.Name}': ContainerName fehlt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
SonicConnection? connection = _sonicConnections
|
||||||
|
.FirstOrDefault(c => string.Equals(c.Name, target.SonicConnectionName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (connection is null)
|
||||||
|
{
|
||||||
|
return (false, "Sonic-Verbindung nicht gefunden",
|
||||||
|
$"Ziel '{target.Name}': SonicConnection '{target.SonicConnectionName}' ist nicht in AppSettings konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using SonicManagementClient client = new(connection);
|
||||||
|
|
||||||
|
if (importXapi)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(target.XapiSourcePath))
|
||||||
|
{
|
||||||
|
return (false, "XApi-Import fehlgeschlagen",
|
||||||
|
$"Ziel '{target.Name}': XapiSourcePath fehlt bei RestartType=SonicContainerWithXapi.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await client.ImportXapiAndRestartAsync(target.ContainerName, target.XapiSourcePath, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await client.RestartContainerAsync(target.ContainerName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool Success, string Status, string? Detail)> ExecuteCommandAsync(
|
||||||
|
DeploymentTarget target,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(target.RestartCommand))
|
||||||
|
{
|
||||||
|
return (true, "Neustart übersprungen", "RestartType=None");
|
||||||
|
}
|
||||||
|
|
||||||
|
int timeoutSeconds = Math.Clamp(target.RestartTimeoutSeconds, 1, 600);
|
||||||
|
|
||||||
|
ProcessStartInfo startInfo = new()
|
||||||
|
{
|
||||||
|
FileName = target.RestartCommand,
|
||||||
|
Arguments = target.RestartArguments ?? string.Empty,
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Process process = new() { StartInfo = startInfo };
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
return (false, "Neustart fehlgeschlagen", "Prozess konnte nicht gestartet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||||
|
Task<string> stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||||
|
|
||||||
|
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(timeoutCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||||
|
return (false, "Neustart Timeout", $"Timeout nach {timeoutSeconds}s.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string detail = BuildDetail(process.ExitCode, await stdoutTask, await stderrTask);
|
||||||
|
return process.ExitCode == 0
|
||||||
|
? (true, "Neustart ok", detail)
|
||||||
|
: (false, "Neustart fehlgeschlagen", detail);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (false, "Neustart fehlgeschlagen", ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDetail(int exitCode, string stdout, string stderr)
|
||||||
|
{
|
||||||
|
string detail = $"ExitCode={exitCode}";
|
||||||
|
|
||||||
|
stdout = Truncate(stdout).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(stdout))
|
||||||
|
{
|
||||||
|
detail += "; out=" + stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr = Truncate(stderr).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(stderr))
|
||||||
|
{
|
||||||
|
detail += "; err=" + stderr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string value, int max = 400)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value) || value.Length <= max)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value[..max] + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
public sealed class RunLogger : IDisposable
|
||||||
|
{
|
||||||
|
private static readonly Regex ConnectionStringSecretRegex = new(
|
||||||
|
@"(Password|Pwd|Passwort)\s*=\s*[^;]+",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex InlineSecretRegex = new(
|
||||||
|
@"(Password|Pwd)\s*[:=]\s*\S+",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private readonly StreamWriter _writer;
|
||||||
|
private readonly object _sync = new();
|
||||||
|
public string LogFilePath { get; }
|
||||||
|
|
||||||
|
public RunLogger(string logDirectory)
|
||||||
|
{
|
||||||
|
string directory = PathResolver.ResolvePath(logDirectory);
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
|
string fileName = $"run-{DateTime.Now:yyyyMMdd-HHmmss}.log";
|
||||||
|
LogFilePath = Path.Combine(directory, fileName);
|
||||||
|
|
||||||
|
_writer = new StreamWriter(LogFilePath, append: false, Encoding.UTF8)
|
||||||
|
{
|
||||||
|
AutoFlush = true
|
||||||
|
};
|
||||||
|
|
||||||
|
Write($"Run gestartet von {Environment.UserName} auf {Environment.MachineName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Write(string message)
|
||||||
|
{
|
||||||
|
string line = $"{DateTimeOffset.Now:yyyy-MM-dd HH:mm:ss.fff} | {Redact(message)}";
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_writer.WriteLine(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
|
_writer.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Redact(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(message))
|
||||||
|
{
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keine Passwörter / Connection-Secrets in Logs.
|
||||||
|
string redacted = ConnectionStringSecretRegex.Replace(message, "$1=***");
|
||||||
|
return InlineSecretRegex.Replace(redacted, "$1=***");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt alle konfigurierten Sonic-Management-Verbindungen nach ihren Containern
|
||||||
|
/// und gleicht die gefundenen Container mit den bereits konfigurierten Deployment-Zielen ab.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SonicContainerDiscovery
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<SonicConnection> _connections;
|
||||||
|
|
||||||
|
public SonicContainerDiscovery(IReadOnlyList<SonicConnection> connections)
|
||||||
|
{
|
||||||
|
_connections = connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasConnections => _connections.Count > 0;
|
||||||
|
public IReadOnlyList<SonicConnection> Connections => _connections;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verbindet sich mit der angegebenen Sonic-Instanz, liest die Container-Liste
|
||||||
|
/// und reichert sie mit konfigurierten Ziel-Metadaten an.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SonicDiscoveryResult> DiscoverAsync(
|
||||||
|
string connectionName,
|
||||||
|
IReadOnlyList<DeploymentTarget> knownTargets,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
SonicConnection? connection = _connections
|
||||||
|
.FirstOrDefault(c => string.Equals(c.Name, connectionName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (connection is null)
|
||||||
|
{
|
||||||
|
return SonicDiscoveryResult.Failed(connectionName,
|
||||||
|
$"Sonic-Verbindung '{connectionName}' ist nicht in AppSettings konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using SonicManagementClient client = new(connection);
|
||||||
|
|
||||||
|
(bool reachable, string? pingError, string? resolvedPath) = await client.CheckConnectionAsync(cancellationToken);
|
||||||
|
if (!reachable)
|
||||||
|
{
|
||||||
|
return SonicDiscoveryResult.Failed(connectionName,
|
||||||
|
$"Management-Konsole nicht erreichbar: {pingError}");
|
||||||
|
}
|
||||||
|
|
||||||
|
(bool listOk, IReadOnlyList<string> containerNames, string? listError) =
|
||||||
|
await client.GetContainersAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!listOk)
|
||||||
|
{
|
||||||
|
// Fallback: CheckConnection erfolgreich, aber /containers nicht gefunden –
|
||||||
|
// dies kann passieren wenn die API-Pfade abweichen. Container-Namen sind dann leer.
|
||||||
|
containerNames = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DeploymentTarget> discovered = BuildTargets(connection, containerNames, knownTargets);
|
||||||
|
|
||||||
|
return new SonicDiscoveryResult
|
||||||
|
{
|
||||||
|
ConnectionName = connectionName,
|
||||||
|
Success = true,
|
||||||
|
ErrorMessage = listOk ? null : $"Container-Liste konnte nicht geladen werden: {listError}",
|
||||||
|
DiscoveredTargets = discovered,
|
||||||
|
RawContainerNames = containerNames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<DeploymentTarget> BuildTargets(
|
||||||
|
SonicConnection connection,
|
||||||
|
IReadOnlyList<string> containerNames,
|
||||||
|
IReadOnlyList<DeploymentTarget> knownTargets)
|
||||||
|
{
|
||||||
|
List<DeploymentTarget> result = [];
|
||||||
|
int syntheticId = -1;
|
||||||
|
|
||||||
|
foreach (string containerName in containerNames)
|
||||||
|
{
|
||||||
|
// Bekanntes, voll-konfiguriertes Ziel suchen (nach ContainerName + SonicConnectionName)
|
||||||
|
DeploymentTarget? existing = knownTargets.FirstOrDefault(t =>
|
||||||
|
string.Equals(t.ContainerName, containerName, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
string.Equals(t.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
result.Add(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Minimales Ziel aus der Discovery erzeugen.
|
||||||
|
// TargetDirectory/CertificateFileName sind leer → Preflight-Validator zeigt Warnung.
|
||||||
|
result.Add(new DeploymentTarget
|
||||||
|
{
|
||||||
|
Id = syntheticId--,
|
||||||
|
Name = $"{connection.Name} / {containerName}",
|
||||||
|
Environment = connection.DomainName,
|
||||||
|
IsActive = true,
|
||||||
|
TargetDirectory = string.Empty,
|
||||||
|
CertificateFileName = string.Empty,
|
||||||
|
ContainerName = containerName,
|
||||||
|
RestartType = RestartType.SonicContainer,
|
||||||
|
SonicConnectionName = connection.Name,
|
||||||
|
SortOrder = result.Count * 10
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konfigurierte Ziele dieser Verbindung, die NICHT in der Discovery aufgetaucht sind,
|
||||||
|
// trotzdem anzeigen (könnten offline / gestoppt sein).
|
||||||
|
foreach (DeploymentTarget known in knownTargets)
|
||||||
|
{
|
||||||
|
if (!string.Equals(known.SonicConnectionName, connection.Name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool alreadyAdded = result.Any(r =>
|
||||||
|
string.Equals(r.ContainerName, known.ContainerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (!alreadyAdded)
|
||||||
|
{
|
||||||
|
result.Add(known);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SonicDiscoveryResult
|
||||||
|
{
|
||||||
|
public required string ConnectionName { get; init; }
|
||||||
|
public bool Success { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
public IReadOnlyList<DeploymentTarget> DiscoveredTargets { get; init; } = [];
|
||||||
|
public IReadOnlyList<string> RawContainerNames { get; init; } = [];
|
||||||
|
|
||||||
|
public static SonicDiscoveryResult Failed(string connectionName, string error)
|
||||||
|
=> new() { ConnectionName = connectionName, Success = false, ErrorMessage = error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verwaltet Sonic ESB Container über die Management Console.
|
||||||
|
///
|
||||||
|
/// Modus = WinRm (Standard, Sonic 10.x):
|
||||||
|
/// PowerShell Remoting (Invoke-Command) auf dem Sonic-Server.
|
||||||
|
/// Erfordert WinRM auf dem Zielrechner: Enable-PSRemoting -Force
|
||||||
|
///
|
||||||
|
/// Modus = HttpApi:
|
||||||
|
/// HTTP REST API mit automatischer Pfad-Erkennung.
|
||||||
|
/// Probiert: /mf/rest/v1, /api/v1, /sonic/management, /containers
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SonicManagementClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SonicConnection _connection;
|
||||||
|
private readonly HttpClient? _http;
|
||||||
|
private readonly WinRmExecutor? _winRm;
|
||||||
|
|
||||||
|
// Gecachter HTTP-Basis-Pfad (nur im HttpApi-Modus)
|
||||||
|
private string? _resolvedContainerBasePath;
|
||||||
|
|
||||||
|
private static readonly string[] CandidateContainerPaths =
|
||||||
|
[
|
||||||
|
"/mf/rest/v1/domains/{domain}/containers",
|
||||||
|
"/api/v1/domains/{domain}/containers",
|
||||||
|
"/sonic/management/domains/{domain}/containers",
|
||||||
|
"/containers"
|
||||||
|
];
|
||||||
|
|
||||||
|
public SonicManagementClient(SonicConnection connection)
|
||||||
|
{
|
||||||
|
_connection = connection;
|
||||||
|
|
||||||
|
if (connection.ManagementMode == SonicManagementMode.WinRm)
|
||||||
|
{
|
||||||
|
_winRm = new WinRmExecutor(connection);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
HttpClientHandler handler = new()
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback =
|
||||||
|
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||||
|
};
|
||||||
|
|
||||||
|
_http = new HttpClient(handler)
|
||||||
|
{
|
||||||
|
BaseAddress = BuildHttpBaseUri(connection.ConnectionUrl, connection.ManagementHttpPort),
|
||||||
|
Timeout = TimeSpan.FromSeconds(Math.Clamp(connection.TimeoutSeconds, 5, 300))
|
||||||
|
};
|
||||||
|
|
||||||
|
string credentials = Convert.ToBase64String(
|
||||||
|
Encoding.UTF8.GetBytes($"{connection.Username}:{connection.Password}"));
|
||||||
|
_http.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
_http.DefaultRequestHeaders.Accept.Add(
|
||||||
|
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Öffentliche API
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft die Verbindung zur Management Console.
|
||||||
|
/// WinRm: TCP-Ping auf WinRM-Port + Test-PSSession.
|
||||||
|
/// Http: Probe gegen bekannte API-Pfade.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool Success, string? Error, string? ResolvedPath)> CheckConnectionAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connection.ManagementMode == SonicManagementMode.WinRm)
|
||||||
|
{
|
||||||
|
(bool ok, string? error) = await _winRm!.TestConnectionAsync(cancellationToken);
|
||||||
|
return (ok, error, ok ? $"WinRM auf {ExtractHost(_connection.ConnectionUrl)}:{_connection.WinRmPort}" : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? path = await ResolveContainerBasePathAsync(cancellationToken);
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
return (false,
|
||||||
|
$"Keine Sonic HTTP-API unter {_http!.BaseAddress} gefunden.\n" +
|
||||||
|
$"Geprüfte Pfade: {string.Join(", ", GetCandidatePaths())}\n" +
|
||||||
|
$"Tipp: ManagementMode auf 'WinRm' setzen falls kein HTTP-API vorhanden.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, null, path);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return (false,
|
||||||
|
$"HTTP-Timeout nach {_connection.TimeoutSeconds}s – Port {_connection.ManagementHttpPort} nicht erreichbar.\n" +
|
||||||
|
$"Tipp: ManagementMode auf 'WinRm' setzen.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (false, ex.Message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Listet alle Container der Domain auf.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool Success, IReadOnlyList<string> ContainerNames, string? Error)> GetContainersAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connection.ManagementMode == SonicManagementMode.WinRm)
|
||||||
|
{
|
||||||
|
return await GetContainersViaWinRmAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GetContainersViaHttpAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Startet den Container neu.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool Success, string Status, string? Detail)> RestartContainerAsync(
|
||||||
|
string containerName,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connection.ManagementMode == SonicManagementMode.WinRm)
|
||||||
|
{
|
||||||
|
return await RestartViaWinRmAsync(containerName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await RestartViaHttpAsync(containerName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Importiert XApi-Ressourcen und startet den Container neu.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool Success, string Status, string? Detail)> ImportXapiAndRestartAsync(
|
||||||
|
string containerName,
|
||||||
|
string xapiSourcePath,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_connection.ManagementMode == SonicManagementMode.WinRm)
|
||||||
|
{
|
||||||
|
return await ImportXapiViaWinRmAsync(containerName, xapiSourcePath, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ImportXapiViaHttpAsync(containerName, xapiSourcePath, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// WinRM-Implementierungen
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task<(bool, IReadOnlyList<string>, string?)> GetContainersViaWinRmAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_connection.WinRmContainerListScript))
|
||||||
|
{
|
||||||
|
return (false, [],
|
||||||
|
"WinRmContainerListScript ist nicht konfiguriert.\n" +
|
||||||
|
"Beispiel: \"Get-Service -DisplayName 'Sonic*' | Select-Object -ExpandProperty DisplayName\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
string script = WinRmExecutor.ApplyScriptTemplate(
|
||||||
|
_connection.WinRmContainerListScript,
|
||||||
|
containerName: string.Empty,
|
||||||
|
domainName: _connection.DomainName);
|
||||||
|
|
||||||
|
(bool ok, string? output, string? error) =
|
||||||
|
await _winRm!.RunScriptAsync(script, cancellationToken);
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return (false, [], $"WinRM Container-Liste fehlgeschlagen: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<string> names = (output ?? string.Empty)
|
||||||
|
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(l => l.Length > 0)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return (true, names, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string, string?)> RestartViaWinRmAsync(
|
||||||
|
string containerName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_connection.WinRmRestartScript))
|
||||||
|
{
|
||||||
|
return (false, "Neustart fehlgeschlagen",
|
||||||
|
"WinRmRestartScript ist nicht konfiguriert.\n" +
|
||||||
|
"Beispiel für Windows-Service: \"Restart-Service -Name 'CT-ZADBService' -Force\"\n" +
|
||||||
|
"Platzhalter {container} wird durch den Container-Namen ersetzt.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string script = WinRmExecutor.ApplyScriptTemplate(
|
||||||
|
_connection.WinRmRestartScript,
|
||||||
|
containerName,
|
||||||
|
_connection.DomainName);
|
||||||
|
|
||||||
|
(bool ok, string? output, string? error) =
|
||||||
|
await _winRm!.RunScriptAsync(script, cancellationToken);
|
||||||
|
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return (false, "Neustart fehlgeschlagen (WinRM)",
|
||||||
|
$"Fehler: {error}\nAusgabe: {output}");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(
|
||||||
|
TimeSpan.FromSeconds(Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120)),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return (true, "Container neugestartet (WinRM)",
|
||||||
|
$"Ausgabe: {output ?? "(keine)"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string, string?)> ImportXapiViaWinRmAsync(
|
||||||
|
string containerName, string xapiSourcePath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_connection.WinRmXapiImportScript))
|
||||||
|
{
|
||||||
|
return (false, "XApi-Import fehlgeschlagen",
|
||||||
|
"WinRmXapiImportScript ist nicht konfiguriert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string script = WinRmExecutor.ApplyScriptTemplate(
|
||||||
|
_connection.WinRmXapiImportScript,
|
||||||
|
containerName,
|
||||||
|
_connection.DomainName,
|
||||||
|
xapiSourcePath);
|
||||||
|
|
||||||
|
(bool importOk, string? importOut, string? importErr) =
|
||||||
|
await _winRm!.RunScriptAsync(script, cancellationToken);
|
||||||
|
|
||||||
|
if (!importOk)
|
||||||
|
{
|
||||||
|
return (false, "XApi-Import fehlgeschlagen (WinRM)", importErr ?? importOut);
|
||||||
|
}
|
||||||
|
|
||||||
|
(bool restartOk, string restartStatus, string? restartDetail) =
|
||||||
|
await RestartViaWinRmAsync(containerName, cancellationToken);
|
||||||
|
|
||||||
|
return restartOk
|
||||||
|
? (true, "XApi importiert + Container neugestartet (WinRM)",
|
||||||
|
$"Import: {importOut} | Restart: {restartDetail}")
|
||||||
|
: (false, restartStatus, restartDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// HTTP-Implementierungen
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task<(bool, IReadOnlyList<string>, string?)> GetContainersViaHttpAsync(
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? basePath = await ResolveContainerBasePathAsync(cancellationToken);
|
||||||
|
if (basePath is null)
|
||||||
|
{
|
||||||
|
return (false, [], "Kein HTTP-API-Pfad gefunden. ManagementMode auf 'WinRm' setzen?");
|
||||||
|
}
|
||||||
|
|
||||||
|
using HttpResponseMessage response = await _http!.GetAsync(basePath, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return (false, [], $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||||
|
}
|
||||||
|
|
||||||
|
string json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return (true, ParseContainerNames(json), null);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
return (false, [], ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string, string?)> RestartViaHttpAsync(
|
||||||
|
string containerName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string encoded = Uri.EscapeDataString(containerName);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string? basePath = await ResolveContainerBasePathAsync(cancellationToken);
|
||||||
|
if (basePath is null)
|
||||||
|
{
|
||||||
|
return (false, "Neustart fehlgeschlagen",
|
||||||
|
"Kein HTTP-API-Pfad gefunden. ManagementMode auf 'WinRm' setzen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_connection.ContainerRestartPath))
|
||||||
|
{
|
||||||
|
string customPath = ApplyTemplate(_connection.ContainerRestartPath, encoded);
|
||||||
|
(bool ok, _, string? d) = await PostAsync(customPath, cancellationToken);
|
||||||
|
if (ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet", d); }
|
||||||
|
}
|
||||||
|
|
||||||
|
(bool r1Ok, _, string? r1d) = await PostAsync($"{basePath}/{encoded}/restart", cancellationToken);
|
||||||
|
if (r1Ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet", r1d); }
|
||||||
|
|
||||||
|
(bool r2Ok, _, string? r2d) = await PutStateAsync(basePath, encoded, "running", cancellationToken);
|
||||||
|
if (r2Ok) { await DelayAsync(cancellationToken); return (true, "Container neugestartet (State-API)", r2d); }
|
||||||
|
|
||||||
|
return await StopThenStartAsync(basePath, encoded, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { throw; }
|
||||||
|
catch (Exception ex) { return (false, "Neustart fehlgeschlagen", ex.Message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string, string?)> ImportXapiViaHttpAsync(
|
||||||
|
string containerName, string xapiSourcePath, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!File.Exists(xapiSourcePath))
|
||||||
|
{
|
||||||
|
return (false, "XApi-Import fehlgeschlagen", $"Quelldatei nicht gefunden: {xapiSourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
string encoded = Uri.EscapeDataString(containerName);
|
||||||
|
string? basePath = await ResolveContainerBasePathAsync(cancellationToken);
|
||||||
|
if (basePath is null) return (false, "XApi-Import fehlgeschlagen", "Kein HTTP-API-Pfad.");
|
||||||
|
|
||||||
|
string path = $"{basePath}/{encoded}/xapi/import";
|
||||||
|
await using FileStream fs = File.OpenRead(xapiSourcePath);
|
||||||
|
string mt = xapiSourcePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ? "application/zip" : "application/xml";
|
||||||
|
using StreamContent content = new(fs);
|
||||||
|
content.Headers.ContentType = new MediaTypeHeaderValue(mt);
|
||||||
|
using HttpResponseMessage rsp = await _http!.PostAsync(path, content, cancellationToken);
|
||||||
|
|
||||||
|
if (!rsp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string body = await rsp.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
return (false, "XApi-Import fehlgeschlagen", $"HTTP {(int)rsp.StatusCode}: {Truncate(body)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
(bool restartOk, string rs, string? rd) = await RestartViaHttpAsync(containerName, cancellationToken);
|
||||||
|
return restartOk
|
||||||
|
? (true, "XApi importiert + Container neugestartet", rd)
|
||||||
|
: (false, rs, rd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// HTTP-Pfad-Erkennung
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task<string?> ResolveContainerBasePathAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (_resolvedContainerBasePath is not null) return _resolvedContainerBasePath;
|
||||||
|
|
||||||
|
foreach (string p in GetCandidatePaths())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpResponseMessage r = await _http!.GetAsync(p, cancellationToken);
|
||||||
|
if (r.IsSuccessStatusCode || r.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
_resolvedContainerBasePath = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (HttpRequestException) { }
|
||||||
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IEnumerable<string> GetCandidatePaths()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_connection.ContainerListPath))
|
||||||
|
yield return ApplyTemplate(_connection.ContainerListPath, string.Empty).TrimEnd('/');
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_connection.ApiBasePath))
|
||||||
|
{
|
||||||
|
string d = Uri.EscapeDataString(_connection.DomainName);
|
||||||
|
yield return $"{_connection.ApiBasePath.TrimEnd('/')}/domains/{d}/containers";
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string pattern in CandidateContainerPaths)
|
||||||
|
yield return pattern.Replace("{domain}", Uri.EscapeDataString(_connection.DomainName), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// HTTP-Aktions-Helfer
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
private async Task<(bool, string, string?)> PostAsync(string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using HttpResponseMessage r = await _http!.PostAsync(path, null, ct);
|
||||||
|
if (r.IsSuccessStatusCode)
|
||||||
|
return (true, $"OK ({(int)r.StatusCode})", $"POST {path} → {(int)r.StatusCode}");
|
||||||
|
string body = await r.Content.ReadAsStringAsync(ct);
|
||||||
|
return (false, $"Fehler ({(int)r.StatusCode})", $"POST {path} → {(int)r.StatusCode}: {Truncate(body)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string, string?)> PutStateAsync(
|
||||||
|
string basePath, string encoded, string state, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string path = $"{basePath}/{encoded}";
|
||||||
|
using StringContent body = new($"{{\"state\":\"{state}\"}}", Encoding.UTF8, "application/json");
|
||||||
|
using HttpResponseMessage r = await _http!.PutAsync(path, body, ct);
|
||||||
|
if (r.IsSuccessStatusCode)
|
||||||
|
return (true, $"State={state}", $"PUT {path} state={state} → {(int)r.StatusCode}");
|
||||||
|
string b = await r.Content.ReadAsStringAsync(ct);
|
||||||
|
return (false, "State fehlgeschlagen", $"PUT {path} → {(int)r.StatusCode}: {Truncate(b)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool, string, string?)> StopThenStartAsync(
|
||||||
|
string basePath, string encoded, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string stopPath = string.IsNullOrWhiteSpace(_connection.ContainerStopPath)
|
||||||
|
? $"{basePath}/{encoded}/stop"
|
||||||
|
: ApplyTemplate(_connection.ContainerStopPath, encoded);
|
||||||
|
|
||||||
|
string startPath = string.IsNullOrWhiteSpace(_connection.ContainerStartPath)
|
||||||
|
? $"{basePath}/{encoded}/start"
|
||||||
|
: ApplyTemplate(_connection.ContainerStartPath, encoded);
|
||||||
|
|
||||||
|
(bool sOk, _, string? sd) = await PostAsync(stopPath, ct);
|
||||||
|
if (!sOk) return (false, "Container-Stop fehlgeschlagen", sd);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(3), ct);
|
||||||
|
|
||||||
|
(bool stOk, _, string? std) = await PostAsync(startPath, ct);
|
||||||
|
if (!stOk) return (false, "Container-Start fehlgeschlagen", std);
|
||||||
|
|
||||||
|
await DelayAsync(ct);
|
||||||
|
return (true, "Container neugestartet (Stop+Start)", $"{sd} | {std}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DelayAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
int d = Math.Clamp(_connection.PostRestartDelaySeconds, 0, 120);
|
||||||
|
if (d > 0) await Task.Delay(TimeSpan.FromSeconds(d), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ApplyTemplate(string template, string encodedName)
|
||||||
|
=> template
|
||||||
|
.Replace("{domain}", Uri.EscapeDataString(_connection.DomainName), StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("{container}", encodedName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static Uri BuildHttpBaseUri(string connectionUrl, int httpPort)
|
||||||
|
{
|
||||||
|
try { return new Uri($"http://{new Uri(connectionUrl).Host}:{httpPort}"); }
|
||||||
|
catch { return new Uri(connectionUrl); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractHost(string connectionUrl)
|
||||||
|
{
|
||||||
|
try { return new Uri(connectionUrl).Host; }
|
||||||
|
catch { return connectionUrl; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseContainerNames(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument doc = JsonDocument.Parse(json);
|
||||||
|
JsonElement root = doc.RootElement;
|
||||||
|
List<string> names = [];
|
||||||
|
|
||||||
|
IEnumerable<JsonElement> elements = root.ValueKind == JsonValueKind.Array
|
||||||
|
? root.EnumerateArray()
|
||||||
|
: root.ValueKind == JsonValueKind.Object
|
||||||
|
? new[] { "containers", "data", "items", "result" }
|
||||||
|
.Where(root.TryGetProperty)
|
||||||
|
.SelectMany(k => { root.TryGetProperty(k, out JsonElement a); return a.EnumerateArray(); })
|
||||||
|
: [];
|
||||||
|
|
||||||
|
foreach (JsonElement el in elements)
|
||||||
|
{
|
||||||
|
string? name = el.ValueKind == JsonValueKind.String
|
||||||
|
? el.GetString()
|
||||||
|
: new[] { "name", "containerName", "id", "configId" }
|
||||||
|
.Where(k => el.TryGetProperty(k, out _))
|
||||||
|
.Select(k => { el.TryGetProperty(k, out JsonElement p); return p.GetString(); })
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (name is not null) names.Add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string v, int max = 400)
|
||||||
|
=> v.Length <= max ? v : v[..max] + "…";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_http?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Net.Security;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
public sealed class TlsCertificateProbe
|
||||||
|
{
|
||||||
|
private readonly int _timeoutSeconds;
|
||||||
|
private readonly int _retryCount;
|
||||||
|
|
||||||
|
public TlsCertificateProbe(int timeoutSeconds = 8, int retryCount = 2)
|
||||||
|
{
|
||||||
|
_timeoutSeconds = Math.Clamp(timeoutSeconds, 1, 60);
|
||||||
|
_retryCount = Math.Clamp(retryCount, 0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeAsync(
|
||||||
|
DeploymentTarget target,
|
||||||
|
string expectedFingerprintSha256,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(target.TlsHost))
|
||||||
|
{
|
||||||
|
return (true, "TLS übersprungen", "Kein TlsHost konfiguriert.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
int port = target.TlsPort is > 0 and <= 65535 ? target.TlsPort.Value : 443;
|
||||||
|
|
||||||
|
// TlsServerName überschreibt den SNI-Hostnamen wenn gesetzt (wichtig bei IP-Adressen)
|
||||||
|
string serverName = string.IsNullOrWhiteSpace(target.TlsServerName)
|
||||||
|
? target.TlsHost
|
||||||
|
: target.TlsServerName;
|
||||||
|
|
||||||
|
string expected = NormalizeFingerprint(expectedFingerprintSha256);
|
||||||
|
|
||||||
|
Exception? lastError = null;
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt <= _retryCount; attempt++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await ProbeOnceAsync(
|
||||||
|
target.TlsHost,
|
||||||
|
port,
|
||||||
|
serverName,
|
||||||
|
expected,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastError = ex;
|
||||||
|
if (attempt < _retryCount)
|
||||||
|
{
|
||||||
|
await Task.Delay(400, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
false,
|
||||||
|
"TLS nicht erreichbar",
|
||||||
|
lastError?.Message ?? $"Keine Verbindung zu {target.TlsHost}:{port}",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool Success, string Status, string? Detail, string? ObservedFingerprint)> ProbeOnceAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
string serverName,
|
||||||
|
string expectedFingerprint,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using TcpClient client = new();
|
||||||
|
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds));
|
||||||
|
|
||||||
|
await client.ConnectAsync(host, port, timeoutCts.Token);
|
||||||
|
|
||||||
|
await using SslStream sslStream = new(
|
||||||
|
client.GetStream(),
|
||||||
|
leaveInnerStreamOpen: false,
|
||||||
|
userCertificateValidationCallback: static (_, _, _, _) => true);
|
||||||
|
|
||||||
|
await sslStream.AuthenticateAsClientAsync(
|
||||||
|
new SslClientAuthenticationOptions
|
||||||
|
{
|
||||||
|
TargetHost = serverName, // SNI: muss zum CN/SAN im Zertifikat passen
|
||||||
|
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12
|
||||||
|
| System.Security.Authentication.SslProtocols.Tls13
|
||||||
|
},
|
||||||
|
timeoutCts.Token);
|
||||||
|
|
||||||
|
if (sslStream.RemoteCertificate is null)
|
||||||
|
{
|
||||||
|
return (false, "TLS Fail", "Kein Remote-Zertifikat erhalten.", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
using X509Certificate2 remote = new(sslStream.RemoteCertificate);
|
||||||
|
string actual = Convert.ToHexString(SHA256.HashData(remote.RawData));
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(expectedFingerprint))
|
||||||
|
{
|
||||||
|
// Kein erwarteter Fingerprint konfiguriert – nur Konnektivität prüfen
|
||||||
|
return (true, "TLS Pass (kein Fingerprint-Vergleich)", $"{host}:{port} erreichbar. Fingerprint={actual}", actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(actual, expectedFingerprint, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return (true, "TLS Pass", $"{host}:{port} Fingerprint stimmt überein.", actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
false,
|
||||||
|
"TLS Fail",
|
||||||
|
$"{host}:{port} Fingerprint weicht ab. Erwartet={expectedFingerprint}, Ist={actual}",
|
||||||
|
actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeFingerprint(string fingerprint)
|
||||||
|
{
|
||||||
|
return fingerprint
|
||||||
|
.Replace(":", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace(" ", string.Empty, StringComparison.Ordinal)
|
||||||
|
.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
using ZA.CoreService.ESBCertificateManager.Models;
|
||||||
|
|
||||||
|
namespace ZA.CoreService.ESBCertificateManager.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Führt PowerShell-Befehle via WinRM (Invoke-Command) auf dem Sonic-Server aus.
|
||||||
|
///
|
||||||
|
/// Voraussetzungen auf dem Zielrechner:
|
||||||
|
/// - WinRM muss aktiviert sein: Enable-PSRemoting -Force
|
||||||
|
/// - Ausführungsrichtlinie: Set-ExecutionPolicy RemoteSigned
|
||||||
|
///
|
||||||
|
/// Voraussetzungen auf dem App-Rechner (einmalig, als Admin):
|
||||||
|
/// - Set-Item WSMan:\localhost\Client\TrustedHosts -Value "dekun-painwbdet"
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WinRmExecutor
|
||||||
|
{
|
||||||
|
private readonly SonicConnection _connection;
|
||||||
|
|
||||||
|
public WinRmExecutor(SonicConnection connection)
|
||||||
|
{
|
||||||
|
_connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft die WinRM-Konnektivität und ob der Sonic-Server per TCP erreichbar ist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool Success, string? Error)> TestConnectionAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string host = ExtractHost(_connection.ConnectionUrl);
|
||||||
|
|
||||||
|
// TCP-Ping auf WinRM-Port
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using System.Net.Sockets.TcpClient tcp = new();
|
||||||
|
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
cts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_connection.TimeoutSeconds, 5, 30)));
|
||||||
|
|
||||||
|
await tcp.ConnectAsync(host, _connection.WinRmPort, cts.Token);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
return (false,
|
||||||
|
$"WinRM-Port {_connection.WinRmPort} auf '{host}' nicht erreichbar: {ex.Message}\n" +
|
||||||
|
$"Auf dem Zielrechner ausführen: Enable-PSRemoting -Force");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kurztest: Hostname zurückgeben
|
||||||
|
(bool ok, string? output, string? error) = await RunScriptAsync(
|
||||||
|
"$env:COMPUTERNAME", cancellationToken);
|
||||||
|
|
||||||
|
return ok
|
||||||
|
? (true, null)
|
||||||
|
: (false, $"WinRM-Verbindung fehlgeschlagen: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Führt einen Scriptblock auf dem Remote-Rechner aus und gibt Stdout zurück.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(bool Success, string? Output, string? Error)> RunScriptAsync(
|
||||||
|
string scriptBlock,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string host = ExtractHost(_connection.ConnectionUrl);
|
||||||
|
|
||||||
|
// Passwort als SecureString – bleibt im PowerShell-Prozess, wird nicht als Argument übergeben
|
||||||
|
// Stattdessen: Scriptblock über stdin senden
|
||||||
|
string fullScript = BuildScript(host, scriptBlock);
|
||||||
|
|
||||||
|
ProcessStartInfo psi = new()
|
||||||
|
{
|
||||||
|
FileName = "powershell.exe",
|
||||||
|
Arguments = "-NonInteractive -NoProfile -ExecutionPolicy Bypass -Command -",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardInput = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
StandardInputEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
|
||||||
|
using Process process = new() { StartInfo = psi };
|
||||||
|
|
||||||
|
if (!process.Start())
|
||||||
|
{
|
||||||
|
return (false, null, "PowerShell-Prozess konnte nicht gestartet werden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skript über stdin – Credentials gehen NICHT als sichtbares Argument durch
|
||||||
|
await process.StandardInput.WriteAsync(fullScript);
|
||||||
|
process.StandardInput.Close();
|
||||||
|
|
||||||
|
using CancellationTokenSource timeoutCts =
|
||||||
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(Math.Clamp(_connection.TimeoutSeconds, 10, 600)));
|
||||||
|
|
||||||
|
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||||
|
Task<string> stderrTask = process.StandardError.ReadToEndAsync(cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(timeoutCts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try { process.Kill(entireProcessTree: true); } catch { /* ignore */ }
|
||||||
|
return (false, null, $"WinRM-Ausführung Timeout nach {_connection.TimeoutSeconds}s.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string stdout = (await stdoutTask).Trim();
|
||||||
|
string stderr = (await stderrTask).Trim();
|
||||||
|
|
||||||
|
if (process.ExitCode == 0)
|
||||||
|
{
|
||||||
|
return (true, stdout, stderr.Length > 0 ? stderr : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (false, stdout.Length > 0 ? stdout : null,
|
||||||
|
stderr.Length > 0 ? Truncate(stderr) : $"PowerShell ExitCode={process.ExitCode}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildScript(string host, string scriptBlock)
|
||||||
|
{
|
||||||
|
// Passwort über Variable, nicht als Argument – verhindert Sichtbarkeit in Prozessliste
|
||||||
|
string escapedPwd = _connection.Password.Replace("'", "''");
|
||||||
|
string escapedUser = _connection.Username.Replace("'", "''");
|
||||||
|
string escapedHost = host.Replace("'", "''");
|
||||||
|
|
||||||
|
return
|
||||||
|
"$ErrorActionPreference = 'Stop'\n" +
|
||||||
|
$"$secPwd = ConvertTo-SecureString '{escapedPwd}' -AsPlainText -Force\n" +
|
||||||
|
$"$cred = New-Object System.Management.Automation.PSCredential('{escapedUser}', $secPwd)\n" +
|
||||||
|
$"Invoke-Command -ComputerName '{escapedHost}' -Port {_connection.WinRmPort} -Credential $cred -ScriptBlock {{\n" +
|
||||||
|
$" {scriptBlock}\n" +
|
||||||
|
"} -ErrorAction Stop";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ersetzt Platzhalter in einem konfigurierten WinRM-Script.
|
||||||
|
/// {container} → Container-Name (einfache Hochkommas werden verdoppelt)
|
||||||
|
/// {domain} → Domain-Name
|
||||||
|
/// {xapiPath} → Pfad zur XApi-Quelldatei
|
||||||
|
/// </summary>
|
||||||
|
public static string ApplyScriptTemplate(string template, string containerName,
|
||||||
|
string domainName = "", string xapiPath = "")
|
||||||
|
=> template
|
||||||
|
.Replace("{container}", containerName.Replace("'", "''"),
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("{domain}", domainName.Replace("'", "''"),
|
||||||
|
StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("{xapiPath}", xapiPath.Replace("'", "''"),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string ExtractHost(string connectionUrl)
|
||||||
|
{
|
||||||
|
try { return new Uri(connectionUrl).Host; }
|
||||||
|
catch { return connectionUrl; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string s, int max = 600)
|
||||||
|
=> s.Length <= max ? s : s[..max] + "…";
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/*
|
||||||
|
ESB Certificate Manager – Zielkonfiguration (SQL Server)
|
||||||
|
Offline vorbereitet; zur späteren Nutzung auf dem Ziel-SQL-Server ausführen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.SonicConnection', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.SonicConnection
|
||||||
|
(
|
||||||
|
Id INT NOT NULL IDENTITY(1, 1),
|
||||||
|
Name NVARCHAR(128) NOT NULL, -- Eindeutiger Bezeichner, referenziert von DeploymentTarget
|
||||||
|
DomainName NVARCHAR(128) NOT NULL, -- Sonic-Domain-Name, z.B. "proalpha-test"
|
||||||
|
ManagementUrl NVARCHAR(512) NOT NULL, -- HTTP-URL der Management Console, z.B. "http://host:8080"
|
||||||
|
ApiBasePath NVARCHAR(128) NOT NULL CONSTRAINT DF_SonicConnection_ApiBasePath DEFAULT (N'/api/v1'),
|
||||||
|
Username NVARCHAR(128) NOT NULL,
|
||||||
|
-- Passwort wird in der Anwendung verschlüsselt gespeichert; hier nur als Verweis
|
||||||
|
PasswordHash NVARCHAR(512) NOT NULL CONSTRAINT DF_SonicConnection_PwdHash DEFAULT (N''),
|
||||||
|
TimeoutSeconds INT NOT NULL CONSTRAINT DF_SonicConnection_Timeout DEFAULT (30),
|
||||||
|
PostRestartDelaySecs INT NOT NULL CONSTRAINT DF_SonicConnection_Delay DEFAULT (15),
|
||||||
|
IsActive BIT NOT NULL CONSTRAINT DF_SonicConnection_IsActive DEFAULT (1),
|
||||||
|
CONSTRAINT PK_SonicConnection PRIMARY KEY CLUSTERED (Id),
|
||||||
|
CONSTRAINT UQ_SonicConnection_Name UNIQUE (Name)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.DeploymentTarget', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.DeploymentTarget
|
||||||
|
(
|
||||||
|
Id INT NOT NULL IDENTITY(1, 1),
|
||||||
|
Name NVARCHAR(128) NOT NULL,
|
||||||
|
Environment NVARCHAR(64) NOT NULL,
|
||||||
|
IsActive BIT NOT NULL CONSTRAINT DF_DeploymentTarget_IsActive DEFAULT (1),
|
||||||
|
TargetDirectory NVARCHAR(512) NOT NULL,
|
||||||
|
CertificateFileName NVARCHAR(260) NOT NULL,
|
||||||
|
ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentTarget_Container DEFAULT (N''),
|
||||||
|
RestartType NVARCHAR(32) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartType DEFAULT (N'None'),
|
||||||
|
RestartCommand NVARCHAR(512) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartCmd DEFAULT (N''),
|
||||||
|
RestartArguments NVARCHAR(1024) NOT NULL CONSTRAINT DF_DeploymentTarget_RestartArgs DEFAULT (N''),
|
||||||
|
RestartTimeoutSeconds INT NOT NULL CONSTRAINT DF_DeploymentTarget_RestartTimeout DEFAULT (60),
|
||||||
|
-- Sonic ESB Management
|
||||||
|
SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentTarget_SonicConn DEFAULT (N''),
|
||||||
|
XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DeploymentTarget_XapiSrc DEFAULT (N''),
|
||||||
|
-- TLS-Probe
|
||||||
|
TlsHost NVARCHAR(255) NOT NULL CONSTRAINT DF_DeploymentTarget_TlsHost DEFAULT (N''),
|
||||||
|
TlsPort INT NULL,
|
||||||
|
SortOrder INT NOT NULL CONSTRAINT DF_DeploymentTarget_SortOrder DEFAULT (0),
|
||||||
|
CONSTRAINT PK_DeploymentTarget PRIMARY KEY CLUSTERED (Id),
|
||||||
|
CONSTRAINT CK_DeploymentTarget_RestartType CHECK (
|
||||||
|
RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
-- Neue Spalten zu bestehender Tabelle hinzufügen (idempotent)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.DeploymentTarget')
|
||||||
|
AND name = N'SonicConnectionName')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.DeploymentTarget
|
||||||
|
ADD SonicConnectionName NVARCHAR(128) NOT NULL
|
||||||
|
CONSTRAINT DF_DeploymentTarget_SonicConn DEFAULT (N'');
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.DeploymentTarget')
|
||||||
|
AND name = N'XapiSourcePath')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.DeploymentTarget
|
||||||
|
ADD XapiSourcePath NVARCHAR(512) NOT NULL
|
||||||
|
CONSTRAINT DF_DeploymentTarget_XapiSrc DEFAULT (N'');
|
||||||
|
END
|
||||||
|
|
||||||
|
-- CHECK-Constraint um neue RestartType-Werte erweitern
|
||||||
|
IF EXISTS (SELECT 1 FROM sys.check_constraints
|
||||||
|
WHERE parent_object_id = OBJECT_ID(N'dbo.DeploymentTarget')
|
||||||
|
AND name = N'CK_DeploymentTarget_RestartType')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.DeploymentTarget DROP CONSTRAINT CK_DeploymentTarget_RestartType;
|
||||||
|
ALTER TABLE dbo.DeploymentTarget ADD CONSTRAINT CK_DeploymentTarget_RestartType
|
||||||
|
CHECK (RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi'));
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.DeploymentRunHistory', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.DeploymentRunHistory
|
||||||
|
(
|
||||||
|
Id BIGINT NOT NULL IDENTITY(1, 1),
|
||||||
|
StartedAtUtc DATETIME2(3) NOT NULL,
|
||||||
|
FinishedAtUtc DATETIME2(3) NULL,
|
||||||
|
UserName NVARCHAR(128) NOT NULL,
|
||||||
|
MachineName NVARCHAR(128) NOT NULL CONSTRAINT DF_DeploymentRunHistory_Machine DEFAULT (N''),
|
||||||
|
OverallSuccess BIT NULL,
|
||||||
|
Summary NVARCHAR(2000) NULL,
|
||||||
|
CONSTRAINT PK_DeploymentRunHistory PRIMARY KEY CLUSTERED (Id)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.DeploymentRunHistory')
|
||||||
|
AND name = N'MachineName')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.DeploymentRunHistory
|
||||||
|
ADD MachineName NVARCHAR(128) NOT NULL
|
||||||
|
CONSTRAINT DF_DeploymentRunHistory_Machine DEFAULT (N'');
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF OBJECT_ID(N'dbo.DeploymentRunDetail', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.DeploymentRunDetail
|
||||||
|
(
|
||||||
|
Id BIGINT NOT NULL IDENTITY(1, 1),
|
||||||
|
RunId BIGINT NOT NULL,
|
||||||
|
TargetId INT NOT NULL,
|
||||||
|
TargetName NVARCHAR(128) NOT NULL,
|
||||||
|
Success BIT NOT NULL,
|
||||||
|
StatusText NVARCHAR(256) NOT NULL,
|
||||||
|
Detail NVARCHAR(2000) NULL,
|
||||||
|
Steps NVARCHAR(MAX) NULL,
|
||||||
|
CONSTRAINT PK_DeploymentRunDetail PRIMARY KEY CLUSTERED (Id),
|
||||||
|
CONSTRAINT FK_DeploymentRunDetail_Run
|
||||||
|
FOREIGN KEY (RunId) REFERENCES dbo.DeploymentRunHistory (Id)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
/*
|
||||||
|
Beispiel-Insert:
|
||||||
|
|
||||||
|
INSERT INTO dbo.SonicConnection (Name, DomainName, ManagementUrl, Username, PasswordHash)
|
||||||
|
VALUES (N'DE-Test', N'proalpha-test', N'http://dekun-painwbdet:8080', N'Administrator', N'<encrypted>');
|
||||||
|
|
||||||
|
INSERT INTO dbo.DeploymentTarget
|
||||||
|
(
|
||||||
|
Name, Environment, IsActive, TargetDirectory, CertificateFileName,
|
||||||
|
ContainerName, RestartType, SonicConnectionName,
|
||||||
|
TlsHost, TlsPort, SortOrder
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
N'DE-Test Container A', N'TEST', 1, N'\\share\esb\certs\a', N'esb-cert.cer',
|
||||||
|
N'sonic-container-a', N'SonicContainer', N'DE-Test',
|
||||||
|
N'dekun-painwbdet', 13070, 10
|
||||||
|
),
|
||||||
|
(
|
||||||
|
N'DE-Test Container B (XApi)', N'TEST', 1, N'\\share\esb\certs\b', N'esb-cert.cer',
|
||||||
|
N'sonic-container-b', N'SonicContainerWithXapi', N'DE-Test',
|
||||||
|
N'dekun-painwbdet', 13070, 20
|
||||||
|
);
|
||||||
|
*/
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/*
|
||||||
|
ESB Certificate Manager – vollständiges Datenbankschema v2
|
||||||
|
Idempotent; kann auf einem leeren Schema oder nach 001_CreateSchema.sql ausgeführt werden.
|
||||||
|
|
||||||
|
Tabellen:
|
||||||
|
dbo.SonicConnection – Sonic-ESB-Management-Instanzen
|
||||||
|
dbo.DeploymentTargets – Deployment-Ziele mit allen Konfigurationsfeldern
|
||||||
|
dbo.DeploymentRuns – Ein Eintrag pro Deployment-Lauf
|
||||||
|
dbo.DeploymentTargetResults – Detailergebnis pro Ziel und Lauf
|
||||||
|
|
||||||
|
Always Encrypted (optional):
|
||||||
|
Zur Nutzung von Always Encrypted auf der Spalte PasswordHash in SonicConnection
|
||||||
|
die Blöcke unterhalb des Kommentars "-- ALWAYS ENCRYPTED" auskommentieren
|
||||||
|
und den Schlüsselnamen anpassen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SonicConnection
|
||||||
|
-- ============================================================
|
||||||
|
IF OBJECT_ID(N'dbo.SonicConnection', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.SonicConnection
|
||||||
|
(
|
||||||
|
Id INT NOT NULL IDENTITY(1, 1),
|
||||||
|
Name NVARCHAR(128) NOT NULL, -- Eindeutiger Bezeichner, referenziert von DeploymentTargets
|
||||||
|
DomainName NVARCHAR(128) NOT NULL, -- Sonic-Domain, z.B. "proalpha-test"
|
||||||
|
ConnectionUrl NVARCHAR(512) NOT NULL, -- Sonic-Broker-URL, z.B. "tcp://dekun-painwbdet:13070"
|
||||||
|
ManagementHttpPort INT NOT NULL CONSTRAINT DF_SonicConnection_HttpPort DEFAULT (8080),
|
||||||
|
ApiBasePath NVARCHAR(128) NOT NULL CONSTRAINT DF_SonicConnection_ApiBase DEFAULT (N'/api/v1'),
|
||||||
|
Username NVARCHAR(128) NOT NULL,
|
||||||
|
PasswordHash NVARCHAR(512) NOT NULL CONSTRAINT DF_SonicConnection_PwdHash DEFAULT (N''),
|
||||||
|
-- ALWAYS ENCRYPTED: PasswordEncrypted NVARCHAR(512) ENCRYPTED WITH (
|
||||||
|
-- COLUMN_ENCRYPTION_KEY = CEK_SonicPwd,
|
||||||
|
-- ENCRYPTION_TYPE = DETERMINISTIC,
|
||||||
|
-- ALGORITHM = 'AEAD_AES_256_CBC_HMAC_SHA_256'
|
||||||
|
-- ) NULL,
|
||||||
|
TimeoutSeconds INT NOT NULL CONSTRAINT DF_SonicConnection_Timeout DEFAULT (30),
|
||||||
|
PostRestartDelaySecs INT NOT NULL CONSTRAINT DF_SonicConnection_Delay DEFAULT (15),
|
||||||
|
IsActive BIT NOT NULL CONSTRAINT DF_SonicConnection_Active DEFAULT (1),
|
||||||
|
CONSTRAINT PK_SonicConnection PRIMARY KEY CLUSTERED (Id),
|
||||||
|
CONSTRAINT UQ_SonicConnection_Name UNIQUE (Name)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
-- ConnectionUrl-Spalte nachrüsten (Migration von ManagementUrl)
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.SonicConnection') AND name = N'ConnectionUrl')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.SonicConnection ADD ConnectionUrl NVARCHAR(512) NOT NULL
|
||||||
|
CONSTRAINT DF_SonicConnection_ConnUrl DEFAULT (N'');
|
||||||
|
END
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns
|
||||||
|
WHERE object_id = OBJECT_ID(N'dbo.SonicConnection') AND name = N'ManagementHttpPort')
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.SonicConnection ADD ManagementHttpPort INT NOT NULL
|
||||||
|
CONSTRAINT DF_SonicConnection_HttpPort DEFAULT (8080);
|
||||||
|
END
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- DeploymentTargets
|
||||||
|
-- ============================================================
|
||||||
|
IF OBJECT_ID(N'dbo.DeploymentTargets', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.DeploymentTargets
|
||||||
|
(
|
||||||
|
Id INT NOT NULL IDENTITY(1, 1),
|
||||||
|
Name NVARCHAR(100) NOT NULL,
|
||||||
|
Environment NVARCHAR(64) NOT NULL CONSTRAINT DF_DT_Env DEFAULT (N''),
|
||||||
|
IsActive BIT NOT NULL CONSTRAINT DF_DT_IsActive DEFAULT (1),
|
||||||
|
|
||||||
|
-- Zertifikat-Ablage
|
||||||
|
CertificateTargetPath NVARCHAR(500) NOT NULL,
|
||||||
|
CertificateFileName NVARCHAR(260) NOT NULL CONSTRAINT DF_DT_CertFile DEFAULT (N''),
|
||||||
|
|
||||||
|
-- Neustart-Konfiguration
|
||||||
|
RestartType NVARCHAR(30) NOT NULL CONSTRAINT DF_DT_RestartType DEFAULT (N'None'),
|
||||||
|
RestartHost NVARCHAR(255) NULL,
|
||||||
|
RestartCommand NVARCHAR(2000) NULL,
|
||||||
|
RestartArguments NVARCHAR(1024) NOT NULL CONSTRAINT DF_DT_RestartArgs DEFAULT (N''),
|
||||||
|
RestartTimeoutSeconds INT NOT NULL CONSTRAINT DF_DT_RestartTimeout DEFAULT (60),
|
||||||
|
|
||||||
|
-- Sonic ESB
|
||||||
|
SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_SonicConn DEFAULT (N''),
|
||||||
|
ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_Container DEFAULT (N''),
|
||||||
|
XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DT_XapiSrc DEFAULT (N''),
|
||||||
|
|
||||||
|
-- TLS-Probe
|
||||||
|
TlsHost NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsHost DEFAULT (N''),
|
||||||
|
TlsPort INT NOT NULL CONSTRAINT DF_DT_TlsPort DEFAULT (443),
|
||||||
|
TlsServerName NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsServerName DEFAULT (N''),
|
||||||
|
ExpectedFingerprint NVARCHAR(128) NULL,
|
||||||
|
|
||||||
|
SortOrder INT NOT NULL CONSTRAINT DF_DT_SortOrder DEFAULT (0),
|
||||||
|
|
||||||
|
CONSTRAINT PK_DeploymentTargets PRIMARY KEY CLUSTERED (Id),
|
||||||
|
CONSTRAINT CK_DeploymentTargets_RestartType CHECK (
|
||||||
|
RestartType IN (N'None', N'Command', N'SonicContainer', N'SonicContainerWithXapi')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
-- Neue Spalten idempotent nachrüsten
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'TlsServerName')
|
||||||
|
ALTER TABLE dbo.DeploymentTargets ADD TlsServerName NVARCHAR(255) NOT NULL CONSTRAINT DF_DT_TlsServerName DEFAULT (N'');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'ExpectedFingerprint')
|
||||||
|
ALTER TABLE dbo.DeploymentTargets ADD ExpectedFingerprint NVARCHAR(128) NULL;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'SonicConnectionName')
|
||||||
|
ALTER TABLE dbo.DeploymentTargets ADD SonicConnectionName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_SonicConn DEFAULT (N'');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'ContainerName')
|
||||||
|
ALTER TABLE dbo.DeploymentTargets ADD ContainerName NVARCHAR(128) NOT NULL CONSTRAINT DF_DT_Container DEFAULT (N'');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.DeploymentTargets') AND name = N'XapiSourcePath')
|
||||||
|
ALTER TABLE dbo.DeploymentTargets ADD XapiSourcePath NVARCHAR(512) NOT NULL CONSTRAINT DF_DT_XapiSrc DEFAULT (N'');
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- DeploymentRuns (ein Datensatz pro Deployment-Lauf)
|
||||||
|
-- ============================================================
|
||||||
|
IF OBJECT_ID(N'dbo.DeploymentRuns', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.DeploymentRuns
|
||||||
|
(
|
||||||
|
Id UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
StartedAtUtc DATETIME2(3) NOT NULL,
|
||||||
|
FinishedAtUtc DATETIME2(3) NULL,
|
||||||
|
SourceFile NVARCHAR(500) NOT NULL,
|
||||||
|
SourceFingerprint NVARCHAR(128) NOT NULL,
|
||||||
|
StartedBy NVARCHAR(255) NOT NULL,
|
||||||
|
MachineName NVARCHAR(255) NOT NULL CONSTRAINT DF_DR_Machine DEFAULT (N''),
|
||||||
|
OverallStatus NVARCHAR(30) NOT NULL, -- 'Running' | 'Success' | 'PartialFailure' | 'Failure'
|
||||||
|
ErrorMessage NVARCHAR(MAX) NULL,
|
||||||
|
CONSTRAINT PK_DeploymentRuns PRIMARY KEY CLUSTERED (Id)
|
||||||
|
);
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- DeploymentTargetResults (ein Datensatz pro Ziel und Lauf)
|
||||||
|
-- ============================================================
|
||||||
|
IF OBJECT_ID(N'dbo.DeploymentTargetResults', N'U') IS NULL
|
||||||
|
BEGIN
|
||||||
|
CREATE TABLE dbo.DeploymentTargetResults
|
||||||
|
(
|
||||||
|
Id INT NOT NULL IDENTITY(1, 1),
|
||||||
|
DeploymentRunId UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
TargetId INT NOT NULL,
|
||||||
|
TargetName NVARCHAR(128) NOT NULL,
|
||||||
|
StartedAtUtc DATETIME2(3) NOT NULL,
|
||||||
|
FinishedAtUtc DATETIME2(3) NULL,
|
||||||
|
CopySucceeded BIT NOT NULL CONSTRAINT DF_DTR_Copy DEFAULT (0),
|
||||||
|
RestartSucceeded BIT NOT NULL CONSTRAINT DF_DTR_Restart DEFAULT (0),
|
||||||
|
TlsSucceeded BIT NOT NULL CONSTRAINT DF_DTR_Tls DEFAULT (0),
|
||||||
|
ObservedFingerprint NVARCHAR(128) NULL,
|
||||||
|
Status NVARCHAR(30) NOT NULL,
|
||||||
|
ErrorMessage NVARCHAR(MAX) NULL,
|
||||||
|
CONSTRAINT PK_DeploymentTargetResults PRIMARY KEY CLUSTERED (Id),
|
||||||
|
CONSTRAINT FK_DTR_Run FOREIGN KEY (DeploymentRunId) REFERENCES dbo.DeploymentRuns (Id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_DTR_RunId ON dbo.DeploymentTargetResults (DeploymentRunId);
|
||||||
|
END
|
||||||
|
GO
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Beispieldaten
|
||||||
|
-- ============================================================
|
||||||
|
/*
|
||||||
|
INSERT INTO dbo.SonicConnection (Name, DomainName, ConnectionUrl, Username, PasswordHash)
|
||||||
|
VALUES (N'DE-Test', N'proalpha-test', N'tcp://dekun-painwbdet:13070', N'Administrator', N'<encrypted>');
|
||||||
|
|
||||||
|
INSERT INTO dbo.DeploymentTargets
|
||||||
|
(Name, Environment, IsActive, CertificateTargetPath, CertificateFileName,
|
||||||
|
RestartType, SonicConnectionName, ContainerName,
|
||||||
|
TlsHost, TlsPort, TlsServerName, SortOrder)
|
||||||
|
VALUES
|
||||||
|
(N'DE-Test Container A', N'TEST', 1,
|
||||||
|
N'\\dekun-painwbdet\sonic\certs', N'server.pfx',
|
||||||
|
N'SonicContainer', N'DE-Test', N'sonic-container-a',
|
||||||
|
N'dekun-painwbdet', 443, N'esb-test.firma.local', 10),
|
||||||
|
(N'DE-Test Container B (XApi)', N'TEST', 1,
|
||||||
|
N'\\dekun-painwbdet\sonic\certs', N'server.pfx',
|
||||||
|
N'SonicContainerWithXapi', N'DE-Test', N'sonic-container-b',
|
||||||
|
N'dekun-painwbdet', 443, N'esb-test.firma.local', 20);
|
||||||
|
*/
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Assets\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="Data\targets.sample.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="Demo\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="Sql\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"ConnectionString": "",
|
||||||
|
"UseOfflineSampleData": true,
|
||||||
|
"SampleTargetsPath": "Data/targets.sample.json",
|
||||||
|
"LogDirectory": "Logs",
|
||||||
|
"TlsTimeoutSeconds": 8,
|
||||||
|
"TlsRetryCount": 2,
|
||||||
|
"SonicConnections": [
|
||||||
|
{
|
||||||
|
"Name": "DE-Test",
|
||||||
|
"DomainName": "proalpha-test",
|
||||||
|
"ConnectionUrl": "tcp://dekun-painwbdet:13070",
|
||||||
|
"ManagementHttpPort": 8080,
|
||||||
|
"ApiBasePath": "/api/v1",
|
||||||
|
"Username": "Administrator",
|
||||||
|
"Password": "Administrator",
|
||||||
|
"TimeoutSeconds": 30,
|
||||||
|
"PostRestartDelaySeconds": 15,
|
||||||
|
|
||||||
|
"ContainerListPath": "",
|
||||||
|
"ContainerRestartPath": "",
|
||||||
|
"ContainerStopPath": "",
|
||||||
|
"ContainerStartPath": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user