flags.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"errors"
"flag"
"fmt"
"log/slog"
"math"
"net"
"net/url"
"os"
"strconv"
"time"
"github.com/sophotechlabs/spinoza/internal/cluster"
"github.com/sophotechlabs/spinoza/internal/debugcontainer"
"github.com/sophotechlabs/spinoza/internal/helm"
"github.com/sophotechlabs/spinoza/internal/imagepin"
"github.com/sophotechlabs/spinoza/internal/nodeshell"
)
var errHelp = errors.New("help requested")
var errUnknownLogFormat = errors.New("log-format has to be json or text")
const (
defaultQPS = 50
defaultBurst = 100
defaultSync = 30 * time.Second
defaultWarm = 8
defaultCountBudget = 20 * time.Second
defaultCountPerType = 5 * time.Second
defaultCountConcurrency = 24
)
type settings struct {
addr string
openBrowser bool
tokenFile string
logLevel slog.Level
logFormat string
metricsAddr string
recordSessions bool
auditInterval time.Duration
auditWebhook string
auditRetention time.Duration
showVersion bool
showLicense bool
pprof bool
nodeShell bool
startView string
serve serving
cluster cluster.Options
}
func parseFlags(args []string) (settings, error) {
flags := flag.NewFlagSet("spinoza", flag.ContinueOnError)
addr := flags.String("addr", envOr("SPINOZA_ADDR", "127.0.0.1:34115"), "listen address; loopback only")
openBrowser := flags.Bool("open", envBool("SPINOZA_OPEN"), "open the default browser on start")
tokenFile := flags.String("token-file", envOr("SPINOZA_TOKEN_FILE", ""), "write this run's access token to this file (mode 0600) so scripts can read it")
logLevel := flags.String("log-level", envOr("SPINOZA_LOG_LEVEL", "info"), "log level: debug, info, warn or error")
logFormat := flags.String("log-format", envOr("SPINOZA_LOG_FORMAT", ""), "log format: json or text; text on your own machine and json when serving a cluster")
metricsAddr := flags.String("metrics-addr", envOr("SPINOZA_METRICS_ADDR", ""), "extra listen address serving only /metrics, with no session; keep it inside the cluster")
recordSessions := flags.Bool("record-sessions", envBool("SPINOZA_RECORD_SESSIONS"), "keep a transcript of every exec and node shell beside its audit entry; admins read them")
auditInterval := flags.Duration("audit-interval", envDuration("SPINOZA_AUDIT_INTERVAL", 0), "re-run the checks on this interval and keep what each run found; off when zero")
auditWebhook := flags.String("audit-webhook", envOr("SPINOZA_AUDIT_WEBHOOK", ""), "post a json summary here when a scheduled run differs from the baseline")
auditRetention := flags.Duration("audit-retention", envDuration("SPINOZA_AUDIT_RETENTION", 0), "how long recorded changes and audit runs are kept; kept for good when zero")
showVersion := flags.Bool("version", false, "print the version and exit")
showLicense := flags.Bool("license", false, "print the license and exit")
profiler := flags.Bool("pprof", envBool("SPINOZA_PPROF"), "mount net/http/pprof under /debug/pprof, behind the same auth; off by default")
debugImage := flags.String("debug-image", envOr("SPINOZA_DEBUG_IMAGE", debugcontainer.DefaultImage), "image used for debug containers")
nodeShell := flags.Bool("node-shell", envBool("SPINOZA_NODE_SHELL"), "allow a root shell on a node, which creates a privileged pod")
nodeShellImage := flags.String("node-shell-image", envOr("SPINOZA_NODE_SHELL_IMAGE", debugcontainer.DefaultImage), "image the node shell pod runs")
nodeShellNamespace := flags.String("node-shell-namespace", envOr("SPINOZA_NODE_SHELL_NAMESPACE", nodeshell.DefaultNamespace), "namespace the node shell pod is created in")
kubectlBinary := flags.String("kubectl", envOr("SPINOZA_KUBECTL", debugcontainer.DefaultBinary), "kubectl binary used to create debug containers")
helmBinary := flags.String("helm", envOr("SPINOZA_HELM", helm.DefaultBinary), "helm binary used to roll back and uninstall releases")
promSpec := flags.String("prometheus", envOr("SPINOZA_PROMETHEUS", ""), "prometheus service as namespace/service:port; discovered when empty")
kubeconfig := flags.String("kubeconfig", envOr("SPINOZA_KUBECONFIG", ""), "kubeconfig to read; the usual lookup rules when empty")
startView := flags.String("view", envOr("SPINOZA_START_VIEW", ""), "view to open on when nothing else asks for one")
startContext := flags.String("context", envOr("SPINOZA_START_CONTEXT", ""), "kubeconfig context to open on start; the current one when empty")
clientQPS := flags.Float64("qps", envFloat("SPINOZA_QPS", defaultQPS), "apiserver requests per second this client allows itself")
clientBurst := flags.Int("burst", envInt("SPINOZA_BURST", defaultBurst), "apiserver requests this client may burst to")
syncTimeout := flags.Duration("sync-timeout", envDuration("SPINOZA_SYNC_TIMEOUT", defaultSync), "how long one resource type may take to fill its cache")
warmConcurrency := flags.Int("warm-concurrency", envInt("SPINOZA_WARM_CONCURRENCY", defaultWarm), "resource types warmed at once for the gitops and flux views")
countBudget := flags.Duration("count-budget", envDuration("SPINOZA_COUNT_BUDGET", defaultCountBudget), "total time the sidebar counts may take")
countPerType := flags.Duration("count-timeout", envDuration("SPINOZA_COUNT_TIMEOUT", defaultCountPerType), "time one resource type may take to be counted")
countConcurrency := flags.Int("count-concurrency", envInt("SPINOZA_COUNT_CONCURRENCY", defaultCountConcurrency), "resource types counted at once")
serving := registerCluster(flags)
err := flags.Parse(args)
if errors.Is(err, flag.ErrHelp) {
return settings{}, errHelp
}
if err != nil {
return settings{}, err
}
level, levelErr := parseLevel(*logLevel)
if levelErr != nil {
return settings{}, levelErr
}
format, formatErr := parseLogFormat(*logFormat)
if formatErr != nil {
return settings{}, formatErr
}
served, servedErr := serving.settings()
if servedErr != nil {
return settings{}, servedErr
}
clusterSettings := cluster.Options{
Impersonate: served.on && served.impersonate,
DebugImage: *debugImage,
NodeShellImage: *nodeShellImage,
NodeShellNS: *nodeShellNamespace,
KubectlBinary: *kubectlBinary,
HelmBinary: *helmBinary,
PromSpec: *promSpec,
Kubeconfig: *kubeconfig,
Context: *startContext,
ClientQPS: float32(*clientQPS),
ClientBurst: *clientBurst,
SyncTimeout: *syncTimeout,
WarmConcurrency: *warmConcurrency,
CountBudget: *countBudget,
CountPerType: *countPerType,
CountConcurrency: *countConcurrency,
}
if !*showVersion && !*showLicense {
runnable := runnableSettings{
served: served,
cluster: clusterSettings,
nodeShell: *nodeShell,
addr: *addr,
metrics: *metricsAddr,
webhook: *auditWebhook,
interval: *auditInterval,
}
checkErr := runnable.check()
if checkErr != nil {
return settings{}, checkErr
}
}
return settings{
addr: listenAddress(*addr, served.on, wasGiven(flags, "addr")),
openBrowser: *openBrowser,
tokenFile: *tokenFile,
logLevel: level,
logFormat: logFormatFor(format, served.on),
metricsAddr: *metricsAddr,
recordSessions: *recordSessions,
auditInterval: *auditInterval,
auditWebhook: *auditWebhook,
auditRetention: *auditRetention,
showVersion: *showVersion,
showLicense: *showLicense,
pprof: *profiler,
nodeShell: *nodeShell,
startView: *startView,
serve: served,
cluster: clusterSettings,
}, nil
}
const minAuditInterval = time.Minute
type runnableSettings struct {
served serving
cluster cluster.Options
nodeShell bool
addr string
metrics string
webhook string
interval time.Duration
}
func (rs runnableSettings) check() error {
servedErr := rs.served.check()
if servedErr != nil {
return servedErr
}
limitErr := validateClusterLimits(rs.cluster)
if limitErr != nil {
return limitErr
}
if rs.nodeShell && !imagepin.Valid(rs.cluster.NodeShellImage) {
return errors.New("node-shell-image must be pinned by sha256 digest")
}
metricsErr := checkMetricsAddr(rs.metrics, rs.addr)
if metricsErr != nil {
return metricsErr
}
webhookErr := checkAuditWebhook(rs.webhook)
if webhookErr != nil {
return webhookErr
}
if rs.interval != 0 && rs.interval < minAuditInterval {
return fmt.Errorf("audit-interval has to be at least %s; the checks read the whole cluster", minAuditInterval)
}
return nil
}
func checkMetricsAddr(metrics, main string) error {
if metrics == "" {
return nil
}
_, _, err := net.SplitHostPort(metrics)
if err != nil {
return fmt.Errorf("metrics-addr %q must be host:port", metrics)
}
if metrics == main {
return errors.New("metrics-addr has to differ from addr; the metrics port answers without a session")
}
return nil
}
func checkAuditWebhook(raw string) error {
if raw == "" {
return nil
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("audit-webhook %q is not a url", raw)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("audit-webhook %q has to be http or https", raw)
}
if parsed.Host == "" {
return fmt.Errorf("audit-webhook %q names no host", raw)
}
return nil
}
func validateClusterLimits(options cluster.Options) error {
qps := float64(options.ClientQPS)
if qps <= 0 || math.IsNaN(qps) || math.IsInf(qps, 0) {
return errors.New("qps must be a finite positive number")
}
if options.ClientBurst <= 0 {
return errors.New("burst must be positive")
}
if options.SyncTimeout <= 0 {
return errors.New("sync-timeout must be positive")
}
if options.WarmConcurrency <= 0 {
return errors.New("warm-concurrency must be positive")
}
if options.CountBudget <= 0 {
return errors.New("count-budget must be positive")
}
if options.CountPerType <= 0 {
return errors.New("count-timeout must be positive")
}
if options.CountConcurrency <= 0 {
return errors.New("count-concurrency must be positive")
}
return nil
}
func wasGiven(flags *flag.FlagSet, name string) bool {
found := false
flags.Visit(func(one *flag.Flag) {
if one.Name == name {
found = true
}
})
return found
}
func listenAddress(addr string, serving, given bool) string {
if !serving || given {
return addr
}
_, fromEnv := os.LookupEnv("SPINOZA_ADDR")
if fromEnv {
return addr
}
return clusterAddr
}
func parseLevel(name string) (slog.Level, error) {
switch name {
case "debug":
return slog.LevelDebug, nil
case "info":
return slog.LevelInfo, nil
case "warn":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, fmt.Errorf("log level %q is not one of debug, info, warn, error", name)
}
}
func envOr(name, fallback string) string {
value, present := os.LookupEnv(name)
if !present {
return fallback
}
return value
}
func envInt(name string, fallback int) int {
value, present := os.LookupEnv(name)
if !present {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func envFloat(name string, fallback float64) float64 {
value, present := os.LookupEnv(name)
if !present {
return fallback
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return fallback
}
return parsed
}
func envDuration(name string, fallback time.Duration) time.Duration {
value, present := os.LookupEnv(name)
if !present {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}
func envBool(name string) bool {
value, present := os.LookupEnv(name)
if !present {
return false
}
parsed, err := strconv.ParseBool(value)
if err != nil {
return false
}
return parsed
}
func settingsFromArgs() (settings, error) {
return parseFlags(os.Args[1:])
}
func writeTokenFile(path, token string) error {
if path == "" {
return nil
}
_ = os.Remove(path)
err := os.WriteFile(path, []byte(token+"\n"), 0o600)
if err != nil {
return fmt.Errorf("token file: %w", err)
}
return nil
}