feat(updateservice): Plattform-Dimension, signierte Releases, Update mit Rollback
Behebt eine Reihe zusammenhaengender Fehler im Update-Weg, die zusammen verhindert haben, fuer mehr als eine Plattform auszuliefern - und die im Fehlerfall halb aktualisierte Installationen hinterliessen. Server - Migration 009: Spalte platform samt neuem Unique-Key. Zuvor verdraengte das zuletzt veroeffentlichte Paket alle anderen Plattformen derselben Version, weil ON DUPLICATE KEY auf (slug, version, channel) griff. Ein Linux-System zog sich damit das Windows-Paket. - Aufloesungsregel: je Version das plattformgenaue Paket, sonst das plattformunabhaengige. Ein Client ohne Plattformangabe sieht ausschliesslich 'any' - lieber kein Update als das falsche. - manifest_json wird endlich befuellt; die Spalte blieb bisher immer leer, wodurch die API nie der Rueckfall sein konnte, als der sie gedacht war. - Releases werden serverseitig mit RSA-SHA256 signiert, neuer Endpunkt /api/updateservice/v1/pubkey. Bewusst kein HMAC: der Pruefende laeuft auf fremden Systemen und darf den Signierschluessel nicht besitzen. Packager - Bricht ab, statt die Versionshistorie zu verlieren. Schlug das Lesen der bestehenden latest.json fehl, ersetzte ein leeres catch die komplette Historie durch einen einzigen Eintrag - ohne jede Meldung. - Echte Glob-Muster. Zuvor trafen "logs/**" und "scratch/**" aus der mitgelieferten Beispielkonfiguration nie zu. - preservePatterns: Konfigurationsvorlagen werden ausgeliefert, ersetzen am Ziel aber keine vorhandene Datei. Eine settings.json mit Zugangsdaten ueberschrieb bisher beim Update die Konfiguration jedes Zielsystems. - Warnt vor Dateien, die nach Zugangsdaten aussehen und auf keiner Liste stehen. - Prueft --version gegen die Hauptassembly. Eine Abweichung fuehrte zu einer Endlosschleife: Clients aktualisieren, melden weiter die alte Version, halten das Release erneut fuer neu. - --platform mit Ableitung aus dem Publish-Pfad. Agent - Anwenden mit Plan, Backup und vollstaendigem Rollback. Die Stelle war als "Atomic Replace with Backup" kommentiert und war eine Kopierschleife. - Verwaiste Dateien werden entfernt, aber nur solche aus dem Manifest der Vorversion. Was nicht aus einem Release stammt, bleibt liegen. - Das laufende Agent-Binary wird zur Seite gelegt statt ueberschrieben. - API-Rueckfall in FetchManifestAsync; bisher nur im SDK vorhanden, weshalb die Anwendung "Update verfuegbar" und der Agent "kein Release" sagen konnte. - Installierte Version aus --current-version oder manifest.json statt des Textes "Unbekannt", der als 0 gelesen wurde und jede Version neuer erscheinen liess. Reparatur funktioniert damit auch ohne manifest.json. - Setzt das Ausfuehrungsbit fuer Linux-Pakete, die unter Windows gebaut wurden. SDK - ResolveAgentPath() liefert den plattformrichtigen Namen; ein fest verdrahtetes "update-agent.exe" wird unter Linux nie gefunden. - LaunchUpdateAgent uebergibt jetzt --restart (wurde nie uebergeben, die Anwendung blieb nach dem Update zu), --wait-for-pid (kein Wettlauf mehr mit dem Herunterfahren) und --platform. Enthaelt ausserdem die bislang nicht committete Arbeit an Watchdog, Lizenz- Client und cli/tick.php samt Migration 008; die betroffenen Dateien liessen sich nicht getrennt stagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5f9b0c5596
commit
2388b5abe1
@@ -10,11 +10,21 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Deploymentcenter.Client;
|
||||
using Deploymentcenter.Client.Models;
|
||||
using FluentFTP;
|
||||
|
||||
namespace Deploymentcenter.Packager
|
||||
{
|
||||
/// <summary>
|
||||
/// Wird geworfen, wenn die vorhandene Versionshistorie nicht sicher
|
||||
/// gelesen werden konnte. Dann darf latest.json nicht geschrieben werden.
|
||||
/// </summary>
|
||||
internal sealed class ReleaseHistoryException : Exception
|
||||
{
|
||||
public ReleaseHistoryException(string message) : base(message) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Konfiguration des Packagers.
|
||||
///
|
||||
@@ -39,9 +49,34 @@ namespace Deploymentcenter.Packager
|
||||
/// </summary>
|
||||
public string ApiToken { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Dateien, die gar nicht erst ins Paket kommen.
|
||||
///
|
||||
/// Die Muster werden seit dieser Fassung als echte Globs ausgewertet.
|
||||
/// Zuvor verstand der Abgleich nur "*.endung" und exakte Namen, sodass
|
||||
/// Eintraege wie "logs/**" nie zutrafen - sie standen in der
|
||||
/// Beispielkonfiguration und erweckten den Eindruck, es sei etwas
|
||||
/// ausgeschlossen.
|
||||
/// </summary>
|
||||
public List<string> ExcludePatterns { get; set; } = new List<string>
|
||||
{
|
||||
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json", "*.log", "logs/*"
|
||||
"*.pdb", "*.xml", "appsettings.Development.json", "appsettings.Staging.json",
|
||||
"*.log", "logs/**", "*.tmp"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Dateien, die ins Paket gehoeren, am Ziel aber eine vorhandene
|
||||
/// Fassung nicht ersetzen duerfen.
|
||||
///
|
||||
/// Ausschluss und Schutz sind zwei verschiedene Dinge: eine
|
||||
/// Konfigurationsvorlage soll ausgeliefert werden, damit eine
|
||||
/// Erstinstallation vollstaendig ist - beim Update darf sie die
|
||||
/// eingerichteten Werte des Zielsystems aber nicht ueberschreiben.
|
||||
/// </summary>
|
||||
public List<string> PreservePatterns { get; set; } = new List<string>
|
||||
{
|
||||
"appsettings.json", "appsettings.Production.json", "settings.json",
|
||||
"config.json", ".env"
|
||||
};
|
||||
|
||||
/// <summary>Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei.</summary>
|
||||
@@ -72,9 +107,15 @@ namespace Deploymentcenter.Packager
|
||||
static async Task<int> Main(string[] args)
|
||||
{
|
||||
Console.WriteLine("=================================================");
|
||||
Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v1.0 ");
|
||||
Console.WriteLine(" Deploymentcenter Packager & Deploy Tool v2.0 ");
|
||||
Console.WriteLine("=================================================");
|
||||
|
||||
if (HasFlag(args, "--help") || HasFlag(args, "-h"))
|
||||
{
|
||||
ShowHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
string project = GetArg(args, "--project", "-p") ?? "myapp";
|
||||
string version = GetArg(args, "--version", "-v") ?? "1.0.0";
|
||||
string channel = GetArg(args, "--channel", "-c") ?? "prod";
|
||||
@@ -119,6 +160,7 @@ namespace Deploymentcenter.Packager
|
||||
Console.WriteLine(" Das Paket wird gebaut und hochgeladen, aber das Deploymentcenter");
|
||||
Console.WriteLine(" erfaehrt nichts davon - Veroeffentlichen erfordert seit Version 2.0");
|
||||
Console.WriteLine(" ein Token mit dem Recht updateservice:publish.");
|
||||
Console.WriteLine(" Ohne Registrierung entsteht ausserdem keine Signatur.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
@@ -126,34 +168,109 @@ namespace Deploymentcenter.Packager
|
||||
if (!Directory.Exists(publishDir))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"[ERROR] Publish directory does not exist: {publishDir}");
|
||||
Console.WriteLine($"[FEHLER] Publish-Verzeichnis existiert nicht: {publishDir}");
|
||||
Console.ResetColor();
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[INFO] Packaging Project : {project}");
|
||||
Console.WriteLine($"[INFO] Version : {version}");
|
||||
Console.WriteLine($"[INFO] Channel : {channel}");
|
||||
Console.WriteLine($"[INFO] Publish Directory : {publishDir}");
|
||||
// ---------------------------------------------------------------
|
||||
// Plattform bestimmen
|
||||
// ---------------------------------------------------------------
|
||||
// Ohne Plattform landeten Pakete verschiedener Laufzeitkennungen
|
||||
// unter derselben Version im selben Kanal und ueberschrieben sich.
|
||||
string platform;
|
||||
string platformSource;
|
||||
|
||||
// 1. Gather files and filter exclusions
|
||||
string? explicitPlatform = GetArg(args, "--platform") ?? GetArg(args, "--rid");
|
||||
if (!string.IsNullOrWhiteSpace(explicitPlatform))
|
||||
{
|
||||
platform = PlatformId.Normalize(explicitPlatform);
|
||||
platformSource = "Argument";
|
||||
}
|
||||
else
|
||||
{
|
||||
string? inferred = PlatformId.InferFromPath(publishDir);
|
||||
if (inferred != null)
|
||||
{
|
||||
platform = inferred;
|
||||
platformSource = "aus dem Publish-Pfad abgeleitet";
|
||||
}
|
||||
else
|
||||
{
|
||||
platform = PlatformId.Any;
|
||||
platformSource = "Standard";
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[INFO] Projekt : {project}");
|
||||
Console.WriteLine($"[INFO] Version : {version}");
|
||||
Console.WriteLine($"[INFO] Kanal : {channel}");
|
||||
Console.WriteLine($"[INFO] Plattform : {platform} ({platformSource})");
|
||||
Console.WriteLine($"[INFO] Publish-Verzeichnis: {publishDir}");
|
||||
|
||||
if (platform == PlatformId.Any && !HasFlag(args, "--allow-any-platform"))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("[WARNUNG] Keine Plattform angegeben - das Release gilt als plattformunabhaengig.");
|
||||
Console.WriteLine(" Wird fuer mehrere Laufzeitkennungen gebaut, ueberschreiben sich die");
|
||||
Console.WriteLine(" Pakete gegenseitig. Mit --platform win-x64 (o. ae.) trennen.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Version gegen die Hauptassembly pruefen
|
||||
// ---------------------------------------------------------------
|
||||
// Weicht die veroeffentlichte Version von der einkompilierten ab,
|
||||
// meldet die Anwendung nach dem Update weiterhin die alte Version,
|
||||
// haelt das Release fuer neu und aktualisiert bei jedem Start
|
||||
// erneut - eine Endlosschleife ueber die gesamte Installationsbasis.
|
||||
if (!VerifyVersionAgainstAssembly(publishDir, project, version, GetArg(args, "--main-assembly"),
|
||||
HasFlag(args, "--ignore-version-mismatch")))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Dateien einsammeln
|
||||
// ---------------------------------------------------------------
|
||||
var allFiles = Directory.GetFiles(publishDir, "*", SearchOption.AllDirectories);
|
||||
var filteredFiles = new List<string>();
|
||||
var preservedRelPaths = new List<string>();
|
||||
|
||||
foreach (var file in allFiles)
|
||||
{
|
||||
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
|
||||
if (IsExcluded(relPath, config.ExcludePatterns))
|
||||
|
||||
if (GlobMatcher.IsMatch(relPath, config.ExcludePatterns))
|
||||
{
|
||||
Console.WriteLine($" [EXCLUDED] {relPath}");
|
||||
Console.WriteLine($" [AUSGESCHLOSSEN] {relPath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
filteredFiles.Add(file);
|
||||
|
||||
if (GlobMatcher.IsMatch(relPath, config.PreservePatterns))
|
||||
{
|
||||
preservedRelPaths.Add(relPath);
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine($"[INFO] Total files selected for package: {filteredFiles.Count}");
|
||||
Console.WriteLine($"[INFO] Dateien im Paket : {filteredFiles.Count}");
|
||||
|
||||
// 2. Prepare staging directory
|
||||
if (preservedRelPaths.Count > 0)
|
||||
{
|
||||
Console.WriteLine($"[INFO] Davon geschuetzt : {preservedRelPaths.Count} (ersetzen am Ziel keine vorhandene Datei)");
|
||||
foreach (var p in preservedRelPaths)
|
||||
{
|
||||
Console.WriteLine($" [GESCHUETZT] {p}");
|
||||
}
|
||||
}
|
||||
|
||||
WarnAboutUnprotectedSecrets(filteredFiles, publishDir, config);
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Paket bauen
|
||||
// ---------------------------------------------------------------
|
||||
string outputTempDir = Path.Combine(Path.GetTempPath(), "dc_packager_" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outputTempDir);
|
||||
|
||||
@@ -165,16 +282,17 @@ namespace Deploymentcenter.Packager
|
||||
string gitCommitShort = GetGitCommitShort();
|
||||
string buildDateUtc = DateTime.UtcNow.ToString("o");
|
||||
|
||||
// Build Manifest
|
||||
var packageManifest = new PackageManifest
|
||||
{
|
||||
ProjectId = project,
|
||||
Version = version,
|
||||
Channel = channel,
|
||||
Platform = platform,
|
||||
BuildDate = buildDateUtc,
|
||||
GitCommit = gitCommit,
|
||||
GitCommitShort = gitCommitShort,
|
||||
Changelog = changelog,
|
||||
Preserve = new List<string>(config.PreservePatterns),
|
||||
Files = new List<PackageFileEntry>()
|
||||
};
|
||||
|
||||
@@ -191,12 +309,11 @@ namespace Deploymentcenter.Packager
|
||||
});
|
||||
}
|
||||
|
||||
// Write manifest.json
|
||||
string manifestJson = JsonSerializer.Serialize(packageManifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
var manifestOptions = new JsonSerializerOptions { WriteIndented = true };
|
||||
string manifestJson = JsonSerializer.Serialize(packageManifest, manifestOptions);
|
||||
await File.WriteAllTextAsync(manifestJsonPath, manifestJson);
|
||||
|
||||
// 3. Create package.tar.gz
|
||||
Console.WriteLine("[INFO] Creating package.tar.gz archive...");
|
||||
Console.WriteLine("[INFO] Erzeuge package.tar.gz ...");
|
||||
string archiveStaging = Path.Combine(outputTempDir, "archive_root");
|
||||
Directory.CreateDirectory(archiveStaging);
|
||||
|
||||
@@ -208,10 +325,8 @@ namespace Deploymentcenter.Packager
|
||||
File.Copy(file, targetFile, true);
|
||||
}
|
||||
|
||||
// Also place manifest.json inside archive root
|
||||
File.Copy(manifestJsonPath, Path.Combine(archiveStaging, "manifest.json"), true);
|
||||
|
||||
// Compress to tar.gz using System.Formats.Tar + GZipStream
|
||||
using (var fs = File.Create(packageTarGzPath))
|
||||
using (var gz = new GZipStream(fs, CompressionLevel.Optimal))
|
||||
{
|
||||
@@ -222,16 +337,23 @@ namespace Deploymentcenter.Packager
|
||||
string packageSha256 = ComputeSha256(packageTarGzPath);
|
||||
await File.WriteAllTextAsync(sha256FilePath, packageSha256);
|
||||
|
||||
Console.WriteLine($"[SUCCESS] Package created successfully! ({packageSizeBytes} bytes)");
|
||||
Console.WriteLine($"[INFO] Package SHA256: {packageSha256}");
|
||||
Console.WriteLine($"[OK] Paket erstellt ({packageSizeBytes} Bytes)");
|
||||
Console.WriteLine($"[INFO] SHA256: {packageSha256}");
|
||||
|
||||
// 4. FTP Upload to LEMP Release Server
|
||||
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}";
|
||||
// ---------------------------------------------------------------
|
||||
// Hochladen
|
||||
// ---------------------------------------------------------------
|
||||
// Plattformunabhaengige Releases behalten den alten Pfad ohne
|
||||
// Zwischenebene, damit bereits ausgelieferte Anwendungen ihre
|
||||
// Updates weiterhin finden.
|
||||
string platformSegment = PlatformId.PathSegment(platform);
|
||||
string remoteChannelPath = $"{remoteBase.TrimEnd('/')}/{project}/{channel}{platformSegment}";
|
||||
string remoteVersionPath = $"{remoteChannelPath}/{version}";
|
||||
|
||||
Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})...");
|
||||
Console.WriteLine($"[INFO] Upload nach {ftpHost}:{config.FtpPort} ({remoteVersionPath}) ...");
|
||||
|
||||
bool ftpSucceeded = false;
|
||||
bool historyPreserved = true;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -240,44 +362,76 @@ namespace Deploymentcenter.Packager
|
||||
|
||||
await ftp.CreateDirectory(remoteVersionPath);
|
||||
|
||||
// Upload package.tar.gz, package.tar.gz.sha256, and manifest.json
|
||||
await ftp.UploadFile(packageTarGzPath, $"{remoteVersionPath}/package.tar.gz", FtpRemoteExists.Overwrite);
|
||||
await ftp.UploadFile(sha256FilePath, $"{remoteVersionPath}/package.tar.gz.sha256", FtpRemoteExists.Overwrite);
|
||||
await ftp.UploadFile(manifestJsonPath, $"{remoteVersionPath}/manifest.json", FtpRemoteExists.Overwrite);
|
||||
|
||||
Console.WriteLine("[SUCCESS] Files uploaded to version directory!");
|
||||
Console.WriteLine("[OK] Paketdateien hochgeladen.");
|
||||
|
||||
// 5. Update remote channel latest.json
|
||||
// -----------------------------------------------------------
|
||||
// latest.json fortschreiben
|
||||
// -----------------------------------------------------------
|
||||
string remoteLatestJsonPath = $"{remoteChannelPath}/latest.json";
|
||||
ReleaseManifest channelManifest = new ReleaseManifest
|
||||
|
||||
var channelManifest = new ReleaseManifest
|
||||
{
|
||||
ProjectId = project,
|
||||
Channel = channel,
|
||||
Platform = platform,
|
||||
Versions = new List<VersionInfo>()
|
||||
};
|
||||
|
||||
// Read existing latest.json if present on FTP
|
||||
// Die vorherige Fassung startete mit leerer Versionsliste und
|
||||
// verschluckte jeden Fehler beim Lesen der bestehenden Datei
|
||||
// in einem leeren catch. Schlug Download oder Parsen fehl,
|
||||
// wurde die gesamte Historie durch einen einzigen Eintrag
|
||||
// ersetzt - ohne jede Meldung. Jetzt bricht der Vorgang ab,
|
||||
// bevor latest.json geschrieben wird.
|
||||
if (await ftp.FileExists(remoteLatestJsonPath))
|
||||
{
|
||||
string tempLatestLocal = Path.Combine(outputTempDir, "existing_latest.json");
|
||||
var status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
|
||||
if (status == FtpStatus.Success && File.Exists(tempLatestLocal))
|
||||
FtpStatus status;
|
||||
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
|
||||
var existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
|
||||
if (existingManifest != null && existingManifest.Versions != null)
|
||||
{
|
||||
channelManifest.Versions = existingManifest.Versions;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
status = await ftp.DownloadFile(tempLatestLocal, remoteLatestJsonPath, FtpLocalExists.Overwrite);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ReleaseHistoryException(
|
||||
$"Die vorhandene latest.json konnte nicht geladen werden: {ex.Message}");
|
||||
}
|
||||
|
||||
if (status != FtpStatus.Success || !File.Exists(tempLatestLocal))
|
||||
{
|
||||
throw new ReleaseHistoryException(
|
||||
"Die vorhandene latest.json konnte nicht geladen werden (Download nicht erfolgreich).");
|
||||
}
|
||||
|
||||
ReleaseManifest? existingManifest;
|
||||
try
|
||||
{
|
||||
string existingJson = await File.ReadAllTextAsync(tempLatestLocal);
|
||||
existingManifest = JsonSerializer.Deserialize<ReleaseManifest>(existingJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ReleaseHistoryException(
|
||||
$"Die vorhandene latest.json ist nicht lesbar: {ex.Message}");
|
||||
}
|
||||
|
||||
if (existingManifest?.Versions == null)
|
||||
{
|
||||
throw new ReleaseHistoryException(
|
||||
"Die vorhandene latest.json enthaelt keine auswertbare Versionsliste.");
|
||||
}
|
||||
|
||||
channelManifest.Versions = existingManifest.Versions;
|
||||
Console.WriteLine($"[INFO] Bestehende Historie gelesen: {channelManifest.Versions.Count} Eintraege.");
|
||||
}
|
||||
|
||||
// Construct new version info
|
||||
string packagePublicUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz";
|
||||
string packagePublicUrl =
|
||||
$"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz";
|
||||
|
||||
var newVersionInfo = new VersionInfo
|
||||
{
|
||||
@@ -289,65 +443,99 @@ namespace Deploymentcenter.Packager
|
||||
Sha256 = packageSha256,
|
||||
SizeBytes = packageSizeBytes,
|
||||
Changelog = changelog,
|
||||
IsCritical = isCritical
|
||||
IsCritical = isCritical,
|
||||
Platform = platform
|
||||
};
|
||||
|
||||
// Remove duplicate version entry if re-publishing same version
|
||||
channelManifest.Versions.RemoveAll(v => v.Version.Equals(version, StringComparison.OrdinalIgnoreCase));
|
||||
channelManifest.Versions.Insert(0, newVersionInfo);
|
||||
|
||||
// Keep last 15 releases
|
||||
if (channelManifest.Versions.Count > 15)
|
||||
// Nach Versionsordnung sortieren, damit "latest" auch dann
|
||||
// stimmt, wenn nachtraeglich eine aeltere Version gebaut wird.
|
||||
channelManifest.Versions.Sort((a, b) => UpdateClient.CompareVersions(b.Version, a.Version));
|
||||
|
||||
// Aeltere Eintraege werden nur aus der Liste genommen, die
|
||||
// Dateien bleiben auf dem Server liegen. Ein Rollback auf eine
|
||||
// herausgefallene Version ist ueber die Liste nicht mehr
|
||||
// erreichbar - deshalb der Hinweis statt stiller Kuerzung.
|
||||
const int keep = 15;
|
||||
if (channelManifest.Versions.Count > keep)
|
||||
{
|
||||
channelManifest.Versions = channelManifest.Versions.Take(15).ToList();
|
||||
var dropped = channelManifest.Versions.Skip(keep).Select(v => v.Version).ToList();
|
||||
channelManifest.Versions = channelManifest.Versions.Take(keep).ToList();
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[HINWEIS] latest.json fuehrt {keep} Versionen. Nicht mehr gelistet: "
|
||||
+ string.Join(", ", dropped));
|
||||
Console.WriteLine(" Die Dateien liegen weiterhin auf dem Server, sind ueber den Agenten");
|
||||
Console.WriteLine(" aber nicht mehr auswaehlbar.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
channelManifest.Latest = channelManifest.Versions.FirstOrDefault();
|
||||
|
||||
string updatedLatestJson = JsonSerializer.Serialize(channelManifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
string updatedLatestJson = JsonSerializer.Serialize(channelManifest, manifestOptions);
|
||||
string localLatestJsonPath = Path.Combine(outputTempDir, "latest.json");
|
||||
await File.WriteAllTextAsync(localLatestJsonPath, updatedLatestJson);
|
||||
|
||||
await ftp.UploadFile(localLatestJsonPath, remoteLatestJsonPath, FtpRemoteExists.Overwrite);
|
||||
Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!");
|
||||
Console.WriteLine("[OK] latest.json fortgeschrieben.");
|
||||
|
||||
await ftp.Disconnect();
|
||||
ftpSucceeded = true;
|
||||
}
|
||||
catch (ReleaseHistoryException ex)
|
||||
{
|
||||
historyPreserved = false;
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"[FEHLER] {ex.Message}");
|
||||
Console.WriteLine(" latest.json wurde NICHT geschrieben - die bestehende Historie ist");
|
||||
Console.WriteLine(" unveraendert. Die Paketdateien dieser Version liegen bereits auf dem");
|
||||
Console.WriteLine(" Server; nach Behebung der Ursache genuegt ein erneuter Aufruf.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}");
|
||||
Console.WriteLine(" Das Paket wurde NICHT ausgeliefert.");
|
||||
Console.WriteLine(" Das Paket wurde NICHT vollstaendig ausgeliefert.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
// 6. Deploymentcenter benachrichtigen
|
||||
//
|
||||
// Zuvor stand hier ein leeres catch, und ohne Erfolgsfall wurde gar
|
||||
// nichts ausgegeben. Ein fehlgeschlagener Aufruf blieb damit
|
||||
// unsichtbar, waehrend das Programm am Ende Erfolg meldete.
|
||||
// ---------------------------------------------------------------
|
||||
// Deploymentcenter benachrichtigen
|
||||
// ---------------------------------------------------------------
|
||||
bool apiNotified = false;
|
||||
bool signed = false;
|
||||
string apiMessage = "uebersprungen (kein Token gesetzt)";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(apiToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
|
||||
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish";
|
||||
|
||||
var payload = new
|
||||
// Das Dateimanifest wandert mit. Damit kann die API als
|
||||
// vollwertiger Rueckfall dienen, wenn die statische
|
||||
// latest.json fehlt oder der FTP-Upload scheiterte.
|
||||
using var manifestDoc = JsonDocument.Parse(manifestJson);
|
||||
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
product_slug = project,
|
||||
version = version,
|
||||
channel = channel,
|
||||
release_notes = changelog,
|
||||
download_url = $"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}/{version}/package.tar.gz",
|
||||
sha256_hash = packageSha256,
|
||||
git_commit = gitCommitShort,
|
||||
size_bytes = packageSizeBytes,
|
||||
is_critical = isCritical
|
||||
["product_slug"] = project,
|
||||
["version"] = version,
|
||||
["channel"] = channel,
|
||||
["platform"] = platform,
|
||||
["release_notes"] = changelog,
|
||||
["download_url"] =
|
||||
$"{config.ApiBaseUrl.TrimEnd('/')}/releases/{project}/{channel}{platformSegment}/{version}/package.tar.gz",
|
||||
["sha256_hash"] = packageSha256,
|
||||
["git_commit"] = gitCommitShort,
|
||||
["size_bytes"] = packageSizeBytes,
|
||||
["is_critical"] = isCritical,
|
||||
["manifest_json"] = manifestDoc.RootElement.Clone()
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl)
|
||||
@@ -363,6 +551,8 @@ namespace Deploymentcenter.Packager
|
||||
{
|
||||
apiNotified = true;
|
||||
apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen.";
|
||||
signed = string.Equals(ExtractJsonString(body, "signed"), "True", StringComparison.OrdinalIgnoreCase)
|
||||
|| ExtractJsonString(body, "signed") == "true";
|
||||
|
||||
string? autoResolved = ExtractJsonString(body, "auto_resolved");
|
||||
if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0")
|
||||
@@ -385,35 +575,267 @@ namespace Deploymentcenter.Packager
|
||||
if (apiNotified)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"[SUCCESS] {apiMessage}");
|
||||
Console.WriteLine($"[OK] {apiMessage}");
|
||||
Console.ResetColor();
|
||||
|
||||
if (!signed)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("[HINWEIS] Das Release ist unsigniert - auf dem Server ist kein");
|
||||
Console.WriteLine(" Signierschluessel hinterlegt (security.release_private_key).");
|
||||
Console.WriteLine(" Der Agent kann die Herkunft des Pakets dann nicht pruefen.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}");
|
||||
Console.ResetColor();
|
||||
}
|
||||
Console.ResetColor();
|
||||
|
||||
// Cleanup temp
|
||||
try { Directory.Delete(outputTempDir, true); } catch { }
|
||||
|
||||
// Der Rueckgabewert bildet jetzt ab, was tatsaechlich passiert ist.
|
||||
// Zuvor wurde immer 0 und "successfully published" gemeldet, selbst
|
||||
// wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren.
|
||||
bool fullySucceeded = ftpSucceeded && apiNotified;
|
||||
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = fullySucceeded ? ConsoleColor.Green : ConsoleColor.Yellow;
|
||||
Console.WriteLine(fullySucceeded
|
||||
? $"[FERTIG] Release {version} fuer {project} ({channel}) vollstaendig veroeffentlicht."
|
||||
: $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}): "
|
||||
? $"[FERTIG] Release {version} fuer {project} ({channel}, {platform}) vollstaendig veroeffentlicht."
|
||||
: $"[UNVOLLSTAENDIG] Release {version} fuer {project} ({channel}, {platform}): "
|
||||
+ $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, "
|
||||
+ $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}.");
|
||||
+ $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}"
|
||||
+ (historyPreserved ? "." : ", Historie unveraendert."));
|
||||
Console.ResetColor();
|
||||
|
||||
return fullySucceeded ? 0 : 2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht die angegebene Version mit der, die tatsaechlich in der
|
||||
/// Hauptassembly steht.
|
||||
///
|
||||
/// Rueckgabe false bedeutet: abbrechen. Laesst sich die Assembly nicht
|
||||
/// bestimmen, wird nur gewarnt - ein nicht pruefbarer Fall ist kein
|
||||
/// Fehler, ein nachgewiesener Widerspruch schon.
|
||||
/// </summary>
|
||||
static bool VerifyVersionAgainstAssembly(
|
||||
string publishDir,
|
||||
string project,
|
||||
string declaredVersion,
|
||||
string? mainAssemblyOverride,
|
||||
bool ignoreMismatch)
|
||||
{
|
||||
string? assemblyPath = ResolveMainAssembly(publishDir, project, mainAssemblyOverride);
|
||||
|
||||
if (assemblyPath == null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("[WARNUNG] Hauptassembly nicht gefunden - die Version konnte nicht gegengeprueft");
|
||||
Console.WriteLine(" werden. Mit --main-assembly <datei> gezielt angeben.");
|
||||
Console.ResetColor();
|
||||
return true;
|
||||
}
|
||||
|
||||
string? actual = ReadAssemblyVersion(assemblyPath);
|
||||
|
||||
if (actual == null)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[WARNUNG] Aus {Path.GetFileName(assemblyPath)} liess sich keine Version lesen.");
|
||||
Console.ResetColor();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (VersionCoresMatch(actual, declaredVersion))
|
||||
{
|
||||
Console.WriteLine($"[OK] Version stimmt mit {Path.GetFileName(assemblyPath)} ueberein ({actual}).");
|
||||
return true;
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ignoreMismatch ? ConsoleColor.Yellow : ConsoleColor.Red;
|
||||
Console.WriteLine($"[{(ignoreMismatch ? "WARNUNG" : "FEHLER")}] Versionskonflikt:");
|
||||
Console.WriteLine($" --version sagt : {declaredVersion}");
|
||||
Console.WriteLine($" {Path.GetFileName(assemblyPath)} sagt : {actual}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Wird so veroeffentlicht, meldet die Anwendung nach dem Update weiterhin");
|
||||
Console.WriteLine(" ihre einkompilierte Version, haelt das Release fuer neu und aktualisiert");
|
||||
Console.WriteLine(" bei jedem Start erneut - auf allen Installationen.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Ueblicher Grund: <Version> steht nur in einem der beteiligten Projekte.");
|
||||
Console.WriteLine(" Gehoert in die Directory.Build.props, damit alle denselben Wert tragen.");
|
||||
|
||||
if (!ignoreMismatch)
|
||||
{
|
||||
Console.WriteLine(" Bewusst gewollt? --ignore-version-mismatch");
|
||||
}
|
||||
|
||||
Console.ResetColor();
|
||||
|
||||
return ignoreMismatch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht die Assembly, deren Version fuer das Release massgeblich ist.
|
||||
/// </summary>
|
||||
static string? ResolveMainAssembly(string publishDir, string project, string? overrideName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(overrideName))
|
||||
{
|
||||
string candidate = Path.IsPathRooted(overrideName!)
|
||||
? overrideName!
|
||||
: Path.Combine(publishDir, overrideName!);
|
||||
|
||||
return File.Exists(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
// 1. Gleichnamig zum Projekt-Slug.
|
||||
foreach (string extension in new[] { ".dll", ".exe" })
|
||||
{
|
||||
string candidate = Path.Combine(publishDir, project + extension);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// 2. Ueber die runtimeconfig.json: sie traegt den Namen der
|
||||
// Startassembly und existiert genau einmal je Anwendung.
|
||||
var runtimeConfigs = Directory.GetFiles(publishDir, "*.runtimeconfig.json", SearchOption.TopDirectoryOnly);
|
||||
if (runtimeConfigs.Length == 1)
|
||||
{
|
||||
string baseName = Path.GetFileName(runtimeConfigs[0]);
|
||||
baseName = baseName.Substring(0, baseName.Length - ".runtimeconfig.json".Length);
|
||||
|
||||
foreach (string extension in new[] { ".dll", ".exe" })
|
||||
{
|
||||
string candidate = Path.Combine(publishDir, baseName + extension);
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Version einer Assembly, ohne sie zu laden.
|
||||
/// ProductVersion entspricht InformationalVersion und damit dem, was
|
||||
/// in der csproj unter <Version> steht.
|
||||
/// </summary>
|
||||
static string? ReadAssemblyVersion(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = FileVersionInfo.GetVersionInfo(path);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(info.ProductVersion))
|
||||
return info.ProductVersion!.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(info.FileVersion))
|
||||
return info.FileVersion!.Trim();
|
||||
}
|
||||
catch { }
|
||||
|
||||
try
|
||||
{
|
||||
var name = System.Reflection.AssemblyName.GetAssemblyName(path);
|
||||
return name.Version?.ToString();
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vergleicht nur den numerischen Kern. "1.4.3" und "1.4.3.0" sind
|
||||
/// dieselbe Version; "1.4.3+abc123" ebenso - Build-Metadaten und
|
||||
/// Vorabkennungen sind fuer diese Pruefung ohne Bedeutung.
|
||||
/// </summary>
|
||||
static bool VersionCoresMatch(string a, string b)
|
||||
{
|
||||
var coreA = VersionCore(a);
|
||||
var coreB = VersionCore(b);
|
||||
|
||||
int length = Math.Max(coreA.Count, coreB.Count);
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
int partA = i < coreA.Count ? coreA[i] : 0;
|
||||
int partB = i < coreB.Count ? coreB[i] : 0;
|
||||
if (partA != partB)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static List<int> VersionCore(string version)
|
||||
{
|
||||
string value = (version ?? string.Empty).Trim().TrimStart('v', 'V');
|
||||
|
||||
int cut = value.IndexOfAny(new[] { '-', '+', ' ' });
|
||||
if (cut >= 0)
|
||||
value = value.Substring(0, cut);
|
||||
|
||||
var core = new List<int>();
|
||||
foreach (string part in value.Split('.'))
|
||||
{
|
||||
string digits = new string(part.Where(char.IsDigit).ToArray());
|
||||
core.Add(digits.Length > 0 ? int.Parse(digits) : 0);
|
||||
}
|
||||
|
||||
if (core.Count == 0)
|
||||
core.Add(0);
|
||||
|
||||
return core;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warnt vor Dateien, die nach Zugangsdaten aussehen und weder
|
||||
/// ausgeschlossen noch geschuetzt sind.
|
||||
///
|
||||
/// Eine settings.json mit Datenbankpasswort und DC-Token, die im
|
||||
/// Publish-Verzeichnis liegt, wandert sonst ins Paket und ueberschreibt
|
||||
/// beim Update die Konfiguration jedes Zielsystems.
|
||||
/// </summary>
|
||||
static void WarnAboutUnprotectedSecrets(List<string> files, string publishDir, PackagerConfig config)
|
||||
{
|
||||
string[] suspicious =
|
||||
{
|
||||
"appsettings*.json", "settings.json", "*.config.json", ".env*",
|
||||
"secrets.json", "connectionstrings.json", "*.pfx", "*.key", "*.pem"
|
||||
};
|
||||
|
||||
var hits = new List<string>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
string relPath = Path.GetRelativePath(publishDir, file).Replace('\\', '/');
|
||||
|
||||
if (GlobMatcher.IsMatch(relPath, config.PreservePatterns))
|
||||
continue;
|
||||
|
||||
if (GlobMatcher.IsMatch(relPath, suspicious))
|
||||
hits.Add(relPath);
|
||||
}
|
||||
|
||||
if (hits.Count == 0)
|
||||
return;
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[WARNUNG] Diese Dateien sehen nach Konfiguration oder Zugangsdaten aus, stehen");
|
||||
Console.WriteLine(" aber weder unter excludePatterns noch unter preservePatterns:");
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
Console.WriteLine($" - {hit}");
|
||||
}
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" Sie werden mit ausgeliefert UND ueberschreiben beim Update die Fassung");
|
||||
Console.WriteLine(" auf dem Zielsystem. Entweder in excludePatterns (gar nicht ausliefern)");
|
||||
Console.WriteLine(" oder in preservePatterns (ausliefern, aber nie ersetzen) aufnehmen.");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein
|
||||
/// vollstaendiges Modell dafuer zu benoetigen.
|
||||
@@ -468,32 +890,24 @@ namespace Deploymentcenter.Packager
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json);
|
||||
var cfg = JsonSerializer.Deserialize<PackagerConfig>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (cfg != null) return cfg;
|
||||
}
|
||||
catch { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Eine unlesbare Konfiguration still zu ignorieren hiesse,
|
||||
// mit leeren Zugangsdaten weiterzumachen und den Nutzer
|
||||
// ueber die Ursache im Unklaren zu lassen.
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[WARNUNG] {Path.GetFileName(path)} ist nicht lesbar: {ex.Message}");
|
||||
Console.WriteLine(" Es gelten Umgebungsvariablen und CLI-Argumente.");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
return new PackagerConfig();
|
||||
}
|
||||
|
||||
static bool IsExcluded(string relPath, List<string> patterns)
|
||||
{
|
||||
string fileName = Path.GetFileName(relPath);
|
||||
foreach (var pattern in patterns)
|
||||
{
|
||||
if (pattern.StartsWith("*."))
|
||||
{
|
||||
string ext = pattern.Substring(1);
|
||||
if (fileName.EndsWith(ext, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
}
|
||||
else if (pattern.Equals(relPath, StringComparison.OrdinalIgnoreCase) || pattern.Equals(fileName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static string ComputeSha256(string file)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
@@ -520,6 +934,30 @@ namespace Deploymentcenter.Packager
|
||||
return args.Any(a => a.Equals(flag, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
static void ShowHelp()
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Aufruf: pack-and-deploy [Optionen]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" --project, -p <slug> Projekt-Slug im Deploymentcenter");
|
||||
Console.WriteLine(" --version, -v <version> Zu veroeffentlichende Version");
|
||||
Console.WriteLine(" --channel, -c <kanal> prod | beta | dev (Vorgabe: prod)");
|
||||
Console.WriteLine(" --platform <rid> win-x64, linux-x64, ... (Vorgabe: aus dem");
|
||||
Console.WriteLine(" Publish-Pfad abgeleitet, sonst 'any')");
|
||||
Console.WriteLine(" --publish-dir, -d <pfad> Ausgabe von dotnet publish");
|
||||
Console.WriteLine(" --changelog <text> Aenderungshinweise");
|
||||
Console.WriteLine(" --critical Als kritisches Update kennzeichnen");
|
||||
Console.WriteLine(" --main-assembly <datei> Assembly fuer die Versionsgegenprobe");
|
||||
Console.WriteLine(" --ignore-version-mismatch Versionskonflikt nur als Warnung behandeln");
|
||||
Console.WriteLine(" --allow-any-platform Warnung zu 'any' unterdruecken");
|
||||
Console.WriteLine(" --config <datei> Abweichende packager.config.json");
|
||||
Console.WriteLine(" --token <token> Token mit updateservice:publish");
|
||||
Console.WriteLine(" --ftp-host/--ftp-user/--ftp-pass/--remote-dir");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Rueckgabewerte: 0 vollstaendig, 1 Konfigurationsfehler, 2 teilweise fehlgeschlagen.");
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static string GetGitCommitLong()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"_apiToken_comment": "Token mit dem Recht updateservice:publish. Im WebUI unter Token-Verwaltung erzeugen.",
|
||||
"apiToken": "",
|
||||
|
||||
"_excludePatterns_comment": "Kommt gar nicht erst ins Paket. Echte Glob-Muster: * trifft innerhalb eines Ordners, ** ueber Ordnergrenzen, ? genau ein Zeichen. Muster ohne / gelten fuer den Dateinamen an beliebiger Stelle.",
|
||||
"excludePatterns": [
|
||||
"*.pdb",
|
||||
"*.xml",
|
||||
@@ -20,5 +21,14 @@
|
||||
"logs/**",
|
||||
"scratch/**",
|
||||
"*.tmp"
|
||||
],
|
||||
|
||||
"_preservePatterns_comment": "Wird ausgeliefert, ersetzt am Ziel aber niemals eine vorhandene Datei. Fuer Konfigurationsvorlagen: die Erstinstallation bekommt sie, ein Update laesst die eingerichteten Werte in Ruhe. Ausschluss und Schutz sind zwei verschiedene Dinge - was hier fehlt und Zugangsdaten enthaelt, ueberschreibt beim Update die Konfiguration jedes Zielsystems.",
|
||||
"preservePatterns": [
|
||||
"appsettings.json",
|
||||
"appsettings.Production.json",
|
||||
"settings.json",
|
||||
"config.json",
|
||||
".env"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user