Files
holos/internal/logger/logger.go
Jeff McCune 8b22ba04e1 cli: add show command and refactor interfaces (#331)
Show subcommand:

This is large change that accomplishes a number of goals.  First, there
was no convenient way to show a build plan without using the debug logs
to indentify the tags to inject, then calling the cue command with the
right incantation to inspect the BuildPlan.

This patch addresses the problem by adding a `holos show buildplans`
command.  The command loads the Platform spec from the platform
directory, then iterates over all Components to produce the BuildPlan.

This patch adds labels and annotations to the platform Components
collection in order to select and filter the output.

Result:

```
❯ holos show components --selector app.holos.run/cluster=local --format=yaml | head
kind: BuildPlan
apiversion: v1alpha5
metadata:
  name: podinfo
spec:
  artifacts:
    - artifact: clusters/local/components/podinfo/podinfo.gen.yaml
      generators:
        - kind: Helm
          output: helm.gen.yaml
```

---

Interface refactor:

This refactors the interface between the `holos` Go CLI layer and the
various core schema data structures.  We now use a proper Go interface.
Concurrent execution over platform components has been improved to
accept a closure function so we can use the same interface method to
process the components.  We use this to show each component and render
each component from different subcommands using the same interface
embedded in the builder.Platform struct.

The embedded interface allows us to easily swap in different versions,
e.g. v1beta1 and eventually v1.  The number of interface methods are
quite small.  14 methods across 4 interfaces in holos/interface.go.

---

Remove old versions:

This patch removes support for versions prior to v1alpha5 in an effort
to clean up cruft.
2024-11-20 11:23:51 -08:00

224 lines
5.9 KiB
Go

// Package logger provides logging configuration and helpers to pass a logger instance through the context.
package logger
import (
"context"
"flag"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/holos-run/holos/internal/console"
"github.com/holos-run/holos/internal/errors"
"github.com/holos-run/holos/internal/tint"
"github.com/holos-run/holos/version"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
)
const ErrKey = "err"
var validLogLevels = []string{"debug", "info", "warn", "error"}
var validLogFormats = []string{"text", "json", "console"}
// stringSlice is a comma separated list of string values
type stringSlice []string
func (s stringSlice) String() string {
return strings.Join((s)[:], ",")
}
func (s stringSlice) Set(value string) error {
_ = append(s, strings.Split(value, ",")...)
return nil
}
// key is an unexported type for keys defined in this package to prevent
// collisions with keys defined in other packages.
type key int
// https://cs.opensource.google/go/go/+/refs/tags/go1.21.1:src/context/context.go;l=140-158
// loggerKey is the key for *slog.Logs values in Contexts. It is not exported;
// clients use NewContext and FromContext instead of this key directly.
var loggerKey key
// NewContext returns a new Context that carries value logger. Use FromContext
// to retrieve the value.
func NewContext(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, logger)
}
// FromContext returns the *slog.Logs previously stored in ctx by NewContext.
// slog.Default() is returned otherwise.
func FromContext(ctx context.Context) (logger *slog.Logger) {
// https://go.dev/ref/spec#Type_assertions
if logger = FromContextMaybe(ctx); logger != nil {
return
}
return slog.Default()
}
// FromContextMaybe returns the *slog.Logs previously stored in ctx by NewContext or nil.
func FromContextMaybe(ctx context.Context) (logger *slog.Logger) {
logger, _ = ctx.Value(loggerKey).(*slog.Logger)
return
}
// FromRootCommand returns a logger from the root cobra.Command context or nil.
func FromRootCommand(cmd *cobra.Command) (logger *slog.Logger) {
// Refer to https://github.com/spf13/cobra/issues/1469#issuecomment-1248067736
if cmd == nil {
return nil
}
for cmd.Parent() != nil {
cmd = cmd.Parent()
}
return FromContextMaybe(cmd.Context())
}
// Config specifies user configurable flag values to create a NewLogger
type Config struct {
level string
format string
dropAttrs stringSlice
flagSet *flag.FlagSet
}
func (c *Config) Level() string {
return c.level
}
func (c *Config) Format() string {
return c.format
}
// GetLogLevel returns a slog.Level configured by the user
//
// A non-zero length DEBUG env var takes precedence over config fields.
func (c *Config) GetLogLevel() slog.Level {
if os.Getenv("DEBUG") != "" {
return slog.LevelDebug
}
switch strings.ToLower(c.level) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func (c *Config) ReplaceAttr(groups []string, a slog.Attr) slog.Attr {
if slices.Contains(c.dropAttrs, a.Key) {
return slog.Attr{}
}
// Check if err
if a.Key == ErrKey {
if err, ok := a.Value.Any().(error); ok {
return tint.Err(err)
}
if err, ok := a.Value.Any().(string); ok {
return tint.Err(errors.New(err))
}
} else if a.Key == slog.SourceKey {
source := a.Value.Any().(*slog.Source)
source.File = filepath.Base(source.File)
}
return a
}
func (c *Config) handler(w io.Writer) (h slog.Handler) {
level := c.GetLogLevel()
switch c.format {
case "text":
noColor := true
if file, ok := w.(*os.File); ok {
noColor = !isatty.IsTerminal(file.Fd())
}
h = tint.NewHandler(w, &tint.Options{
Level: level,
TimeFormat: time.Kitchen,
AddSource: true,
ReplaceAttr: c.ReplaceAttr,
NoColor: noColor,
})
case "console":
h = console.NewHandler(w, &console.Options{Level: level})
default:
h = slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: level,
AddSource: true,
ReplaceAttr: c.ReplaceAttr,
})
}
return
}
// NewLogger returns a *slog.Logs configured by c *Config which writes to w
func (c *Config) NewLogger(w io.Writer) *slog.Logger {
return slog.New(c.handler(w)).With("version", version.Version, "pid", os.Getpid())
}
// NewConfig returns a new logging Config struct
func NewConfig() *Config {
f := flag.NewFlagSet("", flag.ContinueOnError)
c := &Config{flagSet: f}
f.StringVar(&c.level, "log-level", getenv("HOLOS_LOG_LEVEL", "info"), fmt.Sprintf("log level (%s)", strings.Join(validLogLevels, "|")))
f.StringVar(&c.format, "log-format", getenv("HOLOS_LOG_FORMAT", "console"), fmt.Sprintf("log format (%s)", strings.Join(validLogFormats, "|")))
f.Var(&c.dropAttrs, "log-drop", "log attributes to drop (example \"user-agent,version\")")
return c
}
// FlagSet returns the go flag set to configure logging
func (c *Config) FlagSet() *flag.FlagSet {
return c.flagSet
}
// Vet validates the config values
func (c *Config) Vet() error {
if err := c.vetLevel(); err != nil {
return err
}
if err := c.vetFormat(); err != nil {
return err
}
return nil
}
func (c *Config) vetLevel() error {
for _, validLevel := range validLogLevels {
if c.level == validLevel {
return nil
}
}
err := fmt.Errorf("invalid log level: %s is not one of %s", c.level, strings.Join(validLogLevels, ", "))
return errors.Wrap(err)
}
func (c *Config) vetFormat() error {
for _, validFormat := range validLogFormats {
if c.format == validFormat {
return nil
}
}
err := fmt.Errorf("invalid log format: %s is not one of %s", c.format, strings.Join(validLogFormats, ", "))
return errors.Wrap(err)
}
// getenv is equivalent to os.Getenv() with a default value
func getenv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}