// follow.go — an agent's reach (RFC 0021, Reach and following).
//
// An agent with site reach follows the person it is assigned to into
// every repository that person owns, at that person's string there.
// The grants it makes are ordinary repo_caps rows carrying a followed
// mark, so a reconcile only ever touches rows it wrote: a grant an
// admin gave the agent by hand is theirs, not the reconcile's.
//
// A removal sticks. An admin taking a followed grant away writes a
// follow_skip row (through SetCapsGuarded), and the reconcile leaves
// that repository alone until somebody grants the agent there by hand.
package oltp
import (
"database/sql"
"fmt"
"log"
)
const followSchema = `
CREATE TABLE IF NOT EXISTS follow_skip (
agent TEXT NOT NULL,
repo TEXT NOT NULL,
PRIMARY KEY (agent, repo)
);`
var followMigrations = []string{
// reach is '' (repo: follows nobody) or 'site'. Team reach waits on
// team-owned agents (RFC 0021 rung 8).
`ALTER TABLE account ADD COLUMN reach TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE repo_caps ADD COLUMN followed INTEGER NOT NULL DEFAULT 0`,
}
// ReachSite is the one reach a store accepts today besides the default.
const ReachSite = "site"
// Reach is an account's reach: "" for a repo agent and for a person.
func (d *DB) Reach(name string) string {
var r string
_ = d.db.QueryRow(`SELECT reach FROM account WHERE name = ?`, name).Scan(&r)
return r
}
// SetReach gives an agent a reach and applies it at once. Only an
// agent has one, and a person has at most one site agent: a second
// would make "your site agent" two answers.
func (d *DB) SetReach(agent, reach string) error {
if reach != "" && reach != ReachSite {
return Refusal{fmt.Sprintf("%q is not a reach: use site, or nothing for an agent that works only where it is granted", reach)}
}
a, ok := d.AccountByName(agent)
if !ok || !a.Agent() {
return Refusal{fmt.Sprintf("%s is not an agent; only an agent has a reach", agent)}
}
if reach == ReachSite {
who := assignedTo(a)
if who == "" {
return Refusal{fmt.Sprintf("%s is assigned to nobody, so there is nobody for it to follow", agent)}
}
if other := d.SiteAgentOf(who); other != "" && other != agent {
return Refusal{fmt.Sprintf("%s already has a site agent, %s; take its reach away first", who, other)}
}
}
if _, err := d.w.Exec(`UPDATE account SET reach = ? WHERE name = ?`, reach, agent); err != nil {
return err
}
return d.ReconcileFollowing(agent)
}
// assignedTo is who an agent follows when its reach is site: its
// operator for a person's agent, the answerable site admin for a forge
// agent.
func assignedTo(a Account) string {
if a.Operator == forgeOperator {
return a.Answerable
}
return a.Operator
}
// SiteAgentOf names the person's site agent, or "".
func (d *DB) SiteAgentOf(person string) string {
rows, err := d.db.Query(`SELECT name FROM account WHERE reach = ? AND operator != ''`, ReachSite)
if err != nil {
return ""
}
var names []string
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
names = append(names, n)
}
}
rows.Close()
for _, n := range names {
if a, ok := d.AccountByName(n); ok && assignedTo(a) == person {
return n
}
}
return ""
}
// Follows names who an agent follows, or "" when it follows nobody.
func (d *DB) Follows(agent string) string {
if d.Reach(agent) != ReachSite {
return ""
}
a, ok := d.AccountByName(agent)
if !ok || a.Disabled != "" {
return ""
}
return assignedTo(a)
}
// FollowedOn reports whether the agent's grant on repo is one the
// reconcile made.
func (d *DB) FollowedOn(agent, repo string) bool {
var f int
_ = d.db.QueryRow(`SELECT followed FROM repo_caps WHERE account = ? AND repo = ?`, agent, repo).Scan(&f)
return f != 0
}
// ReconcileFollowing makes an agent's followed grants match what it
// should hold: its person's string on each repository the person owns,
// except where a removal was recorded. It adds, changes and removes
// only rows carrying the followed mark, so running it again changes
// nothing.
func (d *DB) ReconcileFollowing(agent string) error {
want := map[string]string{}
if who := d.Follows(agent); who != "" {
rows, err := d.db.Query(`SELECT r.name, coalesce(c.caps, '') FROM repo r
LEFT JOIN repo_caps c ON c.repo = r.name AND c.account = ?
WHERE r.owner = ? AND r.name NOT IN (SELECT repo FROM follow_skip WHERE agent = ?)`,
who, who, agent)
if err != nil {
return err
}
for rows.Next() {
var repo, caps string
if err := rows.Scan(&repo, &caps); err != nil {
rows.Close()
return err
}
if caps = noSetup(caps); caps != "" {
want[repo] = caps
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
}
have := map[string]string{}
followed := map[string]bool{}
rows, err := d.db.Query(`SELECT repo, caps, followed FROM repo_caps WHERE account = ?`, agent)
if err != nil {
return err
}
for rows.Next() {
var repo, caps string
var f int
if err := rows.Scan(&repo, &caps, &f); err != nil {
rows.Close()
return err
}
have[repo], followed[repo] = caps, f != 0
}
rows.Close()
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for repo, caps := range want {
cur, exists := have[repo]
switch {
case !exists:
_, err = tx.Exec(`INSERT INTO repo_caps (account, repo, caps, followed) VALUES (?, ?, ?, 1)`, agent, repo, caps)
case followed[repo] && cur != caps:
_, err = tx.Exec(`UPDATE repo_caps SET caps = ? WHERE account = ? AND repo = ?`, caps, agent, repo)
}
if err != nil {
return err
}
}
for repo := range have {
if _, keep := want[repo]; keep || !followed[repo] {
continue
}
if _, err := tx.Exec(`DELETE FROM repo_caps WHERE account = ? AND repo = ? AND followed = 1`, agent, repo); err != nil {
return err
}
}
return tx.Commit()
}
// reconcileFollowersOf brings every agent following person up to date.
// Called after a write that can change what they should hold: a deed
// changing hands, a person's string changing on a repository they own.
func (d *DB) reconcileFollowersOf(person string) {
if person == "" {
return
}
if a := d.SiteAgentOf(person); a != "" {
if err := d.ReconcileFollowing(a); err != nil {
log.Printf("oltp: follow: %s for %s: %v", a, person, err)
}
}
}
// reconcileFollowersOn is reconcileFollowersOf for a repository's owner.
func (d *DB) reconcileFollowersOn(repo string) {
var owner string
if err := d.db.QueryRow(`SELECT owner FROM repo WHERE name = ?`, repo).Scan(&owner); err != nil {
return
}
d.reconcileFollowersOf(owner)
}
// ReconcileAllFollowing runs every agent with a reach once, so nothing
// drifts across a restart or a write made outside these paths.
func (d *DB) ReconcileAllFollowing() error {
rows, err := d.db.Query(`SELECT name FROM account WHERE reach != ''`)
if err != nil {
return err
}
var names []string
for rows.Next() {
var n string
if rows.Scan(&n) == nil {
names = append(names, n)
}
}
rows.Close()
for _, n := range names {
if err := d.ReconcileFollowing(n); err != nil {
return fmt.Errorf("oltp: follow: %s: %w", n, err)
}
}
return nil
}
// handEdit is setCaps for a change a person makes, inside the guarded
// transaction: it takes the row out of the reconcile's hands. Taking a
// followed grant away records the removal, so it sticks; granting by
// hand clears any recorded removal and makes the row the admin's.
func handEdit(tx *sql.Tx, account, repo, caps string) error {
if noSetup(caps) == "" {
var f int
if err := tx.QueryRow(`SELECT followed FROM repo_caps WHERE account = ? AND repo = ?`, account, repo).Scan(&f); err == nil && f != 0 {
if _, err := tx.Exec(`INSERT OR IGNORE INTO follow_skip (agent, repo) VALUES (?, ?)`, account, repo); err != nil {
return err
}
}
} else if _, err := tx.Exec(`DELETE FROM follow_skip WHERE agent = ? AND repo = ?`, account, repo); err != nil {
return err
}
if err := setCaps(tx, account, repo, caps); err != nil {
return err
}
_, err := tx.Exec(`UPDATE repo_caps SET followed = 0 WHERE account = ? AND repo = ?`, account, repo)
return err
}