implemented NATS event bus, websocket hub upgrade, and audit log
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// EventBus is the platform event bus interface.
|
||||
// Publish sends a typed payload to the given subject.
|
||||
// Subscribe registers a handler for a subject pattern (supports NATS wildcards).
|
||||
// The returned func() unsubscribes when called.
|
||||
type EventBus interface {
|
||||
Publish(subject string, payload any) error
|
||||
Subscribe(subject string, handler func(subject string, data []byte)) (func(), error)
|
||||
Close()
|
||||
}
|
||||
|
||||
// NATSBus is the NATS-backed EventBus. Events are published to core NATS subjects.
|
||||
// Phase 2A uses core NATS (ephemeral). Phase 2B will add JetStream for CI durability.
|
||||
type NATSBus struct {
|
||||
nc *nats.Conn
|
||||
}
|
||||
|
||||
func NewNATSBus(url string) (*NATSBus, error) {
|
||||
nc, err := nats.Connect(url,
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2*time.Second),
|
||||
nats.DisconnectErrHandler(func(_ *nats.Conn, err error) {
|
||||
if err != nil {
|
||||
log.Printf("nats: disconnected: %v", err)
|
||||
}
|
||||
}),
|
||||
nats.ReconnectHandler(func(_ *nats.Conn) {
|
||||
log.Printf("nats: reconnected")
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nats connect %s: %w", url, err)
|
||||
}
|
||||
log.Printf("nats: connected to %s", url)
|
||||
return &NATSBus{nc: nc}, nil
|
||||
}
|
||||
|
||||
func (b *NATSBus) Publish(subject string, payload any) error {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
return b.nc.Publish(subject, data)
|
||||
}
|
||||
|
||||
func (b *NATSBus) Subscribe(subject string, handler func(subject string, data []byte)) (func(), error) {
|
||||
sub, err := b.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
handler(msg.Subject, msg.Data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() { sub.Unsubscribe() }, nil //nolint:errcheck
|
||||
}
|
||||
|
||||
func (b *NATSBus) Close() {
|
||||
if err := b.nc.Drain(); err != nil {
|
||||
log.Printf("nats: drain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// NoOpBus is a no-op EventBus used when NATS_URL is not configured.
|
||||
// Events are silently dropped. The app works normally; just no real-time push.
|
||||
type NoOpBus struct{}
|
||||
|
||||
func (NoOpBus) Publish(_ string, _ any) error { return nil }
|
||||
func (NoOpBus) Subscribe(_ string, _ func(string, []byte)) (func(), error) { return func() {}, nil }
|
||||
func (NoOpBus) Close() {}
|
||||
|
||||
// New returns a NATSBus if url is non-empty, otherwise a NoOpBus.
|
||||
func New(url string) (EventBus, error) {
|
||||
if url == "" {
|
||||
log.Printf("events: NATS_URL not set — using no-op bus (real-time push disabled)")
|
||||
return NoOpBus{}, nil
|
||||
}
|
||||
return NewNATSBus(url)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package events
|
||||
|
||||
// Subject constants for all platform events.
|
||||
// Wildcards: ">" matches one or more tokens, "*" matches exactly one token.
|
||||
const (
|
||||
// Repository lifecycle
|
||||
SubjectRepoCreated = "repo.created"
|
||||
SubjectRepoDeleted = "repo.deleted"
|
||||
SubjectRepoUpdated = "repo.updated"
|
||||
|
||||
// Git push
|
||||
SubjectPushReceived = "push.received"
|
||||
|
||||
// Pull requests
|
||||
SubjectPROpened = "pr.opened"
|
||||
SubjectPRMerged = "pr.merged"
|
||||
SubjectPRClosed = "pr.closed"
|
||||
SubjectPRReopened = "pr.reopened"
|
||||
SubjectPRUpdated = "pr.updated"
|
||||
|
||||
// Issues
|
||||
SubjectIssueOpened = "issue.opened"
|
||||
SubjectIssueClosed = "issue.closed"
|
||||
SubjectIssueReopened = "issue.reopened"
|
||||
|
||||
// CI/CD (Phase 2B)
|
||||
SubjectPipelineTriggered = "pipeline.triggered"
|
||||
SubjectPipelineStarted = "pipeline.started"
|
||||
SubjectPipelineCompleted = "pipeline.completed"
|
||||
SubjectPipelineFailed = "pipeline.failed"
|
||||
SubjectJobQueued = "job.queued"
|
||||
SubjectJobStarted = "job.started"
|
||||
SubjectJobCompleted = "job.completed"
|
||||
SubjectJobFailed = "job.failed"
|
||||
SubjectArtifactPublished = "artifact.published"
|
||||
|
||||
// Deployments (Phase 3A)
|
||||
SubjectDeploymentStarted = "deployment.started"
|
||||
SubjectDeploymentSucceeded = "deployment.succeeded"
|
||||
SubjectDeploymentFailed = "deployment.failed"
|
||||
SubjectDeploymentRolledBack = "deployment.rolled_back"
|
||||
|
||||
// Environments (Phase 3D)
|
||||
SubjectEnvironmentDriftDetected = "environment.drift_detected"
|
||||
|
||||
// Audit
|
||||
SubjectAuditEvent = "audit.event"
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
package events
|
||||
|
||||
import "time"
|
||||
|
||||
type RepoEvent struct {
|
||||
RepoID int64 `json:"repoId"`
|
||||
RepoName string `json:"repoName"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
ActorID int64 `json:"actorId"`
|
||||
ActorName string `json:"actorName"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type PushEvent struct {
|
||||
RepoID int64 `json:"repoId"`
|
||||
RepoName string `json:"repoName"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
Ref string `json:"ref"`
|
||||
Before string `json:"before"`
|
||||
After string `json:"after"`
|
||||
Pusher string `json:"pusher"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type PREvent struct {
|
||||
PRID int64 `json:"prId"`
|
||||
RepoID int64 `json:"repoId"`
|
||||
RepoName string `json:"repoName"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
Title string `json:"title"`
|
||||
SourceBranch string `json:"sourceBranch"`
|
||||
TargetBranch string `json:"targetBranch"`
|
||||
AuthorID int64 `json:"authorId"`
|
||||
AuthorName string `json:"authorName"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type IssueEvent struct {
|
||||
IssueID int64 `json:"issueId"`
|
||||
RepoID int64 `json:"repoId"`
|
||||
RepoName string `json:"repoName"`
|
||||
OwnerName string `json:"ownerName"`
|
||||
Title string `json:"title"`
|
||||
AuthorID int64 `json:"authorId"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
ActorID int64 `json:"actorId"`
|
||||
ActorName string `json:"actorName"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourcePath string `json:"resourcePath"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
// WSEnvelope wraps any event for delivery over the WebSocket connection.
|
||||
type WSEnvelope struct {
|
||||
Subject string `json:"subject"`
|
||||
Payload []byte `json:"payload"`
|
||||
}
|
||||
Reference in New Issue
Block a user