diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6a68411..49d5070 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,6 +15,8 @@ builds: goarch: - amd64 - arm64 + tags: + - release flags: - -trimpath ldflags: diff --git a/README.md b/README.md index 02641f1..04d5254 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,24 @@ # Launchr -Launchr is a CLI action runner that executes actions inside short-lived local containers. -Actions are defined in `action.yaml` files, which are automatically discovered in the filesystem. -They can be placed anywhere that makes sense semantically. You can find action examples [here](example) and in the [documentation](docs). -Launchr has a plugin system that allows to extend its functionality. See [core plugins](plugins), [compose](https://github.com/launchrctl/compose) and [documentation](docs). +Launchr is a versatile CLI action runner that executes tasks defined in local or embeded yaml files across multiple runtimes: +- Short-lived container (docker) +- Shell (host) +- Golang (as plugin) + +It supports: +- Arguments and options +- Automatic action discovery +- Automatic path-based naming of local actions +- Seamless extensibility via a plugin system + +Actions are defined in `action.yaml` files: +- either on local filesystem: Useful for project-specific actions +- or embeded as plugin: Useful for common and shared actions + +You can find action examples [here](example), here and in the [documentation](docs). + +Launchr has a plugin system that allows to extend its functionality. See [core plugins](plugins), [official plugins](https://github.com/launchrctl#org-repositories) and [documentation](docs). + ## Table of contents @@ -12,6 +27,7 @@ Launchr has a plugin system that allows to extend its functionality. See [core p * [Installation from source](#installation-from-source) * [Development](#development) + ## Usage Build `launchr` from source locally. Build dependencies: @@ -30,6 +46,7 @@ If you face any issues with `launchr`: 1. Open an issue in the repo. 2. Share the app version with `launchr --version` + ## Installation ### Installation from source @@ -78,8 +95,9 @@ Useful make commands: 2. Test the code - `make test` 3. Lint the code - `make lint` + ## Publishing a new release -- Just create new release [from UI](https://github.com/launchrctl/launchr/releases/new) +- Create a new Github release [from UI](https://github.com/launchrctl/launchr/releases/new) - Github Action will compile new binaries using [goreleaser](https://goreleaser.com/) and attach them to release diff --git a/app.go b/app.go index 37977a2..a61b9f7 100644 --- a/app.go +++ b/app.go @@ -3,9 +3,9 @@ package launchr import ( "errors" "fmt" + "io" "os" "reflect" - "strings" "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/action" @@ -40,6 +40,11 @@ func (app *appImpl) SetStreams(s Streams) { app.streams = s } func (app *appImpl) RegisterFS(fs ManagedFS) { app.mFS = append(app.mFS, fs) } func (app *appImpl) GetRegisteredFS() []ManagedFS { return app.mFS } +func (app *appImpl) SensitiveWriter(w io.Writer) io.Writer { + return NewMaskingWriter(w, app.SensitiveMask()) +} +func (app *appImpl) SensitiveMask() *SensitiveMask { return launchr.GlobalSensitiveMask() } + func (app *appImpl) RootCmd() *Command { return app.cmd } func (app *appImpl) CmdEarlyParsed() launchr.CmdEarlyParsed { return app.earlyCmd } @@ -87,21 +92,32 @@ func (app *appImpl) init() error { //Long: ``, // @todo SilenceErrors: true, // Handled manually. Version: version, + PersistentPreRunE: func(cmd *Command, args []string) error { + plugins := launchr.GetPluginByType[PersistentPreRunPlugin](app.pluginMngr) + Log().Debug("hook PersistentPreRunPlugin", "plugins", plugins) + for _, p := range plugins { + if err := p.V.PersistentPreRun(cmd, args); err != nil { + Log().Debug("error on PersistentPreRunPlugin", "plugin", p.K.String()) + return err + } + } + return nil + }, RunE: func(cmd *Command, _ []string) error { return cmd.Help() }, } app.earlyCmd = launchr.EarlyPeekCommand() // Set io streams. - app.SetStreams(StandardStreams()) - app.cmd.SetIn(app.streams.In()) + app.SetStreams(MaskedStdStreams(app.SensitiveMask())) + app.cmd.SetIn(app.streams.In().Reader()) app.cmd.SetOut(app.streams.Out()) app.cmd.SetErr(app.streams.Err()) // Set working dir and config dir. app.cfgDir = "." + name app.workDir = launchr.MustAbs(".") - actionsPath := launchr.MustAbs(os.Getenv(strings.ToUpper(name + "_ACTIONS_PATH"))) + actionsPath := launchr.MustAbs(EnvVarActionsPath.Get()) // Initialize managed FS for action discovery. app.mFS = make([]ManagedFS, 0, 4) app.RegisterFS(action.NewDiscoveryFS(os.DirFS(actionsPath), app.GetWD())) @@ -121,35 +137,51 @@ func (app *appImpl) init() error { app.AddService(app.pluginMngr) app.AddService(config) + Log().Debug("initialising application") + // Run OnAppInit hook. - for _, p := range launchr.GetPluginByType[OnAppInitPlugin](app.pluginMngr) { + plugins := launchr.GetPluginByType[OnAppInitPlugin](app.pluginMngr) + Log().Debug("hook OnAppInitPlugin", "plugins", plugins) + for _, p := range plugins { if err = p.V.OnAppInit(app); err != nil { + Log().Debug("error on OnAppInit", "plugin", p.K.String()) return err } } + Log().Debug("init success", "wd", app.workDir, "actions_dir", actionsPath) return nil } func (app *appImpl) exec() error { + Log().Debug("executing command") if app.earlyCmd.IsVersion { app.cmd.SetVersionTemplate(Version().Full()) return app.cmd.Execute() } // Add application commands from plugins. - for _, p := range launchr.GetPluginByType[CobraPlugin](app.pluginMngr) { + plugins := launchr.GetPluginByType[CobraPlugin](app.pluginMngr) + Log().Debug("hook CobraPlugin", "plugins", plugins) + for _, p := range plugins { if err := p.V.CobraAddCommands(app.cmd); err != nil { + Log().Debug("error on CobraAddCommands", "plugin", p.K.String()) return err } } - return app.cmd.Execute() + err := app.cmd.Execute() + if err != nil { + Log().Debug("execution error", "err", err) + } + + return err } // Execute is an entrypoint to the launchr app. func (app *appImpl) Execute() int { defer func() { + Log().Debug("shutdown cleanup") if err := launchr.Cleanup(); err != nil { Term().Warning().Printfln("Error on application shutdown cleanup:\n %s", err) } diff --git a/example/actions/shell/action.yaml b/example/actions/shell/action.yaml new file mode 100644 index 0000000..a8990a3 --- /dev/null +++ b/example/actions/shell/action.yaml @@ -0,0 +1,34 @@ +action: + title: arguments + description: Test passing options to executed command + options: + - name: firstoption + title: First option + type: string + default: "" + - name: secondoption + title: Second option + description: Option to do something + type: boolean + default: false + +runtime: + type: shell + env: + MY_ENV_VAR: "my_env_var" + script: | + date + pwd + whoami + env + echo "Current bin path: {{ .current_bin }}" + echo "Version:" + {{ .current_bin }} --version + echo "" + echo "Help:" + {{ .current_bin }} --help + echo $${MY_ENV_VAR} + {{ .action_dir }}/main.sh "{{ .firstoption }}" "{{ .secondoption }}" + echo "Running timer for 60 seconds" + bash -c "for i in \$(seq 60); do echo \$$i; sleep 1; done" + echo "Finish" diff --git a/example/actions/shell/main.sh b/example/actions/shell/main.sh new file mode 100755 index 0000000..3481c29 --- /dev/null +++ b/example/actions/shell/main.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +echo +# Print the total count of arguments +echo "Total number of arguments passed to the script: $#" +count=1 +for arg in "$@" +do + if [ -z "$arg" ]; then + echo "- Argument $count: \"\"" + else + echo "- Argument $count: $arg" + fi + count=$((count + 1)) +done diff --git a/gen.go b/gen.go index 6a0472a..100e798 100644 --- a/gen.go +++ b/gen.go @@ -27,7 +27,9 @@ func (app *appImpl) gen() error { } // Call generate functions on plugins. - for _, p := range launchr.GetPluginByType[GeneratePlugin](app.pluginMngr) { + plugins := launchr.GetPluginByType[GeneratePlugin](app.pluginMngr) + Log().Debug("hook GeneratePlugin", "plugins", plugins) + for _, p := range plugins { if !isRelease && strings.HasPrefix(p.K.GetPackagePath(), PkgPath) { // Skip core packages if not requested. // Implemented for development of plugins to prevent generating of main.go. @@ -35,6 +37,7 @@ func (app *appImpl) gen() error { } err = p.V.Generate(config) if err != nil { + Log().Debug("error on Generate", "plugin", p.K.String()) return err } } diff --git a/internal/launchr/config_test.go b/internal/launchr/config_test.go index c437ca7..31411ae 100644 --- a/internal/launchr/config_test.go +++ b/internal/launchr/config_test.go @@ -19,7 +19,6 @@ func (f fsmy) MapFS() fstest.MapFS { func Test_ConfigFromFS(t *testing.T) { t.Parallel() - assert := assert.New(t) type testYamlFieldSub struct { Field1 string `yaml:"field1"` @@ -57,11 +56,11 @@ func Test_ConfigFromFS(t *testing.T) { expInvalid := expValType{ StructErr: "error(s) decoding", } - var errCheck = func(err error, errStr string) { + var errCheck = func(t *testing.T, err error, errStr string) { if errStr == "" { - assert.True(assert.NoError(err)) + assert.NoError(t, err) } else { - assert.ErrorContains(err, errStr) + assert.ErrorContains(t, err, errStr) } } @@ -79,36 +78,36 @@ func Test_ConfigFromFS(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() cfg := ConfigFromFS(tt.fs.MapFS()) - assert.NotNil(cfg) + assert.NotNil(t, cfg) var err error var val1, val1c testYamlFieldSub var valcustom testYamlCustomTag var val1ptr *testYamlFieldSub // Check struct. err = cfg.Get("test_perm", &val1) - errCheck(err, tt.expVal.StructErr) - assert.Equal(tt.expVal.Struct, val1) + errCheck(t, err, tt.expVal.StructErr) + assert.Equal(t, tt.expVal.Struct, val1) // Check custom. err = cfg.Get("test_custom", &valcustom) - errCheck(err, tt.expVal.CustomTagErr) - assert.Equal(tt.expVal.CustomTag, valcustom) + errCheck(t, err, tt.expVal.CustomTagErr) + assert.Equal(t, tt.expVal.CustomTag, valcustom) // Check cache works. err = cfg.Get("test_perm", &val1c) - errCheck(err, tt.expVal.StructErr) - assert.Equal(val1, val1c) + errCheck(t, err, tt.expVal.StructErr) + assert.Equal(t, val1, val1c) // Check pointer to a struct. err = cfg.Get("test_ptr", &val1ptr) - errCheck(err, tt.expVal.PtrErr) - assert.Equal(tt.expVal.Ptr, val1ptr) + errCheck(t, err, tt.expVal.PtrErr) + assert.Equal(t, tt.expVal.Ptr, val1ptr) // Check primitives. var val2s string var val2int int _ = cfg.Get("field1", &val2s) _ = cfg.Get("field2", &val2int) - assert.Equal(tt.expVal.Primitive.Field1, val2s) - assert.Equal(tt.expVal.Primitive.Field2, val2int) + assert.Equal(t, tt.expVal.Primitive.Field1, val2s) + assert.Equal(t, tt.expVal.Primitive.Field2, val2int) }) } } diff --git a/internal/launchr/filepath.go b/internal/launchr/filepath.go index e4eb38a..a594108 100644 --- a/internal/launchr/filepath.go +++ b/internal/launchr/filepath.go @@ -133,7 +133,7 @@ func MkdirTemp(pattern string) (string, error) { basePath = cand if name != "" { newBase := filepath.Join(basePath, name) - err = os.Mkdir(newBase, 0750) + err = os.Mkdir(newBase, 0700) if err != nil && !os.IsExist(err) { // Try next candidate. continue diff --git a/internal/launchr/filepath_test.go b/internal/launchr/filepath_test.go index 3b2425f..8c445ff 100644 --- a/internal/launchr/filepath_test.go +++ b/internal/launchr/filepath_test.go @@ -10,6 +10,7 @@ import ( ) func TestMkdirTemp(t *testing.T) { + t.Parallel() dir, err := MkdirTemp("test") require.NoError(t, err) require.NotEmpty(t, dir) @@ -23,6 +24,7 @@ func TestMkdirTemp(t *testing.T) { } func TestFsRealpath(t *testing.T) { + t.Parallel() // Test basic dir fs. rootfs := os.DirFS("../../") path := FsRealpath(rootfs) diff --git a/internal/launchr/log.go b/internal/launchr/log.go index e762c2a..a94beb5 100644 --- a/internal/launchr/log.go +++ b/internal/launchr/log.go @@ -37,6 +37,49 @@ const ( LogLevelError // LogLevelError is the log level for errors. ) +// LogLevel string constants. +const ( + LogLevelStrDisabled string = "NONE" // LogLevelStrDisabled does never print. + LogLevelStrDebug string = "DEBUG" // LogLevelStrDebug is the log level for debug. + LogLevelStrInfo string = "INFO" // LogLevelStrInfo is the log level for info. + LogLevelStrWarn string = "WARN" // LogLevelStrWarn is the log level for warnings. + LogLevelStrError string = "ERROR" // LogLevelStrError is the log level for errors. +) + +// String implements [fmt.Stringer] interface. +func (l LogLevel) String() string { + switch l { + case LogLevelDebug: + return LogLevelStrDebug + case LogLevelInfo: + return LogLevelStrInfo + case LogLevelWarn: + return LogLevelStrWarn + case LogLevelError: + return LogLevelStrError + default: + return LogLevelStrDisabled + } +} + +// LogLevelFromString translates a log level string to +func LogLevelFromString(s string) LogLevel { + switch s { + case LogLevelStrDisabled: + return LogLevelDisabled + case LogLevelStrError: + return LogLevelError + case LogLevelStrWarn: + return LogLevelWarn + case LogLevelStrInfo: + return LogLevelInfo + case LogLevelStrDebug: + return LogLevelDebug + default: + return LogLevelDisabled + } +} + // LogOptions is a common interface to allow adjusting the logger. type LogOptions interface { // Level returns the currently set log level. diff --git a/internal/launchr/sensitive.go b/internal/launchr/sensitive.go new file mode 100644 index 0000000..148da98 --- /dev/null +++ b/internal/launchr/sensitive.go @@ -0,0 +1,171 @@ +package launchr + +import ( + "bytes" + "io" +) + +var globalSensitiveMask *SensitiveMask + +func init() { + globalSensitiveMask = NewSensitiveMask("****") +} + +// GlobalSensitiveMask returns global app sensitive mask. +func GlobalSensitiveMask() *SensitiveMask { + return globalSensitiveMask +} + +// MaskingWriter is a writer that masks sensitive data in the input stream. +// It buffers data to handle cases where sensitive data spans across writes. +type MaskingWriter struct { + w io.Writer + mask *SensitiveMask + buf bytes.Buffer +} + +// NewMaskingWriter initializes a new MaskingWriter. +func NewMaskingWriter(wrappedWriter io.Writer, mask *SensitiveMask) io.WriteCloser { + return &MaskingWriter{ + w: wrappedWriter, + mask: mask, + buf: bytes.Buffer{}, + } +} + +// Write applies masking to the input and writes to the wrapped writer. +func (m *MaskingWriter) Write(p []byte) (n int, err error) { + // Append the new data to the buf. + m.buf.Write(p) + + // Process the buf and mask the sensitive values. + data := m.buf.Bytes() + masked, lastOrigEnd, lastMatchEnd := m.mask.ReplaceAll(data) + + // Write the fully masked content up to the last complete match only. + // Keep any leftover (incomplete) data in the buf. + if lastMatchEnd >= 0 { + remaining := data[lastOrigEnd:] + m.buf.Reset() + m.buf.Write(remaining) + + processed := masked[:lastMatchEnd] + // Write the processed portion. + if _, writeErr := m.w.Write(processed); writeErr != nil { + return 0, writeErr + } + } + + // If no complete sensitive data was found, keep everything in the buf. + // Write the buffer periodically if the input slice `p` is less than its capacity. + if len(p) < cap(p) && m.buf.Len() > 0 { + // Write all remaining buffer content after masking. + if _, writeErr := m.w.Write(m.buf.Bytes()); writeErr != nil { + return 0, writeErr + } + // Reset the buffer after writing. + m.buf.Reset() + } + + return len(p), nil +} + +// Close flushes any remaining data in the buf. +func (m *MaskingWriter) Close() error { + if m.buf.Len() > 0 { + // Write the remainder of the buf after masking. + masked, _, _ := m.mask.ReplaceAll(m.buf.Bytes()) + if _, err := m.w.Write(masked); err != nil { + return err + } + m.buf.Reset() + } + if w, ok := m.w.(io.Closer); ok { + return w.Close() + } + return nil +} + +// SensitiveMask replaces sensitive strings with a mask. +type SensitiveMask struct { + strings [][]byte + mask []byte +} + +// String implements [fmt.Stringer] to occasionally not render sensitive data. +func (p *SensitiveMask) String() string { return "" } + +// ReplaceAll replaces sensitive strings in the given bytes b. +// It returns the modified string and last index of replaced parts to track where the last change was made +// for before change and after change bytes. +func (p *SensitiveMask) ReplaceAll(b []byte) (resultBytes []byte, lastBefore, lastAfter int) { + // Create a buffer to build the result. + var result bytes.Buffer + start := 0 + + // Initialize tracking variables + lastBefore = -1 + lastAfter = -1 + + if len(p.strings) == 0 { + return b, lastBefore, lastAfter + } + + for start < len(b) { + earliestMatchIndex := -1 + matchLength := 0 + + // Look for all substrings and find the earliest occurrence. + for _, s := range p.strings { + if idx := bytes.Index(b[start:], s); idx != -1 { + // If this is the earliest match so far, update earliestMatchIndex and matchLength. + absoluteIdx := start + idx + if earliestMatchIndex == -1 || absoluteIdx < earliestMatchIndex { + earliestMatchIndex = absoluteIdx + matchLength = len(s) + } + } + } + + // If a match was found, replace it with the mask. + if earliestMatchIndex != -1 { + // Update lastBefore to track the index before replacing. + lastBefore = earliestMatchIndex + matchLength + + // Write everything up to the match. + result.Write(b[start:earliestMatchIndex]) + // Write the mask instead of the matched string. + result.Write(p.mask) + + // Update lastAfter to track the index after replacing. + lastAfter = result.Len() + + // Move the start index past the matched string. + start = earliestMatchIndex + matchLength + } else { + // If no matches are found, append the rest of the buffer and break. + result.Write(b[start:]) + break + } + } + + // Return the final result and the tracked indices. + return result.Bytes(), lastBefore, lastAfter +} + +// AddString adds a string to mask. +func (p *SensitiveMask) AddString(s string) { + p.strings = append(p.strings, []byte(s)) +} + +// NewSensitiveMask creates a sensitive mask replacing strings with mask value. +func NewSensitiveMask(mask string, strings ...string) *SensitiveMask { + bytestrings := make([][]byte, len(strings)) + for i := 0; i < len(strings); i++ { + bytestrings[i] = []byte(strings[i]) + } + return &SensitiveMask{ + mask: []byte(mask), + strings: bytestrings, + } +} diff --git a/internal/launchr/sensitive_linux.go b/internal/launchr/sensitive_linux.go new file mode 100644 index 0000000..040f4f0 --- /dev/null +++ b/internal/launchr/sensitive_linux.go @@ -0,0 +1,23 @@ +//go:build release + +package launchr + +import ( + "golang.org/x/sys/unix" +) + +func init() { + err := setProcNotDumpable() + if err != nil { + panic(err) + } +} + +func setProcNotDumpable() error { + // Disable core dumps and prevent ptrace attacks + err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0) + if err != nil { + return err + } + return nil +} diff --git a/internal/launchr/sensitive_test.go b/internal/launchr/sensitive_test.go new file mode 100644 index 0000000..1bbc06b --- /dev/null +++ b/internal/launchr/sensitive_test.go @@ -0,0 +1,77 @@ +package launchr + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_MaskingWriter(t *testing.T) { + t.Parallel() + type testCase struct { + name string + chunks [][]byte // Stream parts to simulate multiple writes + mask *SensitiveMask // Mask replacement + exp string // Expected output after masking + } + mask := NewSensitiveMask("****", "987-65-4321", "123-45-6789", "\"\\escaped\nnewline") + tests := []testCase{ + { + name: "Empty mask", + chunks: [][]byte{[]byte("This is a clean stream with no sensitive data.")}, + mask: NewSensitiveMask("****"), + exp: "This is a clean stream with no sensitive data.", + }, + { + name: "No values to mask", + chunks: [][]byte{[]byte("This is a clean stream with no sensitive data.")}, + mask: mask, + exp: "This is a clean stream with no sensitive data.", + }, + { + name: "Two values to mask", + chunks: [][]byte{[]byte("Sensitive data: \"\\escaped\nnewline, 123-45-6789 and also 987-65-4321.")}, + mask: mask, + exp: "Sensitive data: ****, **** and also ****.", + }, + { + name: "Sensitive value split across writes", + chunks: [][]byte{ + []byte("Sensitive data: 123-45"), + []byte("-6789 and 987-65-4321 appears split."), + []byte("\"\\escaped\nnewline"), + []byte("This is a clean stream with no sensitive data."), + }, + mask: mask, + exp: "Sensitive data: **** and **** appears split.****This is a clean stream with no sensitive data.", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Set up the buffer to capture output + out := &bytes.Buffer{} + + // Create the MaskingWriter wrapping the out + mwriter := NewMaskingWriter(out, tt.mask) + + // Simulate multiple writes + for _, part := range tt.chunks { + _, err := mwriter.Write(part) + require.NoError(t, err) + } + + // Flush any remaining data in the buffer + err := mwriter.Close() + require.NoError(t, err) + + // Validate the final output + assert.Equal(t, tt.exp, out.String()) + }) + } + +} diff --git a/pkg/driver/signals.go b/internal/launchr/signals.go similarity index 60% rename from pkg/driver/signals.go rename to internal/launchr/signals.go index 3354402..62f818b 100644 --- a/pkg/driver/signals.go +++ b/internal/launchr/signals.go @@ -1,4 +1,4 @@ -package driver +package launchr import ( "context" @@ -6,14 +6,10 @@ import ( gosignal "os/signal" "github.com/moby/sys/signal" - - "github.com/launchrctl/launchr/internal/launchr" ) -// ForwardAllSignals forwards signals to the container -// -// The channel you pass in must already be setup to receive any signals you want to forward. -func ForwardAllSignals(ctx context.Context, cli ContainerRunner, cid string, sigc <-chan os.Signal) { +// HandleSignals forwards signals to the handler. +func HandleSignals(ctx context.Context, sigc <-chan os.Signal, killFn func(s os.Signal, sig string) error) { var ( s os.Signal ok bool @@ -48,16 +44,16 @@ func ForwardAllSignals(ctx context.Context, cli ContainerRunner, cid string, sig continue } - if err := cli.ContainerKill(ctx, cid, sig); err != nil { - launchr.Log().Debug("error sending signal", "cid", cid, "error", err) + if err := killFn(s, sig); err != nil { + Log().Debug("error sending signal", "error", err, "sig", sig) } } } -// NotifyAllSignals starts watching interrupt signals. -func NotifyAllSignals() chan os.Signal { +// NotifySignals starts watching interrupt signals. +func NotifySignals(sig ...os.Signal) chan os.Signal { sigc := make(chan os.Signal, 128) - gosignal.Notify(sigc) + gosignal.Notify(sigc, sig...) return sigc } diff --git a/pkg/driver/signals_unix.go b/internal/launchr/signals_unix.go similarity index 88% rename from pkg/driver/signals_unix.go rename to internal/launchr/signals_unix.go index 015a37a..74b50f5 100644 --- a/pkg/driver/signals_unix.go +++ b/internal/launchr/signals_unix.go @@ -1,6 +1,6 @@ //go:build unix -package driver +package launchr import ( "os" diff --git a/pkg/driver/signals_windows.go b/internal/launchr/signals_windows.go similarity index 84% rename from pkg/driver/signals_windows.go rename to internal/launchr/signals_windows.go index 99eb62c..23c04ad 100644 --- a/pkg/driver/signals_windows.go +++ b/internal/launchr/signals_windows.go @@ -1,6 +1,6 @@ //go:build windows -package driver +package launchr import "os" diff --git a/internal/launchr/streams.go b/internal/launchr/streams.go index 988e5a8..6001c50 100644 --- a/internal/launchr/streams.go +++ b/internal/launchr/streams.go @@ -17,6 +17,7 @@ type Streams interface { Out() *Out // Err returns the writer used for stderr. Err() io.Writer + io.Closer } type commonStream struct { @@ -81,10 +82,18 @@ func (o *Out) GetTtySize() (uint, uint) { return uint(ws.Height), uint(ws.Width) } +// Writer returns the wrapped writer. +func (o *Out) Writer() io.Writer { + return o.out +} + // NewOut returns a new [Out] object from a [io.Writer]. func NewOut(out io.Writer) *Out { fd, isTerminal := mobyterm.GetFdInfo(out) - return &Out{commonStream: commonStream{fd: fd, isTerminal: isTerminal}, out: out} + return &Out{ + commonStream: commonStream{fd: fd, isTerminal: isTerminal}, + out: out, + } } // In is an input stream used by the app to read user input. @@ -120,6 +129,11 @@ func (i *In) CheckTty(attachStdin, ttyMode bool) error { return nil } +// Reader returns the wrapped reader. +func (i *In) Reader() io.ReadCloser { + return i.in +} + // NewIn returns a new [In] object from a [io.ReadCloser] func NewIn(in io.ReadCloser) *In { fd, isTerminal := mobyterm.GetFdInfo(in) @@ -136,22 +150,76 @@ func (cli *appCli) In() *In { return cli.in } func (cli *appCli) Out() *Out { return cli.out } func (cli *appCli) Err() io.Writer { return cli.err } -// StandardStreams sets a cli in, out and err streams with the standard streams. -func StandardStreams() Streams { - // Set terminal emulation based on platform as required. - stdin, stdout, stderr := mobyterm.StdStreams() - return &appCli{ - in: NewIn(stdin), - out: NewOut(stdout), - err: stderr, +func (cli *appCli) Close() error { + err := cli.in.Close() + if err != nil { + return err + } + if out, ok := cli.out.out.(io.Closer); ok { + err = out.Close() + if err != nil { + return err + } + } + + if errout, ok := cli.err.(io.Closer); ok { + err = errout.Close() + if err != nil { + return err + } + } + return nil +} + +// NewBasicStreams creates streams with given in, out and err streams. +// Give decorate functions to extend functionality. +func NewBasicStreams(in io.ReadCloser, out io.Writer, err io.Writer, fns ...StreamsModifierFn) Streams { + if in == nil { + in = io.NopCloser(strings.NewReader("")) + } + streams := &appCli{ + in: NewIn(in), + out: NewOut(out), + err: err, } + for _, fn := range fns { + fn(streams) + } + return streams +} + +// MaskedStdStreams sets a cli in, out and err streams with the standard streams and with masking of sensitive data. +func MaskedStdStreams(mask *SensitiveMask) Streams { + // Set terminal emulation based on platform as required. + stdin, stdout, stderr := StdInOutErr() + return NewBasicStreams(stdin, stdout, stderr, WithSensitiveMask(mask)) +} + +// StdInOutErr returns the standard streams (stdin, stdout, stderr). +// +// On Windows, it attempts to turn on VT handling on all std handles if +// supported, or falls back to terminal emulation. On Unix, this returns +// the standard [os.Stdin], [os.Stdout] and [os.Stderr]. +func StdInOutErr() (stdIn io.ReadCloser, stdOut, stdErr io.Writer) { + return mobyterm.StdStreams() } // NoopStreams provides streams like /dev/null. func NoopStreams() Streams { - return &appCli{ - in: NewIn(io.NopCloser(strings.NewReader(""))), - out: NewOut(io.Discard), - err: io.Discard, + return NewBasicStreams( + nil, + io.Discard, + io.Discard, + ) +} + +// StreamsModifierFn is a decorator function for a stream. +type StreamsModifierFn func(streams *appCli) + +// WithSensitiveMask decorates streams with a given mask. +func WithSensitiveMask(m *SensitiveMask) StreamsModifierFn { + return func(streams *appCli) { + streams.out.out = NewMaskingWriter(streams.out.out, m) + streams.err = NewMaskingWriter(streams.err, m) } } diff --git a/internal/launchr/types.go b/internal/launchr/types.go index 183aeeb..01530e7 100644 --- a/internal/launchr/types.go +++ b/internal/launchr/types.go @@ -6,14 +6,38 @@ import ( "io/fs" "os" "path/filepath" + "strconv" + "strings" "text/template" "github.com/spf13/cobra" ) +// Application environment variables. +const ( + // EnvVarRootParentPID defines parent process id. May be used by forked processes. + EnvVarRootParentPID = EnvVar("root_ppid") + // EnvVarActionsPath defines path where to search for actions. + EnvVarActionsPath = EnvVar("actions_path") + // EnvVarLogLevel defines currently set log level, see --log-level or -v flag. + EnvVarLogLevel = EnvVar("log_level") + // EnvVarLogFormat defines currently set log format, see --log-format flag. + EnvVarLogFormat = EnvVar("log_format") + // EnvVarQuietMode defines if the application should output anything, see --quiet flag. + EnvVarQuietMode = EnvVar("quiet_mode") +) + // PkgPath is a main module path. const PkgPath = "github.com/launchrctl/launchr" +func init() { + // Set parent pid for subprocesses. + ppid := EnvVarRootParentPID.Get() + if ppid == "" { + _ = EnvVarRootParentPID.Set(strconv.Itoa(os.Getpid())) + } +} + // Command is a type alias for [cobra.Command]. // to reduce direct dependency on cobra in packages. type Command = cobra.Command @@ -37,6 +61,10 @@ type App interface { // GetService retrieves a service of type [v] and assigns it to [v]. // Panics if a service is not found. GetService(v any) + // SensitiveWriter wraps given writer with a sensitive mask. + SensitiveWriter(w io.Writer) io.Writer + // SensitiveMask returns current sensitive mask to add values to mask. + SensitiveMask() *SensitiveMask // RegisterFS registers a File System in launchr. // It may be a FS for action discovery, see [action.DiscoveryFS]. @@ -112,6 +140,13 @@ type CobraPlugin interface { CobraAddCommands(root *Command) error } +// PersistentPreRunPlugin is an interface to implement a plugin +// to run before any command is run and all arguments are parsed. +type PersistentPreRunPlugin interface { + Plugin + PersistentPreRun(cmd *Command, args []string) error +} + // Template provides templating functionality to generate files. type Template struct { Tmpl string // Tmpl is a template string. @@ -233,3 +268,34 @@ func (e ExitError) Error() string { func (e ExitError) ExitCode() int { return e.code } + +// EnvVar defines an environment variable and provides an interface to interact with it +// by prefixing the current app name. +// For example, if "my_var" is given as the variable name and the app name is "launchr", +// the accessed environment variable will be "LAUNCHR_MY_VAR". +type EnvVar string + +// String implements [fmt.Stringer] interface. +func (key EnvVar) String() string { + return strings.ToUpper(name + "_" + string(key)) +} + +// EnvString returns an os string of env variable with a value val. +func (key EnvVar) EnvString(val string) string { + return key.String() + "=" + val +} + +// Get returns env variable value. +func (key EnvVar) Get() string { + return os.Getenv(key.String()) +} + +// Set sets env variable. +func (key EnvVar) Set(val string) error { + return os.Setenv(key.String(), val) +} + +// Unset unsets env variable. +func (key EnvVar) Unset() error { + return os.Unsetenv(key.String()) +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index ba6b269..dfa8379 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -17,6 +17,7 @@ import ( ) func Test_Action(t *testing.T) { + t.Parallel() assert := assert.New(t) require := require.New(t) @@ -99,6 +100,7 @@ func Test_Action(t *testing.T) { } func Test_Action_NewYAMLFromFS(t *testing.T) { + t.Parallel() // Prepare FS. fsys := genFsTestMapActions(1, validFullYaml, genPathTypeArbitrary) // Get first key to make subdir. @@ -133,6 +135,7 @@ func Test_Action_NewYAMLFromFS(t *testing.T) { } func Test_ActionInput(t *testing.T) { + t.Parallel() assert := assert.New(t) require := require.New(t) a := NewFromYAML("input_test", []byte(validMultipleArgsAndOpts)) @@ -215,6 +218,7 @@ func Test_ActionInput(t *testing.T) { } func Test_ActionInputValidate(t *testing.T) { + t.Parallel() type inputProcessFn func(_ *testing.T, a *Action, input *Input) type testCase struct { name string diff --git a/pkg/action/discover.go b/pkg/action/discover.go index 67bf3ff..1fad2db 100644 --- a/pkg/action/discover.go +++ b/pkg/action/discover.go @@ -219,8 +219,8 @@ func (ad *Discovery) Discover(ctx context.Context) ([]*Action, error) { func (ad *Discovery) parseFile(f string) *Action { loader := ad.ds.Loader( ad.fs.OpenCallback(f), - envProcessor{}, inputProcessor{}, + envProcessor{}, ) a := New(ad.idp, loader, ad.fs, f) a.SetWorkDir(launchr.MustAbs(ad.fs.wd)) diff --git a/pkg/action/discover_test.go b/pkg/action/discover_test.go index 6f65aaf..4168801 100644 --- a/pkg/action/discover_test.go +++ b/pkg/action/discover_test.go @@ -62,6 +62,7 @@ func Test_Discover(t *testing.T) { } func Test_Discover_ActionWD(t *testing.T) { + t.Parallel() // Test if working directory is correctly set to actions on discovery. tfs := genFsTestMapActions(1, validEmptyVersionYaml, genPathTypeValid) var expFPath string diff --git a/pkg/action/loader.go b/pkg/action/loader.go index 7203308..9c01a55 100644 --- a/pkg/action/loader.go +++ b/pkg/action/loader.go @@ -8,6 +8,8 @@ import ( "strings" "syscall" "text/template" + + "github.com/launchrctl/launchr/internal/launchr" ) // Loader is an interface for loading an action file. @@ -188,4 +190,10 @@ func addPredefinedVariables(data map[string]any, a *Action) { data["current_working_dir"] = a.wd // app working directory data["actions_base_dir"] = a.fs.Realpath() // root directory where the action was found data["action_dir"] = a.Dir() // directory of action file + // Get the path of the executable on the host. + bin, err := os.Executable() + if err != nil { + bin = launchr.Version().Name + } + data["current_bin"] = bin } diff --git a/pkg/action/loader_test.go b/pkg/action/loader_test.go index 27349ec..0bfee81 100644 --- a/pkg/action/loader_test.go +++ b/pkg/action/loader_test.go @@ -33,6 +33,7 @@ func testLoaderAction() *Action { } func Test_EnvProcessor(t *testing.T) { + t.Parallel() proc := envProcessor{} _ = os.Setenv("TEST_ENV1", "VAL1") _ = os.Setenv("TEST_ENV2", "VAL2") @@ -42,6 +43,7 @@ func Test_EnvProcessor(t *testing.T) { } func Test_InputProcessor(t *testing.T) { + t.Parallel() act := testLoaderAction() ctx := LoadContext{Action: act} proc := inputProcessor{} @@ -70,6 +72,7 @@ func Test_InputProcessor(t *testing.T) { } func Test_YamlTplCommentsProcessor(t *testing.T) { + t.Parallel() act := testLoaderAction() ctx := LoadContext{Action: act} proc := NewPipeProcessor( @@ -98,6 +101,7 @@ t: {{ .arg1 }} # {{ .optUnd }} } func Test_PipeProcessor(t *testing.T) { + t.Parallel() act := testLoaderAction() ctx := LoadContext{Action: act} proc := NewPipeProcessor( diff --git a/pkg/action/manager.go b/pkg/action/manager.go index 2268df8..ac45479 100644 --- a/pkg/action/manager.go +++ b/pkg/action/manager.go @@ -39,8 +39,6 @@ type Manager interface { // GetValueProcessors returns list of available processors GetValueProcessors() map[string]ValueProcessor - // DefaultRuntime provides the default action runtime. - DefaultRuntime() Runtime // Run executes an action in foreground. Run(ctx context.Context, a *Action) (RunInfo, error) // RunBackground executes an action in background. @@ -217,10 +215,6 @@ func (m *actionManagerMap) SetActionIDProvider(p IDProvider) { m.idProvider = p } -func (m *actionManagerMap) DefaultRuntime() Runtime { - return NewContainerRuntimeDocker() -} - // RunInfo stores information about a running action. type RunInfo struct { ID string @@ -299,9 +293,16 @@ func (m *actionManagerMap) RunInfoByID(id string) (RunInfo, bool) { } // WithDefaultRuntime adds a default [Runtime] for an action. -func WithDefaultRuntime(m Manager, a *Action) { - if a.Runtime() == nil { - a.SetRuntime(m.DefaultRuntime()) +func WithDefaultRuntime(_ Manager, a *Action) { + if a.Runtime() != nil { + return + } + def, _ := a.Raw() + switch def.Runtime.Type { + case runtimeTypeContainer: + a.SetRuntime(NewContainerRuntimeDocker()) + case runtimeTypeShell: + a.SetRuntime(NewShellRuntime()) } } diff --git a/pkg/action/process_test.go b/pkg/action/process_test.go index a6c7908..1addbcf 100644 --- a/pkg/action/process_test.go +++ b/pkg/action/process_test.go @@ -188,6 +188,7 @@ func addTestValueProcessors(am Manager) { } func Test_ActionsValueProcessor(t *testing.T) { + t.Parallel() am := NewManager() addTestValueProcessors(am) diff --git a/pkg/action/runtime.container.go b/pkg/action/runtime.container.go index 5770624..e91fc4c 100644 --- a/pkg/action/runtime.container.go +++ b/pkg/action/runtime.container.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "os" osuser "os/user" "path/filepath" "runtime" @@ -259,9 +260,11 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { if !runConfig.Tty { log.Debug("watching container signals") - sigc := driver.NotifyAllSignals() - go driver.ForwardAllSignals(ctx, c.crt, cid, sigc) - defer driver.StopCatchSignals(sigc) + sigc := launchr.NotifySignals() + go launchr.HandleSignals(ctx, sigc, func(_ os.Signal, sig string) error { + return c.crt.ContainerKill(ctx, cid, sig) + }) + defer launchr.StopCatchSignals(sigc) } // Attach streams to the terminal. diff --git a/pkg/action/runtime.shell.go b/pkg/action/runtime.shell.go new file mode 100644 index 0000000..8c2dc06 --- /dev/null +++ b/pkg/action/runtime.shell.go @@ -0,0 +1,79 @@ +package action + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "runtime" + "syscall" + + "github.com/launchrctl/launchr/internal/launchr" +) + +type runtimeShell struct { +} + +// NewShellRuntime creates a new action shell runtime. +func NewShellRuntime() Runtime { + return &runtimeShell{} +} + +func (r *runtimeShell) Clone() Runtime { + return NewShellRuntime() +} + +func (r *runtimeShell) Init(_ context.Context, _ *Action) (err error) { + if runtime.GOOS == "windows" { + return fmt.Errorf("shell runtime is not supported in Windows") + } + return nil +} + +func (r *runtimeShell) Execute(ctx context.Context, a *Action) (err error) { + streams := a.Input().Streams() + rt := a.RuntimeDef() + defaultShell := os.Getenv("SHELL") + if defaultShell == "" { + defaultShell = "/bin/bash" + } + + cmd := exec.CommandContext(ctx, defaultShell, "-l", "-c", rt.Shell.Script) //nolint:gosec // G204 user script is expected. + cmd.Dir = a.WorkDir() + cmd.Env = append(os.Environ(), rt.Shell.Env...) + cmd.Stdout = streams.Out() + cmd.Stderr = streams.Err() + // Do no attach stdin, as it may not work as expected. + + err = cmd.Start() + if err != nil { + return err + } + + // If we attached with TTY, all signals will be processed by a child process. + sigc := launchr.NotifySignals() + go launchr.HandleSignals(ctx, sigc, func(s os.Signal, _ string) error { + launchr.Log().Debug("forwarding signal for action", "sig", s, "pid", cmd.Process.Pid) + return syscall.Kill(-cmd.Process.Pid, s.(syscall.Signal)) + }) + defer launchr.StopCatchSignals(sigc) + + cmdErr := cmd.Wait() + var exitErr *exec.ExitError + if errors.As(cmdErr, &exitErr) { + exitCode := exitErr.ExitCode() + msg := fmt.Sprintf("action %q finished with exit code %d", a.ID, exitCode) + // Process was interrupted. + if exitCode == -1 { + exitCode = 130 + msg = fmt.Sprintf("action %q was interrupted, finished with exit code %d", a.ID, exitCode) + } + return launchr.NewExitError(exitCode, msg) + } + return cmdErr +} + +func (r *runtimeShell) Close() error { + return nil +} diff --git a/pkg/action/yaml.def.go b/pkg/action/yaml.def.go index 66d1a15..02f7063 100644 --- a/pkg/action/yaml.def.go +++ b/pkg/action/yaml.def.go @@ -27,9 +27,12 @@ const ( sErrActionDefMissing = "action definition is missing in the declaration" sErrEmptyProcessorID = "invalid configuration, processor ID is required" + sErrEmptyScript = "script field cannot be empty" + // Runtime types. runtimeTypePlugin DefRuntimeType = "plugin" runtimeTypeContainer DefRuntimeType = "container" + runtimeTypeShell DefRuntimeType = "shell" ) type errUnsupportedActionVersion struct { @@ -173,7 +176,7 @@ func (r *DefRuntimeType) UnmarshalYAML(n *yaml.Node) (err error) { } *r = DefRuntimeType(s) switch *r { - case runtimeTypePlugin, runtimeTypeContainer: + case runtimeTypePlugin, runtimeTypeContainer, runtimeTypeShell: return nil case "": return yamlTypeErrorLine("empty runtime type", n.Line, n.Column) @@ -211,10 +214,32 @@ func (r *DefRuntimeContainer) UnmarshalYAML(n *yaml.Node) (err error) { return err } +// DefRuntimeShell has shell-specific runtime configuration. +type DefRuntimeShell struct { + Env EnvSlice `yaml:"env"` + Script string `yaml:"script"` +} + +// UnmarshalYAML implements [yaml.Unmarshaler] to parse runtime shell definition. +func (r *DefRuntimeShell) UnmarshalYAML(n *yaml.Node) (err error) { + type yamlT DefRuntimeShell + var y yamlT + if err = n.Decode(&y); err != nil { + return err + } + *r = DefRuntimeShell(y) + if len(r.Script) == 0 { + l, c := yamlNodeLineCol(n, "script") + return yamlTypeErrorLine(sErrEmptyScript, l, c) + } + return err +} + // DefRuntime contains action runtime configuration. type DefRuntime struct { Type DefRuntimeType `yaml:"type"` Container *DefRuntimeContainer + Shell *DefRuntimeShell } // UnmarshalYAML implements [yaml.Unmarshaler] to parse runtime definition. @@ -247,6 +272,9 @@ func (r *DefRuntime) UnmarshalYAML(n *yaml.Node) (err error) { case runtimeTypeContainer: err = n.Decode(&r.Container) return err + case runtimeTypeShell: + err = n.Decode(&r.Shell) + return err default: // Error is already returned on runtime type parsing. panic(fmt.Sprintf("runtime type not implemented: %s", r.Type)) diff --git a/plugins/actionscobra/cobra.go b/plugins/actionscobra/cobra.go index 9af2105..3a83c17 100644 --- a/plugins/actionscobra/cobra.go +++ b/plugins/actionscobra/cobra.go @@ -28,10 +28,7 @@ func CobraImpl(a *action.Action, streams launchr.Streams) (*launchr.Command, err // @todo: have aliases documented in help Short: getDesc(def.Title, def.Description), Aliases: def.Aliases, - RunE: func(cmd *launchr.Command, args []string) (err error) { - // Don't show usage help on a runtime error. - cmd.SilenceUsage = true - + PreRunE: func(cmd *launchr.Command, args []string) error { // Set action input. argsNamed, err := action.ArgsPosToNamed(a, args) if err != nil { @@ -56,6 +53,12 @@ func CobraImpl(a *action.Action, streams launchr.Streams) (*launchr.Command, err return err } + return nil + }, + RunE: func(cmd *launchr.Command, _ []string) (err error) { + // Don't show usage help on a runtime error. + cmd.SilenceUsage = true + // @todo can we use action manager here and Manager.Run() return a.Execute(cmd.Context()) }, diff --git a/plugins/actionscobra/plugin.go b/plugins/actionscobra/plugin.go index 3d2bff1..d18b612 100644 --- a/plugins/actionscobra/plugin.go +++ b/plugins/actionscobra/plugin.go @@ -63,9 +63,12 @@ func (p *Plugin) discoverActions() (err error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - for _, pldisc := range launchr.GetPluginByType[action.DiscoveryPlugin](p.pm) { + plugins := launchr.GetPluginByType[action.DiscoveryPlugin](p.pm) + launchr.Log().Debug("hook DiscoveryPlugin", "plugins", plugins) + for _, pldisc := range plugins { actions, errDis := pldisc.V.DiscoverActions(ctx) if errDis != nil { + launchr.Log().Debug("error on DiscoverActions", "plugin", pldisc.K.String()) return errDis } @@ -94,9 +97,12 @@ func (p *Plugin) discoverActions() (err error) { } // Alter all registered actions. - for _, p := range launchr.GetPluginByType[action.AlterActionsPlugin](p.pm) { - err = p.V.AlterActions() + plalter := launchr.GetPluginByType[action.AlterActionsPlugin](p.pm) + launchr.Log().Debug("hook AlterActionsPlugin", "plugins", plalter) + for _, pl := range plalter { + err = pl.V.AlterActions() if err != nil { + launchr.Log().Debug("error on AlterActions", "plugin", pl.K.String()) return err } } diff --git a/plugins/builder/builder.go b/plugins/builder/builder.go index ceaa113..bbfd3cf 100644 --- a/plugins/builder/builder.go +++ b/plugins/builder/builder.go @@ -191,6 +191,7 @@ func (b *Builder) goBuild(ctx context.Context) error { } else { ldflags = append(ldflags, "-s", "-w") args = append(args, "-trimpath") + b.Tags = append(b.Tags, "release") } args = append(args, "-ldflags", strings.Join(ldflags, " ")) diff --git a/plugins/verbosity/plugin.go b/plugins/verbosity/plugin.go index a740324..2412401 100644 --- a/plugins/verbosity/plugin.go +++ b/plugins/verbosity/plugin.go @@ -2,7 +2,7 @@ package verbosity import ( - "errors" + "fmt" "math" "github.com/launchrctl/launchr/internal/launchr" @@ -45,7 +45,10 @@ func (e *LogFormat) Set(v string) error { *e = lf return nil default: - return errors.New(`must be one of "plain" or "json"`) + return fmt.Errorf( + `must be one of %s, %s, %s`, + LogFormatPlain, LogFormatJSON, LogFormatPretty, + ) } } @@ -54,11 +57,38 @@ func (e *LogFormat) Type() string { return "LogFormat" } +type logLevelStr string + +// Set implements [fmt.Stringer] interface. +func (e *logLevelStr) String() string { + return string(*e) +} + +// Set implements [github.com/spf13/pflag.Value] interface. +func (e *logLevelStr) Set(v string) error { + switch v { + case launchr.LogLevelStrDisabled, launchr.LogLevelStrDebug, launchr.LogLevelStrInfo, launchr.LogLevelStrWarn, launchr.LogLevelStrError: + *e = logLevelStr(v) + return nil + default: + return fmt.Errorf( + `must be one of %s, %s, %s, %s, %s`, + launchr.LogLevelStrDisabled, launchr.LogLevelStrDebug, launchr.LogLevelStrInfo, launchr.LogLevelStrWarn, launchr.LogLevelStrError, + ) + } +} + +// Type implements [github.com/spf13/pflag.Value] interface. +func (e *logLevelStr) Type() string { + return "logLevelStr" +} + // OnAppInit implements [launchr.OnAppInitPlugin] interface. func (p Plugin) OnAppInit(app launchr.App) error { verbosity := 0 quiet := false var logFormat LogFormat + var logLvlStr logLevelStr // Assert we are able to access internal functionality. appInternal, ok := app.(launchr.AppInternal) @@ -72,7 +102,8 @@ func (p Plugin) OnAppInit(app launchr.App) error { unkFlagsBkp := pflags.ParseErrorsWhitelist.UnknownFlags pflags.ParseErrorsWhitelist.UnknownFlags = true pflags.CountVarP(&verbosity, "verbose", "v", "log verbosity level, use -vvvv DEBUG, -vvv INFO, -vv WARN, -v ERROR") - pflags.VarP(&logFormat, "log-format", "", "log format, may be pretty, plain or json (default pretty)") + pflags.VarP(&logLvlStr, "log-level", "", "log level, same as -v, can be: DEBUG, INFO, WARN, ERROR or NONE (default NONE)") + pflags.VarP(&logFormat, "log-format", "", "log format, can be: pretty, plain or json (default pretty)") pflags.BoolVarP(&quiet, "quiet", "q", false, "disable output to the console") // Parse available flags. @@ -85,18 +116,37 @@ func (p Plugin) OnAppInit(app launchr.App) error { panic(err) } pflags.ParseErrorsWhitelist.UnknownFlags = unkFlagsBkp + + // Set quiet mode. launchr.Term().EnableOutput() + if !quiet && launchr.EnvVarQuietMode.Get() == "1" { + quiet = true + } if quiet { + _ = launchr.EnvVarQuietMode.Set("1") launchr.Term().DisableOutput() app.SetStreams(launchr.NoopStreams()) } + // Select log level based on priority of definition. + logLevel := launchr.LogLevelDisabled + if pflags.Changed("log-level") { + logLevel = launchr.LogLevelFromString(string(logLvlStr)) + } else if pflags.Changed("verbose") { + logLevel = logLevelFlagInt(verbosity) + } else if logLvlEnv := launchr.EnvVarLogLevel.Get(); logLvlEnv != "" { + logLevel = launchr.LogLevelFromString(logLvlEnv) + } + streams := app.Streams() out := streams.Out() // Set terminal output. launchr.Term().SetOutput(out) // Enable logger. - if verbosity > 0 { + if logLevel != launchr.LogLevelDisabled { + if logFormat == "" && launchr.EnvVarLogFormat.Get() != "" { + logFormat = LogFormat(launchr.EnvVarLogFormat.Get()) + } var logger *launchr.Logger switch logFormat { case LogFormatPlain: @@ -107,8 +157,11 @@ func (p Plugin) OnAppInit(app launchr.App) error { logger = launchr.NewConsoleLogger(out) } launchr.SetLogger(logger) + // Save env variable for subprocesses. + _ = launchr.EnvVarLogLevel.Set(logLevel.String()) + _ = launchr.EnvVarLogFormat.Set(logFormat.String()) } - launchr.Log().SetLevel(logLevelFlagInt(verbosity)) + launchr.Log().SetLevel(logLevel) cmd.SetOut(out) cmd.SetErr(streams.Err()) return nil diff --git a/types.go b/types.go index 94e4d00..1f60e38 100644 --- a/types.go +++ b/types.go @@ -28,6 +28,20 @@ var ( builtWith string //nolint:unused ) +// Application environment variables. +const ( + // EnvVarRootParentPID defines parent process id. May be used by forked processes. + EnvVarRootParentPID = launchr.EnvVarRootParentPID + // EnvVarActionsPath defines path where to search for actions. + EnvVarActionsPath = launchr.EnvVarActionsPath + // EnvVarLogLevel defines currently set log level. + EnvVarLogLevel = launchr.EnvVarLogLevel + // EnvVarLogFormat defines currently set log format, see --log-format flag. + EnvVarLogFormat = launchr.EnvVarLogFormat + // EnvVarQuietMode defines if the application should output anything, see --quiet flag. + EnvVarQuietMode = launchr.EnvVarQuietMode +) + // Re-export types aliases for usage by external modules. type ( // App stores global application state. @@ -54,6 +68,13 @@ type ( TextPrinter = launchr.TextPrinter // Streams is an interface which exposes the standard input and output streams. Streams = launchr.Streams + + // SensitiveMask replaces sensitive strings with a mask. + SensitiveMask = launchr.SensitiveMask + // MaskingWriter is a writer that masks sensitive data in the input stream. + // It buffers data to handle cases where sensitive data spans across writes. + MaskingWriter = launchr.MaskingWriter + // In is an input stream used by the app to read user input. In = launchr.In // Out is an output stream used by the app to write normal program output. @@ -71,6 +92,9 @@ type ( ActionsAlterPlugin = action.AlterActionsPlugin // CobraPlugin is an interface to implement a plugin for cobra. CobraPlugin = launchr.CobraPlugin + // PersistentPreRunPlugin is an interface to implement a plugin + // to run before any command is run and all arguments are parsed. + PersistentPreRunPlugin = launchr.PersistentPreRunPlugin // GeneratePlugin is an interface to generate supporting files before build. GeneratePlugin = launchr.GeneratePlugin // GenerateConfig defines generation config. @@ -88,6 +112,11 @@ type ( // ExitError is an error holding an error code of executed command. ExitError = launchr.ExitError + // EnvVar defines an environment variable and provides an interface to interact with it + // by prefixing the current app name. + // For example, if "my_var" is given as the variable name and the app name is "launchr", + // the accessed environment variable will be "LAUNCHR_MY_VAR". + EnvVar = launchr.EnvVar ) // Version provides app version info. @@ -111,12 +140,30 @@ func NewIn(in io.ReadCloser) *In { return launchr.NewIn(in) } // NewOut returns a new [Out] object from a [io.Writer]. func NewOut(out io.Writer) *Out { return launchr.NewOut(out) } -// StandardStreams sets a cli in, out and err streams with the standard streams. -func StandardStreams() Streams { return launchr.StandardStreams() } +// MaskedStdStreams sets a cli in, out and err streams with the standard streams and with masking of sensitive data. +func MaskedStdStreams(mask *SensitiveMask) Streams { return launchr.MaskedStdStreams(mask) } + +// NewBasicStreams creates streams with given in, out and err streams. +// Give decorate functions to extend functionality. +func NewBasicStreams(in io.ReadCloser, out io.Writer, err io.Writer, fns ...launchr.StreamsModifierFn) Streams { + return launchr.NewBasicStreams(in, out, err, fns...) +} // NoopStreams provides streams like /dev/null. func NoopStreams() Streams { return launchr.NoopStreams() } +// StdInOutErr returns the standard streams (stdin, stdout, stderr). +// +// On Windows, it attempts to turn on VT handling on all std handles if +// supported, or falls back to terminal emulation. On Unix, this returns +// the standard [os.Stdin], [os.Stdout] and [os.Stderr]. +func StdInOutErr() (stdIn io.ReadCloser, stdOut, stdErr io.Writer) { return launchr.StdInOutErr() } + +// NewMaskingWriter initializes a new MaskingWriter. +func NewMaskingWriter(w io.Writer, mask *SensitiveMask) io.WriteCloser { + return launchr.NewMaskingWriter(w, mask) +} + // Log returns the default logger. func Log() *Logger { return launchr.Log() }