Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ builds:
goarch:
- amd64
- arm64
tags:
- release
flags:
- -trimpath
ldflags:
Expand Down
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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

46 changes: 39 additions & 7 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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()))
Expand All @@ -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)
}
Expand Down
34 changes: 34 additions & 0 deletions example/actions/shell/action.yaml
Original file line number Diff line number Diff line change
@@ -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"
15 changes: 15 additions & 0 deletions example/actions/shell/main.sh
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ 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.
continue
}
err = p.V.Generate(config)
if err != nil {
Log().Debug("error on Generate", "plugin", p.K.String())
return err
}
}
Expand Down
29 changes: 14 additions & 15 deletions internal/launchr/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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)
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/launchr/filepath.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/launchr/filepath_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

func TestMkdirTemp(t *testing.T) {
t.Parallel()
dir, err := MkdirTemp("test")
require.NoError(t, err)
require.NotEmpty(t, dir)
Expand All @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions internal/launchr/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading