The hourly release of ADR 0229's brake still ended in the mesh acting alone on a mistake. A consumer now stays active until the same unasked set holds for five passes, waits for a person past three or half of those held, is disabled and marked rather than withdrawn, comes back as it was when asked again, and is deleted only through the provider's delete tool. The backend keeps the mark, so a restart forgets nothing and finds what was withdrawn before.
204 lines
7.7 KiB
Go
204 lines
7.7 KiB
Go
package main
|
|
|
|
// What retired means in postgres (novox/hq ADR 0230).
|
|
//
|
|
// **Retired is the login locked, the database kept, and the role marked.** `ALTER ROLE … NOLOGIN` —
|
|
// the consumer's login can no longer connect, as itself, to anything — and its open sessions are
|
|
// ended. The database, its owner, its grants and every byte in it stay exactly as they were: CONNECT is
|
|
// not revoked and the database still allows connections, because the backup contribution dumps every
|
|
// database and a retired one is still worth backing up, and because re-enabling must give it back as it
|
|
// was. The mark is the role's comment, a JSON object the mesh owns:
|
|
//
|
|
// {"mesh":"consumer","node":"ace"} active
|
|
// {"mesh":"consumer","node":"ace","retired":"2026-10-06T…Z","why":"…"} retired
|
|
//
|
|
// Asked for again, the ordinary create sets LOGIN and the password again and writes the active mark.
|
|
// Deleted — only through `cleanup delete` — the database is dropped, then the role, unless it still owns
|
|
// another database (a set-aside copy), which is said and kept.
|
|
//
|
|
// **What the mesh made is recognised by its mark, or by its shape before the mark existed**: a role
|
|
// valid until 'infinity' (only the mesh's role statement says that) that owns a database of its own
|
|
// name. A role of any other shape is somebody else's and is never listed, retired or deleted. A database
|
|
// renamed aside — `<name>_deleted_<yyyymmdd>`, by postgres_retire_database or by hand — is listed as
|
|
// retired too, since that date, so a person sees it and can delete it.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// KindSetAside is a database renamed aside, listed for deletion beside the retired consumers.
|
|
const KindSetAside = "set-aside-database"
|
|
|
|
// roleMark is the comment the mesh keeps on a consumer's role.
|
|
type roleMark struct {
|
|
Mesh string `json:"mesh"`
|
|
Node string `json:"node,omitempty"`
|
|
Retired string `json:"retired,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
func readMark(comment string) (roleMark, bool) {
|
|
var m roleMark
|
|
if comment == "" || json.Unmarshal([]byte(comment), &m) != nil || m.Mesh != KindConsumer {
|
|
return roleMark{}, false
|
|
}
|
|
return m, true
|
|
}
|
|
|
|
// MarkStatement is the comment that marks a role as the mesh's consumer, active or retired.
|
|
func MarkStatement(role string, m roleMark) string {
|
|
m.Mesh = KindConsumer
|
|
b, _ := json.Marshal(m)
|
|
return fmt.Sprintf("COMMENT ON ROLE %s IS %s", Ident(role), Literal(string(b)))
|
|
}
|
|
|
|
// MarkActive writes the active mark — after create, which has already set LOGIN.
|
|
func (c *Client) MarkActive(ctx context.Context, role, node string) error {
|
|
_, err := c.Query(ctx, "", MarkStatement(role, roleMark{Node: node}))
|
|
return err
|
|
}
|
|
|
|
// RetireRole locks the login, ends its sessions and marks it retired with when and why. Its database
|
|
// is not touched. A role that does not exist has nothing to retire.
|
|
func (c *Client) RetireRole(ctx context.Context, role, why string, at time.Time) error {
|
|
r, err := c.Query(ctx, "", "SELECT coalesce(shobj_description(oid, 'pg_authid'), '') FROM pg_roles WHERE rolname = "+
|
|
Literal(role))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(r.Rows) == 0 {
|
|
return nil
|
|
}
|
|
prev, _ := readMark(cell(r.Rows[0], 0))
|
|
if err := c.LockRole(ctx, role); err != nil {
|
|
return err
|
|
}
|
|
_, err = c.Query(ctx, "", MarkStatement(role, roleMark{Node: prev.Node, Retired: at.UTC().Format(time.RFC3339), Why: why}))
|
|
return err
|
|
}
|
|
|
|
var setAside = regexp.MustCompile(`_deleted_(\d{8})$`)
|
|
|
|
// InventoryStatement lists every role that may be the mesh's, with its login, mark, shape and the size
|
|
// of its own database.
|
|
const InventoryStatement = `SELECT r.rolname, r.rolcanlogin, coalesce(shobj_description(r.oid, 'pg_authid'), '') AS mark,
|
|
coalesce(r.rolvaliduntil = 'infinity', false) AS mesh_shaped,
|
|
(SELECT pg_database_size(d.datname) FROM pg_database d WHERE d.datname = r.rolname AND d.datdba = r.oid) AS size
|
|
FROM pg_roles r
|
|
WHERE r.rolname !~ '^pg_' AND NOT r.rolsuper AND r.rolname <> ` + "'" + Reader + "'" + `
|
|
ORDER BY r.rolname`
|
|
|
|
// SetAsideStatement lists the databases renamed aside.
|
|
const SetAsideStatement = `SELECT datname, pg_database_size(datname) FROM pg_database WHERE datname ~ '_deleted_[0-9]{8}$' ORDER BY datname`
|
|
|
|
// Inventory is what this server holds that the mesh made.
|
|
func (c *Client) Inventory(ctx context.Context) (Inventory, error) {
|
|
inv := Inventory{Active: []string{}, Retired: []Retired{}}
|
|
r, err := c.Query(ctx, "", InventoryStatement)
|
|
if err != nil {
|
|
return inv, err
|
|
}
|
|
for _, row := range r.Rows {
|
|
name, login, comment, shaped, size := cell(row, 0), cell(row, 1) == "t", cell(row, 2), cell(row, 3) == "t", cell(row, 4)
|
|
m, marked := readMark(comment)
|
|
if !marked && !(shaped && size != "") {
|
|
continue // not the mesh's
|
|
}
|
|
if login && m.Retired == "" {
|
|
inv.Active = append(inv.Active, name)
|
|
continue
|
|
}
|
|
at, _ := time.Parse(time.RFC3339, m.Retired)
|
|
inv.Retired = append(inv.Retired, Retired{Consumer: name, Node: m.Node, RetiredAt: at, Why: m.Why,
|
|
SizeBytes: sizeOf(size), Kind: KindConsumer})
|
|
}
|
|
r, err = c.Query(ctx, "", SetAsideStatement)
|
|
if err != nil {
|
|
return inv, err
|
|
}
|
|
for _, row := range r.Rows {
|
|
name := cell(row, 0)
|
|
m := setAside.FindStringSubmatch(name)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
at, _ := time.Parse("20060102", m[1])
|
|
inv.Retired = append(inv.Retired, Retired{Consumer: name, RetiredAt: at, SizeBytes: sizeOf(cell(row, 1)),
|
|
Why: "renamed aside — by postgres_retire_database, or by hand", Kind: KindSetAside})
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
// DeleteRetired drops what a retired entry names: a consumer's database and then its role, or one
|
|
// database set aside. Answers the bytes freed.
|
|
func (c *Client) DeleteRetired(ctx context.Context, r Retired) (int64, error) {
|
|
if r.Kind == KindSetAside {
|
|
if !setAside.MatchString(r.Consumer) {
|
|
return 0, fmt.Errorf("%s is not a database set aside", r.Consumer)
|
|
}
|
|
return c.dropDatabase(ctx, r.Consumer)
|
|
}
|
|
// Only a retired one: the login must be locked here and now, whatever the caller believed.
|
|
row, err := c.Query(ctx, "", "SELECT rolcanlogin FROM pg_roles WHERE rolname = "+Literal(r.Consumer))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(row.Rows) > 0 && cell(row.Rows[0], 0) == "t" {
|
|
return 0, fmt.Errorf("%s can log in — it is active, not retired, and is not deleted", r.Consumer)
|
|
}
|
|
freed, err := c.dropDatabase(ctx, r.Consumer)
|
|
if err != nil {
|
|
return freed, err
|
|
}
|
|
if len(row.Rows) == 0 {
|
|
return freed, nil
|
|
}
|
|
owns, err := c.Query(ctx, "", "SELECT datname FROM pg_database WHERE datdba = (SELECT oid FROM pg_roles WHERE rolname = "+
|
|
Literal(r.Consumer)+")")
|
|
if err != nil {
|
|
return freed, err
|
|
}
|
|
if len(owns.Rows) > 0 {
|
|
return freed, fmt.Errorf("%s's database is dropped; the role is kept because it still owns %s — delete that first",
|
|
r.Consumer, cell(owns.Rows[0], 0))
|
|
}
|
|
_, err = c.Query(ctx, "", fmt.Sprintf("DROP ROLE %s", Ident(r.Consumer)))
|
|
return freed, err
|
|
}
|
|
|
|
func (c *Client) dropDatabase(ctx context.Context, database string) (int64, error) {
|
|
r, err := c.Query(ctx, "", "SELECT pg_database_size(datname) FROM pg_database WHERE datname = "+Literal(database))
|
|
if err != nil || len(r.Rows) == 0 {
|
|
return 0, err
|
|
}
|
|
freed := sizeOf(cell(r.Rows[0], 0))
|
|
if _, err := c.Query(ctx, "", "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = "+
|
|
Literal(database)+" AND pid <> pg_backend_pid()"); err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := c.Query(ctx, "", fmt.Sprintf("DROP DATABASE %s", Ident(database))); err != nil {
|
|
return 0, err
|
|
}
|
|
return freed, nil
|
|
}
|
|
|
|
func cell(row []*string, i int) string {
|
|
if i < len(row) && row[i] != nil {
|
|
return *row[i]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func sizeOf(s string) int64 {
|
|
n, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
return n
|
|
}
|