package main
import (
"context"
"errors"
"log"
"strings"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/errlog"
"craftdesign.group/quarry/internal/notify"
"craftdesign.group/quarry/internal/oltp"
)
// adminsTopic is the group every fleet-wide alert is addressed to: the
// error journal's first sighting of a fault, and a route that blows its
// render budget. It is a TOPIC, not a person. "admins" as a subscriber
// id names nobody, and notify answers a trigger for an unknown
// subscriber with a success — so an alert addressed that way is
// accepted, delivered to no one, and reported as sent.
const adminsTopic = "admins"
// topicPlane is the slice of notify provisioning the reconcile uses.
type topicPlane interface {
EnsureSubscriber(ctx context.Context, account, email string) error
EnsureTopic(ctx context.Context, key, name string) error
AddTopicMember(ctx context.Context, key, account string) error
RemoveTopicMember(ctx context.Context, key, account string) error
Preferences(ctx context.Context, account string) (notify.SubscriberPreferences, error)
SetPreferences(ctx context.Context, account string, workflows map[string]map[string]bool,
digests map[string]*notify.DigestWindow) error
}
// accountBook is what the forge knows about who exists and what they
// hold — the truth the reconcile makes notify match.
type accountBook interface {
Accounts() ([]oltp.Account, error)
GrantsFor(account string) (map[string]oltp.Role, error)
Repos() ([]oltp.Repo, error)
}
// notifySync is what a reconcile did, for the startup log line.
type notifySync struct{ Subscribers, Members, Quieted int }
// emailOffByDefault writes the explicit "no email" a subscriber has not
// yet answered, for every family that carries an email step.
//
// notify reads an absent preference as ENABLED. The day the family
// workflows gained an email step, that default would have mailed every
// account on the forge about every mention, reply and landing without
// anybody asking — the one thing a notification system must not do.
// So the forge answers for them, once, and only where they have not
// answered themselves: a channel somebody switched on (or off) is a
// key that is present, and is left exactly as they set it.
//
// It returns how many families were quieted, which is zero on every
// start after the first.
func emailOffByDefault(ctx context.Context, np topicPlane, account string) (int, error) {
prefs, err := np.Preferences(ctx, account)
if err != nil {
return 0, err
}
want := map[string]map[string]bool{}
for _, wf := range notify.EmailFamilies() {
if _, chosen := prefs.Workflows[wf]["email"]; chosen {
continue
}
want[wf] = map[string]bool{"email": false}
}
if len(want) == 0 {
return 0, nil
}
// notify merges per workflow, so this touches the email cell alone
// and no digest window: what somebody set elsewhere stays.
if err := np.SetPreferences(ctx, account, want, nil); err != nil {
return 0, err
}
return len(want), nil
}
// reconcileNotify makes notify's subscribers and topics match the
// forge's accounts and grants.
//
// Provisioning has always happened on the WRITE: account.add creates a
// subscriber, a grant joins the repository's activity topic. Every
// account and grant older than that code is therefore unprovisioned,
// and the failure is silent in the worst way — a trigger addressed to a
// topic that was never created is refused with a 404 the caller
// discards, so the forge reports nothing while nobody is told anything.
// This is the sweep that fixes the fleet rather than the next write.
//
// Every call it makes is idempotent, so it runs on every start. A
// failure on one account is collected and the sweep goes on: one
// account notify cannot hold must not cost the other nineteen their
// alerts.
func reconcileNotify(ctx context.Context, np topicPlane, book accountBook) (notifySync, error) {
accounts, err := book.Accounts()
if err != nil {
return notifySync{}, err
}
repos, err := book.Repos()
if err != nil {
return notifySync{}, err
}
var sync notifySync
var errs []error
// Every repository gets its topic whether or not anyone is granted
// it. Deriving the topics from grants left a repository nobody was
// granted with none — quarry's own repository was one, when site
// admins reached it without a grant — so its activity was the one
// thing that could not be delivered.
for _, r := range repos {
if err := np.EnsureTopic(ctx, repoTopicKey(r.Name), r.Name+" activity"); err != nil {
errs = append(errs, err)
}
}
if err := np.EnsureTopic(ctx, adminsTopic, "quarry admins"); err != nil {
// Without the topic there is no point adding members to it, but
// the repository topics below are independent and still worth
// doing.
errs = append(errs, err)
}
for _, a := range accounts {
// The address travels with the account: notify's upsert replaces
// the row, so a sweep that omitted it would blank every address
// it had.
if err := np.EnsureSubscriber(ctx, a.Name, a.Email); err != nil {
errs = append(errs, err)
// A subscriber that does not exist cannot join a topic;
// notify answers those with a 422 that says exactly that.
continue
}
sync.Subscribers++
if n, err := emailOffByDefault(ctx, np, a.Name); err != nil {
errs = append(errs, err)
} else {
sync.Quieted += n
}
// Disabled is WHEN the account was shut, empty while open. A
// shut account is not an admin for this purpose: alerts name
// production faults, and someone who can no longer sign in
// should not keep receiving them.
if a.Admin && a.Disabled == "" {
if err := np.AddTopicMember(ctx, adminsTopic, a.Name); err != nil {
errs = append(errs, err)
} else {
sync.Members++
}
} else if err := np.RemoveTopicMember(ctx, adminsTopic, a.Name); err != nil {
// Removing an absent member is already success in the
// client, so this is a real failure.
errs = append(errs, err)
}
if a.Disabled != "" {
continue
}
// Mirrors are in it and cost nothing — activity is what people
// write, and nobody writes to a mirror.
grants, err := book.GrantsFor(a.Name)
if err != nil {
errs = append(errs, err)
continue
}
member := make([]string, 0, len(grants))
for repo := range grants {
member = append(member, repo)
}
// A site admin reaches what their grants reach (#318). Earlier
// builds joined them to every repository's topic, and nothing
// writes a revoke for a reach that was never a grant, so the
// sweep takes those memberships back. Removing an absent member
// is success, so this costs nothing once they are gone.
if a.Admin {
for _, r := range repos {
if _, held := grants[r.Name]; held {
continue
}
if err := np.RemoveTopicMember(ctx, repoTopicKey(r.Name), a.Name); err != nil {
errs = append(errs, err)
}
}
}
for _, repo := range member {
key := repoTopicKey(repo)
if err := np.EnsureTopic(ctx, key, repo+" activity"); err != nil {
errs = append(errs, err)
continue
}
if err := np.AddTopicMember(ctx, key, a.Name); err != nil {
errs = append(errs, err)
continue
}
sync.Members++
}
}
return sync, errors.Join(errs...)
}
// alertAdmins puts a fault in every site admin's forge inbox.
//
// The attention stream is quarry's own half of RFC 0011 §5 — no notify,
// no external service, nothing to provision — and it is the half the
// inbox page renders when notify has nothing to say. A fleet alert is
// deliberately not "attention" in the person-addressed sense (an error
// is nobody's mention), so it is written here rather than derived from
// the notify event.
//
// Best-effort and silent about its own failures beyond the journal: it
// runs inside the error journal's first-sighting hook, and reporting a
// failure here would be reporting into the thing that is reporting.
//
// An admin who cannot read the repository a fault is about is sent its
// route and kind, not its page or its words (#318): reads answers that,
// per admin.
func alertAdmins(book accountBook, att api.AttentionPlane, e errlog.Entry, reads func(account, repo string) bool) {
if att == nil || book == nil {
return
}
accounts, err := book.Accounts()
if err != nil {
log.Printf("notify: admin alert: %v", err)
return
}
for _, a := range accounts {
if !a.Admin || a.Disabled != "" {
continue
}
seen := e
if !readsAll(reads, a.Name, e) {
seen = e.Redacted()
}
ev := api.AttentionEvent{
Kind: "error", Repo: seen.Repo, From: "quarry",
Summary: seen.Surface + ": " + seen.Message,
// The panel's row for THIS fault, not the top of the list: a
// notice that names a surface and a message and then lands
// you in a page of signatures has made you search for what
// it already knew.
Link: "/fleet/exceptions#exc-" + seen.Sig(),
At: seen.First.UTC().Format(time.RFC3339),
// The same key notify was handed, so the inbox pairs this
// row with its notify twin instead of showing the fault
// twice.
TxnID: "err:" + seen.First.Format(time.RFC3339Nano),
}
if _, err := att.Attend(a.Name, ev); err != nil {
log.Printf("notify: admin alert for %s: %v", a.Name, err)
}
}
}
// alertAdminsTopic is the same fault as one notify trigger to the admins
// group, which is one email to everybody in it. There is no one reader
// to tailor it to, so it carries the fault's words only when every
// admin may read what it is about, and its route and kind otherwise
// (#318). The result is logged, not discarded: an alert channel that
// silently fails is worse than none.
func alertAdminsTopic(ctx context.Context, np api.NotifyPlane, book accountBook, e errlog.Entry,
reads func(account, repo string) bool, origin string) {
if np == nil {
return
}
seen := e
if accounts, err := book.Accounts(); err != nil {
seen = e.Redacted()
} else {
for _, a := range accounts {
if a.Admin && a.Disabled == "" && !readsAll(reads, a.Name, e) {
seen = e.Redacted()
break
}
}
}
err := np.Trigger(ctx, api.NotifyEvent{
Workflow: "quarry-error", To: adminsTopic,
Payload: map[string]any{
"surface": seen.Surface, "path": seen.Path,
"summary": seen.Message,
// Where the fault is written up. Without it the inbox row
// is a sentence that leads nowhere.
"link": strings.TrimSuffix(origin, "/") + "/fleet/exceptions",
},
TxnID: "err:" + e.First.Format(time.RFC3339Nano),
})
if err != nil {
log.Printf("notify: error alert not delivered: %v", err)
}
}
// readsAll reports whether account may read every repository e is about.
func readsAll(reads func(account, repo string) bool, account string, e errlog.Entry) bool {
for _, repo := range e.About() {
if !reads(account, repo) {
return false
}
}
return true
}