Files
mesh-catalog/modules/postgres/cmd/postgres-provider/retire_pg.go
T
jochen 685cb1cb1b Declare every module's data; the backup holder measures it (hq ADR 0233)
Backup lines are derived from each module's data section instead of written by hand; the holder
measures declared items, reads the array under them, and deletes a retired item only after a last
restore point; the Go providers say each held consumer's size so an empty replacement is seen.
2026-10-06 16:47:49 +02:00

207 lines
7.8 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{}, Sizes: map[string]int64{}}
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)
if s := sizeOf(size); s >= 0 {
inv.Sizes[name] = s
}
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
}