Files
llmgw/internal/cli/serverinit_patroni.go
T
Ilker ManapandClaude Opus 5.5 2b29016d5b Ask for a password on Patroni's API and give the store TLS, new clusters and existing ones in place
Patroni's REST API accepted changes from anyone on port 8008, and
PostgreSQL ran with sslmode=disable between machines. Setup now keeps a
fourth secret for the API (carried to members of older clusters), issues
each member a certificate from the cluster's authority, writes
restapi.authentication into each member's config and reloads it, and
turns ssl on with hostssl remote rules through an authenticated PATCH of
the shared configuration. The DSN becomes sslmode=verify-full against the
authority's certificate whenever the cluster has TLS, so a control plane
without the authority's key reconnects too. server add authenticates its
PATCH and lets the new control plane in by hostssl. Tried on the e2e
cluster: unauthenticated PATCH 401, plaintext refused, replication
streaming, the control plane on verify-full.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:11:28 +02:00

215 lines
6.5 KiB
Go

package cli
import (
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// ensurePatroni puts the store in PostgreSQL under Patroni on the machines
// given with --patroni, and moves whatever store server.yaml names into it:
// SQLite, or a PostgreSQL on one machine. This machine is the third etcd vote
// when they are two.
func (in *serverInit) ensurePatroni(ctx context.Context) (err error) {
setup := pgha.Setup{Emit: func(step, detail string, changed bool) {
if changed {
in.done("%s: %s", step, detail)
} else {
in.kept("%s: %s", step, detail)
}
}}
setup.DB = in.patroniNodes
for _, t := range in.patroni {
if len(in.patroniNodes) > 0 {
break
}
user, host, ok := strings.Cut(t, "@")
if !ok {
user, host = "", t
}
remote := &onboard.SSH{Addr: host, User: user}
out, err := remote.Run(ctx, "hostname && sudo -n true", nil)
if err != nil {
return fmt.Errorf("%s: cannot log in and use sudo without a password: %v: %s",
t, err, strings.TrimSpace(string(out)))
}
ip, err := ipv4Of(host)
if err != nil {
return err
}
setup.DB = append(setup.DB, pgha.Node{
Member: pgha.Member{Name: strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]), IP: ip},
Remote: remote,
})
}
self, err := sourceIPToward(setup.DB[0].IP)
if err != nil {
return err
}
name, _ := os.Hostname()
setup.Witness = &pgha.Node{Member: pgha.Member{Name: name, IP: self}, Remote: pgha.Local{}}
setup.Clients = []string{self}
cfg, err := config.Load(in.configPath())
if err != nil {
return err
}
// The cluster's authority signs each member's certificate, and the
// control plane verifies the member it reaches against it: where its
// key is on this machine, and the server has the certificate to read.
setup.CAFile = cfg.AgentTLS.CA
if ca, cerr := loadCA(in.pkiDir); cerr == nil && cfg.AgentTLS.CA != "" {
setup.CA = ca
} else {
in.kept("store: the authority's key is not here, so no member certificates are signed from this machine; " +
"run this on the control plane that holds it to turn TLS on")
}
dsn, err := setup.Run(ctx)
if err != nil {
return err
}
if err := in.writeSecret(patroniRestFile(in.etcDir), setup.RestPassword); err != nil {
return err
}
current := cfg.Auth.Store
if current == "" {
current = in.storeAddress()
}
resolved, _ := store.ResolveAddress(current)
if strings.TrimSpace(resolved) == dsn {
in.kept("store in the Patroni cluster (%s)", in.dsnPath())
return nil
}
// Still while it moves, and started again on the old store if the move
// fails: a gateway down over a database that did not work out is worse
// than one left where it was.
stopped, err := in.stopService(ctx)
if err != nil {
return err
}
defer func() {
if err != nil && stopped {
_ = exec.Command("systemctl", "start", "llmgw-server").Run()
fmt.Fprintf(in.out, " .. llmgw-server started again on its old store\n")
}
}()
if err := in.moveInto(ctx, current, dsn); err != nil {
return err
}
// The old address is kept beside the new one: the way back, if the
// cluster is not what was wanted.
if b, err := os.ReadFile(in.dsnPath()); err == nil && strings.TrimSpace(string(b)) != dsn {
if err := os.WriteFile(in.dsnPath()+".pre-patroni", b, 0o600); err != nil {
return err
}
_ = in.chown(in.dsnPath()+".pre-patroni", true)
}
if err := in.writeDSN(dsn); err != nil {
return err
}
if current != "@"+in.dsnPath() {
if err := setAuthStore(in.configPath(), "@"+in.dsnPath()); err != nil {
return err
}
}
// Started again by the service step that follows, on the new address.
in.done("server.yaml now keeps the store in the Patroni cluster")
return nil
}
// moveInto copies a store into the cluster, unless the cluster already holds
// one — from a run that copied and stopped before changing the address.
func (in *serverInit) moveInto(ctx context.Context, from, to string) error {
src, err := store.Open(ctx, from)
if err != nil {
// Nothing there yet: a first install, with nothing to move.
in.kept("no store at %s to move", from)
return nil
}
defer src.Close()
dst, err := store.Open(ctx, to)
if err != nil {
return fmt.Errorf("opening the cluster: %w", err)
}
defer dst.Close()
empty, err := dst.Empty(ctx)
if err != nil {
return err
}
if !empty {
in.kept("the cluster already holds a store; %s is left as it is", from)
return nil
}
res, err := src.CopyTo(ctx, dst)
if err != nil {
return fmt.Errorf("copying the store into the cluster: %w", err)
}
var rows int64
for _, r := range res {
rows += r.Rows
}
in.done("copied %d rows into the cluster (the old store is kept as it was)", rows)
return nil
}
// sourceIPToward is this machine's address on the route to another: the one
// the other machine sees it connect from, which is what etcd advertises and
// what the database lets in. Not the first --host, which names the gateway
// for its clients and may not resolve yet, or resolve to a floating address.
func sourceIPToward(ip string) (string, error) {
c, err := net.Dial("udp", net.JoinHostPort(ip, "5432"))
if err != nil {
return "", fmt.Errorf("no route from this machine to %s: %w", ip, err)
}
defer c.Close()
return c.LocalAddr().(*net.UDPAddr).IP.String(), nil
}
// ipv4Of is the address a host name or address stands for, as the other
// machines will be told to reach it.
func ipv4Of(host string) (string, error) {
if ip := net.ParseIP(host); ip != nil {
return ip.String(), nil
}
ips, err := net.LookupIP(host)
if err != nil {
return "", fmt.Errorf("%s does not resolve: %w", host, err)
}
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
return v4.String(), nil
}
}
return "", fmt.Errorf("%s has no IPv4 address", host)
}
// patroniRestFile holds what Patroni's API asks for before a change, on the
// control plane: for `server add`, which lets a second one in.
func patroniRestFile(etcDir string) string { return filepath.Join(etcDir, "secrets", "patroni-rest") }
// writeSecret writes a secret root alone reads, unless it is there already.
func (in *serverInit) writeSecret(path, value string) error {
if value == "" {
return nil
}
if b, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(b)) == value { //nolint:gosec // our own secret
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
return os.WriteFile(path, []byte(value+"\n"), 0o600)
}