fix(clients, docs): Lizenz-Antwortformat wiederherstellen, Packager absichern

Regression aus dem vorigen Commit
- /api/license/v1/validate lieferte die Antwort im neuen status/error-Umschlag.
  Der Vertrag dieses Endpunkts ist aber bereits ausgerollt: das Feld "status"
  auf oberster Ebene trägt den Lizenzzustand (valid, revoked, expired ...).
  LicenseClient las dadurch "success" statt "valid" — jeder ausgelieferte
  Client hätte seine Lizenz für ungültig gehalten. Die Lizenz-Endpunkte
  antworten jetzt wieder ohne Umschlag (Http::raw).
  Gefunden durch Ausführen der projekteigenen Test-Suite gegen den Server.

Packager
- FTP-Zugangsdaten standen als Standardwerte im Quelltext und zusätzlich in
  packager.config.json und in der Integrationsanleitung. Alle drei Fundstellen
  bereinigt; die Konfigurationsdatei ist nicht mehr versioniert. Zugangsdaten
  kommen aus Datei, Umgebungsvariablen oder CLI-Argument, sonst bricht das
  Programm mit einer klaren Meldung ab.
- Das Veröffentlichen sendet jetzt ein Token (updateservice:publish) und nutzt
  den Endpunkt /api/updateservice/v1/publish.
- Fehler wurden von einem leeren catch verschluckt, und ohne Erfolgsfall wurde
  gar nichts ausgegeben. Das Werkzeug meldete am Ende immer Erfolg und lieferte
  Rückgabewert 0, selbst wenn FTP-Upload und API-Aufruf fehlgeschlagen waren.
  Jetzt ehrliche Meldungen und Rückgabewerte 0/1/2.
- packager.config.json wurde vom csproj nie ins Ausgabeverzeichnis kopiert,
  weshalb sie dort nie gefunden wurde und stets die hartkodierten Werte griffen.

UpdateClient
- IsVersionNewer entfernte die Vorabkennung, aber kein führendes "v". Damit
  scheiterte Version.TryParse bei "v1.4.2" und es wurde auf einen
  alphabetischen Vergleich zurückgefallen, in dem "v1.9.0" als neuer gilt als
  "v1.10.0" — derselbe Fehler wie zuvor serverseitig im SQL. Ersetzt durch
  einen vollständigen semantischen Vergleich, verifiziert mit 16 Testfällen.
- Der Rückfall auf die API lag in einem catch-Block, aber GetAsync wirft bei
  einem 404 keine Exception. Fehlte die statische latest.json, brach die
  Prüfung ab, statt die API zu befragen.

Dokumentation
- BUGTRACKER_INTEGRATION_GUIDE.md beschrieb denselben Workflow ein zweites Mal
  und war bereits auseinandergelaufen: Aufrufe ohne Token, alte Pfade, weder
  Claim/Lease noch Idempotenz. Ersetzt durch einen Verweis auf das gepflegte
  Agenten-Handbuch samt Übersicht der Änderungen.
- UPDATESERVICE_INTEGRATION_GUIDE.md um Token, Umgebungsvariablen und
  Rückgabewerte ergänzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-07 16:27:02 +02:00
co-authored by Claude Opus 5
parent e7fbc85db4
commit a74c6fd990
10 changed files with 481 additions and 327 deletions
+1
View File
@@ -13,3 +13,4 @@ scripts/deploy_config.json
Serverdaten.txt Serverdaten.txt
*.local.php *.local.php
.env .env
client-dotnet/Deploymentcenter.Packager/packager.config.json
@@ -59,22 +59,39 @@ namespace Deploymentcenter.Client
// Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json // Path pattern: https://domain/releases/{ProjectId}/{channel}/latest.json
string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json"; string staticUrl = $"{cleanBaseUrl}/releases/{projectId}/{channel}/latest.json";
HttpResponseMessage response; // Zuerst die statische latest.json, danach die API.
//
// Der Rueckfall auf die API war zuvor unerreichbar: er lag in
// einem catch, aber GetAsync wirft bei einem 404 keine Exception,
// sondern liefert eine Antwort mit Statuscode. Fehlte die
// latest.json, brach die Pruefung mit "HTTP Error NotFound" ab,
// statt die API zu befragen.
HttpResponseMessage? response = null;
try try
{ {
response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false); response = await _httpClient.GetAsync(staticUrl, cancellationToken).ConfigureAwait(false);
} }
catch catch
{ {
// Fallback check: Deploymentcenter API endpoint response = null;
// Path pattern: https://domain/api/updateservice/v1/check?product={projectId}&version={currentVersion} }
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check?product={Uri.EscapeDataString(projectId)}&version={Uri.EscapeDataString(currentVersion)}";
if (response == null || !response.IsSuccessStatusCode)
{
response?.Dispose();
string apiUrl = $"{cleanBaseUrl}/api/updateservice/v1/check"
+ $"?product={Uri.EscapeDataString(projectId)}"
+ $"&version={Uri.EscapeDataString(currentVersion)}"
+ $"&channel={Uri.EscapeDataString(channel)}";
response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false); response = await _httpClient.GetAsync(apiUrl, cancellationToken).ConfigureAwait(false);
} }
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
result.Message = $"HTTP Error {response.StatusCode} during update check"; result.Message = $"Update-Pruefung fehlgeschlagen: HTTP {(int)response.StatusCode}";
return result; return result;
} }
@@ -214,24 +231,107 @@ namespace Deploymentcenter.Client
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
} }
/// <summary>
/// Prueft, ob <paramref name="remoteVer"/> neuer ist als <paramref name="currentVer"/>.
///
/// Die vorherige Fassung entfernte zwar die Vorabkennung, nicht aber ein
/// fuehrendes "v". Damit scheiterte Version.TryParse bei Angaben wie
/// "v1.4.2" und es wurde auf einen alphabetischen Vergleich
/// zurueckgefallen - dort gilt "v1.9.0" faelschlich als neuer als
/// "v1.10.0". Das entspricht dem Fehler, der serverseitig in der
/// SQL-Abfrage steckte.
/// </summary>
public static bool IsVersionNewer(string currentVer, string remoteVer) public static bool IsVersionNewer(string currentVer, string remoteVer)
{ {
if (string.IsNullOrEmpty(remoteVer)) return false; if (string.IsNullOrWhiteSpace(remoteVer)) return false;
if (string.IsNullOrEmpty(currentVer)) return true; if (string.IsNullOrWhiteSpace(currentVer)) return true;
string CleanVer(string v) return CompareVersions(remoteVer, currentVer) > 0;
}
/// <summary>
/// Vergleicht zwei Versionsangaben nach semantischer Ordnung.
/// Rueckgabe: negativ wenn a &lt; b, 0 bei Gleichstand, positiv wenn a &gt; b.
/// </summary>
public static int CompareVersions(string a, string b)
{
var (coreA, preA) = ParseVersion(a);
var (coreB, preB) = ParseVersion(b);
int length = Math.Max(coreA.Count, coreB.Count);
for (int i = 0; i < length; i++)
{ {
int dash = v.IndexOf('-'); int partA = i < coreA.Count ? coreA[i] : 0;
return dash > 0 ? v.Substring(0, dash) : v; int partB = i < coreB.Count ? coreB[i] : 0;
if (partA != partB)
{
return partA.CompareTo(partB);
}
} }
if (Version.TryParse(CleanVer(currentVer), out var cVer) && // Eine Version ohne Vorabkennung rangiert ueber derselben mit:
Version.TryParse(CleanVer(remoteVer), out var rVer)) // 1.0.0 ist neuer als 1.0.0-rc.1
bool emptyA = preA.Count == 0;
bool emptyB = preB.Count == 0;
if (emptyA && emptyB) return 0;
if (emptyA) return 1;
if (emptyB) return -1;
int preLength = Math.Max(preA.Count, preB.Count);
for (int i = 0; i < preLength; i++)
{ {
return rVer > cVer; if (i >= preA.Count) return -1;
if (i >= preB.Count) return 1;
bool numericA = int.TryParse(preA[i], out int numA);
bool numericB = int.TryParse(preB[i], out int numB);
if (numericA && numericB)
{
if (numA != numB) return numA.CompareTo(numB);
continue;
}
// Rein numerische Bestandteile rangieren unter alphanumerischen.
if (numericA != numericB) return numericA ? -1 : 1;
int cmp = string.CompareOrdinal(preA[i], preB[i]);
if (cmp != 0) return cmp > 0 ? 1 : -1;
} }
return string.Compare(remoteVer, currentVer, StringComparison.OrdinalIgnoreCase) > 0; return 0;
}
private static (List<int> Core, List<string> Prerelease) ParseVersion(string version)
{
string value = (version ?? string.Empty).Trim().TrimStart('v', 'V');
// Build-Metadaten sind fuer die Rangfolge ohne Bedeutung.
int plus = value.IndexOf('+');
if (plus >= 0) value = value.Substring(0, plus);
var prerelease = new List<string>();
int dash = value.IndexOf('-');
if (dash >= 0)
{
string preString = value.Substring(dash + 1);
value = value.Substring(0, dash);
if (preString.Length > 0)
{
prerelease.AddRange(preString.Split('.'));
}
}
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, prerelease);
} }
} }
} }
@@ -18,4 +18,19 @@
<ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" /> <ProjectReference Include="..\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
</ItemGroup> </ItemGroup>
<!--
Die Konfiguration wurde bisher nicht ins Ausgabeverzeichnis kopiert. Da das
Programm sie dort sucht, wurde sie nie gefunden - benutzt wurden die
hartkodierten Standardwerte im Quelltext. Die Datei ist nicht versioniert;
ohne sie greifen Umgebungsvariablen oder CLI-Argumente.
-->
<ItemGroup>
<None Include="packager.config.json" Condition="Exists('packager.config.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="packager.config.example.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>
@@ -15,18 +15,56 @@ using FluentFTP;
namespace Deploymentcenter.Packager namespace Deploymentcenter.Packager
{ {
/// <summary>
/// Konfiguration des Packagers.
///
/// Die Zugangsdaten standen zuvor als Standardwerte direkt im Quelltext und
/// lagen damit im Repository. Sie kommen jetzt ausschliesslich aus
/// packager.config.json (nicht versioniert) oder aus Umgebungsvariablen.
/// Fehlen sie, bricht das Programm mit einer klaren Meldung ab, statt sich
/// mit veralteten Werten zu verbinden.
/// </summary>
public class PackagerConfig public class PackagerConfig
{ {
public string FtpHost { get; set; } = "www531.your-server.de"; public string FtpHost { get; set; } = "";
public int FtpPort { get; set; } = 21; public int FtpPort { get; set; } = 21;
public string FtpUser { get; set; } = "bergisnu_4"; public string FtpUser { get; set; } = "";
public string FtpPass { get; set; } = "o2#M*NN^5EsT"; public string FtpPass { get; set; } = "";
public string FtpRemoteBaseDir { get; set; } = "/public_html/releases"; public string FtpRemoteBaseDir { get; set; } = "/public_html/releases";
public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de"; public string ApiBaseUrl { get; set; } = "https://dc.mhdf.de";
/// <summary>
/// Token mit dem Recht updateservice:publish. Das Veroeffentlichen eines
/// Releases ist nicht mehr unauthentifiziert moeglich.
/// </summary>
public string ApiToken { get; set; } = "";
public List<string> ExcludePatterns { get; set; } = new List<string> 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/*"
}; };
/// <summary>Umgebungsvariablen haben Vorrang vor der Konfigurationsdatei.</summary>
public void ApplyEnvironmentOverrides()
{
FtpHost = Env("DC_FTP_HOST", FtpHost);
FtpUser = Env("DC_FTP_USER", FtpUser);
FtpPass = Env("DC_FTP_PASS", FtpPass);
ApiBaseUrl = Env("DC_API_URL", ApiBaseUrl);
ApiToken = Env("DC_TOKEN", ApiToken);
string port = Env("DC_FTP_PORT", "");
if (int.TryParse(port, out int parsedPort) && parsedPort > 0)
{
FtpPort = parsedPort;
}
}
private static string Env(string name, string fallback)
{
string? value = Environment.GetEnvironmentVariable(name);
return string.IsNullOrWhiteSpace(value) ? fallback : value;
}
} }
class Program class Program
@@ -46,12 +84,43 @@ namespace Deploymentcenter.Packager
string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json"); string configFile = GetArg(args, "--config") ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "packager.config.json");
PackagerConfig config = LoadConfig(configFile); PackagerConfig config = LoadConfig(configFile);
config.ApplyEnvironmentOverrides();
// Override config with explicit CLI args if provided // Reihenfolge: CLI-Argument, dann Umgebungsvariable, dann Datei.
string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost; string ftpHost = GetArg(args, "--ftp-host") ?? config.FtpHost;
string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser; string ftpUser = GetArg(args, "--ftp-user") ?? config.FtpUser;
string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass; string ftpPass = GetArg(args, "--ftp-pass") ?? config.FtpPass;
string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir; string remoteBase = GetArg(args, "--remote-dir") ?? config.FtpRemoteBaseDir;
string apiToken = GetArg(args, "--token") ?? config.ApiToken;
var missing = new List<string>();
if (string.IsNullOrWhiteSpace(ftpHost)) missing.Add("FTP-Host (--ftp-host / DC_FTP_HOST / ftpHost)");
if (string.IsNullOrWhiteSpace(ftpUser)) missing.Add("FTP-Benutzer (--ftp-user / DC_FTP_USER / ftpUser)");
if (string.IsNullOrWhiteSpace(ftpPass)) missing.Add("FTP-Passwort (--ftp-pass / DC_FTP_PASS / ftpPass)");
if (missing.Count > 0)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("[FEHLER] Konfiguration unvollstaendig:");
foreach (var item in missing)
{
Console.WriteLine($" - {item}");
}
Console.ResetColor();
Console.WriteLine();
Console.WriteLine($"Vorlage kopieren: {Path.GetFileName(configFile)}.example -> {Path.GetFileName(configFile)}");
return 1;
}
if (string.IsNullOrWhiteSpace(apiToken))
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("[WARNUNG] Kein API-Token gesetzt (--token / DC_TOKEN / apiToken).");
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.ResetColor();
}
publishDir = Path.GetFullPath(publishDir); publishDir = Path.GetFullPath(publishDir);
if (!Directory.Exists(publishDir)) if (!Directory.Exists(publishDir))
@@ -162,6 +231,8 @@ namespace Deploymentcenter.Packager
Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})..."); Console.WriteLine($"[INFO] Uploading via FTP to {ftpHost}:{config.FtpPort} ({remoteVersionPath})...");
bool ftpSucceeded = false;
try try
{ {
using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort); using var ftp = new AsyncFtpClient(ftpHost, ftpUser, ftpPass, config.FtpPort);
@@ -241,50 +312,153 @@ namespace Deploymentcenter.Packager
Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!"); Console.WriteLine("[SUCCESS] Updated latest.json on FTP server!");
await ftp.Disconnect(); await ftp.Disconnect();
ftpSucceeded = true;
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.ForegroundColor = ConsoleColor.Yellow; Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[WARNING] FTP upload encountered error: {ex.Message}"); Console.WriteLine($"[FEHLER] FTP-Upload fehlgeschlagen: {ex.Message}");
Console.WriteLine(" Das Paket wurde NICHT ausgeliefert.");
Console.ResetColor(); Console.ResetColor();
} }
// 6. Notify Deploymentcenter Web API // 6. Deploymentcenter benachrichtigen
try //
{ // Zuvor stand hier ein leeres catch, und ohne Erfolgsfall wurde gar
using var http = new HttpClient(); // nichts ausgegeben. Ein fehlgeschlagener Aufruf blieb damit
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/index.php"; // unsichtbar, waehrend das Programm am Ende Erfolg meldete.
var payload = new bool apiNotified = false;
{ string apiMessage = "uebersprungen (kein Token gesetzt)";
action = "publish_release",
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 ? 1 : 0
};
string jsonContent = JsonSerializer.Serialize(payload); if (!string.IsNullOrWhiteSpace(apiToken))
var content = new StringContent(jsonContent, Encoding.UTF8, "application/json"); {
var response = await http.PostAsync(apiPublishUrl, content); try
if (response.IsSuccessStatusCode)
{ {
Console.WriteLine("[SUCCESS] Notified Deploymentcenter Web API of new release."); using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
string apiPublishUrl = $"{config.ApiBaseUrl.TrimEnd('/')}/api/updateservice/v1/publish";
var payload = new
{
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
};
var request = new HttpRequestMessage(HttpMethod.Post, apiPublishUrl)
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {apiToken}");
var response = await http.SendAsync(request);
string body = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{
apiNotified = true;
apiMessage = ExtractJsonString(body, "message") ?? "Release im Deploymentcenter eingetragen.";
string? autoResolved = ExtractJsonString(body, "auto_resolved");
if (!string.IsNullOrEmpty(autoResolved) && autoResolved != "0")
{
apiMessage += $" ({autoResolved} Bugtracker-Item(s) automatisch geschlossen)";
}
}
else
{
apiMessage = $"HTTP {(int)response.StatusCode}: "
+ (ExtractJsonString(body, "message") ?? body.Trim());
}
}
catch (Exception ex)
{
apiMessage = $"Aufruf fehlgeschlagen: {ex.Message}";
} }
} }
catch { }
if (apiNotified)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"[SUCCESS] {apiMessage}");
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[WARNUNG] Deploymentcenter nicht benachrichtigt - {apiMessage}");
}
Console.ResetColor();
// Cleanup temp // Cleanup temp
try { Directory.Delete(outputTempDir, true); } catch { } try { Directory.Delete(outputTempDir, true); } catch { }
Console.ForegroundColor = ConsoleColor.Green; // Der Rueckgabewert bildet jetzt ab, was tatsaechlich passiert ist.
Console.WriteLine($"\n[FINISHED] Release v{version} for {project} ({channel}) successfully published!"); // 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}): "
+ $"Upload {(ftpSucceeded ? "ok" : "FEHLGESCHLAGEN")}, "
+ $"Registrierung {(apiNotified ? "ok" : "FEHLGESCHLAGEN")}.");
Console.ResetColor(); Console.ResetColor();
return 0;
return fullySucceeded ? 0 : 2;
}
/// <summary>
/// Liest einen einzelnen Wert aus einer JSON-Antwort, ohne ein
/// vollstaendiges Modell dafuer zu benoetigen.
/// </summary>
static string? ExtractJsonString(string json, string propertyName)
{
try
{
using var doc = JsonDocument.Parse(json);
return FindProperty(doc.RootElement, propertyName);
}
catch
{
return null;
}
}
static string? FindProperty(JsonElement element, string propertyName)
{
if (element.ValueKind != JsonValueKind.Object)
{
return null;
}
if (element.TryGetProperty(propertyName, out var direct))
{
return direct.ValueKind == JsonValueKind.String
? direct.GetString()
: direct.ToString();
}
// Fehlerantworten verpacken die Nachricht in einem "error"-Objekt.
foreach (var child in element.EnumerateObject())
{
if (child.Value.ValueKind == JsonValueKind.Object)
{
string? nested = FindProperty(child.Value, propertyName);
if (nested != null)
{
return nested;
}
}
}
return null;
} }
static PackagerConfig LoadConfig(string path) static PackagerConfig LoadConfig(string path)
@@ -0,0 +1,24 @@
{
"_comment": "Kopie als packager.config.json anlegen und ausfuellen. packager.config.json ist per .gitignore ausgeschlossen. Alternativ ueber Umgebungsvariablen: DC_FTP_HOST, DC_FTP_PORT, DC_FTP_USER, DC_FTP_PASS, DC_API_URL, DC_TOKEN.",
"ftpHost": "ftp.example.com",
"ftpPort": 21,
"ftpUser": "ftp-user",
"ftpPass": "ftp-password",
"ftpRemoteBaseDir": "/public_html/releases",
"apiBaseUrl": "https://dc.example.com",
"_apiToken_comment": "Token mit dem Recht updateservice:publish. Im WebUI unter Token-Verwaltung erzeugen.",
"apiToken": "",
"excludePatterns": [
"*.pdb",
"*.xml",
"appsettings.Development.json",
"appsettings.Staging.json",
"*.log",
"logs/**",
"scratch/**",
"*.tmp"
]
}
@@ -1,18 +0,0 @@
{
"ftpHost": "www531.your-server.de",
"ftpPort": 21,
"ftpUser": "bergisnu_4",
"ftpPass": "o2#M*NN^5EsT",
"ftpRemoteBaseDir": "/public_html/releases",
"apiBaseUrl": "https://dc.mhdf.de",
"excludePatterns": [
"*.pdb",
"*.xml",
"appsettings.Development.json",
"appsettings.Staging.json",
"*.log",
"logs/**",
"scratch/**",
"*.tmp"
]
}
+44 -254
View File
@@ -1,266 +1,56 @@
# 🤖 AI Agent Integration Guide: Deployment Center Bugtracker & Provisioning API # Bugtracker-Integration
> **⚠️ Geändert in Version 2.0** — `POST /api/bugtracker/v1/report` und > **Dieser Leitfaden wurde zusammengeführt.**
> `GET /api/bugtracker/v1/projects` verlangen jetzt zwingend ein Token mit dem >
> passenden Scope; Aufrufe ohne Token liefern `401 unauthorized`. Das > Die vollständige, gepflegte Beschreibung steht im
> Antwortformat wurde vereinheitlicht. Die aktuelle, vollständige Beschreibung > **[Agenten-Handbuch](../public/docs/bugtracker.md)** — öffentlich erreichbar
> steht im **[Agenten-Handbuch](../public/docs/bugtracker.md)** und unter > unter `https://dc.mhdf.de/docs/` und maschinenlesbar unter
> `/api/openapi.json`. Umstellungsschritte: **[UPGRADE.md](./UPGRADE.md)**. > `https://dc.mhdf.de/api/openapi.json`.
>
> Zuvor existierten zwei Beschreibungen desselben Workflows nebeneinander. Sie
This guide defines the standardized protocol and API specifications for autonomous AI Developer Agents interacting with the **Deployment Center Bugtracker & Token Provisioning System**. > waren bereits auseinandergelaufen: die hier stehenden Beispiele riefen die
> Endpunkte ohne Token auf, benutzten `report.php` und `manage/index.php` als
> Pfade und kannten weder Claim/Lease noch Idempotenz. Ein Agent, der diesem
> Dokument folgte, wäre seit Version 2.0 an `401 unauthorized` gescheitert.
--- ---
## 📌 Executive Overview for AI Agents ## Was sich gegenüber der alten Fassung geändert hat
As an autonomous AI agent working on software repositories, you MUST actively track all runtime errors, stack traces, planned features, and backlog items in the central Deployment Center. | Alt | Neu |
|---|---|
| `POST /api/bugtracker/v1/report.php` ohne Token | `POST /api/bugtracker/v1/report` **mit** Token (`bugtracker:report`) |
| `GET /api/bugtracker/v1/projects.php` ohne Token | `GET /api/bugtracker/v1/projects` **mit** Token (`bugtracker:read`) |
| `GET .../manage/index.php?project_slug=…` | `GET .../manage?action=list&project_slug=…` |
| `created_by` frei wählbar | Wird aus dem Token abgeleitet |
| Keine Reservierung — zwei Agenten konnten dasselbe Item bearbeiten | `?action=next` holt und reserviert exklusiv (Claim/Lease) |
| Wiederholte Sendung erzeugte Duplikate | `client_ref` bzw. Header `Idempotency-Key` |
| Fehler nur als Freitext | Stabiler `error.code` zum Auswerten |
### Core Capabilities: ## Kürzestmögliche Fassung
1. **Sub-Token Auto-Provisioning**: Generate restricted sub-tokens for scoped agent tasks.
2. **Automated Error Ingestion**: Report unhandled exceptions with stack traces & automatic SHA-256 deduplication.
3. **Feature & Idea Backlog**: Submit roadmap ideas (`severity: "idea"`) or backlog items (`severity: "wishlist"`).
4. **Active Workflow Management**: Fetch active bugs assigned to your agent ID, update status (`in_progress`, `resolved`), and append diagnostic comments.
---
## 🔑 1. Token Provisioning API
Agents authenticate using a **Master Token** or auto-provisioned **Sub-Token**.
### Endpoint: `POST /api/tokens/v1/provision`
Header: `Authorization: Bearer <MASTER_TOKEN>`
#### Request Payload:
```json
{
"parent_token": "dc_master_myapp_dev_agent_001",
"name": "Codebase Refactoring Agent Token",
"environment": "development",
"scopes": ["bugtracker:report", "bugtracker:manage"],
"expires_in_hours": 24
}
```
#### Response:
```json
{
"status": "success",
"token_id": "tok_s_8912ab",
"raw_token": "dc_sub_myapp_refactor_agent_991",
"scopes": ["bugtracker:report", "bugtracker:manage"],
"environment": "development",
"expires_at": "2026-08-07 21:00:00"
}
```
---
## 📂 1.5. Discovering Monitored Projects API
Before reporting a bug or feature request, an agent can dynamically query all registered projects monitored by the Deployment Center.
### Endpoint: `GET /api/bugtracker/v1/projects.php`
#### Response:
```json
{
"status": "success",
"count": 4,
"projects": [
{
"id": 1,
"slug": "myapp",
"name": "My Application Deluxe",
"notes": "Hauptanwendung für Desktop und Server"
},
{
"id": 2,
"slug": "polytrader",
"name": "PolyTrader Suite Pro",
"notes": "Trading- und Handelssystem Client"
},
{
"id": 3,
"slug": "predictalytics",
"name": "Predictalytics Engine",
"notes": "Datenanalyse und Vorhersage Dienst"
},
{
"id": 4,
"slug": "deploymentcenter",
"name": "Deployment Center",
"notes": "Zentrale Verwaltungs- & Update-Plattform"
}
]
}
```
If an agent discovers an issue or refactoring opportunity in any monitored system (including `deploymentcenter` itself or external dependencies), it can fetch this project list and map the issue to the appropriate `project_slug`.
---
## 🐛 2. Reporting Bugs, Features & Ideas
### Endpoint: `POST /api/bugtracker/v1/report.php`
Header: `Authorization: Bearer <AGENT_TOKEN>`
### A. Reporting an Unhandled Exception / Bug
```json
{
"project_slug": "myapp",
"type": "bug",
"title": "NullReferenceException in UserAuthService.cs line 42",
"description": "Triggered when user logs in without an active session object.",
"error_message": "NullReferenceException: Object reference not set to an instance of an object.",
"stack_trace": "at MyApp.Core.UserAuthService.ValidateToken(String token) in UserAuthService.cs:line 42\nat MyApp.Controllers.AuthController.Login() in AuthController.cs:line 18",
"build_version": "v1.4.2-dev",
"environment": "development",
"severity": "high",
"push_id": "push_wf_8912",
"target_agent": "agent:code-fixer-01",
"tags": "auth, security, csharp",
"created_by": "agent:watchdog-monitor"
}
```
### B. Submitting a Feature Request or Quick Reminder Idea (`severity: "idea"`)
```json
{
"project_slug": "myapp",
"type": "feature_request",
"title": "Automatische Datenbank-Backups vor FTP Deployments",
"description": "Gedanke für später: Vor jedem FTP-Deployment automatisch mysqldump ausführen und im Server-Archiv ablegen.",
"build_version": "v1.6.0-roadmap",
"environment": "development",
"severity": "idea",
"push_id": "push_wf_9910",
"target_agent": "agent:db-optimizer",
"tags": "database, automation, backup",
"created_by": "agent:planner"
}
```
#### Response:
```json
{
"status": "success",
"item_id": 4,
"is_new": true,
"occurrence_count": 1,
"error_hash": "e2c918a514d89a42f",
"type": "bug",
"environment": "development",
"push_id": "push_wf_8912",
"message": "New bug reported successfully."
}
```
---
## 📌 3. Managing Items (Fetching, Updating & Commenting)
### Base Endpoint: `/api/bugtracker/v1/manage/index.php`
Header: `Authorization: Bearer <AGENT_TOKEN>`
### A. Fetching Open Items Assigned to an Agent
```http
GET /api/bugtracker/v1/manage/index.php?project_slug=myapp&status=open&agent=agent:code-fixer-01
```
### B. Updating Status & Details (`POST ?action=update`)
```json
{
"id": 4,
"status": "in_progress",
"severity": "high",
"push_id": "push_wf_8912",
"target_agent": "agent:code-fixer-01",
"tags": "auth, fixed_pending_test",
"author": "agent:code-fixer-01"
}
```
### C. Appending Diagnostic Timeline Comments (`POST ?action=comment`)
```json
{
"id": 4,
"comment": "Ursache identifiziert: $_SESSION['user'] war Null in line 42. Null-Check und Safe Navigation Operator wurden hinzugefügt.",
"action_taken": "code_patched",
"author": "agent:code-fixer-01"
}
```
### D. Marking as Resolved (`POST ?action=resolve`)
```json
{
"id": 4,
"resolved_in_build": "v1.4.3-dev",
"resolution_notes": "Unit tests hinzugefügt und Null-Check in ValidateToken() integriert.",
"author": "agent:code-fixer-01"
}
```
---
## 💻 4. Code Implementation Examples for Agents
### Python Example: Automatic Error Reporter Decorator
```python
import requests
import traceback
import sys
DC_API_URL = "https://dc.mhdf.de/api/bugtracker/v1/report.php"
AGENT_TOKEN = "dc_sub_myapp_agent_live_001"
def report_exception_to_dc(project_slug: str, exc: Exception, env: str = "production", push_id: str = None):
payload = {
"project_slug": project_slug,
"type": "bug",
"title": f"{type(exc).__name__}: {str(exc)}",
"error_message": str(exc),
"stack_trace": traceback.format_exc(),
"build_version": "v1.4.2",
"environment": env,
"severity": "high",
"push_id": push_id,
"created_by": "agent:python-runner"
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {AGENT_TOKEN}"
}
try:
r = requests.post(DC_API_URL, json=payload, headers=headers, timeout=5)
return r.json()
except Exception as e:
print(f"Failed to report to Deployment Center: {e}", file=sys.stderr)
```
### cURL Example: Submit Feature Request / Idea
```bash ```bash
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/report.php" \ # 1. Arbeit holen und übernehmen
-H "Authorization: Bearer dc_sub_myapp_agent_live_001" \ curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=next" \
-H "Authorization: Bearer $DC_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{"project_slug": "myapp", "limit": 1}'
"project_slug": "myapp",
"type": "feature_request", # 2. Zwischenstand festhalten
"title": "Erweiterte Filterung im WebUI Dashboard", curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=comment&id=42" \
"severity": "idea", -H "Authorization: Bearer $DC_TOKEN" \
"push_id": "push_task_1029", -H "Content-Type: application/json" \
"tags": "ui, dashboard", -d '{"comment": "Ursache gefunden.", "action_taken": "investigated"}'
"created_by": "agent:dev-assistant"
}' # 3. Abschließen
curl -X POST "https://dc.mhdf.de/api/bugtracker/v1/manage?action=resolve&id=42" \
-H "Authorization: Bearer $DC_TOKEN" \
-H "Content-Type: application/json" \
-d '{"resolved_in_build": "v1.4.3", "resolution_notes": "Fix in AuthController."}'
``` ```
--- ## Weiterführend
## 🎯 Best Practices for Developer Agents - **[Agenten-Handbuch](../public/docs/bugtracker.md)** — vollständige Referenz mit allen Feldern, Filtern, Fehlercodes und einem Python-Beispiel
- **[Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md)** — Textbaustein für `CLAUDE.md` / `AGENTS.md`
1. **Always set `push_id`**: When executing automated pipelines, pass a `push_id` so all updates can be traced back to the specific execution run. - **[UPGRADE.md](./UPGRADE.md)** — Umstellungsschritte für bestehende Integrationen
2. **Use `severity: "idea"` for thoughts**: When noticing potential refactorings or future improvements during coding, log them immediately as ideas.
3. **Comment before resolving**: Before calling `action=resolve`, write a diagnostic comment explaining **why** and **how** the fix was performed.
+41 -5
View File
@@ -76,26 +76,62 @@ Das Packaging-Tool verpackt den `dotnet publish`-Output, berechnet Hashes, erzeu
pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung" pack-and-deploy --project myapp --version 1.4.0 --channel prod --publish-dir ./bin/Release/net8.0/publish --changelog "Fehlerbehebungen und Performance-Optimierung"
``` ```
### Konfiguration (`packager.config.json`): ### Konfiguration (`packager.config.json`)
> Diese Datei enthält Zugangsdaten und ist per `.gitignore` von der
> Versionskontrolle ausgeschlossen. Vorlage: `packager.config.example.json`.
> In der vorherigen Fassung standen die echten FTP-Zugangsdaten sowohl hier in
> der Anleitung als auch als Standardwerte im Quelltext von `Program.cs`.
```json ```json
{ {
"ftpHost": "www531.your-server.de", "ftpHost": "ftp.example.com",
"ftpPort": 21, "ftpPort": 21,
"ftpUser": "bergisnu_4", "ftpUser": "ftp-user",
"ftpPass": "o2#M*NN^5EsT", "ftpPass": "ftp-password",
"ftpRemoteBaseDir": "/public_html/releases", "ftpRemoteBaseDir": "/public_html/releases",
"apiBaseUrl": "https://dc.mhdf.de", "apiBaseUrl": "https://dc.mhdf.de",
"apiToken": "dc_sub_...",
"excludePatterns": [ "excludePatterns": [
"*.pdb", "*.pdb",
"*.xml", "*.xml",
"appsettings.Development.json", "appsettings.Development.json",
"*.log", "*.log",
"logs/*" "logs/**"
] ]
} }
``` ```
`apiToken` braucht das Recht `updateservice:publish`. Ohne Token baut und lädt
der Packager das Paket zwar hoch, meldet es aber nicht beim Deploymentcenter an
und beendet sich mit Rückgabewert 2.
### Alternative: Umgebungsvariablen
Für CI-Läufe, in denen keine Datei abgelegt werden soll — sie haben Vorrang vor
der Konfigurationsdatei:
```bash
export DC_FTP_HOST=ftp.example.com
export DC_FTP_USER=ftp-user
export DC_FTP_PASS='...'
export DC_TOKEN='dc_sub_...'
pack-and-deploy --project myapp --version 1.4.0 --channel prod \
--publish-dir ./bin/Release/net8.0/publish
```
### Rückgabewerte
| Wert | Bedeutung |
|---|---|
| `0` | Paket gebaut, hochgeladen und im Deploymentcenter registriert |
| `1` | Konfiguration unvollständig oder Publish-Verzeichnis fehlt — nichts wurde ausgeführt |
| `2` | Teilweise fehlgeschlagen: FTP-Upload oder Registrierung ging schief |
Zuvor lieferte das Werkzeug in allen Fällen `0` und meldete „successfully
published", selbst wenn FTP-Upload und API-Aufruf beide fehlgeschlagen waren.
--- ---
## 4. Standalone UpdateAgent (`update-agent`) ## 4. Standalone UpdateAgent (`update-agent`)
+10 -3
View File
@@ -33,23 +33,30 @@ $action = resolveLicenseAction();
switch ($action) { switch ($action) {
// WICHTIG: Die Lizenz-Endpunkte antworten bewusst OHNE den
// status/error-Umschlag der uebrigen API. Ihr Format ist ein bereits
// ausgerollter Vertrag: das Feld "status" auf oberster Ebene traegt den
// Lizenzzustand (valid, revoked, expired, not_found, activation_limit).
// Ein Umschlag mit status=success wuerde von jedem bestehenden Client als
// "nicht valid" interpretiert - saemtliche Lizenzen gaelten als ungueltig.
case 'validate': case 'validate':
if (Http::method() !== 'POST') { if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.'); Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
} }
// Bewusst oeffentlich: Client-Anwendungen pruefen hier ihre Lizenz. // Bewusst oeffentlich: Client-Anwendungen pruefen hier ihre Lizenz.
// Die Antwort verraet nichts ueber fremde Lizenzen. // Die Antwort verraet nichts ueber fremde Lizenzen.
Http::ok(['result' => $service->validate(Http::body(), $ip)]); Http::raw($service->validate(Http::body(), $ip));
case 'deactivate': case 'deactivate':
if (Http::method() !== 'POST') { if (Http::method() !== 'POST') {
Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.'); Http::fail(405, 'method_not_allowed', 'Diese Aktion erwartet POST.');
} }
ApiAuth::requireScope($db, 'license:deactivate'); ApiAuth::requireScope($db, 'license:deactivate');
Http::ok(['result' => $service->deactivate(Http::body(), $ip)]); Http::raw($service->deactivate(Http::body(), $ip));
case 'status': case 'status':
Http::ok(['module' => 'license', 'version' => '2.0']); Http::raw(['status' => 'ok', 'module' => 'license', 'version' => '2.0']);
default: default:
Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [ Http::fail(404, 'unknown_action', 'Endpunkt nicht gefunden.', null, [
+25
View File
@@ -101,6 +101,31 @@ final class Http
self::send(['status' => 'error', 'error' => $error], $status); self::send(['status' => 'error', 'error' => $error], $status);
} }
/**
* Sendet eine Antwort ohne den status/error-Umschlag.
*
* Fuer Schnittstellen mit bereits ausgerollten Konsumenten, deren Format
* feststeht. Konkret die Lizenzpruefung: dort liegt das Feld "status" auf
* oberster Ebene und traegt den Lizenzzustand (valid, revoked, expired ...).
* Ein Umschlag mit status=success wuerde von jedem bestehenden Client als
* "nicht valid" gelesen - die Lizenz gaelte damit ueberall als ungueltig.
*/
public static function raw(array $payload, int $status = 200): void
{
if (!headers_sent()) {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
}
$json = json_encode(
$payload,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE
);
echo $json === false ? '{"status":"error","message":"Antwort nicht kodierbar."}' : $json;
exit;
}
private static function send(array $payload, int $status): void private static function send(array $payload, int $status): void
{ {
if (!headers_sent()) { if (!headers_sent()) {