Converted from the arrangement being replaced, in its shapes rather than theirs. The registry is the manifest the lab already proved, promoted: names its image by digest and is never built (04-ISSUES/029), provides the artifact store, claims it once per machine. Redis is the third provision after a database and a bucket, and the first whose tenancy is a pattern in a shared keyspace rather than a namespace something else enforces. Its provisioner mirrors the postgres one's contract line for line — the manifest, the sealed per-consumer files, the mark, the withdrawal of orphans — and speaks RESP directly: five commands are needed, and a client library large enough to hide them would be most of the program's size. A prefix Redis would read as a pattern is refused, because the grant must mean what the manifest said; grants are persisted with ACL SAVE, or said loudly, because a cache that forgets its tenants on restart reports success until then. Umami asks the mesh for its database and a generated app secret, and carries no state of its own — the arrangement being replaced ran a bundled second postgres beside it. Grafana keeps its dashboards in a declared directory with the image's own owner. Both listen on 3000, as does the forge — which is the mesh's port assignment earning its keep. Traefik is deliberately not converted: the mesh's route provider is mesh-route-proxy, which speaks route grants natively, and a traefik that consumed them would be an adapter nobody has written pretending to be a conversion. All images pinned by real digests, resolved on this workstation today.
361 lines
11 KiB
Go
361 lines
11 KiB
Go
// A provisioner for Redis, in the form the mesh expects one.
|
|
//
|
|
// The mesh generated a password, sealed it to the machine that must accept it, and discarded the
|
|
// plaintext — so it cannot tell Redis to start accepting it. Something on that machine reads what
|
|
// the host wrote and makes it true. This is that something, for the third provision after a
|
|
// database and a bucket: a cache.
|
|
//
|
|
// **It is an example, not part of the control plane** — the same standing as the postgres one,
|
|
// whose contract this mirrors line for line:
|
|
//
|
|
// $GRANTS/mesh.json every consumer, what it asked for, and where its credential is
|
|
// $GRANTS/<node>.secret one consumer's password, alone in the file
|
|
//
|
|
// What a consumer is given is an ACL user scoped to a key prefix. Redis has no databases to hand
|
|
// out — SELECT-numbered ones are deprecated in clusters and shared in spirit — so the unit of
|
|
// tenancy is the keyspace pattern: a consumer contributing `prefix: photos` gets a user that can
|
|
// touch `photos:*` and nothing else. Administration and the dangerous category stay withheld;
|
|
// nothing a tenant is granted can flush the store or read another tenant's keys.
|
|
//
|
|
// Redis is spoken to in RESP directly, over one connection, with no client library. Five commands
|
|
// are needed — AUTH, PING, ACL SETUSER, ACL USERS, ACL DELUSER, ACL SAVE — and a dependency large
|
|
// enough to hide what they do would be most of this program's size.
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// mark is what this provisioner names the users it owns, so it never removes one a person made by
|
|
// hand — the mesh's rule about origins, one level down (novox/hq 04-ISSUES/010).
|
|
const mark = "mesh_"
|
|
|
|
type contribution struct {
|
|
From string `json:"from"`
|
|
Node string `json:"node"`
|
|
// As is what to call the user this consumer will authenticate as. Given by the mesh, never
|
|
// invented here (novox/hq 04-ISSUES/023): the consumer has to present it, so both ends must
|
|
// hold the same derivation.
|
|
As string `json:"as"`
|
|
Secret string `json:"secret"`
|
|
Values map[string]any `json:"values"`
|
|
}
|
|
|
|
type manifest struct {
|
|
Requirement string `json:"requirement"`
|
|
Given []contribution `json:"given"`
|
|
}
|
|
|
|
func main() {
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
if len(os.Args) > 1 && os.Args[1] == "--watch" {
|
|
if err := watch(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "mesh-provision-redis: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
if err := run(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "mesh-provision-redis: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// watch reconciles now, and again whenever what the mesh delivered changes — by content, not by
|
|
// modification time, for the reasons the postgres provisioner records.
|
|
func watch(ctx context.Context) error {
|
|
const every = 10 * time.Second
|
|
var last string
|
|
for {
|
|
state, err := given()
|
|
switch {
|
|
case err != nil:
|
|
fmt.Fprintf(os.Stderr, "cannot read what was granted: %v\n", err)
|
|
case state != last:
|
|
if err := run(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
} else {
|
|
last = state
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-time.After(every):
|
|
}
|
|
}
|
|
}
|
|
|
|
// given digests everything delivered, so a change of any of it is one comparison and no secret is
|
|
// held beyond its hash.
|
|
func given() (string, error) {
|
|
entries, err := os.ReadDir(grantsDir())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
names = append(names, e.Name())
|
|
}
|
|
sort.Strings(names)
|
|
sum := sha256.New()
|
|
for _, name := range names {
|
|
body, err := os.ReadFile(filepath.Join(grantsDir(), name))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Fprintf(sum, "%s:%x\n", name, sha256.Sum256(body))
|
|
}
|
|
return hex.EncodeToString(sum.Sum(nil)), nil
|
|
}
|
|
|
|
func grantsDir() string {
|
|
if grants := os.Getenv("GRANTS"); grants != "" {
|
|
return grants
|
|
}
|
|
return "/var/lib/redis-module/grants"
|
|
}
|
|
|
|
func run(ctx context.Context) error {
|
|
raw, err := os.ReadFile(filepath.Join(grantsDir(), "mesh.json"))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
fmt.Printf("nothing has been granted to this machine\n")
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
var m manifest
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return fmt.Errorf("the manifest at %s is not readable: %w", grantsDir(), err)
|
|
}
|
|
|
|
r, err := connect(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.Close()
|
|
|
|
// Reconciling, not applying a change: the same state from wherever it starts.
|
|
wanted := map[string]bool{}
|
|
for _, c := range m.Given {
|
|
if c.Node == "" {
|
|
continue
|
|
}
|
|
prefix, _ := c.Values["prefix"].(string)
|
|
if prefix == "" {
|
|
return fmt.Errorf("%s asked for a cache and did not say its key prefix", c.From)
|
|
}
|
|
if err := usablePrefix(prefix); err != nil {
|
|
return fmt.Errorf("%s: %w", c.From, err)
|
|
}
|
|
password, err := os.ReadFile(c.Secret)
|
|
if err != nil {
|
|
return fmt.Errorf("%s's credential should be at %s and is not there", c.Node, c.Secret)
|
|
}
|
|
user := c.As
|
|
if user == "" {
|
|
return fmt.Errorf("%s on %s was granted a cache and the mesh did not say what to "+
|
|
"call its user, so there is no name both ends would agree on", c.From, c.Node)
|
|
}
|
|
if !strings.HasPrefix(user, mark) {
|
|
return fmt.Errorf("%s on %s is to be called %q, which does not begin with %q — "+
|
|
"withdrawal finds this provisioner's work by that prefix, so it would never let "+
|
|
"this one go", c.From, c.Node, user, mark)
|
|
}
|
|
wanted[user] = true
|
|
|
|
// One SETUSER, from reset: the whole grant every time, so a rotation replaces the
|
|
// password and a changed prefix replaces the keyspace, with no residue of what was.
|
|
if _, err := r.do("ACL", "SETUSER", user, "reset", "on",
|
|
">"+strings.TrimSpace(string(password)),
|
|
"~"+prefix+":*", "&"+prefix+":*",
|
|
"+@all", "-@admin", "-@dangerous"); err != nil {
|
|
return fmt.Errorf("granting %s: %w", user, err)
|
|
}
|
|
fmt.Printf("granted %s the keyspace %s:*\n", user, prefix)
|
|
}
|
|
|
|
// And every user this provisioner made that nobody asks for any more — the half usually
|
|
// missing, without which a departed consumer keeps a working login for ever.
|
|
users, err := r.strings("ACL", "USERS")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, user := range users {
|
|
if !strings.HasPrefix(user, mark) || wanted[user] {
|
|
continue
|
|
}
|
|
if _, err := r.do("ACL", "DELUSER", user); err != nil {
|
|
return fmt.Errorf("revoking %s: %w", user, err)
|
|
}
|
|
fmt.Printf("revoked %s\n", user)
|
|
}
|
|
|
|
// Persisted, or the next restart forgets every grant. The server runs with an aclfile for
|
|
// exactly this; a server without one refuses the save, and saying so beats a cache that
|
|
// silently loses its tenants on restart.
|
|
if _, err := r.do("ACL", "SAVE"); err != nil {
|
|
return fmt.Errorf("the grants were applied and could not be persisted — a restart would "+
|
|
"forget them: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resp is the five commands this needs, spoken directly.
|
|
type resp struct {
|
|
conn net.Conn
|
|
in *bufio.Reader
|
|
}
|
|
|
|
func connect(ctx context.Context) (*resp, error) {
|
|
where := os.Getenv("MESH_PROVISION_REDIS")
|
|
if where == "" {
|
|
where = "127.0.0.1:6379"
|
|
}
|
|
var d net.Dialer
|
|
conn, err := d.DialContext(ctx, "tcp", where)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := &resp{conn: conn, in: bufio.NewReader(conn)}
|
|
|
|
file := os.Getenv("MESH_PROVISION_PASSWORD_FILE")
|
|
if file == "" {
|
|
return nil, fmt.Errorf("MESH_PROVISION_PASSWORD_FILE is not set, and an unauthenticated " +
|
|
"provisioner would mean an unauthenticated store")
|
|
}
|
|
password, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := r.do("AUTH", strings.TrimSpace(string(password))); err != nil {
|
|
return nil, fmt.Errorf("the store did not accept the password the mesh sealed here: %w", err)
|
|
}
|
|
if _, err := r.do("PING"); err != nil {
|
|
return nil, err
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (r *resp) Close() { _ = r.conn.Close() }
|
|
|
|
// do sends one command and returns the reply, flattened to a string for the simple kinds.
|
|
func (r *resp) do(args ...string) (any, error) {
|
|
var out strings.Builder
|
|
fmt.Fprintf(&out, "*%d\r\n", len(args))
|
|
for _, a := range args {
|
|
fmt.Fprintf(&out, "$%d\r\n%s\r\n", len(a), a)
|
|
}
|
|
if _, err := r.conn.Write([]byte(out.String())); err != nil {
|
|
return nil, err
|
|
}
|
|
return r.read()
|
|
}
|
|
|
|
// strings is do, for the replies that are arrays of bulk strings.
|
|
func (r *resp) strings(args ...string) ([]string, error) {
|
|
reply, err := r.do(args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items, ok := reply.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected an array and the store said %v", reply)
|
|
}
|
|
var out []string
|
|
for _, item := range items {
|
|
if s, ok := item.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *resp) read() (any, error) {
|
|
line, err := r.in.ReadString('\n')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
if line == "" {
|
|
return nil, fmt.Errorf("the store sent an empty reply")
|
|
}
|
|
body := line[1:]
|
|
switch line[0] {
|
|
case '+', ':':
|
|
return body, nil
|
|
case '-':
|
|
// The store's own words, verbatim: it says exactly what it refused and why.
|
|
return nil, fmt.Errorf("%s", body)
|
|
case '$':
|
|
if body == "-1" {
|
|
return nil, nil
|
|
}
|
|
var n int
|
|
fmt.Sscanf(body, "%d", &n)
|
|
buf := make([]byte, n+2)
|
|
if _, err := readFull(r.in, buf); err != nil {
|
|
return nil, err
|
|
}
|
|
return string(buf[:n]), nil
|
|
case '*':
|
|
var n int
|
|
fmt.Sscanf(body, "%d", &n)
|
|
items := make([]any, 0, n)
|
|
for range n {
|
|
item, err := r.read()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, nil
|
|
}
|
|
return nil, fmt.Errorf("the store began a reply with %q, which this does not speak", line[0])
|
|
}
|
|
|
|
func readFull(in *bufio.Reader, buf []byte) (int, error) {
|
|
total := 0
|
|
for total < len(buf) {
|
|
n, err := in.Read(buf[total:])
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
total += n
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
// usablePrefix refuses a prefix that would grant more keyspace than anyone read in the manifest.
|
|
//
|
|
// The grant is written into an ACL pattern, so a prefix carrying pattern characters stops meaning
|
|
// itself: `pho*` granted as `pho*:*` matches every tenant whose name starts with pho. The grant
|
|
// must mean what it says, so anything Redis would read as a pattern is refused rather than
|
|
// escaped — escaping would create a second naming scheme the mesh does not know about.
|
|
func usablePrefix(prefix string) error {
|
|
if prefix == "" {
|
|
return fmt.Errorf("the key prefix is empty, which would grant the whole keyspace")
|
|
}
|
|
if strings.ContainsAny(prefix, " \t\n\r*?[]^{}") {
|
|
return fmt.Errorf("the key prefix %q contains characters Redis reads as a pattern, so "+
|
|
"the grant would match more than it names", prefix)
|
|
}
|
|
return nil
|
|
}
|