Documentation
¶
Overview ¶
Package subscription delivers Pando's events to the people who asked for them: webhooks and notification channels, retried until they arrive or plainly fail (issue #50, R-366 – R-374, design 11).
The service is what the API calls, and through it the CLI, MCP and console (R-261). The dispatcher is the loop that routes the outbox and sends. The router sends Pando's own notifications to the people they are for, as each person's preferences allow.
Index ¶
- Constants
- Variables
- func CheckURL(ctx context.Context, raw string, allowPrivate bool, resolver Resolver) (string, error)
- func Describe(e state.Event, app *EnvelopeApp) api.Notification
- func IsKind(kind string) bool
- func LinkFor(externalURL, appID string) string
- func Matching(subs []state.Subscription, e state.Event) []string
- func NewKey() (secret.Value, error)
- func Sign(key secret.Value, timestamp int64, body []byte) string
- func Verify(key secret.Value, header string, timestamp int64, body []byte, now time.Time, ...) bool
- type Actor
- type Apps
- type Authorizer
- type Channel
- type CreateRequest
- type Created
- type DeliveryDetail
- type Deployments
- type Destination
- type Dispatcher
- func (d *Dispatcher) AdapterHealth(maxAge time.Duration) []telemetry.AdapterHealth
- func (d *Dispatcher) CheckAdapters(ctx context.Context)
- func (d *Dispatcher) Pass(ctx context.Context) bool
- func (d *Dispatcher) Run(ctx context.Context)
- func (d *Dispatcher) WatchAdapters(ctx context.Context, interval time.Duration)
- type Envelope
- type EnvelopeApp
- type FeedItem
- type Groups
- type Inbox
- func (b *Inbox) List(ctx context.Context, p authz.Principal, unreadOnly bool, before string, ...) (InboxPage, error)
- func (b *Inbox) MarkAllRead(ctx context.Context, p authz.Principal) error
- func (b *Inbox) MarkRead(ctx context.Context, p authz.Principal, notificationID string) error
- func (b *Inbox) UnreadCount(ctx context.Context, p authz.Principal) (int, error)
- type InboxPage
- type Kind
- type ListRequest
- type Notifiers
- type PolicyLoader
- type Preferences
- type PreferencesView
- type Resolver
- type Result
- type Router
- type Service
- func (s *Service) Create(ctx context.Context, p authz.Principal, req CreateRequest) (Created, error)
- func (s *Service) Delete(ctx context.Context, p authz.Principal, subscriptionID string) error
- func (s *Service) Delivery(ctx context.Context, p authz.Principal, subscriptionID, deliveryID string) (DeliveryDetail, error)
- func (s *Service) Destinations(ctx context.Context) []Destination
- func (s *Service) Feed(ctx context.Context, appID, before string, limit int) ([]FeedItem, string, error)
- func (s *Service) Get(ctx context.Context, p authz.Principal, subscriptionID string) (state.Subscription, error)
- func (s *Service) List(ctx context.Context, p authz.Principal, req ListRequest) ([]state.Subscription, string, error)
- func (s *Service) ListDeliveries(ctx context.Context, p authz.Principal, subscriptionID, before string, ...) ([]state.Delivery, error)
- func (s *Service) Preferences(ctx context.Context, p authz.Principal) (PreferencesView, error)
- func (s *Service) Redeliver(ctx context.Context, p authz.Principal, subscriptionID, deliveryID string) (state.Delivery, error)
- func (s *Service) RotateKey(ctx context.Context, p authz.Principal, subscriptionID string) (Created, error)
- func (s *Service) SetPreferences(ctx context.Context, p authz.Principal, choices []state.NotificationPreference) (PreferencesView, error)
- func (s *Service) Test(ctx context.Context, p authz.Principal, subscriptionID string) (state.Delivery, error)
- func (s *Service) Update(ctx context.Context, p authz.Principal, subscriptionID string, ...) (state.Subscription, error)
- type TemplateData
- type TokenOwners
- type UpdateRequest
- type Users
- type VerbHolders
Constants ¶
const ( DisableAfter = 24 * time.Hour DisableMinFailures = 5 )
Disabling thresholds [P]: an endpoint that has failed every attempt for a day, and at least five times, is turned off (R-370). Both, so neither one slow failure in a quiet install nor a burst of them in a busy one does it.
const ( HeaderSignature = "Pando-Signature" HeaderTimestamp = "Pando-Timestamp" HeaderEventID = "Pando-Event-Id" HeaderEvent = "Pando-Event" HeaderDelivery = "Pando-Delivery-Id" )
Headers on every webhook delivery (R-369).
const Retention = 30 * 24 * time.Hour
Retention is how long the outbox keeps an event once it has nothing left to deliver [P]. The audit log is the history; this is a queue (R-366).
const WatchedSubscriptionCache = 5 * time.Minute
WatchedSubscriptionCache is how long the enabled subscriptions are reused while this replica is listening for changes to them [P]: every change, made on any replica, drops the list at once, so this only bounds how long a list lives if a notification is somehow lost.
Variables ¶
var Kinds = []Kind{ {api.NotifyAppFailed, "An app failed", "An app you own failed and Pando stopped restarting it.", true}, {api.NotifyDeployFailed, "A deploy failed", "A deploy you started, or of an app you own, failed.", true}, {api.NotifyDeployApproval, "Deploy approvals", "A deploy is waiting for your approval, or one you asked for was answered.", true}, {api.NotifyPolicyViolation, "Below the security minimum", "An app you own is below the installation's minimum security score.", true}, {api.NotifyBackupFailed, "A backup failed", "A backup of an app you own was not taken.", true}, {api.NotifyAppDisk, "An app near its disk limit", "An app you own is near or over its disk limit, or Pando stopped it for staying over.", true}, {api.NotifyAppIdle, "An app nobody uses", "An app you own is about to be stopped or deleted because nobody has used it, or was.", true}, {api.NotifyUpdateAvailable, "Pando updates", "A newer Pando is released. Sent to people who may upgrade it.", true}, {api.NotifyUpgradeFailed, "Upgrade failed", "An in-place upgrade of Pando did not finish.", true}, {api.NotifySubscriptionDisabled, "A subscription was turned off", "One of your event subscriptions kept failing and Pando turned it off.", true}, {api.NotifyAuditSinkDisabled, "An audit destination was turned off", "A destination the audit log is sent to kept failing and Pando turned it off. Sent to people who may send the audit log off the installation.", true}, {api.NotifyAppShared, "An app was shared with you", "Somebody gave you use of an app. It is in your launcher either way.", false}, }
Kinds is every notification Pando sends on its own, in the order the preferences screen lists them.
var RetrySchedule = []time.Duration{ time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 6 * time.Hour, 12 * time.Hour, }
RetrySchedule is how long to wait after each failed attempt before the next [P]. The first attempt is immediate; after the last of these fails the delivery is marked failed and can be sent again by hand. About a day in all, which is also how long an endpoint may fail everything before it is turned off — so a delivery outlives a short outage and does not outlive a dead one.
Functions ¶
func CheckURL ¶
func CheckURL(ctx context.Context, raw string, allowPrivate bool, resolver Resolver) (string, error)
CheckURL refuses a webhook address before it is saved, so a person finds out now rather than from a delivery log (R-372). The dialer checks again at every send; this is the readable half, not the enforcing one.
func Describe ¶
func Describe(e state.Event, app *EnvelopeApp) api.Notification
Describe is an event as a message a person reads: a subject naming what happened and to what, and the event's fields under it. Notify adapters lay these out for their platform; none of them has to know the catalog.
func LinkFor ¶
LinkFor is where in the console an event is seen: the app's page for an app event, the Events screen otherwise. Empty when Pando does not know its own address (external_url).
func Matching ¶
func Matching(subs []state.Subscription, e state.Event) []string
Matching is which subscriptions an event goes to: those whose filter names it, on its app or install-wide, made before the event happened. An event with no app reaches install-wide subscriptions only. A test event is never routed; it was made for one subscription.
func Sign ¶
Sign returns the signature header for a body sent at timestamp: the HMAC-SHA256, keyed by the whole signing key as given, of the timestamp in Unix seconds, a full stop, and the body exactly as sent.
The timestamp is inside what is signed so a receiver that refuses old timestamps refuses a replayed delivery, and the event ID is in a header so a receiver can drop a delivery it already has (R-369).
Types ¶
type Actor ¶
type Actor struct {
Kind string `json:"kind"`
ID string `json:"id,omitempty"`
OnBehalfOf string `json:"on_behalf_of,omitempty"`
}
Actor is who caused an event: a person, a token, or Pando itself.
type Authorizer ¶
type Authorizer interface {
CheckControl(ctx context.Context, p authz.Principal, appID string, verb authz.Verb) error
CheckInstall(ctx context.Context, p authz.Principal, verb authz.Verb) error
Allows(ctx context.Context, p authz.Principal, appID string, verb authz.Verb) (bool, error)
AllowsInstall(ctx context.Context, p authz.Principal, verb authz.Verb) (bool, error)
}
Authorizer is the part of authz.Authorizer the service uses.
type Channel ¶
Channel is a notification adapter that reaches people, as a preferences screen offers it.
type CreateRequest ¶
type CreateRequest struct {
// AppID is the app it is about; empty subscribes install-wide.
AppID string `json:"app_id,omitempty"`
Events []string `json:"events"`
Destination string `json:"destination"`
URL string `json:"url,omitempty"`
AdapterID string `json:"adapter_id,omitempty"`
Description string `json:"description,omitempty"`
// Channel is the channel an adapter whose capabilities say Channels
// posts to, from GET /notify/{id}/channels; refused for every other.
Channel string `json:"channel,omitempty"`
// How a webhook is sent (R-375). Header values are secret.Value: they
// are read from the request, sealed, and never written back out.
Method string `json:"method,omitempty"`
ContentType string `json:"content_type,omitempty"`
Headers map[string]secret.Value `json:"headers,omitempty"`
PayloadTemplate string `json:"payload_template,omitempty"`
}
CreateRequest is a new subscription.
type Created ¶
type Created struct {
state.Subscription
SigningKey *secretOnce `json:"signing_key,omitempty"`
}
Created is a subscription as it was made, with its signing key — shown this once and never again (R-371).
type DeliveryDetail ¶
type DeliveryDetail struct {
state.Delivery
// Payload is the body as it is sent: JSON when it is JSON, otherwise
// the text of a templated body.
Payload any `json:"payload"`
}
DeliveryDetail is one delivery, every attempt at it, and what was sent.
type Deployments ¶
type Deployments interface {
ByID(ctx context.Context, deploymentID string) (state.Deployment, bool, error)
}
Deployments reads who started a deploy.
type Destination ¶
type Destination struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Audience api.NotifyAudience `json:"audience"`
// Channels says a subscription to it names a channel, from
// GET /notify/{id}/channels.
Channels bool `json:"channels,omitempty"`
}
Destination is a notification adapter a subscription may send through.
type Dispatcher ¶
type Dispatcher struct {
Events *state.Events
Subscriptions *state.Subscriptions
Deliveries *state.Deliveries
Keys *state.SubscriptionSecrets
// Tokens resolves an account token that owns a subscription (R-060).
Tokens interface {
Active(ctx context.Context, tokenID string) (state.Token, bool, error)
}
// ExternalURL is how a browser reaches Pando, for the link in each
// delivery. Empty sends none.
ExternalURL string
// Who Pando's own failure notifications go to (announce.go). Each is
// optional; without one, those people are not told.
Deployments Deployments
Holders VerbHolders
TokenOwners TokenOwners
Authz Authorizer
Policy PolicyLoader
Apps Apps
Users Users
Groups Groups
Registry *api.Registry
// Notifiers are the notification adapters a subscription sends through.
// Nil reads them from Registry.
Notifiers Notifiers
// Notifier tells a subscription's owner it was turned off. Optional.
Notifier interface {
Notify(ctx context.Context, n api.Notification) error
}
Audit func(ctx context.Context, e audit.Event)
Clock clock.Clock
Logger *zap.Logger
// Client sends webhooks. Nil builds the guarded client (R-372); tests
// supply their own only to reach a local server, with policy allowing it.
Client *http.Client
// Interval is how often the outbox is read when it was empty last time.
Interval time.Duration
// SubscriptionCache is how long the list of enabled subscriptions is
// reused for routing [P]: when zero, WatchedSubscriptionCache while Run is
// listening for changes (migration 61) and three seconds while it is not;
// never when negative. Routing used to read it — three joins — on every
// pass of every replica (issue #72). It decides only which deliveries are queued;
// whether one is sent is decided at send time, as its owner, now (R-368),
// and a subscription turned off is not sent to however it was routed.
SubscriptionCache time.Duration
// Senders is how many deliveries are sent at once. Eight when zero.
Senders int
// contains filtered or unexported fields
}
Dispatcher routes the outbox and sends what it routes.
func (*Dispatcher) AdapterHealth ¶
func (d *Dispatcher) AdapterHealth(maxAge time.Duration) []telemetry.AdapterHealth
AdapterHealth is the last round's health checks, or nothing when there has been none within maxAge. Only the leader checks (WatchAdapters is a leader job), so a replica that stopped leading stops reporting rather than repeat what it last saw.
func (*Dispatcher) CheckAdapters ¶
func (d *Dispatcher) CheckAdapters(ctx context.Context)
CheckAdapters is one round of WatchAdapters.
func (*Dispatcher) Pass ¶
func (d *Dispatcher) Pass(ctx context.Context) bool
Pass routes what is waiting and sends what is due, once, and reports whether there was anything to do. Run does the same continuously; Pass is the one-shot form, for a test or a caller that wants it done now.
func (*Dispatcher) Run ¶
func (d *Dispatcher) Run(ctx context.Context)
Run routes and delivers until ctx ends.
Routing and sending are separate loops (issue #72). Sending pulls deliveries as senders come free, Senders at a time, rather than claiming a batch and waiting for the slowest webhook in it before claiming the next; routing tells it when it has queued something. Pruning the outbox is the retention job's, on the leader: it used to run here, on every replica.
func (*Dispatcher) WatchAdapters ¶
func (d *Dispatcher) WatchAdapters(ctx context.Context, interval time.Duration)
WatchAdapters checks every adapter's health every interval and records a change as an event: adapter.unhealthy when a check starts failing, adapter.recovered when it passes again. An adapter healthy since start says nothing.
type Envelope ¶
type Envelope struct {
ID string `json:"id"`
Type string `json:"type"`
OccurredAt time.Time `json:"occurred_at"`
App *EnvelopeApp `json:"app,omitempty"`
Actor Actor `json:"actor"`
Data map[string]any `json:"data"`
// Link is where in the console to look, when Pando's external_url is
// set (R-369).
Link string `json:"link,omitempty"`
}
Envelope is the body of a webhook delivery. Its shape is the contract with whoever receives it, documented in docs/events.md; data is the catalogued fields of the event and nothing else.
type EnvelopeApp ¶
type EnvelopeApp struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Slug string `json:"slug,omitempty"`
}
EnvelopeApp names the app an event is about.
type FeedItem ¶
type FeedItem struct {
Envelope
Subject string `json:"subject"`
Body string `json:"body,omitempty"`
Fields []api.NotificationField `json:"fields,omitempty"`
}
FeedItem is one event in an app's feed: what the envelope carries, and the same event described for a person.
type Inbox ¶
type Inbox struct {
Store *state.Notifications
}
Inbox is the console's notifications, read by the person they are for (R-377): what the console notify adapter records, which until issue #50 nothing displayed.
func (*Inbox) List ¶
func (b *Inbox) List(ctx context.Context, p authz.Principal, unreadOnly bool, before string, limit int) (InboxPage, error)
List returns the caller's notifications, newest first.
func (*Inbox) MarkAllRead ¶
MarkAllRead marks every one of the caller's notifications read.
func (*Inbox) MarkRead ¶
MarkRead marks one of the caller's notifications read. Another person's is left alone, without saying it exists.
func (*Inbox) UnreadCount ¶
UnreadCount is how many of the caller's notifications are unread: the bell's badge, which every open console asks about on every screen, so it reads the count alone rather than a page of the inbox with it (issue #72).
type InboxPage ¶
type InboxPage struct {
Notifications []state.Notification `json:"notifications"`
Unread int `json:"unread"`
NextBefore string `json:"next_before"`
}
InboxPage is one page of a person's inbox, and how many are unread in all.
type Kind ¶
type Kind struct {
Kind api.NotificationKind `json:"kind"`
Label string `json:"label"`
Description string `json:"description"`
DefaultOn bool `json:"default_on"`
}
Kind is one of Pando's own notifications — the ones it sends to particular people without anybody subscribing — and whether it reaches them unless they say otherwise (R-373).
type ListRequest ¶
type ListRequest struct {
AppID string
// Everyone lists every person's subscriptions, which needs
// install.events.manage. Otherwise the list is the caller's own.
Everyone bool
// Page is which page, newest first (issue #72).
Page state.Page
}
ListRequest narrows a list.
type Notifiers ¶ added in v0.11.0
type Notifiers interface {
Notify(ctx context.Context, id string) (api.NotifyAdapter, bool)
Usable(ctx context.Context) ([]notifyconn.Adapter, error)
}
Notifiers is the install's notification adapters (notifyconn.Service): built from their rows on each use, so one added or connected since startup is sent through at once.
type PolicyLoader ¶
PolicyLoader reads host policy.
type Preferences ¶
type Preferences interface {
List(ctx context.Context, userID string) ([]state.NotificationPreference, error)
}
Preferences reads a person's notification choices.
type PreferencesView ¶
type PreferencesView struct {
Kinds []Kind `json:"kinds"`
Channels []Channel `json:"channels"`
Choices []state.NotificationPreference `json:"choices"`
}
PreferencesView is everything a person may choose and what they have chosen: every kind on every channel, defaults filled in, so a client shows the answer rather than working it out (R-261).
type Resolver ¶
type Resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
Resolver looks a host name up. net.DefaultResolver in production.
type Result ¶
Result is how one attempt went.
type Router ¶
type Router struct {
Registry *api.Registry
// Notifiers are the notification adapters. Nil reads them from Registry.
Notifiers Notifiers
Preferences Preferences
Users Users
Logger *zap.Logger
}
Router sends Pando's own notifications — a deploy waiting for approval, an app that failed — to the people they name, on every channel that reaches people, as each person's preferences allow (R-373).
A channel adapter (Slack, Teams, Discord, ntfy) is skipped: a message meant for one person must not land in a room. Channels hear from subscriptions.
Best effort, like what it replaced: an adapter that fails is logged and the rest still send, and telling people never blocks what it is about (R-159).
type Service ¶
type Service struct {
Subscriptions *state.Subscriptions
Deliveries *state.Deliveries
Events *state.Events
Keys *state.SubscriptionSecrets
// ExternalURL is how a browser reaches Pando, for links. Empty sends none.
ExternalURL string
Prefs *state.NotificationPreferences
Authz Authorizer
Policy PolicyLoader
Apps Apps
Registry *api.Registry
// Notifiers are the notification adapters a subscription may send
// through. Nil reads them from Registry.
Notifiers Notifiers
// Resolver looks webhook hosts up when one is saved. Nil uses the
// system resolver.
Resolver Resolver
Audit func(ctx context.Context, e audit.Event)
Clock clock.Clock
Logger *zap.Logger
}
Service manages subscriptions.
func (*Service) Create ¶
func (s *Service) Create(ctx context.Context, p authz.Principal, req CreateRequest) (Created, error)
Create makes a subscription. A webhook gets a signing key, returned here once.
func (*Service) Delivery ¶
func (s *Service) Delivery(ctx context.Context, p authz.Principal, subscriptionID, deliveryID string) (DeliveryDetail, error)
Delivery returns one delivery in full.
func (*Service) Destinations ¶
func (s *Service) Destinations(ctx context.Context) []Destination
Destinations lists every notification adapter a subscription may send through, for a form that offers them to somebody who may not read the adapters themselves. IDs and kinds only: an adapter's configuration is the adapters screen's.
func (*Service) Feed ¶
func (s *Service) Feed(ctx context.Context, appID, before string, limit int) ([]FeedItem, string, error)
Feed is an app's recent events, for anyone who can see the app (R-378). The handler has already checked app.view.
func (*Service) Get ¶
func (s *Service) Get(ctx context.Context, p authz.Principal, subscriptionID string) (state.Subscription, error)
Get returns one subscription.
func (*Service) List ¶
func (s *Service) List(ctx context.Context, p authz.Principal, req ListRequest) ([]state.Subscription, string, error)
List returns one page of the caller's subscriptions, or everybody's, and the cursor for the next page (empty after the last).
func (*Service) ListDeliveries ¶
func (s *Service) ListDeliveries(ctx context.Context, p authz.Principal, subscriptionID, before string, limit int) ([]state.Delivery, error)
ListDeliveries lists a subscription's recent deliveries.
func (*Service) Preferences ¶
Preferences returns the caller's notification preferences (R-373).
func (*Service) Redeliver ¶
func (s *Service) Redeliver(ctx context.Context, p authz.Principal, subscriptionID, deliveryID string) (state.Delivery, error)
Redeliver sends a delivery again, with the whole retry schedule ahead of it.
func (*Service) RotateKey ¶
func (s *Service) RotateKey(ctx context.Context, p authz.Principal, subscriptionID string) (Created, error)
RotateKey replaces a webhook's signing key and returns the new one, once. Deliveries signed with the old key and not yet sent are signed with the new one when they go, because a delivery is signed as it is sent.
func (*Service) SetPreferences ¶
func (s *Service) SetPreferences(ctx context.Context, p authz.Principal, choices []state.NotificationPreference) (PreferencesView, error)
SetPreferences records the caller's choices. Each names a kind Pando sends and a channel that reaches people; anything else is refused rather than stored, so a typo does not look like a preference that took.
type TemplateData ¶
type TemplateData struct {
ID string
Type string
OccurredAt time.Time
App EnvelopeApp
Actor Actor
Data map[string]any
// Subject, Body and Fields are the event as a notification describes it.
Subject string
Body string
Fields []api.NotificationField
Link string
// Envelope is Pando's own body, for a template that wraps it.
Envelope Envelope
}
TemplateData is what a body template is given: the event as the envelope carries it, and the same event described for a person.
{"text": {{json .Subject}}, "url": {{json .Link}}}
{{.Type}} on {{.App.Name}}: {{index .Data "error_code"}}
type TokenOwners ¶
TokenOwners resolves a delegated token to the person it acts for.
type UpdateRequest ¶
type UpdateRequest struct {
Events *[]string `json:"events,omitempty"`
URL *string `json:"url,omitempty"`
AdapterID *string `json:"adapter_id,omitempty"`
Channel *string `json:"channel,omitempty"`
Description *string `json:"description,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Method *string `json:"method,omitempty"`
ContentType *string `json:"content_type,omitempty"`
Headers *map[string]secret.Value `json:"headers,omitempty"`
PayloadTemplate *string `json:"payload_template,omitempty"`
}
UpdateRequest changes a subscription. Nil fields are left alone. The destination's kind and the app are fixed: a different one is a different subscription. Headers, when given, replace every header the webhook had.