Compare commits

..

8 Commits

Author SHA1 Message Date
Jeff McCune
a3c26bc30a Vendor tint and adjust colors to solarized dark
Makes the colors look nicer with solarized dark.  We probably need to
make solarized an option and have them default to look nice with basic
ansi colors.
2024-02-09 12:34:45 -08:00
Jeff McCune
190d0d2922 Normalize log messages
Make the log messages clear and readable, for example:

    holos render --log-format=json --log-level=debug \
      --cluster-name core2 ./docs/examples/platforms/reference/projects/secrets/components/namespaces/ \
      2> >(jq -r '"\(.source.file):\(.source.line)\t" + .msg')

The msg field is intended to have an imperative verb, ideally in the
past tense, followed by an actionable noun.  Past tense indicates
success where as the "could not foo: "+err error form indicates an
attempt to do something that failed.

    config.go:91    finalized config from flags
    builder.go:115  cue export --out yaml ./platforms/reference/projects/secrets/components/namespaces
    builder.go:85   wrote deploy/clusters/core2/components/prod-secrets-namespaces/prod-secrets-namespaces.gen.yaml
    render.go:30    rendered prod-secrets-namespaces
2024-02-09 11:47:33 -08:00
Jeff McCune
18be35a0e4 Write component output for gitops
Write the result of the cue evaluation to a cluster specific path for
git ops.  The written file works with kubectl apply -f and a future
change will add the flux Kustomization and ArgoCD Application resources
to manage the same api objects using a gitops method.

    holos render --cluster-name core2 ./docs/examples/platforms/reference/projects/secrets/components/namespaces/
2024-02-09 11:06:13 -08:00
Jeff McCune
e2b1fa0d47 Rename cue out field to content and add content-type
Content seems more appropriate of a field name, and it makes sense since
we are likely to output other formats than yaml, probably json too.  We
need to discriminate on content type, so also add a contentType field.

Semantics are meant to be the same has the http content type header, but
simple.
2024-02-08 22:26:07 -08:00
Jeff McCune
e018deef5a Add name field to cue output schema
The intent is for all of the output formats to share a common `name`
field, useful to construct a file name to write rendered output to for
git ops.

This is equivalent to the OrderedComponent name specified in the
platform.yaml in the prototype.
2024-02-08 22:09:00 -08:00
Jeff McCune
ba21165e67 Add holos render subcommand to write output 2024-02-08 21:45:27 -08:00
Jeff McCune
ae007df1f7 Debug log equivalent build cue command
export HOLOS_LOG_LEVEL=debug
export HOLOS_LOG_FORMAT=json
holos build ./docs/examples/platforms/reference/projects/secrets/components/namespaces >/dev/null 2> >(jq -r 'select(.cue) | .cue')

-- expect --
(cd /home/jeff/workspace/holos-run/holos/docs/examples && cue export --out text -e out ./platforms/reference/projects/secrets/components/namespaces)
2024-02-08 21:43:46 -08:00
Jeff McCune
4a9073f5be Wire main config to stderr instead of stdout 2024-02-08 06:42:11 -08:00
20 changed files with 767 additions and 81 deletions

9
.gitignore vendored
View File

@@ -1,6 +1,7 @@
bin
/vendor
/.idea
bin/
vendor/
.idea/
coverage.out
dist/
*.hold/
/deploy/

View File

@@ -1,5 +1,7 @@
package holos
// e.g. prod-secrets-namespaces
metadata: name: "\(#InputKeys.stage)-\(#InputKeys.project)-namespaces"
objects: [
#Namespace & {
metadata: name: "external-secrets"

View File

@@ -2,3 +2,8 @@ package holos
// Output schema
{} & #KubernetesObjects
#InputKeys: {
project: "secrets"
service: "eso"
}

View File

@@ -30,7 +30,7 @@ _apiVersion: "holos.run/v1alpha1"
// cluster is usually the only key necessary when working with a component on the command line.
cluster: string @tag(cluster, type=string)
// stage is usually set by the platform or project.
stage: string @tag(stage, type=string)
stage: *"prod" | string @tag(stage, type=string)
// project is usually set by the platform or project.
project: string @tag(project, type=string)
// service is usually set by the component.
@@ -68,8 +68,12 @@ _Platform: #Platform
apiVersion: _apiVersion
// kind is a discriminator of the type of output
kind: #PlatformSpec.kind | #KubernetesObjects.kind | #ChartValues.kind
// out holds the text output
out: string | *""
// name holds a unique name suitable for a filename
metadata: name: string
// contentType is the standard MIME type indicating the content type of the content field
contentType: *"application/yaml" | "application/json"
// content holds the content text output
content: string | *""
// debug returns arbitrary debug output.
debug?: _
}
@@ -82,7 +86,7 @@ _Platform: #Platform
// objects holds a list of the kubernetes api objects to configure.
objects: [...metav1.#TypeMeta] | *[]
// out holds the rendered yaml text stream of kubernetes api objects.
out: yaml.MarshalStream(objects)
content: yaml.MarshalStream(objects)
// platform returns the platform data structure for visibility / troubleshooting.
platform: _Platform
}

1
go.mod
View File

@@ -4,7 +4,6 @@ go 1.21.5
require (
cuelang.org/go v0.7.0
github.com/lmittmann/tint v1.0.4
github.com/mattn/go-isatty v0.0.20
github.com/spf13/cobra v1.7.0
)

2
go.sum
View File

@@ -26,8 +26,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=

View File

@@ -4,33 +4,27 @@ import (
"fmt"
"github.com/holos-run/holos/pkg/config"
"github.com/holos-run/holos/pkg/internal/builder"
"github.com/holos-run/holos/pkg/version"
"github.com/holos-run/holos/pkg/wrapper"
"github.com/spf13/cobra"
"strings"
)
// newCmd returns a new subcommand
func newCmd(name string) *cobra.Command {
cmd := &cobra.Command{
Use: name,
Version: version.Version,
Args: cobra.NoArgs,
CompletionOptions: cobra.CompletionOptions{
HiddenDefaultCmd: true,
},
RunE: func(c *cobra.Command, args []string) error {
return wrapper.Wrap(fmt.Errorf("could not run %v: not implemented", c.Name()))
},
SilenceUsage: true,
SilenceErrors: true,
}
return cmd
}
// build is the internal implementation of the build cli command
func build(cmd *cobra.Command, args []string) error {
build := builder.New(builder.Entrypoints(args))
return build.Run(cmd.Context())
results, err := build.Run(cmd.Context())
if err != nil {
return err
}
outs := make([]string, 0, len(results))
for _, result := range results {
outs = append(outs, result.Content)
}
out := strings.Join(outs, "---\n")
if _, err := fmt.Fprintln(cmd.OutOrStdout(), out); err != nil {
return wrapper.Wrap(err)
}
return nil
}
// newBuildCmd returns the build subcommand for the root command

46
pkg/cli/render.go Normal file
View File

@@ -0,0 +1,46 @@
package cli
import (
"fmt"
"github.com/holos-run/holos/pkg/config"
"github.com/holos-run/holos/pkg/internal/builder"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/wrapper"
"github.com/spf13/cobra"
)
func makeRenderRunFunc(cfg *config.Config) runFunc {
return func(cmd *cobra.Command, args []string) error {
if cfg.ClusterName() == "" {
return wrapper.Wrap(fmt.Errorf("missing cluster name"))
}
ctx := cmd.Context()
log := logger.FromContext(ctx)
build := builder.New(builder.Entrypoints(args))
results, err := build.Run(cmd.Context())
if err != nil {
return wrapper.Wrap(err)
}
for _, result := range results {
path := result.Filename(cfg.WriteTo(), cfg.ClusterName())
if err := result.Save(ctx, path); err != nil {
return wrapper.Wrap(err)
}
log.InfoContext(ctx, "rendered "+result.Name(), "status", "ok", "action", "save", "path", path, "name", result.Name())
}
return nil
}
}
// newRenderCmd returns the render subcommand for the root command
func newRenderCmd(cfg *config.Config) *cobra.Command {
cmd := newCmd("render [directory...]")
cmd.Args = cobra.MinimumNArgs(1)
cmd.Short = "write kubernetes api objects to the filesystem"
cmd.Flags().SortFlags = false
cmd.Flags().AddGoFlagSet(cfg.WriteFlagSet())
cmd.Flags().AddGoFlagSet(cfg.ClusterFlagSet())
cmd.RunE = makeRenderRunFunc(cfg)
return cmd
}

View File

@@ -1,13 +1,17 @@
package cli
import (
"fmt"
"github.com/holos-run/holos/pkg/config"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/version"
"github.com/holos-run/holos/pkg/wrapper"
"github.com/spf13/cobra"
"log/slog"
)
type runFunc func(c *cobra.Command, args []string) error
// New returns a new root *cobra.Command for command line execution.
func New(cfg *config.Config) *cobra.Command {
rootCmd := &cobra.Command{
@@ -32,16 +36,35 @@ func New(cfg *config.Config) *cobra.Command {
return nil
},
RunE: func(c *cobra.Command, args []string) error {
cfg.Logger().InfoContext(c.Context(), "hello")
return nil
return c.Usage()
},
}
rootCmd.SetVersionTemplate("{{.Version}}\n")
rootCmd.Flags().SortFlags = false
rootCmd.Flags().AddGoFlagSet(cfg.LogFlagSet())
rootCmd.SetOut(cfg.Stdout())
rootCmd.PersistentFlags().SortFlags = false
rootCmd.PersistentFlags().AddGoFlagSet(cfg.LogFlagSet())
// build subcommand
// subcommands
rootCmd.AddCommand(newBuildCmd(cfg))
rootCmd.AddCommand(newRenderCmd(cfg))
return rootCmd
}
// newCmd returns a new subcommand
func newCmd(name string) *cobra.Command {
cmd := &cobra.Command{
Use: name,
Version: version.Version,
Args: cobra.NoArgs,
CompletionOptions: cobra.CompletionOptions{
HiddenDefaultCmd: true,
},
RunE: func(c *cobra.Command, args []string) error {
return wrapper.Wrap(fmt.Errorf("could not run %v: not implemented", c.Name()))
},
SilenceUsage: true,
SilenceErrors: true,
}
return cmd
}

View File

@@ -11,9 +11,10 @@ import (
)
func newCommand() (*cobra.Command, *bytes.Buffer) {
var b bytes.Buffer
cmd := New(config.New(config.Stderr(&b)))
return cmd, &b
var b1, b2 bytes.Buffer
// discard stdout for now, it's a bunch of usage messages.
cmd := New(config.New(config.Stdout(&b1), config.Stderr(&b2)))
return cmd, &b2
}
func TestNewRoot(t *testing.T) {
@@ -61,9 +62,10 @@ func TestLogOutput(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("could not execute: %v", err)
}
stderr := b.String()
if !strings.Contains(stderr, "config lifecycle") {
t.Fatalf("lifecycle message missing: stderr: %v", stderr)
have := strings.TrimSpace(b.String())
want := "finalized config from flags"
if !strings.Contains(have, want) {
t.Fatalf("have does not contain want\n\thave: %#v\n\twant: %#v", have, want)
}
}

View File

@@ -29,25 +29,38 @@ func Stderr(w io.Writer) Option {
// New returns a new top level cli Config.
func New(opts ...Option) *Config {
o := &options{
cfgOptions := &options{
stdout: os.Stdout,
stderr: os.Stderr,
}
for _, f := range opts {
f(o)
for _, option := range opts {
option(cfgOptions)
}
return &Config{
logConfig: logger.NewConfig(),
options: o,
writeFlagSet := flag.NewFlagSet("", flag.ContinueOnError)
clusterFlagSet := flag.NewFlagSet("", flag.ContinueOnError)
cfg := &Config{
logConfig: logger.NewConfig(),
writeTo: getenv("HOLOS_WRITE_TO", "deploy"),
clusterName: getenv("HOLOS_CLUSTER_NAME", ""),
writeFlagSet: writeFlagSet,
clusterFlagSet: clusterFlagSet,
options: cfgOptions,
}
writeFlagSet.StringVar(&cfg.writeTo, "write-to", cfg.writeTo, "write to directory")
clusterFlagSet.StringVar(&cfg.clusterName, "cluster-name", cfg.clusterName, "cluster name")
return cfg
}
// Config holds configuration for the whole program, used by main()
type Config struct {
logConfig *logger.Config
logger *slog.Logger
options *options
finalized bool
logConfig *logger.Config
writeTo string
clusterName string
logger *slog.Logger
options *options
finalized bool
writeFlagSet *flag.FlagSet
clusterFlagSet *flag.FlagSet
}
// LogFlagSet returns the logging *flag.FlagSet
@@ -55,6 +68,16 @@ func (c *Config) LogFlagSet() *flag.FlagSet {
return c.logConfig.FlagSet()
}
// WriteFlagSet returns a *flag.FlagSet wired to c *Config. Useful for commands that write files.
func (c *Config) WriteFlagSet() *flag.FlagSet {
return c.writeFlagSet
}
// ClusterFlagSet returns a *flag.FlagSet wired to c *Config. Useful for commands scoped to one cluster.
func (c *Config) ClusterFlagSet() *flag.FlagSet {
return c.clusterFlagSet
}
// Finalize validates the config and finalizes the startup lifecycle based on user configuration.
func (c *Config) Finalize() error {
if c.finalized {
@@ -65,7 +88,7 @@ func (c *Config) Finalize() error {
}
l := c.Logger()
c.logger = l
l.Debug("config lifecycle", "state", "finalized")
l.Debug("finalized config from flags", "state", "finalized")
c.finalized = true
return nil
}
@@ -89,6 +112,30 @@ func (c *Config) NewTopLevelLogger() *slog.Logger {
return c.logConfig.NewTopLevelLogger(c.options.stderr)
}
// Stderr should be used instead of os.Stderr to capture output for tests.
func (c *Config) Stderr() io.Writer {
return c.options.stderr
}
// Stdout should be used instead of os.Stdout to capture output for tests.
func (c *Config) Stdout() io.Writer {
return c.options.stdout
}
// WriteTo returns the write to path configured by flags.
func (c *Config) WriteTo() string {
return c.writeTo
}
// ClusterName returns the configured cluster name
func (c *Config) ClusterName() string {
return c.clusterName
}
// 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
}

View File

@@ -5,10 +5,10 @@ import (
"testing"
)
func newConfig() (*Config, *bytes.Buffer) {
// newConfig returns a new *Config with stderr wired to a bytes.Buffer.
func newConfig() (cfg *Config, stderr *bytes.Buffer) {
var b bytes.Buffer
c := New(Stdout(&b))
return c, &b
return New(Stderr(&b)), &b
}
func TestConfigFinalize(t *testing.T) {
@@ -22,11 +22,22 @@ func TestConfigFinalize(t *testing.T) {
}
func TestConfigFinalizeTwice(t *testing.T) {
cfg, _ := newConfig()
cfg, stderr := newConfig()
if err := cfg.Finalize(); err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf("want: %#v have: %#v", nil, err)
}
if err := cfg.Finalize(); err == nil {
t.Fatalf("want error got nil")
t.Fatalf("want: error have: %#v", err)
} else {
want := "could not finalize: already finalized"
have := err.Error()
if want != have {
t.Fatalf("want: %#v have: %#v", want, have)
}
}
want := ""
have := stderr.String()
if want != have {
t.Fatalf("want: %#v have: %#v", want, have)
}
}

View File

@@ -6,6 +6,7 @@ package builder
import (
"context"
"fmt"
"github.com/holos-run/holos/pkg/logger"
"github.com/holos-run/holos/pkg/wrapper"
"os"
"path/filepath"
@@ -45,16 +46,54 @@ type buildInfo struct {
Kind string `json:"kind,omitempty"`
}
type out struct {
Out string `json:"out,omitempty"`
// Metadata represents the standard metadata fields of the cue output
type Metadata struct {
Name string `json:"name,omitempty"`
}
func (b *Builder) Run(ctx context.Context) error {
// Result is the build result for display or writing.
type Result struct {
Metadata Metadata `json:"metadata,omitempty"`
Content string `json:"content,omitempty"`
}
// Name returns the metadata name of the result. Equivalent to the
// OrderedComponent name specified in platform.yaml in the holos prototype.
func (r *Result) Name() string {
return r.Metadata.Name
}
func (r *Result) Filename(writeTo string, cluster string) string {
return filepath.Join(writeTo, "clusters", cluster, "components", r.Name(), r.Name()+".gen.yaml")
}
// Save writes the content to the filesystem for git ops.
func (r *Result) Save(ctx context.Context, path string) error {
if r.Name() == "" {
return wrapper.Wrap(fmt.Errorf("missing name from cue result"))
}
log := logger.FromContext(ctx)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, os.FileMode(0775)); err != nil {
log.WarnContext(ctx, "could not mkdir", "path", dir, "err", err)
return wrapper.Wrap(err)
}
if err := os.WriteFile(path, []byte(r.Content), os.FileMode(0644)); err != nil {
log.WarnContext(ctx, "could not write", "path", path, "err", err)
return wrapper.Wrap(err)
}
log.DebugContext(ctx, "wrote "+path, "action", "mkdir", "path", path, "status", "ok")
return nil
}
func (b *Builder) Run(ctx context.Context) ([]*Result, error) {
log := logger.FromContext(ctx)
cueCtx := cuecontext.New()
results := make([]*Result, 0, len(b.cfg.args))
dir, err := b.findCueMod()
if err != nil {
return wrapper.Wrap(err)
return nil, wrapper.Wrap(err)
}
cfg := load.Config{Dir: dir}
@@ -64,47 +103,50 @@ func (b *Builder) Run(ctx context.Context) error {
for idx, path := range b.cfg.args {
target, err := filepath.Abs(path)
if err != nil {
return wrapper.Wrap(fmt.Errorf("could not find absolute path: %w", err))
return nil, wrapper.Wrap(fmt.Errorf("could not find absolute path: %w", err))
}
relPath, err := filepath.Rel(dir, target)
if err != nil {
return wrapper.Wrap(fmt.Errorf("invalid argument, must be relative to cue.mod: %w", err))
return nil, wrapper.Wrap(fmt.Errorf("invalid argument, must be relative to cue.mod: %w", err))
}
args[idx] = "./" + relPath
relPath = "./" + relPath
args[idx] = relPath
equiv := fmt.Sprintf("cue export --out yaml %v", relPath)
log.Debug(equiv)
}
instances := load.Instances(args, &cfg)
for _, instance := range instances {
var info buildInfo
var result Result
results = append(results, &result)
if err := instance.Err; err != nil {
return wrapper.Wrap(fmt.Errorf("could not load: %w", err))
return nil, wrapper.Wrap(fmt.Errorf("could not load: %w", err))
}
value := cueCtx.BuildInstance(instance)
if err := value.Err(); err != nil {
return wrapper.Wrap(fmt.Errorf("could not build: %w", err))
return nil, wrapper.Wrap(fmt.Errorf("could not build: %w", err))
}
if err := value.Validate(); err != nil {
return wrapper.Wrap(fmt.Errorf("could not validate: %w", err))
return nil, wrapper.Wrap(fmt.Errorf("could not validate: %w", err))
}
if err := value.Decode(&info); err != nil {
return wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
return nil, wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
}
switch kind := info.Kind; kind {
case "KubernetesObjects":
var out out
if err := value.Decode(&out); err != nil {
return wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
if err := value.Decode(&result); err != nil {
return nil, wrapper.Wrap(fmt.Errorf("could not decode: %w", err))
}
fmt.Printf(out.Out)
default:
return wrapper.Wrap(fmt.Errorf("build kind not implemented: %v", kind))
return nil, wrapper.Wrap(fmt.Errorf("build kind not implemented: %v", kind))
}
}
return nil
return results, nil
}
// findCueMod returns the root module location containing the cue.mod file or

View File

@@ -5,9 +5,9 @@ import (
"context"
"flag"
"fmt"
"github.com/holos-run/holos/pkg/tint"
"github.com/holos-run/holos/pkg/version"
"github.com/holos-run/holos/pkg/wrapper"
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
"io"
"log/slog"
@@ -166,9 +166,9 @@ func (c *Config) NewLogger(w io.Writer) *slog.Logger {
func NewConfig() *Config {
f := flag.NewFlagSet("", flag.ContinueOnError)
c := &Config{flagSet: f}
f.StringVar(&c.level, "log-level", "info", fmt.Sprintf("Log Level (%s)", strings.Join(validLogLevels, "|")))
f.StringVar(&c.format, "log-format", "text", fmt.Sprintf("Log format (%s)", strings.Join(validLogFormats, "|")))
f.Var(&c.dropAttrs, "log-drop", "Log attributes to drop, e.g. \"user-agent,version\"")
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", "text"), fmt.Sprintf("log format (%s)", strings.Join(validLogFormats, "|")))
f.Var(&c.dropAttrs, "log-drop", "log attributes to drop (example \"user-agent,version\")")
return c
}
@@ -207,3 +207,11 @@ func (c *Config) vetFormat() error {
err := fmt.Errorf("invalid log format: %s is not one of %s", c.format, strings.Join(validLogFormats, ", "))
return wrapper.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
}

46
pkg/tint/buffer.go Normal file
View File

@@ -0,0 +1,46 @@
package tint
import "sync"
type buffer []byte
var bufPool = sync.Pool{
New: func() any {
b := make(buffer, 0, 1024)
return (*buffer)(&b)
},
}
func newBuffer() *buffer {
return bufPool.Get().(*buffer)
}
func (b *buffer) Free() {
// To reduce peak allocation, return only smaller buffers to the pool.
const maxBufferSize = 16 << 10
if cap(*b) <= maxBufferSize {
*b = (*b)[:0]
bufPool.Put(b)
}
}
func (b *buffer) Write(bytes []byte) (int, error) {
*b = append(*b, bytes...)
return len(bytes), nil
}
func (b *buffer) WriteByte(char byte) error {
*b = append(*b, char)
return nil
}
func (b *buffer) WriteString(str string) (int, error) {
*b = append(*b, str...)
return len(str), nil
}
func (b *buffer) WriteStringIf(ok bool, str string) (int, error) {
if !ok {
return 0, nil
}
return b.WriteString(str)
}

2
pkg/tint/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package tint copied from https://github.com/lmittmann/tint/tree/v1.0.4 to adjust the colors
package tint

452
pkg/tint/handler.go Normal file
View File

@@ -0,0 +1,452 @@
/*
Package tint implements a zero-dependency [slog.Handler] that writes tinted
(colorized) logs. The output format is inspired by the [zerolog.ConsoleWriter]
and [slog.TextHandler].
The output format can be customized using [Options], which is a drop-in
replacement for [slog.HandlerOptions].
# Customize Attributes
Options.ReplaceAttr can be used to alter or drop attributes. If set, it is
called on each non-group attribute before it is logged.
See [slog.HandlerOptions] for details.
w := os.Stderr
logger := slog.New(
tint.NewHandler(w, &tint.Options{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey && len(groups) == 0 {
return slog.Attr{}
}
return a
},
}),
)
# Automatically Enable Colors
Colors are enabled by default and can be disabled using the Options.NoColor
attribute. To automatically enable colors based on the terminal capabilities,
use e.g. the [go-isatty] package.
w := os.Stderr
logger := slog.New(
tint.NewHandler(w, &tint.Options{
NoColor: !isatty.IsTerminal(w.Fd()),
}),
)
# Windows Support
Color support on Windows can be added by using e.g. the [go-colorable] package.
w := os.Stderr
logger := slog.New(
tint.NewHandler(colorable.NewColorable(w), nil),
)
[zerolog.ConsoleWriter]: https://pkg.go.dev/github.com/rs/zerolog#ConsoleWriter
[go-isatty]: https://pkg.go.dev/github.com/mattn/go-isatty
[go-colorable]: https://pkg.go.dev/github.com/mattn/go-colorable
*/
package tint
import (
"context"
"encoding"
"fmt"
"io"
"log/slog"
"path/filepath"
"runtime"
"strconv"
"sync"
"time"
"unicode"
)
// ANSI modes
const (
ansiReset = "\033[0m"
ansiFaint = "\033[2m"
ansiResetFaint = "\033[22m"
ansiBrightRed = "\033[91m"
ansiBrightGreen = "\033[92m"
ansiBrightYellow = "\033[93m"
ansiBrightRedFaint = "\033[91;2m"
// Following colors look good with solarized dark
ansiRed = "\033[31m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiBlue = "\033[34m"
ansiMagenta = "\033[35m"
ansiCyan = "\033[36m"
ansiLightGray = "\033[37m"
ansiLightRed = "\033[91m"
ansiLightMagenta = "\033[95m"
)
const errKey = "err"
var (
defaultLevel = slog.LevelInfo
defaultTimeFormat = time.StampMilli
)
// Options for a slog.Handler that writes tinted logs. A zero Options consists
// entirely of default values.
//
// Options can be used as a drop-in replacement for [slog.HandlerOptions].
type Options struct {
// Enable source code location (Default: false)
AddSource bool
// Minimum level to log (Default: slog.LevelInfo)
Level slog.Leveler
// ReplaceAttr is called to rewrite each non-group attribute before it is logged.
// See https://pkg.go.dev/log/slog#HandlerOptions for details.
ReplaceAttr func(groups []string, attr slog.Attr) slog.Attr
// Time format (Default: time.StampMilli)
TimeFormat string
// Disable color (Default: false)
NoColor bool
}
// NewHandler creates a [slog.Handler] that writes tinted logs to Writer w,
// using the default options. If opts is nil, the default options are used.
func NewHandler(w io.Writer, opts *Options) slog.Handler {
h := &handler{
w: w,
level: defaultLevel,
timeFormat: defaultTimeFormat,
}
if opts == nil {
return h
}
h.addSource = opts.AddSource
if opts.Level != nil {
h.level = opts.Level
}
h.replaceAttr = opts.ReplaceAttr
if opts.TimeFormat != "" {
h.timeFormat = opts.TimeFormat
}
h.noColor = opts.NoColor
return h
}
// handler implements a [slog.Handler].
type handler struct {
attrsPrefix string
groupPrefix string
groups []string
mu sync.Mutex
w io.Writer
addSource bool
level slog.Leveler
replaceAttr func([]string, slog.Attr) slog.Attr
timeFormat string
noColor bool
}
func (h *handler) clone() *handler {
return &handler{
attrsPrefix: h.attrsPrefix,
groupPrefix: h.groupPrefix,
groups: h.groups,
w: h.w,
addSource: h.addSource,
level: h.level,
replaceAttr: h.replaceAttr,
timeFormat: h.timeFormat,
noColor: h.noColor,
}
}
func (h *handler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level.Level()
}
func (h *handler) Handle(_ context.Context, r slog.Record) error {
// get a buffer from the sync pool
buf := newBuffer()
defer buf.Free()
rep := h.replaceAttr
// write time
if !r.Time.IsZero() {
val := r.Time.Round(0) // strip monotonic to match Attr behavior
if rep == nil {
h.appendTime(buf, r.Time)
_ = buf.WriteByte(' ')
} else if a := rep(nil /* groups */, slog.Time(slog.TimeKey, val)); a.Key != "" {
if a.Value.Kind() == slog.KindTime {
h.appendTime(buf, a.Value.Time())
} else {
h.appendValue(buf, a.Value, false)
}
_ = buf.WriteByte(' ')
}
}
// write level
if rep == nil {
h.appendLevel(buf, r.Level)
_ = buf.WriteByte(' ')
} else if a := rep(nil /* groups */, slog.Any(slog.LevelKey, r.Level)); a.Key != "" {
h.appendValue(buf, a.Value, false)
_ = buf.WriteByte(' ')
}
// write source
if h.addSource {
fs := runtime.CallersFrames([]uintptr{r.PC})
f, _ := fs.Next()
if f.File != "" {
src := &slog.Source{
Function: f.Function,
File: f.File,
Line: f.Line,
}
if rep == nil {
h.appendSource(buf, src)
_ = buf.WriteByte(' ')
} else if a := rep(nil /* groups */, slog.Any(slog.SourceKey, src)); a.Key != "" {
h.appendValue(buf, a.Value, false)
_ = buf.WriteByte(' ')
}
}
}
// write message
if rep == nil {
_, _ = buf.WriteStringIf(!h.noColor, ansiLightGray)
_, _ = buf.WriteString(r.Message)
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
_ = buf.WriteByte(' ')
} else if a := rep(nil /* groups */, slog.String(slog.MessageKey, r.Message)); a.Key != "" {
_, _ = buf.WriteStringIf(!h.noColor, ansiLightGray)
h.appendValue(buf, a.Value, false)
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
_ = buf.WriteByte(' ')
}
// write handler attributes
if len(h.attrsPrefix) > 0 {
_, _ = buf.WriteString(h.attrsPrefix)
}
// write attributes
r.Attrs(func(attr slog.Attr) bool {
h.appendAttr(buf, attr, h.groupPrefix, h.groups)
return true
})
if len(*buf) == 0 {
return nil
}
(*buf)[len(*buf)-1] = '\n' // replace last space with newline
h.mu.Lock()
defer h.mu.Unlock()
_, err := h.w.Write(*buf)
return err
}
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
h2 := h.clone()
buf := newBuffer()
defer buf.Free()
// write attributes to buffer
for _, attr := range attrs {
h.appendAttr(buf, attr, h.groupPrefix, h.groups)
}
h2.attrsPrefix = h.attrsPrefix + string(*buf)
return h2
}
func (h *handler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
h2 := h.clone()
h2.groupPrefix += name + "."
h2.groups = append(h2.groups, name)
return h2
}
func (h *handler) appendTime(buf *buffer, t time.Time) {
_, _ = buf.WriteStringIf(!h.noColor, ansiFaint)
*buf = t.AppendFormat(*buf, h.timeFormat)
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
}
func (h *handler) appendLevel(buf *buffer, level slog.Level) {
switch {
case level < slog.LevelInfo:
_, _ = buf.WriteString("DBG")
appendLevelDelta(buf, level-slog.LevelDebug)
case level < slog.LevelWarn:
_, _ = buf.WriteStringIf(!h.noColor, ansiBlue)
_, _ = buf.WriteString("INF")
appendLevelDelta(buf, level-slog.LevelInfo)
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
case level < slog.LevelError:
_, _ = buf.WriteStringIf(!h.noColor, ansiYellow)
_, _ = buf.WriteString("WRN")
appendLevelDelta(buf, level-slog.LevelWarn)
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
default:
_, _ = buf.WriteStringIf(!h.noColor, ansiRed)
_, _ = buf.WriteString("ERR")
appendLevelDelta(buf, level-slog.LevelError)
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
}
}
func appendLevelDelta(buf *buffer, delta slog.Level) {
if delta == 0 {
return
} else if delta > 0 {
_ = buf.WriteByte('+')
}
*buf = strconv.AppendInt(*buf, int64(delta), 10)
}
func (h *handler) appendSource(buf *buffer, src *slog.Source) {
dir, file := filepath.Split(src.File)
_, _ = buf.WriteStringIf(!h.noColor, ansiLightMagenta)
_, _ = buf.WriteString(filepath.Join(filepath.Base(dir), file))
_ = buf.WriteByte(':')
_, _ = buf.WriteString(strconv.Itoa(src.Line))
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
}
func (h *handler) appendAttr(buf *buffer, attr slog.Attr, groupsPrefix string, groups []string) {
attr.Value = attr.Value.Resolve()
if rep := h.replaceAttr; rep != nil && attr.Value.Kind() != slog.KindGroup {
attr = rep(groups, attr)
attr.Value = attr.Value.Resolve()
}
if attr.Equal(slog.Attr{}) {
return
}
if attr.Value.Kind() == slog.KindGroup {
if attr.Key != "" {
groupsPrefix += attr.Key + "."
groups = append(groups, attr.Key)
}
for _, groupAttr := range attr.Value.Group() {
h.appendAttr(buf, groupAttr, groupsPrefix, groups)
}
} else if err, ok := attr.Value.Any().(tintError); ok {
// append tintError
h.appendTintError(buf, err, groupsPrefix)
_ = buf.WriteByte(' ')
} else {
h.appendKey(buf, attr.Key, groupsPrefix)
h.appendValue(buf, attr.Value, true)
_ = buf.WriteByte(' ')
}
}
func (h *handler) appendKey(buf *buffer, key, groups string) {
_, _ = buf.WriteStringIf(!h.noColor, ansiFaint)
appendString(buf, groups+key, true)
_ = buf.WriteByte('=')
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
}
func (h *handler) appendValue(buf *buffer, v slog.Value, quote bool) {
switch v.Kind() {
case slog.KindString:
appendString(buf, v.String(), quote)
case slog.KindInt64:
*buf = strconv.AppendInt(*buf, v.Int64(), 10)
case slog.KindUint64:
*buf = strconv.AppendUint(*buf, v.Uint64(), 10)
case slog.KindFloat64:
*buf = strconv.AppendFloat(*buf, v.Float64(), 'g', -1, 64)
case slog.KindBool:
*buf = strconv.AppendBool(*buf, v.Bool())
case slog.KindDuration:
appendString(buf, v.Duration().String(), quote)
case slog.KindTime:
appendString(buf, v.Time().String(), quote)
case slog.KindAny:
switch cv := v.Any().(type) {
case slog.Level:
h.appendLevel(buf, cv)
case encoding.TextMarshaler:
data, err := cv.MarshalText()
if err != nil {
break
}
appendString(buf, string(data), quote)
case *slog.Source:
h.appendSource(buf, cv)
default:
appendString(buf, fmt.Sprintf("%+v", v.Any()), quote)
}
}
}
func (h *handler) appendTintError(buf *buffer, err error, groupsPrefix string) {
_, _ = buf.WriteStringIf(!h.noColor, ansiLightRed)
appendString(buf, groupsPrefix+errKey, true)
_ = buf.WriteByte('=')
_, _ = buf.WriteStringIf(!h.noColor, ansiRed)
appendString(buf, err.Error(), true)
_, _ = buf.WriteStringIf(!h.noColor, ansiReset)
}
func appendString(buf *buffer, s string, quote bool) {
if quote && needsQuoting(s) {
*buf = strconv.AppendQuote(*buf, s)
} else {
_, _ = buf.WriteString(s)
}
}
func needsQuoting(s string) bool {
if len(s) == 0 {
return true
}
for _, r := range s {
if unicode.IsSpace(r) || r == '"' || r == '=' || !unicode.IsPrint(r) {
return true
}
}
return false
}
type tintError struct{ error }
// Err returns a tinted (colorized) [slog.Attr] that will be written in red color
// by the [tint.Handler]. When used with any other [slog.Handler], it behaves as
//
// slog.Any("err", err)
func Err(err error) slog.Attr {
if err != nil {
err = tintError{err}
}
return slog.Any(errKey, err)
}

View File

@@ -1 +1 @@
1
3

View File

@@ -39,6 +39,8 @@ func (e *ErrorAt) Error() string {
// Wrap wraps err in a ErrorAt or returns err if err is nil, already a
// ErrorAt, or caller info is not available.
//
// XXX: Refactor to wrap.Err(error, ...slog.Attr). Often want to add attributes for the top level logger.
func Wrap(err error) error {
// Nothing to do
if err == nil {

2
scripts/msgs Executable file
View File

@@ -0,0 +1,2 @@
#! /bin/bash
exec jq -r '"\(.source.file):\(.source.line)\t" + .msg'