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: "). // // 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, }, }) }