notify

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package notify tells people when their backups need attention.

The failure this exists for is the silent one: a Space that quietly stopped backing up months ago is worse than one that never started, because the family believes they are covered. Every event is therefore *recorded* first and delivered second — a durable record is the part the status board and the tests rely on, delivery is best-effort on top.

Audiences are split deliberately (decisions.md #15). A member of a Space hears about that Space: its run failed, its backups are stale. The operator hears about the machinery: a target is unreachable, credentials were rejected. Operator events carry **no space id, no user, no file counts** — the admin is a configuration actor, not a data actor, and must not learn which Spaces exist or how they are doing from a notification.

Nothing here ever formats key material, credentials, paths or file names: the only free text an event carries is the sanitized message the runner already deemed safe to show a user.

Index

Constants

View Source
const (
	// DefaultMinStaleAfter is the floor on the staleness threshold, whatever
	// the schedule says. Nothing is reported stale sooner than this.
	DefaultMinStaleAfter = 36 * time.Hour
	// DefaultRepeatAfter is how long before an unchanged stale Space is
	// reported again. Nagging daily is how notifications get muted.
	DefaultRepeatAfter = 7 * 24 * time.Hour
)
View Source
const DefaultSMTPTimeout = 30 * time.Second

DefaultSMTPTimeout bounds one mail delivery. A mail server that accepts the connection and then says nothing must not hold a scheduler slot, or the shutdown drain, for longer than this (review-2026-10.md F7).

View Source
const StaleLookback = 50

StaleLookback is how many of a Space's most recent backup records a caller must hand to Assess. It is a contract, not a tuning knob: two callers passing different windows could disagree about when the last success was.

Variables

This section is empty.

Functions

This section is empty.

Types

type Audience

type Audience string

Audience decides who may see an event.

const (
	// AudienceSpaceMembers is for the members of one Space.
	AudienceSpaceMembers Audience = "space_members"
	// AudienceOperator is for whoever runs the service. Operator events must
	// never identify a Space or a user (decisions.md #15).
	AudienceOperator Audience = "operator"
)

type Classification

type Classification struct {
	// MemberMessage is the user-safe text shown to the Space's members.
	MemberMessage string
	// Operational reports whether the operator has to act. When true, an
	// anonymous operator event is recorded alongside the member event.
	Operational bool
	// OperatorMessage is the operator's text. It must not identify a Space or
	// a user; the Reporter rejects the event if it somehow does.
	OperatorMessage string
	// Silent suppresses notification entirely. Some "failures" are not news to
	// anybody — a run skipped because another was already going, or one cut
	// short by shutdown.
	Silent bool
}

Classification says what a failed run means and to whom.

func DefaultClassification

func DefaultClassification() Classification

DefaultClassification is what an unrecognised failure means: tell the member something went wrong, do not speculate, do not page the operator.

type Classifier

type Classifier func(err error) Classification

Classifier maps a run error onto a Classification.

type Clock

type Clock interface {
	Now() time.Time
}

Clock supplies the current time, injected so tests are deterministic.

type Event

type Event struct {
	ID       string   `json:"id"`
	Kind     Kind     `json:"kind"`
	Audience Audience `json:"audience"`
	// SpaceID is set for space-scoped events only, and must be empty for
	// operator events.
	SpaceID string `json:"space_id,omitempty"`
	// Message is user-safe text. It never contains internal detail.
	Message   string    `json:"message"`
	CreatedAt time.Time `json:"created_at"`
}

Event is one thing worth telling somebody about.

func (Event) Scope

func (e Event) Scope() string

Scope returns the storage scope an event belongs to.

type Kind

type Kind string

Kind classifies an event.

const (
	// KindRunFailed reports one failed run of a Space. Space-scoped.
	KindRunFailed Kind = "run_failed"
	// KindBackupStale reports that a Space has had no successful backup for
	// longer than its schedule allows. This is the important one: it is what
	// catches failures nobody noticed.
	KindBackupStale Kind = "backup_stale"
	// KindTargetUnavailable reports that a backup target could not be used.
	// Operator-scoped and deliberately anonymous.
	KindTargetUnavailable Kind = "target_unavailable"
	// KindStateUnreadable reports that stored records could not be decoded.
	// Operator-scoped: it carries a count and nothing else, because the key of
	// such a document contains the space id it belongs to (decisions.md #15).
	KindStateUnreadable Kind = "state_unreadable"
)

type LogSink

type LogSink struct {
	Logger *slog.Logger
}

LogSink writes events to the service log. It is always wired: an operator reading logs must be able to see what users were told.

func (LogSink) Deliver

func (s LogSink) Deliver(_ context.Context, e Event) error

Deliver logs one event.

type Monitor

type Monitor struct {
	// contains filtered or unexported fields
}

Monitor reports Spaces whose backups have gone stale.

func NewMonitor

func NewMonitor(deps MonitorDeps, opts MonitorOptions) (*Monitor, error)

NewMonitor constructs a Monitor.

func (*Monitor) Sweep

func (m *Monitor) Sweep(ctx context.Context, now time.Time)

Sweep checks every scheduled Space and notifies about the stale ones. It is safe to call on every scheduler tick: re-notification is rate-limited.

type MonitorDeps

type MonitorDeps struct {
	Configs  spacecfg.Store
	Jobs     jobs.Store
	Events   Store
	Notifier *Notifier
	Logger   *slog.Logger
}

MonitorDeps are the Monitor's collaborators.

type MonitorOptions

type MonitorOptions struct {
	// MinStaleAfter is the floor on the threshold; zero uses the default.
	MinStaleAfter time.Duration
	// RepeatAfter is the re-notification interval; zero uses the default.
	RepeatAfter time.Duration
}

MonitorOptions tunes staleness detection. The zero value is usable.

type Notifier

type Notifier struct {
	// contains filtered or unexported fields
}

Notifier records events and fans them out to sinks.

func New

func New(store Store, opts Options) (*Notifier, error)

New constructs a Notifier.

func (*Notifier) Notify

func (n *Notifier) Notify(ctx context.Context, e Event) (Event, error)

Notify records an event and delivers it. Recording is what must succeed; delivery failures are logged and swallowed, because a notification nobody could email is still a notification the status board must show.

func (*Notifier) OperatorEvent

func (n *Notifier) OperatorEvent(ctx context.Context, kind Kind, message string) (Event, error)

OperatorEvent records an operational event. The signature has no space parameter on purpose: it is not possible to leak one through this path.

func (*Notifier) SpaceEvent

func (n *Notifier) SpaceEvent(ctx context.Context, kind Kind, spaceID, message string) (Event, error)

SpaceEvent records an event for the members of one Space.

type Options

type Options struct {
	// Sinks deliver events; may be empty, in which case events are recorded
	// only (and still visible through the API).
	Sinks []Sink
	// Clock is injected for deterministic tests.
	Clock Clock
	// Logger receives delivery diagnostics.
	Logger *slog.Logger
}

Options configures a Notifier.

type Reporter

type Reporter struct {
	// contains filtered or unexported fields
}

Reporter records notifications for run outcomes.

func NewReporter

func NewReporter(n *Notifier, classify Classifier, logger *slog.Logger) *Reporter

NewReporter constructs a Reporter. A nil classifier uses the default.

func (*Reporter) RunFinished

func (r *Reporter) RunFinished(ctx context.Context, spaceID string, err error)

RunFinished records the notifications a finished run warrants. A successful run is deliberately silent: backups that work are not news.

type SMTPConfig

type SMTPConfig struct {
	// Host and Port address the mail server, e.g. "smtp.example.org", 587.
	Host string
	Port int
	// Username and Password authenticate; leave empty for an open relay on a
	// trusted network.
	Username string
	Password string
	// From is the envelope sender.
	From string
	// OperatorTo receives operator-audience events. When empty, operator events
	// are recorded but not mailed.
	OperatorTo string
	// Timeout bounds one delivery, from dialling to QUIT. Zero uses
	// DefaultSMTPTimeout.
	Timeout time.Duration
}

SMTPConfig is the operator's mail configuration. The password arrives from a Secret-backed environment variable and is never logged.

func (SMTPConfig) Valid

func (c SMTPConfig) Valid() bool

Valid reports whether the configuration can send anything.

type SMTPEnv

type SMTPEnv struct {
	Host       string        `env:"SMTP_HOST" doc:"Mail server for operator notifications."`
	Port       int           `env:"SMTP_PORT" doc:"Mail server port, such as 587. 0 disables mail."`
	Username   string        `env:"SMTP_USERNAME" doc:"Mail server login, if it requires one."`
	Password   config.Secret `env:"SMTP_PASSWORD" doc:"Mail server password."`
	From       string        `env:"SMTP_FROM" doc:"Sender address."`
	OperatorTo string        `env:"NOTIFY_OPERATOR_EMAIL" doc:"Where operator notifications go. They never name a Space."`
}

SMTPEnv is the mail server operator notifications go through, as the environment declares it (internal/config). All of host, port, sender and recipient are needed; otherwise notifications are recorded and logged only.

func (SMTPEnv) Config

func (e SMTPEnv) Config() SMTPConfig

Config is the SMTP sink's configuration from e.

type SMTPSink

type SMTPSink struct {
	// contains filtered or unexported fields
}

SMTPSink mails operator events.

func NewSMTPSink

func NewSMTPSink(cfg SMTPConfig) (*SMTPSink, error)

NewSMTPSink constructs a sink from operator configuration.

func (*SMTPSink) Deliver

func (s *SMTPSink) Deliver(ctx context.Context, e Event) error

Deliver mails an event to its audience. Events with no email path are a no-op, not an error.

type Sink

type Sink interface {
	// Deliver sends one event. Name it in errors, never its recipients' data.
	Deliver(ctx context.Context, e Event) error
}

Sink delivers an event somewhere a human will see it. Delivery failures are never fatal: the record is already durable.

type StaleRule

type StaleRule struct {
	// MinStaleAfter is the floor on the threshold; zero or less uses the
	// default.
	MinStaleAfter time.Duration
}

StaleRule decides whether a Space's backups have gone stale. The zero value applies DefaultMinStaleAfter.

func (StaleRule) Assess

func (r StaleRule) Assess(cfg spacecfg.Config, backups []jobs.Job, now time.Time) Staleness

Assess applies the rule. backups must be the Space's backup-kind history, newest first, read with a limit of StaleLookback.

A Space whose scheduled backups are switched off is not stale: it is off, and saying otherwise would be a warning nobody can clear except by turning backups on.

type Staleness

type Staleness struct {
	// Stale reports whether the Space has gone too long without a successful
	// backup.
	Stale bool
	// Since is the last successful backup or, if there has never been one,
	// when the Space was configured. Zero when there is nothing to measure
	// from.
	Since time.Time
}

Staleness is the rule's verdict.

type StateStore

type StateStore struct {
	// contains filtered or unexported fields
}

StateStore is a Store backed by durable state.

func NewStateStore

func NewStateStore(st state.Store, clock Clock) *StateStore

NewStateStore returns a Store persisting to st.

func (*StateStore) Append

func (s *StateStore) Append(ctx context.Context, e Event) (Event, error)

Append records an event.

func (*StateStore) List

func (s *StateStore) List(ctx context.Context, spaceID string, limit int) ([]Event, error)

List returns a Space's events, newest first.

func (*StateStore) ListOperator

func (s *StateStore) ListOperator(ctx context.Context, limit int) ([]Event, error)

ListOperator returns operator events, newest first.

func (*StateStore) PruneBefore

func (s *StateStore) PruneBefore(ctx context.Context, cutoff time.Time) (int, error)

PruneBefore deletes events created before cutoff. Candidates are chosen from key names, so no document is read to decide.

type Store

type Store interface {
	// Append records an event, filling in id and timestamp when absent.
	Append(ctx context.Context, e Event) (Event, error)
	// List returns the events of one scope, newest first. A scope is a space
	// id for member events; use ListOperator for operator events. A limit of
	// zero or less means "all".
	List(ctx context.Context, spaceID string, limit int) ([]Event, error)
	// ListOperator returns operator events, newest first.
	ListOperator(ctx context.Context, limit int) ([]Event, error)
	// PruneBefore deletes events created before cutoff.
	PruneBefore(ctx context.Context, cutoff time.Time) (int, error)
}

Store persists events.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL