mirror of
https://github.com/holos-run/holos.git
synced 2026-03-19 16:54:58 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3c26bc30a | ||
|
|
190d0d2922 | ||
|
|
18be35a0e4 | ||
|
|
e2b1fa0d47 | ||
|
|
e018deef5a | ||
|
|
ba21165e67 | ||
|
|
ae007df1f7 | ||
|
|
4a9073f5be |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
bin
|
||||
/vendor
|
||||
/.idea
|
||||
bin/
|
||||
vendor/
|
||||
.idea/
|
||||
coverage.out
|
||||
|
||||
dist/
|
||||
*.hold/
|
||||
/deploy/
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package holos
|
||||
|
||||
// e.g. prod-secrets-namespaces
|
||||
metadata: name: "\(#InputKeys.stage)-\(#InputKeys.project)-namespaces"
|
||||
objects: [
|
||||
#Namespace & {
|
||||
metadata: name: "external-secrets"
|
||||
|
||||
@@ -2,3 +2,8 @@ package holos
|
||||
|
||||
// Output schema
|
||||
{} & #KubernetesObjects
|
||||
|
||||
#InputKeys: {
|
||||
project: "secrets"
|
||||
service: "eso"
|
||||
}
|
||||
@@ -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
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||
|
||||
@@ -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
46
pkg/cli/render.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
46
pkg/tint/buffer.go
Normal 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
2
pkg/tint/doc.go
Normal 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
452
pkg/tint/handler.go
Normal 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)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
1
|
||||
3
|
||||
|
||||
@@ -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
2
scripts/msgs
Executable file
@@ -0,0 +1,2 @@
|
||||
#! /bin/bash
|
||||
exec jq -r '"\(.source.file):\(.source.line)\t" + .msg'
|
||||
Reference in New Issue
Block a user