93b6911b34
Every deploy across all four source kinds now writes a workload-scoped
event via a shared plugin.EmitDeployEvent helper (replacing the inline
emit duplicated in static/dockerfile, standardizing static's metadata
key site_id->workload_id, and adding emission to image+compose which
were silent). New indexed event_log.workload_id column, EventLogFilter
.WorkloadID, and GET /api/workloads/{id}/events (id pinned from path).
Frontend: a forge "Activity" panel on /apps/[id] reusing EventLogEntry,
live SSE prepend filtered by workload_id, load-more pagination, an
All/Errors severity filter, and a shared toEventLogEntry mapper. en/ru
i18n parity.
Security: compose's failure status emits a generic reason instead of raw
`docker compose up` output, which can echo app secrets and egresses to
operator webhooks (NotificationURL + event-trigger actions); full detail
stays only in the returned error. Rune-safe 256-rune status cap.
Reviewed: go + typescript APPROVE; security HIGH fixed.
168 lines
5.4 KiB
Go
168 lines
5.4 KiB
Go
package plugin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"unicode/utf8"
|
|
|
|
"github.com/alexei/tinyforge/internal/events"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// capturePublisher records every event published on it so a test can
|
|
// assert on the bus payload. Satisfies plugin.EventPublisher.
|
|
type capturePublisher struct {
|
|
events []events.Event
|
|
}
|
|
|
|
func (c *capturePublisher) Publish(evt events.Event) {
|
|
c.events = append(c.events, evt)
|
|
}
|
|
|
|
// newEmitDeps builds a plugin.Deps backed by an in-memory store and a
|
|
// capturing publisher. Mirrors the in-memory store pattern used by the
|
|
// store + source-plugin tests.
|
|
func newEmitDeps(t *testing.T) (Deps, *capturePublisher) {
|
|
t.Helper()
|
|
st, err := store.New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = st.Close() })
|
|
pub := &capturePublisher{}
|
|
return Deps{Store: st, Events: pub}, pub
|
|
}
|
|
|
|
func TestEmitDeployEvent(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status string
|
|
wantSeverity string
|
|
}{
|
|
{name: "deployed is info", status: "deployed", wantSeverity: "info"},
|
|
{name: "deploying is info", status: "deploying", wantSeverity: "info"},
|
|
{name: "failed is error", status: "failed: pull foo failed", wantSeverity: "error"},
|
|
{name: "failed bare is error", status: "failed", wantSeverity: "error"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
deps, pub := newEmitDeps(t)
|
|
w := Workload{ID: "wl-123", Name: "my-app"}
|
|
|
|
EmitDeployEvent(deps, w, "image", tt.status)
|
|
|
|
// Persisted row carries the workload scope + derived severity.
|
|
rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListEvents: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("got %d persisted events, want 1", len(rows))
|
|
}
|
|
got := rows[0]
|
|
if got.Severity != tt.wantSeverity {
|
|
t.Errorf("severity = %q, want %q", got.Severity, tt.wantSeverity)
|
|
}
|
|
if got.Source != "image" {
|
|
t.Errorf("source = %q, want %q", got.Source, "image")
|
|
}
|
|
if got.WorkloadID != w.ID {
|
|
t.Errorf("workload_id = %q, want %q", got.WorkloadID, w.ID)
|
|
}
|
|
wantMsg := w.Name + ": " + tt.status
|
|
if got.Message != wantMsg {
|
|
t.Errorf("message = %q, want %q", got.Message, wantMsg)
|
|
}
|
|
|
|
// Metadata JSON carries workload_id / workload_name / status.
|
|
var meta map[string]string
|
|
if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil {
|
|
t.Fatalf("unmarshal metadata %q: %v", got.Metadata, err)
|
|
}
|
|
if meta["workload_id"] != w.ID {
|
|
t.Errorf("metadata workload_id = %q, want %q", meta["workload_id"], w.ID)
|
|
}
|
|
if meta["workload_name"] != w.Name {
|
|
t.Errorf("metadata workload_name = %q, want %q", meta["workload_name"], w.Name)
|
|
}
|
|
if meta["status"] != tt.status {
|
|
t.Errorf("metadata status = %q, want %q", meta["status"], tt.status)
|
|
}
|
|
|
|
// The persisted row is also re-published on the bus as an
|
|
// EventLog so SSE clients see it live.
|
|
if len(pub.events) != 1 {
|
|
t.Fatalf("got %d published events, want 1", len(pub.events))
|
|
}
|
|
ev := pub.events[0]
|
|
if ev.Type != events.EventLog {
|
|
t.Errorf("event type = %q, want %q", ev.Type, events.EventLog)
|
|
}
|
|
payload, ok := ev.Payload.(events.EventLogPayload)
|
|
if !ok {
|
|
t.Fatalf("payload type = %T, want events.EventLogPayload", ev.Payload)
|
|
}
|
|
if payload.WorkloadID != w.ID {
|
|
t.Errorf("payload workload_id = %q, want %q", payload.WorkloadID, w.ID)
|
|
}
|
|
if payload.Severity != tt.wantSeverity {
|
|
t.Errorf("payload severity = %q, want %q", payload.Severity, tt.wantSeverity)
|
|
}
|
|
if payload.ID != got.ID {
|
|
t.Errorf("payload id = %d, want %d", payload.ID, got.ID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmitDeployEvent_CapsLongStatus verifies a long failure status (e.g. one
|
|
// embedding raw subprocess output) is bounded to maxDeployStatusRunes runes in
|
|
// both the persisted message and metadata, cut on a UTF-8 boundary, while
|
|
// severity is still derived from the original "failed" prefix.
|
|
func TestEmitDeployEvent_CapsLongStatus(t *testing.T) {
|
|
deps, pub := newEmitDeps(t)
|
|
w := Workload{ID: "wl-cap", Name: "app"}
|
|
|
|
// Multibyte body so a naive byte-slice would corrupt a rune; prefix with
|
|
// "failed: " so the severity check exercises the pre-cap derivation.
|
|
longStatus := "failed: " + strings.Repeat("é", 400)
|
|
EmitDeployEvent(deps, w, "compose", longStatus)
|
|
|
|
rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListEvents: %v", err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("got %d events, want 1", len(rows))
|
|
}
|
|
got := rows[0]
|
|
|
|
if got.Severity != "error" {
|
|
t.Errorf("severity = %q, want error (derived from pre-cap prefix)", got.Severity)
|
|
}
|
|
|
|
var meta map[string]string
|
|
if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil {
|
|
t.Fatalf("unmarshal metadata: %v", err)
|
|
}
|
|
capped := meta["status"]
|
|
if rc := len([]rune(capped)); rc != maxDeployStatusRunes+1 { // +1 for the ellipsis rune
|
|
t.Errorf("capped status = %d runes, want %d", rc, maxDeployStatusRunes+1)
|
|
}
|
|
if !utf8.ValidString(capped) {
|
|
t.Errorf("capped status is not valid UTF-8: %q", capped)
|
|
}
|
|
if !strings.HasSuffix(capped, "…") {
|
|
t.Errorf("capped status missing ellipsis suffix: %q", capped)
|
|
}
|
|
wantMsg := w.Name + ": " + capped
|
|
if got.Message != wantMsg {
|
|
t.Errorf("message = %q, want %q", got.Message, wantMsg)
|
|
}
|
|
if len(pub.events) != 1 {
|
|
t.Fatalf("got %d published events, want 1", len(pub.events))
|
|
}
|
|
}
|