feat(lifecycle): expose diagnostics and notify failures
CI / validate (pull_request) Successful in 25m57s
CI / validate (pull_request) Successful in 25m57s
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]")];
|
||||
|
||||
Reference in New Issue
Block a user