feat(lifecycle): expose diagnostics and notify failures
CI / validate (pull_request) Successful in 25m57s

This commit is contained in:
2026-08-15 18:52:03 +02:00
parent 6196542633
commit 06f99750ea
6 changed files with 187 additions and 2 deletions
+12
View File
@@ -51,9 +51,21 @@ type Diagnostic struct {
CreatedAt string
}
type OperationHistory struct {
OperationID string `json:"operation_id"`
Kind string `json:"kind"`
State string `json:"state"`
Phase string `json:"phase"`
ErrorCode string `json:"error_code,omitempty"`
CreatedAt string `json:"created_at"`
CompletedAt string `json:"completed_at,omitempty"`
Diagnostic *Diagnostic `json:"diagnostic,omitempty"`
}
type DiagnosticRepository interface {
RecordDiagnostic(context.Context, Diagnostic) error
ListDiagnostics(context.Context, string, int) ([]Diagnostic, error)
ListOperationHistory(context.Context, string, int) ([]OperationHistory, error)
}
type LifecycleRepository interface {
+8 -2
View File
@@ -275,6 +275,8 @@ func (s *Service) recipients(ctx context.Context, event Event) []string {
}
func categoryFor(typ string) string {
switch {
case strings.HasSuffix(typ, ".failed"):
return "server_error"
case strings.HasPrefix(typ, "start."):
return "server_start"
case strings.HasPrefix(typ, "stop."):
@@ -285,8 +287,6 @@ func categoryFor(typ string) string {
return "restore"
case strings.HasPrefix(typ, "update."):
return "update"
case strings.HasSuffix(typ, ".failed"):
return "server_error"
case strings.HasPrefix(typ, "installation_request."):
return "administration"
}
@@ -584,6 +584,12 @@ func renderText(e Event) string {
if e.Action != "" {
parts = append(parts, "Action: "+e.Action)
}
if e.OperationID != "" {
parts = append(parts, "Operation: "+e.OperationID)
}
if !e.Timestamp.IsZero() {
parts = append(parts, "Date: "+e.Timestamp.UTC().Format(time.RFC3339))
}
if e.Message != "" {
parts = append(parts, e.Message)
}
+84
View File
@@ -1,11 +1,14 @@
package notification_test
import (
"bufio"
"bytes"
"context"
"net"
"path/filepath"
"strings"
"testing"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/notification"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/persistence/sqlite"
@@ -39,6 +42,87 @@ func TestChannelSecretsAreEncryptedAndWriteOnly(t *testing.T) {
}
}
func TestSMTPFailureNotificationIncludesLifecycleContext(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.ExecContext(ctx, `INSERT INTO users(id,username,email,password_hash,global_role,created_at) VALUES('admin','admin','admin@example.test','x','admin','2026-01-01T00:00:00Z')`); err != nil {
t.Fatal(err)
}
s, _ := notification.New(db, bytes.Repeat([]byte{5}, 32))
if err := s.SetPreferences(ctx, "admin", map[string]bool{"server_error": true}); err != nil {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
mail := make(chan string, 1)
go func() {
c, _ := listener.Accept()
if c == nil {
return
}
defer c.Close()
_, _ = c.Write([]byte("220 test\r\n"))
scanner := bufio.NewScanner(c)
var body strings.Builder
data := false
for scanner.Scan() {
line := scanner.Text()
if data {
if line == "." {
mail <- body.String()
_, _ = c.Write([]byte("250 queued\r\n"))
data = false
} else {
body.WriteString(line + "\n")
}
continue
}
switch {
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"), strings.HasPrefix(line, "MAIL FROM"), strings.HasPrefix(line, "RCPT TO"):
_, _ = c.Write([]byte("250 ok\r\n"))
case line == "DATA":
data = true
_, _ = c.Write([]byte("354 data\r\n"))
case line == "QUIT":
_, _ = c.Write([]byte("221 bye\r\n"))
return
}
}
}()
host, port, _ := net.SplitHostPort(listener.Addr().String())
if _, err := s.Upsert(ctx, "", notification.Input{Name: "smtp", Type: "email", Enabled: true, Events: []string{"start.failed"}, Config: map[string]string{"host": host, "port": port, "from": "dogama@example.test", "tls_mode": "none"}}); err != nil {
t.Fatal(err)
}
e := notification.Event{Type: "start.failed", Title: "Instance failed", Message: "Code: DGM-START-001", Game: "Palworld", InstanceName: "Broken", Action: "start", OperationID: "operation-42", Timestamp: time.Now().UTC()}
if err := s.Queue(ctx, e); err != nil {
t.Fatal(err)
}
if err := s.RunDue(ctx); err != nil {
t.Fatal(err)
}
var deliveryStatus, deliveryError string
if err := db.QueryRowContext(ctx, `SELECT status,last_error_code FROM notification_deliveries`).Scan(&deliveryStatus, &deliveryError); err != nil {
t.Fatal(err)
}
select {
case body := <-mail:
for _, want := range []string{"Palworld", "Broken", "operation-42", "DGM-START-001", "Date:"} {
if !strings.Contains(body, want) {
t.Fatalf("mail missing %q: %s", want, body)
}
}
case <-time.After(time.Second):
t.Fatalf("mail not sent; delivery=%s error=%s", deliveryStatus, deliveryError)
}
}
func TestDeliveryBlocksPrivateWebhookAndRetriesWithRedactedError(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "dogama.db"))
+24
View File
@@ -381,6 +381,30 @@ func (r *Repository) ListDiagnostics(ctx context.Context, instanceID string, lim
return result, nil
}
func (r *Repository) ListOperationHistory(ctx context.Context, instanceID string, limit int) ([]instance.OperationHistory, error) {
if limit < 1 || limit > 50 {
limit = 20
}
rows, err := r.db.QueryContext(ctx, `SELECT o.id,o.kind,o.state,o.phase,COALESCE(o.error_code,''),o.created_at,COALESCE(o.completed_at,''),COALESCE(d.step,''),COALESCE(d.details,'') FROM instance_operations o LEFT JOIN operation_diagnostics d ON d.operation_id=o.id WHERE o.instance_id=? ORDER BY o.created_at DESC LIMIT ?`, instanceID, limit)
if err != nil {
return nil, fmt.Errorf("list operation history: %w", err)
}
defer rows.Close()
var out []instance.OperationHistory
for rows.Next() {
var v instance.OperationHistory
var step, details string
if err := rows.Scan(&v.OperationID, &v.Kind, &v.State, &v.Phase, &v.ErrorCode, &v.CreatedAt, &v.CompletedAt, &step, &details); err != nil {
return nil, fmt.Errorf("scan operation history: %w", err)
}
if details != "" {
v.Diagnostic = &instance.Diagnostic{OperationID: v.OperationID, InstanceID: instanceID, Step: step, ErrorCode: v.ErrorCode, Details: details, CreatedAt: v.CreatedAt}
}
out = append(out, v)
}
return out, rows.Err()
}
func (r *Repository) UpdateObservation(ctx context.Context, instanceID, lifecycleState, observedState, containerID string, desiredRunning bool, errorCode string) error {
desired := 0
if desiredRunning {
+35
View File
@@ -301,6 +301,7 @@ func newHandlerServicesWithCatalog(authService *auth.Service, repository reposit
mux.HandleFunc("PUT /api/v1/instances/{id}/memberships/{userID}/permissions/{permission}", s.permissionOverrideSet)
mux.HandleFunc("DELETE /api/v1/instances/{id}/memberships/{userID}/permissions/{permission}", s.permissionOverrideDelete)
if lifecycle != nil {
mux.HandleFunc("GET /api/v1/instances/{id}/diagnostics", s.instanceDiagnostics)
mux.HandleFunc("GET /api/v1/instances/{id}", s.instanceInspect)
mux.HandleFunc("GET /api/v1/instances/{id}/stats", s.instanceStats)
mux.HandleFunc("POST /api/v1/instances/{id}/install", s.instanceInstall)
@@ -911,6 +912,35 @@ func (s *server) instanceStats(w http.ResponseWriter, r *http.Request) {
s.apiJSON(w, http.StatusOK, stats)
}
// instanceDiagnostics is deliberately administrator-only. The operation
// history contains Docker details that must never be made available merely to
// an instance member.
func (s *server) instanceDiagnostics(w http.ResponseWriter, r *http.Request) {
user, ok := s.requireAPIUser(w, r, false)
if !ok {
return
}
if user.Role != "admin" {
s.apiProblem(w, http.StatusForbidden, "diagnostics_forbidden", "Diagnostics are restricted to administrators.")
return
}
repository, ok := s.repository.(instance.DiagnosticRepository)
if !ok {
s.apiProblem(w, http.StatusServiceUnavailable, "diagnostics_unavailable", "Diagnostics are unavailable.")
return
}
if _, err := s.repository.GetInstance(r.Context(), r.PathValue("id")); err != nil {
s.lifecycleProblem(w, err)
return
}
history, err := repository.ListOperationHistory(r.Context(), r.PathValue("id"), 20)
if err != nil {
s.apiProblem(w, http.StatusInternalServerError, "diagnostics_unavailable", "Diagnostics are unavailable.")
return
}
s.apiJSON(w, http.StatusOK, map[string]any{"operations": history})
}
func (s *server) instanceInstall(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAPIUser(w, r, true)
if !ok {
@@ -995,6 +1025,11 @@ func (s *server) runLifecycleAction(w http.ResponseWriter, r *http.Request, acti
}
result, err := action(r.Context(), r.PathValue("id"))
if err != nil {
code := "DGM-DOCKER-001"
if strings.HasPrefix(err.Error(), "DGM-") {
code = strings.SplitN(err.Error(), ":", 2)[0]
}
s.queueNotification(r, notification.Event{Type: "start.failed", Title: "Instance lifecycle action failed", Message: "The instance operation failed. Code: " + code, Action: "start", OperationID: result.OperationID, Severity: "error"})
s.lifecycleProblem(w, err)
return
}
+24
View File
@@ -37,6 +37,30 @@ if (deploymentForm) {
}, { once: true });
}
// The backend, not a timer, is the source for this history. A 403 simply
// means the signed-in user is not an administrator and leaves no technical
// data in the DOM.
const detailMatch = window.location.pathname.match(/^\/instances\/([^/]+)$/);
if (detailMatch) {
fetch(`/api/v1/instances/${encodeURIComponent(detailMatch[1])}/diagnostics`, { credentials: "same-origin" })
.then((response) => response.ok ? response.json() : null)
.then((data) => {
if (!data || !data.operations || !data.operations.length) return;
const section = document.createElement("section");
section.className = "panel";
section.innerHTML = "<h2>Operation diagnostics</h2><p class=muted>Administrator-only technical history.</p>";
data.operations.forEach((operation) => {
const item = document.createElement("details");
const summary = document.createElement("summary");
summary.textContent = `${operation.created_at} · ${operation.kind} · ${operation.state}${operation.error_code ? ` · ${operation.error_code}` : ""} · ${operation.operation_id}`;
item.append(summary);
if (operation.diagnostic) { const detail = document.createElement("pre"); detail.textContent = operation.diagnostic.details; item.append(detail); }
section.append(item);
});
document.querySelector(".instance-detail-lower")?.append(section);
}).catch(() => {});
}
const catalogSearch = document.querySelector("#catalog-search");
if (catalogSearch) {
const cards = [...document.querySelectorAll("[data-catalog-card]")];