check / check (push) Waiting to run
The receiver, resubmit and replay handlers now read and check the request and make one call. Engine.StoreEvent stores an event with a pending delivery for each active target and the totals in one transaction, then queues them; Engine.CreateReplay does the same for one replay delivery. The handlers take *delivery.Engine, so the Notifier interface and its bridge are gone, and the engine's notify is no longer exported. Archives and CircuitBreakers stay because the handler tests fake them. The tests that read a queued task moved to the engine's tests; the handler tests use an engine that is never started. Model: opus-5-5
475 lines
13 KiB
Go
475 lines
13 KiB
Go
package delivery
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"go.uber.org/fx"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/archive"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/metrics"
|
|
)
|
|
|
|
// Exported constants for test access.
|
|
const (
|
|
ExportDeliveryChannelSize = deliveryChannelSize
|
|
ExportRetryChannelSize = retryChannelSize
|
|
ExportDefaultFailureThreshold = defaultFailureThreshold
|
|
ExportDefaultCooldown = defaultCooldown
|
|
|
|
// ExportMaxBodyLog is the cap the engine applies to a
|
|
// recorded response body. The event log's handling of a cut
|
|
// response is written against this number, so a test has to
|
|
// be able to name it.
|
|
ExportMaxBodyLog = maxBodyLog
|
|
|
|
// ExportPendingSweepMinAge is how long a delivery must sit at
|
|
// pending before the sweep treats it as stranded. A test has to
|
|
// name it to age a row past the bound.
|
|
ExportPendingSweepMinAge = pendingSweepMinAge
|
|
)
|
|
|
|
// NewTestGuard builds an SSRF Guard from an explicit egress
|
|
// allowlist, without going through config. Passing no prefixes
|
|
// yields the default guard, which blocks every private/reserved
|
|
// range.
|
|
func NewTestGuard(allowed ...netip.Prefix) *Guard {
|
|
return &Guard{allowed: allowed}
|
|
}
|
|
|
|
// ExportCheckIP exposes the guard's single decision point, so a
|
|
// test can assert the policy both the validator and the dialer
|
|
// inherit without needing a live destination.
|
|
func (g *Guard) ExportCheckIP(ip net.IP) error {
|
|
return g.checkIP(ip)
|
|
}
|
|
|
|
// ExportAlwaysBlockedNetworks exposes alwaysBlockedNetworks.
|
|
func ExportAlwaysBlockedNetworks() []*net.IPNet {
|
|
return alwaysBlockedNetworks
|
|
}
|
|
|
|
// ExportBlockedNetworks exposes blockedNetworks.
|
|
func ExportBlockedNetworks() []*net.IPNet {
|
|
return blockedNetworks
|
|
}
|
|
|
|
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
|
|
func ExportBlockedPublicNetworks() []*net.IPNet {
|
|
return blockedPublicNetworks
|
|
}
|
|
|
|
// ExportIsForwardableHeader exposes isForwardableHeader.
|
|
func ExportIsForwardableHeader(name string) bool {
|
|
return isForwardableHeader(name)
|
|
}
|
|
|
|
// ExportApplyRequestHeaders exposes applyRequestHeaders, so a test
|
|
// can inspect the header set an outbound delivery actually carries
|
|
// and the origin-scoped names it reports for the redirect policy.
|
|
func ExportApplyRequestHeaders(
|
|
req *http.Request,
|
|
event *database.Event,
|
|
configured map[string]string,
|
|
userAgent string,
|
|
) []string {
|
|
return applyRequestHeaders(req, event, configured, userAgent)
|
|
}
|
|
|
|
// ExportTruncate exposes truncate for testing.
|
|
func ExportTruncate(s string, maxLen int) string {
|
|
return truncate(s, maxLen)
|
|
}
|
|
|
|
// ExportDeliverHTTP delivers via the http target for testing.
|
|
func (e *Engine) ExportDeliverHTTP(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
task *Task,
|
|
) {
|
|
e.httpTarget.Deliver(ctx, webhookDB, d, task, e)
|
|
}
|
|
|
|
// ExportDeliverHTTPWithScheduler delivers via the http target, handing
|
|
// any retry to sched instead of the engine, so a test can see the
|
|
// delay each retry is given.
|
|
func (e *Engine) ExportDeliverHTTPWithScheduler(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
task *Task,
|
|
sched Scheduler,
|
|
) {
|
|
e.httpTarget.Deliver(ctx, webhookDB, d, task, sched)
|
|
}
|
|
|
|
// ExportDeliverDatabase delivers via the database target.
|
|
func (e *Engine) ExportDeliverDatabase(
|
|
webhookDB *gorm.DB, d *database.Delivery,
|
|
) {
|
|
e.targets[database.TargetTypeDatabase].Deliver(
|
|
context.Background(), webhookDB, d, &Task{}, e,
|
|
)
|
|
}
|
|
|
|
// ExportDeliverLog delivers via the log target for testing.
|
|
func (e *Engine) ExportDeliverLog(
|
|
webhookDB *gorm.DB, d *database.Delivery,
|
|
) {
|
|
e.targets[database.TargetTypeLog].Deliver(
|
|
context.Background(), webhookDB, d, &Task{}, e,
|
|
)
|
|
}
|
|
|
|
// ExportDeliverSlack delivers via the slack target for
|
|
// testing.
|
|
func (e *Engine) ExportDeliverSlack(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
) {
|
|
task := &Task{
|
|
DeliveryID: d.ID,
|
|
TargetID: d.TargetID,
|
|
MaxAttempts: d.Target.MaxAttempts,
|
|
AttemptNum: 1,
|
|
}
|
|
|
|
e.targets[database.TargetTypeSlack].Deliver(
|
|
ctx, webhookDB, d, task, e,
|
|
)
|
|
}
|
|
|
|
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
|
// target type, so no metric moves.
|
|
func (e *Engine) ExportUpdateDeliveryStatus(
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
status database.DeliveryStatus,
|
|
) error {
|
|
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
|
}
|
|
|
|
// ExportNotify exposes notify.
|
|
func (e *Engine) ExportNotify(tasks []Task) {
|
|
e.notify(tasks)
|
|
}
|
|
|
|
// ExportProcessNewTask exposes processNewTask.
|
|
func (e *Engine) ExportProcessNewTask(
|
|
ctx context.Context, task *Task,
|
|
) {
|
|
e.processNewTask(ctx, task)
|
|
}
|
|
|
|
// ExportProcessRetryTask exposes processRetryTask.
|
|
func (e *Engine) ExportProcessRetryTask(
|
|
ctx context.Context, task *Task,
|
|
) {
|
|
e.processRetryTask(ctx, task)
|
|
}
|
|
|
|
// ExportEventForTask exposes the event reconstruction the delivery
|
|
// paths run: buildEventFromTask followed by hydrateEvent.
|
|
func (e *Engine) ExportEventForTask(
|
|
webhookDB *gorm.DB, task *Task,
|
|
) (database.Event, error) {
|
|
return e.hydrateEvent(
|
|
webhookDB, buildEventFromTask(task), task,
|
|
)
|
|
}
|
|
|
|
// ExportProcessDelivery exposes processDelivery.
|
|
func (e *Engine) ExportProcessDelivery(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
task *Task,
|
|
) {
|
|
e.processDelivery(ctx, webhookDB, d, task)
|
|
}
|
|
|
|
// ExportGetCircuitBreaker exposes the http target's
|
|
// getCircuitBreaker.
|
|
func (e *Engine) ExportGetCircuitBreaker(
|
|
targetID string,
|
|
) *CircuitBreaker {
|
|
return e.httpTarget.getCircuitBreaker(targetID)
|
|
}
|
|
|
|
// ExportSetCircuitBreaker makes cb the http target's circuit breaker
|
|
// for targetID, so a test can use one with a short cooldown.
|
|
func (e *Engine) ExportSetCircuitBreaker(
|
|
targetID string, cb *CircuitBreaker,
|
|
) {
|
|
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
|
}
|
|
|
|
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
|
|
// slack target.
|
|
func (e *Engine) ExportSetSlackCircuitBreaker(
|
|
targetID string, cb *CircuitBreaker,
|
|
) {
|
|
e.slackTarget.circuitBreakers.Store(targetID, cb)
|
|
}
|
|
|
|
// ExportDoHTTPRequest exposes the http target's
|
|
// doHTTPRequest.
|
|
func (e *Engine) ExportDoHTTPRequest(
|
|
ctx context.Context,
|
|
target *database.Target,
|
|
event *database.Event,
|
|
) (int, string, int64, error) {
|
|
return e.httpTarget.doHTTPRequest(ctx, target, event)
|
|
}
|
|
|
|
// ExportClientForRequest exposes the http target's
|
|
// clientForRequest.
|
|
func (e *Engine) ExportClientForRequest(
|
|
timeoutSeconds int,
|
|
originScoped []string,
|
|
) *http.Client {
|
|
return e.httpTarget.clientForRequest(timeoutSeconds, originScoped)
|
|
}
|
|
|
|
// ErrExportTooManyRedirects exposes the sentinel the redirect
|
|
// policy returns once a chain exceeds the hop cap. It carries the
|
|
// Err prefix rather than this file's usual Export one because it
|
|
// is a sentinel error.
|
|
var ErrExportTooManyRedirects = errTooManyRedirects
|
|
|
|
// ExportMaxDeliveryRedirects exposes the redirect hop cap.
|
|
const ExportMaxDeliveryRedirects = maxDeliveryRedirects
|
|
|
|
// ExportSameDeliveryOrigin exposes sameDeliveryOrigin.
|
|
func ExportSameDeliveryOrigin(origin, dest *url.URL) bool {
|
|
return sameDeliveryOrigin(origin, dest)
|
|
}
|
|
|
|
// ExportClient returns the http target's shared HTTP client.
|
|
func (e *Engine) ExportClient() *http.Client {
|
|
return e.httpTarget.client
|
|
}
|
|
|
|
// ExportScheduleRetry exposes ScheduleRetry.
|
|
func (e *Engine) ExportScheduleRetry(
|
|
task Task, delay time.Duration,
|
|
) {
|
|
e.ScheduleRetry(task, delay)
|
|
}
|
|
|
|
// ExportRecoverPendingDeliveries exposes
|
|
// recoverPendingDeliveries.
|
|
func (e *Engine) ExportRecoverPendingDeliveries(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
webhookID string,
|
|
atStart bool,
|
|
) {
|
|
e.recoverPendingDeliveries(
|
|
ctx, webhookDB, webhookID, atStart,
|
|
)
|
|
}
|
|
|
|
// ExportRecoverWebhookDeliveries exposes
|
|
// recoverWebhookDeliveries.
|
|
func (e *Engine) ExportRecoverWebhookDeliveries(
|
|
ctx context.Context, webhookID string, atStart bool,
|
|
) {
|
|
e.recoverWebhookDeliveries(ctx, webhookID, atStart)
|
|
}
|
|
|
|
// ExportRecoverDeliveries exposes recoverDeliveries, the recovery
|
|
// routine.
|
|
func (e *Engine) ExportRecoverDeliveries(
|
|
ctx context.Context, atStart bool,
|
|
) {
|
|
e.recoverDeliveries(ctx, atStart)
|
|
}
|
|
|
|
// ExportStart exposes start for testing.
|
|
func (e *Engine) ExportStart() {
|
|
e.start()
|
|
}
|
|
|
|
// ExportRegisterHooks registers the engine's real fx lifecycle
|
|
// hooks on a lifecycle supplied by a test, so a test can drive
|
|
// the exact OnStart/OnStop functions the application runs and
|
|
// hand OnStart the kind of context fx actually supplies.
|
|
func (e *Engine) ExportRegisterHooks(lc fx.Lifecycle) {
|
|
e.registerHooks(lc)
|
|
}
|
|
|
|
// ExportStop exposes stop for testing.
|
|
func (e *Engine) ExportStop(ctx context.Context) error {
|
|
return e.stop(ctx)
|
|
}
|
|
|
|
// ExportWedgeWorker adds a goroutine to the engine's WaitGroup
|
|
// that never observes cancellation and returns only when release
|
|
// is closed. It stands in for a worker stuck inside a delivery
|
|
// target that never returns, which is the only way stop can be
|
|
// made to outlast its context.
|
|
func (e *Engine) ExportWedgeWorker(release <-chan struct{}) {
|
|
e.wg.Go(func() {
|
|
<-release
|
|
})
|
|
}
|
|
|
|
// ExportInflightHeld reports how many deliveries the engine currently
|
|
// owns, so a test can prove ownership is released rather than leaked.
|
|
func (e *Engine) ExportInflightHeld() int {
|
|
return e.inflight.held()
|
|
}
|
|
|
|
// ExportRetainDelivery takes the first reference on a delivery, as the
|
|
// queueing side does. It lets a test put a delivery into the state a
|
|
// worker or a full channel would, without running the pool.
|
|
func (e *Engine) ExportRetainDelivery(deliveryID string) bool {
|
|
return e.inflight.retainIdle(deliveryID)
|
|
}
|
|
|
|
// ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries.
|
|
func (e *Engine) ExportRecoverRetryingDeliveries(
|
|
ctx context.Context, webhookDB *gorm.DB, webhookID string,
|
|
) {
|
|
e.recoverRetryingDeliveries(ctx, webhookDB, webhookID)
|
|
}
|
|
|
|
// ExportFailMissingTarget exposes failMissingTarget, so a test can hand
|
|
// it a delivery as a batch read it earlier.
|
|
func (e *Engine) ExportFailMissingTarget(
|
|
webhookDB *gorm.DB,
|
|
webhookID string,
|
|
d *database.Delivery,
|
|
) {
|
|
e.failMissingTarget(webhookDB, webhookID, d)
|
|
}
|
|
|
|
// ExportSendRecoveredDeliveries exposes sendRecoveredDeliveries, so a
|
|
// test can hand it a target map that lacks a delivery's target.
|
|
func (e *Engine) ExportSendRecoveredDeliveries(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
deliveries []database.Delivery,
|
|
webhookID string,
|
|
targetMap map[string]database.Target,
|
|
settled map[string]struct{},
|
|
) {
|
|
e.sendRecoveredDeliveries(
|
|
ctx, webhookDB, deliveries, webhookID, targetMap, settled,
|
|
)
|
|
}
|
|
|
|
// ExportDeliveryCh returns the delivery channel.
|
|
func (e *Engine) ExportDeliveryCh() chan Task {
|
|
return e.deliveryCh
|
|
}
|
|
|
|
// ExportRetryCh returns the retry channel.
|
|
func (e *Engine) ExportRetryCh() chan Task {
|
|
return e.retryCh
|
|
}
|
|
|
|
// NewTestEngine creates an Engine for unit tests without
|
|
// database dependencies. Its archive writers have no data
|
|
// directory, so a delivery to a database target fails.
|
|
func NewTestEngine(
|
|
log *slog.Logger,
|
|
client *http.Client,
|
|
workers int,
|
|
) *Engine {
|
|
e := &Engine{
|
|
log: log,
|
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
|
retryCh: make(chan Task, retryChannelSize),
|
|
workers: workers,
|
|
mtr: metrics.New(prometheus.NewRegistry()),
|
|
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
|
|
}
|
|
e.initTargets(client)
|
|
|
|
return e
|
|
}
|
|
|
|
// NewTestEngineSmallRetry creates an Engine with a tiny
|
|
// retry channel buffer for overflow testing.
|
|
func NewTestEngineSmallRetry(
|
|
log *slog.Logger,
|
|
) *Engine {
|
|
e := &Engine{
|
|
log: log,
|
|
retryCh: make(chan Task, 1),
|
|
mtr: metrics.New(prometheus.NewRegistry()),
|
|
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
|
|
}
|
|
e.initTargets(nil)
|
|
|
|
return e
|
|
}
|
|
|
|
// NewTestEngineWithDB creates an Engine with a real
|
|
// database and dbManager for integration tests, and archive
|
|
// writers of its own over the same two.
|
|
func NewTestEngineWithDB(
|
|
db *database.Database,
|
|
dbMgr *database.WebhookDBManager,
|
|
log *slog.Logger,
|
|
client *http.Client,
|
|
workers int,
|
|
) *Engine {
|
|
e := &Engine{
|
|
database: db,
|
|
dbManager: dbMgr,
|
|
log: log,
|
|
deliveryCh: make(chan Task, deliveryChannelSize),
|
|
retryCh: make(chan Task, retryChannelSize),
|
|
workers: workers,
|
|
mtr: metrics.New(prometheus.NewRegistry()),
|
|
archives: archive.NewWriters(archive.WritersParams{
|
|
Database: db,
|
|
DBManager: dbMgr,
|
|
Logger: log,
|
|
}),
|
|
}
|
|
e.initTargets(client)
|
|
|
|
return e
|
|
}
|
|
|
|
// ExportArchives returns the engine's archive writers.
|
|
func (e *Engine) ExportArchives() *archive.Writers {
|
|
return e.archives
|
|
}
|
|
|
|
// ExportSetMetrics substitutes the engine's metric set, so a test can
|
|
// assert on collectors registered on a registry it holds.
|
|
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
|
|
e.mtr = mtr
|
|
}
|
|
|
|
// ExportSampleQueueDepths runs one queue depth sample synchronously.
|
|
func (e *Engine) ExportSampleQueueDepths(ctx context.Context) {
|
|
e.sampleQueueDepths(ctx)
|
|
}
|
|
|
|
// NewTestCircuitBreaker creates a CircuitBreaker with
|
|
// custom settings for testing.
|
|
func NewTestCircuitBreaker(
|
|
threshold int, cooldown time.Duration,
|
|
) *CircuitBreaker {
|
|
return &CircuitBreaker{
|
|
state: CircuitClosed,
|
|
threshold: threshold,
|
|
cooldown: cooldown,
|
|
}
|
|
}
|