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.
104 lines
3.6 KiB
Go
104 lines
3.6 KiB
Go
package plugin
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
|
|
"github.com/alexei/tinyforge/internal/events"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// maxDeployStatusRunes bounds the persisted status. This is a defense-in-depth
|
|
// BACKSTOP, not a sanitizer.
|
|
//
|
|
// CALLER CONTRACT: deploy events are persisted indefinitely, rendered in the
|
|
// per-app timeline, AND egress off-box — error-severity events are forwarded
|
|
// to the global NotificationURL (cmd/server) and to operator-configured
|
|
// event-trigger webhooks (internal/events/dispatcher). Callers MUST therefore
|
|
// keep secrets and raw subprocess output (e.g. `docker compose` combined
|
|
// stderr, which can echo the deployed app's own secret-bearing logs) OUT of
|
|
// `status`; emit a curated, secret-free reason and keep verbose detail only in
|
|
// the returned error (server logs + admin deploy result, neither of which
|
|
// egresses). The cap below merely bounds blast radius if something slips
|
|
// through — 256 runes keeps a meaningful reason without letting a status
|
|
// become an unbounded sink.
|
|
const maxDeployStatusRunes = 256
|
|
|
|
// capDeployStatus truncates s to maxDeployStatusRunes runes, appending an
|
|
// ellipsis when it had to cut. Operating on the rune slice keeps the cut on
|
|
// a UTF-8 boundary so multibyte output can't be sliced mid-rune.
|
|
func capDeployStatus(s string) string {
|
|
runes := []rune(s)
|
|
if len(runes) <= maxDeployStatusRunes {
|
|
return s
|
|
}
|
|
return string(runes[:maxDeployStatusRunes]) + "…"
|
|
}
|
|
|
|
// EmitDeployEvent records a workload-scoped deploy event in the event log
|
|
// and publishes it on the bus. Best-effort: logs and returns on failure,
|
|
// never blocks or fails the deploy. `source` is the per-kind event source
|
|
// string ("image","compose","static_site","dockerfile"); `status` is a
|
|
// short human status ("deploying","deployed","failed: <reason>").
|
|
//
|
|
// The metadata always carries workload_id so the per-app activity timeline
|
|
// can be reconstructed even by consumers that only read the JSON blob, and
|
|
// the dedicated workload_id column powers the indexed per-workload query.
|
|
func EmitDeployEvent(deps Deps, w Workload, source, status string) {
|
|
// Audit logging is best-effort and must never crash a real deploy. The
|
|
// production Deps always wires both, but guard so a missing bus/store
|
|
// (e.g. a narrow unit test) degrades to a no-op instead of a panic.
|
|
if deps.Store == nil || deps.Events == nil {
|
|
return
|
|
}
|
|
|
|
// Derive severity from the raw status prefix BEFORE capping, then bound
|
|
// the status that actually gets persisted/displayed/published.
|
|
severity := "info"
|
|
if strings.HasPrefix(status, "failed") {
|
|
severity = "error"
|
|
}
|
|
status = capDeployStatus(status)
|
|
message := fmt.Sprintf("%s: %s", w.Name, status)
|
|
|
|
metaBytes, err := json.Marshal(map[string]string{
|
|
"workload_id": w.ID,
|
|
"workload_name": w.Name,
|
|
"status": status,
|
|
})
|
|
if err != nil {
|
|
slog.Error("plugin: marshal deploy event metadata",
|
|
"source", source, "workload", w.ID, "error", err)
|
|
metaBytes = []byte("{}")
|
|
}
|
|
metadata := string(metaBytes)
|
|
|
|
evt, err := deps.Store.InsertEvent(store.EventLog{
|
|
Source: source,
|
|
Severity: severity,
|
|
Message: message,
|
|
Metadata: metadata,
|
|
WorkloadID: w.ID,
|
|
})
|
|
if err != nil {
|
|
slog.Error("plugin: failed to persist deploy event log",
|
|
"source", source, "workload", w.ID, "error", err)
|
|
return
|
|
}
|
|
|
|
deps.Events.Publish(events.Event{
|
|
Type: events.EventLog,
|
|
Payload: events.EventLogPayload{
|
|
ID: evt.ID,
|
|
Source: source,
|
|
WorkloadID: w.ID,
|
|
Severity: severity,
|
|
Message: message,
|
|
Metadata: metadata,
|
|
CreatedAt: evt.CreatedAt,
|
|
},
|
|
})
|
|
}
|