Files
spinifex/cmd/rds-agent/bootstrap.go
T

172 lines
5.6 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// Fetches the boot material and writes the handoff rds-init is blocked waiting
// for, then points the health probe at the assigned port.
func (a *Agent) bootstrap(ctx context.Context) error {
var cfg *handlers_rds.GetDBBootstrapConfigOutput
if err := retry(ctx, "bootstrap fetch", func(ctx context.Context) error {
fetched, err := a.cp.GetBootstrapConfig(ctx, a.id)
if err != nil {
return err
}
if fetched == nil {
return fmt.Errorf("bootstrap fetch returned no config")
}
cfg = fetched
return nil
}); err != nil {
return err
}
// Once an initialize response reaches the guest, keep retrying that same
// payload. Re-fetching after a local write failure returns attach because the
// control plane has already consumed the one-shot password.
if err := retry(ctx, "bootstrap handoff", func(context.Context) error {
return a.handoffWriter(a.cfg.HandoffDir, cfg)
}); err != nil {
return err
}
if cfg.Port > 0 {
a.probe.setPort(int(cfg.Port))
a.engine.setPort(int(cfg.Port))
}
// Mode is logged but never branched on: rds-init decides whether to initdb
// from the state of the datadir.
slog.Info("rds-agent: bootstrap config delivered",
"mode", cfg.Mode, "port", cfg.Port, "parameters", len(cfg.Parameters))
return nil
}
// The handoff files rds-init reads. They live on tmpfs, so nothing survives a
// reboot and the next boot re-fetches rather than reusing a stale password or a
// re-minted cert.
const (
handoffEnvFile = "bootstrap.env"
handoffParamsFile = "parameters.conf"
handoffCertFile = "server.crt"
handoffKeyFile = "server.key"
// Root-only: only rds-init reads these, and one holds the master password.
handoffMode = 0o600
handoffDirMode = 0o700
)
// bootstrap.env is written last and renamed into place, so its appearance —
// which rds-init waits on — means the whole handoff is complete.
func writeHandoff(dir string, cfg *handlers_rds.GetDBBootstrapConfigOutput) error {
if cfg.MasterUsername == "" {
return fmt.Errorf("bootstrap config carries no master username")
}
if err := os.MkdirAll(dir, handoffDirMode); err != nil {
return fmt.Errorf("create handoff dir %s: %w", dir, err)
}
// MkdirAll leaves an existing directory's mode alone, and this one may have
// been created more permissively earlier in the boot.
if err := os.Chmod(dir, handoffDirMode); err != nil {
return fmt.Errorf("secure handoff dir %s: %w", dir, err)
}
if err := writeHandoffFile(dir, handoffParamsFile, renderParameters(cfg.Parameters)); err != nil {
return err
}
// Cert and key are written as a pair; half of one would start the engine
// with TLS configured against a key it does not have.
if cfg.ServingCertificate != "" && cfg.ServingPrivateKey != "" {
if err := writeHandoffFile(dir, handoffCertFile, cfg.ServingCertificate); err != nil {
return err
}
if err := writeHandoffFile(dir, handoffKeyFile, cfg.ServingPrivateKey); err != nil {
return err
}
}
return writeHandoffFile(dir, handoffEnvFile, renderBootstrapEnv(cfg))
}
// Every value is single-quoted because rds-init sources this with `.`: an
// unquoted password holding a space, `$` or `;` would be word-split or executed.
func renderBootstrapEnv(cfg *handlers_rds.GetDBBootstrapConfigOutput) string {
var b strings.Builder
b.WriteString("# Written by rds-agent. Regenerated on every boot; edits are lost.\n")
writeEnvLine(&b, "RDS_MODE", cfg.Mode)
writeEnvLine(&b, "RDS_MASTER_USERNAME", cfg.MasterUsername)
// Present only in initialize mode: an attach fetch has no password to write
// and rds-init must not find a stale one.
if cfg.MasterUserPassword != nil {
writeEnvLine(&b, "RDS_MASTER_PASSWORD", *cfg.MasterUserPassword)
}
if cfg.DBName != "" {
writeEnvLine(&b, "RDS_DB_NAME", cfg.DBName)
}
if cfg.Port > 0 {
writeEnvLine(&b, "RDS_PORT", strconv.FormatInt(cfg.Port, 10))
}
return b.String()
}
func writeEnvLine(b *strings.Builder, key, value string) {
b.WriteString(key)
b.WriteString("=")
b.WriteString(shellQuote(value))
b.WriteString("\n")
}
// Single quotes suppress every shell expansion; an embedded quote is closed,
// escaped and reopened.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// postgresql.conf syntax. Values are quoted so a setting with a space or unit
// suffix survives; the engine accepts quoted numerics and booleans too.
func renderParameters(params []handlers_rds.Parameter) string {
var b strings.Builder
b.WriteString("# Resolved parameter group, written by rds-agent.\n")
for _, p := range params {
if p.Name == "" {
continue
}
fmt.Fprintf(&b, "%s = '%s'\n", p.Name, strings.ReplaceAll(p.Value, "'", "''"))
}
return b.String()
}
// Atomic: a temp file in the same directory, renamed over the target. The mode
// is set at creation, so content is never briefly readable at the process umask.
func writeHandoffFile(dir, name, content string) error {
path := filepath.Join(dir, name)
tmp := path + ".new"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, handoffMode)
if err != nil {
return fmt.Errorf("create %s: %w", tmp, err)
}
// A failed write must not leave the temp file behind holding a password.
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("write %s: %w", tmp, err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("install %s: %w", path, err)
}
return nil
}