Initial version of scripting (#40)
* scripting: added service and controller for scripting * scripting: have got prompts working Scripts are now running in a separate go-routine. When a prompt is encountered, the script is paused and the user is prompted for input. This means that the script no longer needs to worry about synchronisation issues. * scripting: started working on the session methods * scripting: added methods to get items and attributes * scripting: have got loading of scripts working These act more like plugins and allow defining new commands. * scripting: have got script scheduling working Scripts are now executed on a dedicated goroutine and only one script can run at any one time. * scripting: added session.set_result_set(rs) * scripting: upgraded tamarin to 0.14 * scripting: started working on set_value * tamarin: replaced ad-hoc path with query expressions * scripting: changed value() and set_value() to attr() and set_attr() Also added 'delete_attr()' * scripting: added os.exec() This method is controlled by permissions which govern whether shellouts are allowed Also fixed a resizing bug with the status window which was not properly handling status messages with newlines * scripting: added the session.current_item() method * scripting: added placeholders to query expressions * scripting: added support for setting and deleteing items with placeholders Also refactored the dot AST type so that it support placeholders. Placeholders are not yet supported for subrefs yet, they need to be identifiers. * scripting: made setting the result-set push the current result-set to the backstack * scripting: started working on byte encoding of attribute values * scripting: finished attrcodec * scripting: integrated codec into expression * scripting: added equals and hashcode to queryexpr This finally finishes the work required to store queries in the backstack * scripting: fixed some bugs with the back-stack * scripting: upgraded Tamarin * scripting: removed some commented out code
This commit is contained in:
parent
cd9700569c
commit
c89b09447c
66 changed files with 4588 additions and 281 deletions
36
internal/dynamo-browse/services/scriptmanager/iface.go
Normal file
36
internal/dynamo-browse/services/scriptmanager/iface.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
)
|
||||
|
||||
//go:generate mockery --with-expecter --name UIService
|
||||
//go:generate mockery --with-expecter --name SessionService
|
||||
|
||||
type Ifaces struct {
|
||||
UI UIService
|
||||
Session SessionService
|
||||
}
|
||||
|
||||
type UIService interface {
|
||||
PrintMessage(ctx context.Context, msg string)
|
||||
|
||||
// Prompt should return a channel which will provide the input from the user. If the user
|
||||
// provides no input, prompt should close the channel without providing anything.
|
||||
Prompt(ctx context.Context, msg string) chan string
|
||||
}
|
||||
|
||||
type SessionService interface {
|
||||
Query(ctx context.Context, expr string, queryOptions QueryOptions) (*models.ResultSet, error)
|
||||
|
||||
ResultSet(ctx context.Context) *models.ResultSet
|
||||
SelectedItemIndex(ctx context.Context) int
|
||||
SetResultSet(ctx context.Context, newResultSet *models.ResultSet)
|
||||
}
|
||||
|
||||
type QueryOptions struct {
|
||||
NamePlaceholders map[string]string
|
||||
ValuePlaceholders map[string]types.AttributeValue
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
// Code generated by mockery v2.16.0. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
models "github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
scriptmanager "github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager"
|
||||
)
|
||||
|
||||
// SessionService is an autogenerated mock type for the SessionService type
|
||||
type SessionService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type SessionService_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *SessionService) EXPECT() *SessionService_Expecter {
|
||||
return &SessionService_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, expr, queryOptions
|
||||
func (_m *SessionService) Query(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions) (*models.ResultSet, error) {
|
||||
ret := _m.Called(ctx, expr, queryOptions)
|
||||
|
||||
var r0 *models.ResultSet
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string, scriptmanager.QueryOptions) *models.ResultSet); ok {
|
||||
r0 = rf(ctx, expr, queryOptions)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.ResultSet)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, string, scriptmanager.QueryOptions) error); ok {
|
||||
r1 = rf(ctx, expr, queryOptions)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// SessionService_Query_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Query'
|
||||
type SessionService_Query_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Query is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - expr string
|
||||
// - queryOptions scriptmanager.QueryOptions
|
||||
func (_e *SessionService_Expecter) Query(ctx interface{}, expr interface{}, queryOptions interface{}) *SessionService_Query_Call {
|
||||
return &SessionService_Query_Call{Call: _e.mock.On("Query", ctx, expr, queryOptions)}
|
||||
}
|
||||
|
||||
func (_c *SessionService_Query_Call) Run(run func(ctx context.Context, expr string, queryOptions scriptmanager.QueryOptions)) *SessionService_Query_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string), args[2].(scriptmanager.QueryOptions))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *SessionService_Query_Call) Return(_a0 *models.ResultSet, _a1 error) *SessionService_Query_Call {
|
||||
_c.Call.Return(_a0, _a1)
|
||||
return _c
|
||||
}
|
||||
|
||||
// ResultSet provides a mock function with given fields: ctx
|
||||
func (_m *SessionService) ResultSet(ctx context.Context) *models.ResultSet {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
var r0 *models.ResultSet
|
||||
if rf, ok := ret.Get(0).(func(context.Context) *models.ResultSet); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*models.ResultSet)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SessionService_ResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResultSet'
|
||||
type SessionService_ResultSet_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// ResultSet is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *SessionService_Expecter) ResultSet(ctx interface{}) *SessionService_ResultSet_Call {
|
||||
return &SessionService_ResultSet_Call{Call: _e.mock.On("ResultSet", ctx)}
|
||||
}
|
||||
|
||||
func (_c *SessionService_ResultSet_Call) Run(run func(ctx context.Context)) *SessionService_ResultSet_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *SessionService_ResultSet_Call) Return(_a0 *models.ResultSet) *SessionService_ResultSet_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SelectedItemIndex provides a mock function with given fields: ctx
|
||||
func (_m *SessionService) SelectedItemIndex(ctx context.Context) int {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
var r0 int
|
||||
if rf, ok := ret.Get(0).(func(context.Context) int); ok {
|
||||
r0 = rf(ctx)
|
||||
} else {
|
||||
r0 = ret.Get(0).(int)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// SessionService_SelectedItemIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SelectedItemIndex'
|
||||
type SessionService_SelectedItemIndex_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// SelectedItemIndex is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
func (_e *SessionService_Expecter) SelectedItemIndex(ctx interface{}) *SessionService_SelectedItemIndex_Call {
|
||||
return &SessionService_SelectedItemIndex_Call{Call: _e.mock.On("SelectedItemIndex", ctx)}
|
||||
}
|
||||
|
||||
func (_c *SessionService_SelectedItemIndex_Call) Run(run func(ctx context.Context)) *SessionService_SelectedItemIndex_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *SessionService_SelectedItemIndex_Call) Return(_a0 int) *SessionService_SelectedItemIndex_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetResultSet provides a mock function with given fields: ctx, newResultSet
|
||||
func (_m *SessionService) SetResultSet(ctx context.Context, newResultSet *models.ResultSet) {
|
||||
_m.Called(ctx, newResultSet)
|
||||
}
|
||||
|
||||
// SessionService_SetResultSet_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetResultSet'
|
||||
type SessionService_SetResultSet_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// SetResultSet is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - newResultSet *models.ResultSet
|
||||
func (_e *SessionService_Expecter) SetResultSet(ctx interface{}, newResultSet interface{}) *SessionService_SetResultSet_Call {
|
||||
return &SessionService_SetResultSet_Call{Call: _e.mock.On("SetResultSet", ctx, newResultSet)}
|
||||
}
|
||||
|
||||
func (_c *SessionService_SetResultSet_Call) Run(run func(ctx context.Context, newResultSet *models.ResultSet)) *SessionService_SetResultSet_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(*models.ResultSet))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *SessionService_SetResultSet_Call) Return() *SessionService_SetResultSet_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
type mockConstructorTestingTNewSessionService interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}
|
||||
|
||||
// NewSessionService creates a new instance of SessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
func NewSessionService(t mockConstructorTestingTNewSessionService) *SessionService {
|
||||
mock := &SessionService{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
106
internal/dynamo-browse/services/scriptmanager/mocks/UIService.go
Normal file
106
internal/dynamo-browse/services/scriptmanager/mocks/UIService.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Code generated by mockery v2.16.0. DO NOT EDIT.
|
||||
|
||||
package mocks
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// UIService is an autogenerated mock type for the UIService type
|
||||
type UIService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type UIService_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *UIService) EXPECT() *UIService_Expecter {
|
||||
return &UIService_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// PrintMessage provides a mock function with given fields: ctx, msg
|
||||
func (_m *UIService) PrintMessage(ctx context.Context, msg string) {
|
||||
_m.Called(ctx, msg)
|
||||
}
|
||||
|
||||
// UIService_PrintMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PrintMessage'
|
||||
type UIService_PrintMessage_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// PrintMessage is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - msg string
|
||||
func (_e *UIService_Expecter) PrintMessage(ctx interface{}, msg interface{}) *UIService_PrintMessage_Call {
|
||||
return &UIService_PrintMessage_Call{Call: _e.mock.On("PrintMessage", ctx, msg)}
|
||||
}
|
||||
|
||||
func (_c *UIService_PrintMessage_Call) Run(run func(ctx context.Context, msg string)) *UIService_PrintMessage_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UIService_PrintMessage_Call) Return() *UIService_PrintMessage_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
// Prompt provides a mock function with given fields: ctx, msg
|
||||
func (_m *UIService) Prompt(ctx context.Context, msg string) chan string {
|
||||
ret := _m.Called(ctx, msg)
|
||||
|
||||
var r0 chan string
|
||||
if rf, ok := ret.Get(0).(func(context.Context, string) chan string); ok {
|
||||
r0 = rf(ctx, msg)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(chan string)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// UIService_Prompt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Prompt'
|
||||
type UIService_Prompt_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Prompt is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - msg string
|
||||
func (_e *UIService_Expecter) Prompt(ctx interface{}, msg interface{}) *UIService_Prompt_Call {
|
||||
return &UIService_Prompt_Call{Call: _e.mock.On("Prompt", ctx, msg)}
|
||||
}
|
||||
|
||||
func (_c *UIService_Prompt_Call) Run(run func(ctx context.Context, msg string)) *UIService_Prompt_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *UIService_Prompt_Call) Return(_a0 chan string) *UIService_Prompt_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
type mockConstructorTestingTNewUIService interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}
|
||||
|
||||
// NewUIService creates a new instance of UIService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
func NewUIService(t mockConstructorTestingTNewUIService) *UIService {
|
||||
mock := &UIService{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
67
internal/dynamo-browse/services/scriptmanager/modext.go
Normal file
67
internal/dynamo-browse/services/scriptmanager/modext.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type extModule struct {
|
||||
scriptPlugin *ScriptPlugin
|
||||
}
|
||||
|
||||
func (m *extModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("ext", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("command", m.command, mod),
|
||||
})
|
||||
|
||||
scp.Declare("ext", mod, true)
|
||||
}
|
||||
|
||||
func (m *extModule) command(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("ext.command", 2, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmdName, err := object.AsString(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fnRes, isFnRes := args[1].(*object.Function)
|
||||
if !isFnRes {
|
||||
return object.NewError(errors.New("expected second arg to be a function"))
|
||||
}
|
||||
|
||||
callFn, hasCallFn := object.GetCallFunc(ctx)
|
||||
if !hasCallFn {
|
||||
return object.NewError(errors.New("no callFn found in context"))
|
||||
}
|
||||
|
||||
// This command function will be executed by the script scheduler
|
||||
newCommand := func(ctx context.Context, args []string) error {
|
||||
objArgs := make([]object.Object, len(args))
|
||||
for i, a := range args {
|
||||
objArgs[i] = object.NewString(a)
|
||||
}
|
||||
|
||||
ctx = ctxWithOptions(ctx, m.scriptPlugin.scriptService.options)
|
||||
|
||||
res := callFn(ctx, fnRes.Scope(), fnRes, objArgs)
|
||||
if object.IsError(res) {
|
||||
errObj := res.(*object.Error)
|
||||
return errors.Errorf("command error '%v':%v - %v", m.scriptPlugin.name, cmdName, errObj.Inspect())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.scriptPlugin.definedCommands == nil {
|
||||
m.scriptPlugin.definedCommands = make(map[string]*Command)
|
||||
}
|
||||
m.scriptPlugin.definedCommands[cmdName] = &Command{plugin: m.scriptPlugin, cmdFn: newCommand}
|
||||
return nil
|
||||
}
|
||||
47
internal/dynamo-browse/services/scriptmanager/modos.go
Normal file
47
internal/dynamo-browse/services/scriptmanager/modos.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type osModule struct {
|
||||
}
|
||||
|
||||
func (om *osModule) exec(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("os.exec", 1, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmdExec, objErr := object.AsString(args[0])
|
||||
if objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
opts := optionFromCtx(ctx)
|
||||
if !opts.Permissions.AllowShellCommands {
|
||||
return object.NewErrResult(object.Errorf("permission error: no permission to shell out"))
|
||||
}
|
||||
|
||||
cmd := exec.Command(opts.OSExecShell, "-c", cmdExec)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return object.NewErrResult(object.NewError(err))
|
||||
}
|
||||
|
||||
return object.NewOkResult(object.NewString(string(out)))
|
||||
}
|
||||
|
||||
func (om *osModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("os", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("exec", om.exec, mod),
|
||||
})
|
||||
|
||||
scp.Declare("os", mod, true)
|
||||
}
|
||||
110
internal/dynamo-browse/services/scriptmanager/modos_test.go
Normal file
110
internal/dynamo-browse/services/scriptmanager/modos_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package scriptmanager_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOSModule_Exec(t *testing.T) {
|
||||
t.Run("should run command and return stdout", func(t *testing.T) {
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "false")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "hello world\n")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := os.exec('echo "hello world"')
|
||||
ui.print(res.is_err())
|
||||
ui.print(res.unwrap())
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetDefaultOptions(scriptmanager.Options{
|
||||
OSExecShell: "/bin/bash",
|
||||
Permissions: scriptmanager.Permissions{
|
||||
AllowShellCommands: true,
|
||||
},
|
||||
})
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should refuse to execute command if do not have permissions", func(t *testing.T) {
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "true")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := os.exec('echo "hello world"')
|
||||
ui.print(res.is_err())
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetDefaultOptions(scriptmanager.Options{
|
||||
OSExecShell: "/bin/bash",
|
||||
Permissions: scriptmanager.Permissions{
|
||||
AllowShellCommands: false,
|
||||
},
|
||||
})
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should be able to change permissions which will affect plugins", func(t *testing.T) {
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Loaded the plugin\n")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "true")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
ext.command("mycommand", func() {
|
||||
ui.print(os.exec('echo "this cannot run"').is_err())
|
||||
})
|
||||
|
||||
ui.print(os.exec('echo "Loaded the plugin"').unwrap())
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetDefaultOptions(scriptmanager.Options{
|
||||
OSExecShell: "/bin/bash",
|
||||
Permissions: scriptmanager.Permissions{
|
||||
AllowShellCommands: true,
|
||||
},
|
||||
})
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := srv.LoadScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
srv.SetDefaultOptions(scriptmanager.Options{
|
||||
OSExecShell: "/bin/bash",
|
||||
Permissions: scriptmanager.Permissions{
|
||||
AllowShellCommands: false,
|
||||
},
|
||||
})
|
||||
|
||||
errChan := make(chan error)
|
||||
assert.NoError(t, srv.LookupCommand("mycommand").Invoke(ctx, []string{}, errChan))
|
||||
assert.NoError(t, waitForErr(t, errChan))
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
121
internal/dynamo-browse/services/scriptmanager/modsession.go
Normal file
121
internal/dynamo-browse/services/scriptmanager/modsession.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type sessionModule struct {
|
||||
sessionService SessionService
|
||||
}
|
||||
|
||||
func (um *sessionModule) query(ctx context.Context, args ...object.Object) object.Object {
|
||||
if len(args) == 0 || len(args) > 2 {
|
||||
return object.Errorf("type error: session.query takes either 1 or 2 arguments (%d given)", len(args))
|
||||
}
|
||||
|
||||
var options QueryOptions
|
||||
|
||||
expr, objErr := object.AsString(args[0])
|
||||
if objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
if len(args) == 2 {
|
||||
objMap, objErr := object.AsMap(args[1])
|
||||
if objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
// Placeholders
|
||||
if argsVal, isArgsValMap := objMap.Get("args").(*object.Map); isArgsValMap {
|
||||
options.NamePlaceholders = make(map[string]string)
|
||||
options.ValuePlaceholders = make(map[string]types.AttributeValue)
|
||||
|
||||
for k, val := range argsVal.Value() {
|
||||
switch v := val.(type) {
|
||||
case *object.String:
|
||||
options.NamePlaceholders[k] = v.Value()
|
||||
options.ValuePlaceholders[k] = &types.AttributeValueMemberS{Value: v.Value()}
|
||||
case *object.Int:
|
||||
options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
|
||||
case *object.Float:
|
||||
options.ValuePlaceholders[k] = &types.AttributeValueMemberN{Value: fmt.Sprint(v.Value())}
|
||||
case *object.Bool:
|
||||
options.ValuePlaceholders[k] = &types.AttributeValueMemberBOOL{Value: v.Value()}
|
||||
case *object.NilType:
|
||||
options.ValuePlaceholders[k] = &types.AttributeValueMemberNULL{Value: true}
|
||||
default:
|
||||
return object.Errorf("type error: arg '%v' of type '%v' is not supported", k, val.Type())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := um.sessionService.Query(ctx, expr, options)
|
||||
|
||||
if err != nil {
|
||||
return object.NewErrResult(object.NewError(err))
|
||||
}
|
||||
return object.NewOkResult(&resultSetProxy{resultSet: resp})
|
||||
}
|
||||
|
||||
func (um *sessionModule) resultSet(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("session.result_set", 0, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rs := um.sessionService.ResultSet(ctx)
|
||||
if rs == nil {
|
||||
return object.Nil
|
||||
}
|
||||
return &resultSetProxy{resultSet: rs}
|
||||
}
|
||||
|
||||
func (um *sessionModule) selectedItem(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("session.result_set", 0, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rs := um.sessionService.ResultSet(ctx)
|
||||
idx := um.sessionService.SelectedItemIndex(ctx)
|
||||
if rs == nil || idx < 0 {
|
||||
return object.Nil
|
||||
}
|
||||
|
||||
rsProxy := &resultSetProxy{resultSet: rs}
|
||||
return newItemProxy(rsProxy, idx)
|
||||
}
|
||||
|
||||
func (um *sessionModule) setResultSet(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("session.set_result_set", 1, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resultSetProxy, isResultSetProxy := args[0].(*resultSetProxy)
|
||||
if !isResultSetProxy {
|
||||
return object.NewError(errors.Errorf("type error: expected a resultsset (got %v)", args[0]))
|
||||
}
|
||||
|
||||
um.sessionService.SetResultSet(ctx, resultSetProxy.resultSet)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (um *sessionModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("session", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("query", um.query, mod),
|
||||
object.NewBuiltin("result_set", um.resultSet, mod),
|
||||
object.NewBuiltin("selected_item", um.selectedItem, mod),
|
||||
object.NewBuiltin("set_result_set", um.setResultSet, mod),
|
||||
})
|
||||
|
||||
scp.Declare("session", mod, true)
|
||||
}
|
||||
292
internal/dynamo-browse/services/scriptmanager/modsession_test.go
Normal file
292
internal/dynamo-browse/services/scriptmanager/modsession_test.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
package scriptmanager_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModSession_Query(t *testing.T) {
|
||||
t.Run("should successfully return query result", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
rs.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
|
||||
})
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "2")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[0]['pk'].S = abc")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1]['pk'].S = 1232")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "res[1].attr('size(pk)') = 4")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
ui.print(res.length)
|
||||
ui.print("res[0]['pk'].S = ", res[0].attr("pk"))
|
||||
ui.print("res[1]['pk'].S = ", res[1].attr("pk"))
|
||||
ui.print("res[1].attr('size(pk)') = ", res[1].attr("size(pk)"))
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should return error if query returns error", func(t *testing.T) {
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(nil, errors.New("bang"))
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "true")
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "err(\"bang\")")
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr")
|
||||
ui.print(res.is_err())
|
||||
ui.print(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should set placeholder values", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{
|
||||
NamePlaceholders: map[string]string{
|
||||
"name": "hello",
|
||||
"value": "world",
|
||||
},
|
||||
ValuePlaceholders: map[string]types.AttributeValue{
|
||||
"name": &types.AttributeValueMemberS{Value: "hello"},
|
||||
"value": &types.AttributeValueMemberS{Value: "world"},
|
||||
},
|
||||
}).Return(rs, nil)
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query(":name = $value", {
|
||||
args: {
|
||||
name: "hello",
|
||||
value: "world",
|
||||
},
|
||||
})
|
||||
assert(!res.is_err())
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should support various placeholder value type", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, ":name = $value", scriptmanager.QueryOptions{
|
||||
NamePlaceholders: map[string]string{
|
||||
"str": "hello",
|
||||
},
|
||||
ValuePlaceholders: map[string]types.AttributeValue{
|
||||
"str": &types.AttributeValueMemberS{Value: "hello"},
|
||||
"int": &types.AttributeValueMemberN{Value: "123"},
|
||||
"float": &types.AttributeValueMemberN{Value: "3.14"},
|
||||
"bool": &types.AttributeValueMemberBOOL{Value: true},
|
||||
"nil": &types.AttributeValueMemberNULL{Value: true},
|
||||
},
|
||||
}).Return(rs, nil)
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query(":name = $value", {
|
||||
args: {
|
||||
"str": "hello",
|
||||
"int": 123,
|
||||
"float": 3.14,
|
||||
"bool": true,
|
||||
"nil": nil,
|
||||
},
|
||||
})
|
||||
assert(!res.is_err())
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should return error when placeholder value type is unsupported", func(t *testing.T) {
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query(":name = $value", {
|
||||
args: {
|
||||
"bad": func() { },
|
||||
},
|
||||
})
|
||||
assert(res.is_err())
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.Error(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestModSession_SelectedItem(t *testing.T) {
|
||||
t.Run("should return selected item from service implementation", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
rs.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
|
||||
})
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs)
|
||||
mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(1)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
selItem := session.selected_item()
|
||||
|
||||
assert(selItem != nil, "selItem != nil")
|
||||
assert(selItem.index == 1, "selItem.index")
|
||||
assert(selItem.result_set == session.result_set(), "selItem.result_set")
|
||||
assert(selItem.attr('pk') == '1232', "selItem.attr('pk')")
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should return nil if selected item returns -1", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
rs.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
|
||||
})
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().ResultSet(mock.Anything).Return(rs)
|
||||
mockedSessionService.EXPECT().SelectedItemIndex(mock.Anything).Return(-1)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
selItem := session.selected_item()
|
||||
|
||||
assert(selItem == nil, "selItem != nil")
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestModSession_SetResultSet(t *testing.T) {
|
||||
t.Run("should set the result set on the session", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
rs.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
|
||||
})
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
|
||||
mockedSessionService.EXPECT().SetResultSet(mock.Anything, rs)
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
session.set_result_set(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
60
internal/dynamo-browse/services/scriptmanager/modui.go
Normal file
60
internal/dynamo-browse/services/scriptmanager/modui.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type uiModule struct {
|
||||
uiService UIService
|
||||
}
|
||||
|
||||
func (um *uiModule) print(ctx context.Context, args ...object.Object) object.Object {
|
||||
var msg strings.Builder
|
||||
for _, arg := range args {
|
||||
switch a := arg.(type) {
|
||||
case *object.String:
|
||||
msg.WriteString(a.Value())
|
||||
default:
|
||||
msg.WriteString(a.Inspect())
|
||||
}
|
||||
}
|
||||
|
||||
um.uiService.PrintMessage(ctx, msg.String())
|
||||
return object.Nil
|
||||
}
|
||||
|
||||
func (um *uiModule) prompt(ctx context.Context, args ...object.Object) object.Object {
|
||||
if err := arg.Require("ui.prompt", 1, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg, _ := object.AsString(args[0])
|
||||
respChan := um.uiService.Prompt(ctx, msg)
|
||||
|
||||
select {
|
||||
case resp, hasResp := <-respChan:
|
||||
if hasResp {
|
||||
return object.NewString(resp)
|
||||
} else {
|
||||
return object.NewError(ctx.Err())
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return object.NewError(ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func (um *uiModule) register(scp *scope.Scope) {
|
||||
modScope := scope.New(scope.Opts{})
|
||||
mod := object.NewModule("ui", modScope)
|
||||
|
||||
modScope.AddBuiltins([]*object.Builtin{
|
||||
object.NewBuiltin("print", um.print, mod),
|
||||
object.NewBuiltin("prompt", um.prompt, mod),
|
||||
})
|
||||
|
||||
scp.Declare("ui", mod, true)
|
||||
}
|
||||
98
internal/dynamo-browse/services/scriptmanager/modui_test.go
Normal file
98
internal/dynamo-browse/services/scriptmanager/modui_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package scriptmanager_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModUI_Prompt(t *testing.T) {
|
||||
t.Run("should successfully return prompt value", func(t *testing.T) {
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
ui.print("Hello, world")
|
||||
var name = ui.prompt("What is your name? ")
|
||||
ui.print("Hello, " + name)
|
||||
`)
|
||||
|
||||
promptChan := make(chan string)
|
||||
go func() {
|
||||
promptChan <- "T. Test"
|
||||
}()
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
|
||||
mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, T. Test")
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should return error if prompt was cancelled", func(t *testing.T) {
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
ui.print("Hello, world")
|
||||
var name = ui.prompt("What is your name? ")
|
||||
ui.print("After")
|
||||
`)
|
||||
|
||||
promptChan := make(chan string)
|
||||
close(promptChan)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
|
||||
mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Return(promptChan)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.Error(t, err)
|
||||
|
||||
mockedUIService.AssertNotCalled(t, "Prompt", "after")
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("should return error if context was cancelled", func(t *testing.T) {
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
ui.print("Hello, world")
|
||||
var name = ui.prompt("What is your name? ")
|
||||
ui.print("After")
|
||||
`)
|
||||
|
||||
promptChan := make(chan string)
|
||||
ctx, cancelFn := context.WithCancel(context.Background())
|
||||
defer cancelFn()
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
|
||||
mockedUIService.EXPECT().Prompt(mock.Anything, "What is your name? ").Run(func(ctx context.Context, msg string) {
|
||||
cancelFn()
|
||||
}).Return(promptChan)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.Error(t, err)
|
||||
|
||||
mockedUIService.AssertNotCalled(t, "Prompt", "after")
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
45
internal/dynamo-browse/services/scriptmanager/opts.go
Normal file
45
internal/dynamo-browse/services/scriptmanager/opts.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
// OSExecShell is the shell to use for calls to 'os.exec'. If not defined,
|
||||
// it will use the value of the SHELL environment variable, otherwise it will
|
||||
// default to '/bin/bash'
|
||||
OSExecShell string
|
||||
|
||||
// Permissions are the permissions the script can execute in
|
||||
Permissions Permissions
|
||||
}
|
||||
|
||||
func (opts Options) configuredShell() string {
|
||||
if opts.OSExecShell != "" {
|
||||
return opts.OSExecShell
|
||||
}
|
||||
if shell, hasShell := os.LookupEnv("SHELL"); hasShell {
|
||||
return shell
|
||||
}
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
// Permissions control the set of permissions of a script
|
||||
type Permissions struct {
|
||||
// AllowShellCommands determines whether or not a script can execute shell commands.
|
||||
AllowShellCommands bool
|
||||
}
|
||||
|
||||
type optionCtxKeyType struct{}
|
||||
|
||||
var optionCtxKey = optionCtxKeyType{}
|
||||
|
||||
func optionFromCtx(ctx context.Context) Options {
|
||||
perms, _ := ctx.Value(optionCtxKey).(Options)
|
||||
return perms
|
||||
}
|
||||
|
||||
func ctxWithOptions(ctx context.Context, perms Options) context.Context {
|
||||
return context.WithValue(ctx, optionCtxKey, perms)
|
||||
}
|
||||
240
internal/dynamo-browse/services/scriptmanager/resultsetproxy.go
Normal file
240
internal/dynamo-browse/services/scriptmanager/resultsetproxy.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/cloudcmds/tamarin/arg"
|
||||
"github.com/cloudcmds/tamarin/object"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/queryexpr"
|
||||
"github.com/pkg/errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type resultSetProxy struct {
|
||||
resultSet *models.ResultSet
|
||||
}
|
||||
|
||||
func (r *resultSetProxy) Interface() interface{} {
|
||||
return r.resultSet
|
||||
}
|
||||
|
||||
func (r *resultSetProxy) IsTruthy() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *resultSetProxy) Type() object.Type {
|
||||
return "resultset"
|
||||
}
|
||||
|
||||
func (r *resultSetProxy) Inspect() string {
|
||||
return "resultset"
|
||||
}
|
||||
|
||||
func (r *resultSetProxy) Equals(other object.Object) object.Object {
|
||||
otherRS, isOtherRS := other.(*resultSetProxy)
|
||||
if !isOtherRS {
|
||||
return object.False
|
||||
}
|
||||
|
||||
return object.NewBool(r.resultSet == otherRS.resultSet)
|
||||
}
|
||||
|
||||
// GetItem implements the [key] operator for a container type.
|
||||
func (r *resultSetProxy) GetItem(key object.Object) (object.Object, *object.Error) {
|
||||
idx, err := object.AsInt(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
realIdx := int(idx)
|
||||
if realIdx < 0 {
|
||||
realIdx = len(r.resultSet.Items()) + realIdx
|
||||
}
|
||||
|
||||
if realIdx < 0 || realIdx >= len(r.resultSet.Items()) {
|
||||
return nil, object.NewError(errors.Errorf("index error: index out of range: %v", idx))
|
||||
}
|
||||
|
||||
return newItemProxy(r, realIdx), nil
|
||||
}
|
||||
|
||||
// GetSlice implements the [start:stop] operator for a container type.
|
||||
func (r *resultSetProxy) GetSlice(s object.Slice) (object.Object, *object.Error) {
|
||||
return nil, object.NewError(errors.New("TODO"))
|
||||
}
|
||||
|
||||
// SetItem implements the [key] = value operator for a container type.
|
||||
func (r *resultSetProxy) SetItem(key, value object.Object) *object.Error {
|
||||
return object.NewError(errors.New("TODO"))
|
||||
}
|
||||
|
||||
// DelItem implements the del [key] operator for a container type.
|
||||
func (r *resultSetProxy) DelItem(key object.Object) *object.Error {
|
||||
return object.NewError(errors.New("TODO"))
|
||||
}
|
||||
|
||||
// Contains returns true if the given item is found in this container.
|
||||
func (r *resultSetProxy) Contains(item object.Object) *object.Bool {
|
||||
// TODO
|
||||
return object.False
|
||||
}
|
||||
|
||||
// Len returns the number of items in this container.
|
||||
func (r *resultSetProxy) Len() *object.Int {
|
||||
return object.NewInt(int64(len(r.resultSet.Items())))
|
||||
}
|
||||
|
||||
// Iter returns an iterator for this container.
|
||||
func (r *resultSetProxy) Iter() object.Iterator {
|
||||
// TODO
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *resultSetProxy) GetAttr(name string) (object.Object, bool) {
|
||||
switch name {
|
||||
case "length":
|
||||
return object.NewInt(int64(len(r.resultSet.Items()))), true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type itemProxy struct {
|
||||
resultSetProxy *resultSetProxy
|
||||
itemIndex int
|
||||
item models.Item
|
||||
}
|
||||
|
||||
func newItemProxy(rs *resultSetProxy, itemIndex int) *itemProxy {
|
||||
return &itemProxy{
|
||||
resultSetProxy: rs,
|
||||
itemIndex: itemIndex,
|
||||
item: rs.resultSet.Items()[itemIndex],
|
||||
}
|
||||
}
|
||||
|
||||
func (i *itemProxy) Interface() interface{} {
|
||||
return i.item
|
||||
}
|
||||
|
||||
func (i *itemProxy) IsTruthy() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (i *itemProxy) Type() object.Type {
|
||||
return "item"
|
||||
}
|
||||
|
||||
func (i *itemProxy) Inspect() string {
|
||||
return "item"
|
||||
}
|
||||
|
||||
func (i *itemProxy) Equals(other object.Object) object.Object {
|
||||
// TODO
|
||||
return object.False
|
||||
}
|
||||
|
||||
func (i *itemProxy) GetAttr(name string) (object.Object, bool) {
|
||||
// TODO: this should implement the container interface
|
||||
switch name {
|
||||
case "result_set":
|
||||
return i.resultSetProxy, true
|
||||
case "index":
|
||||
return object.NewInt(int64(i.itemIndex)), true
|
||||
case "attr":
|
||||
return object.NewBuiltin("attr", i.value), true
|
||||
case "set_attr":
|
||||
return object.NewBuiltin("set_attr", i.setValue), true
|
||||
case "delete_attr":
|
||||
return object.NewBuiltin("delete_attr", i.deleteAttr), true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (i *itemProxy) value(ctx context.Context, args ...object.Object) object.Object {
|
||||
if objErr := arg.Require("item.attr", 1, args); objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
str, objErr := object.AsString(args[0])
|
||||
if objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
modExpr, err := queryexpr.Parse(str)
|
||||
if err != nil {
|
||||
return object.Errorf("arg error: invalid path expression: %v", err)
|
||||
}
|
||||
av, err := modExpr.EvalItem(i.item)
|
||||
if err != nil {
|
||||
return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err))
|
||||
}
|
||||
|
||||
// TODO
|
||||
switch v := av.(type) {
|
||||
case *types.AttributeValueMemberS:
|
||||
return object.NewString(v.Value)
|
||||
case *types.AttributeValueMemberN:
|
||||
// TODO: better
|
||||
f, err := strconv.ParseFloat(v.Value, 64)
|
||||
if err != nil {
|
||||
return object.NewError(errors.Errorf("value error: invalid N value: %v", v.Value))
|
||||
}
|
||||
return object.NewFloat(f)
|
||||
}
|
||||
return object.NewError(errors.New("TODO"))
|
||||
}
|
||||
|
||||
func (i *itemProxy) setValue(ctx context.Context, args ...object.Object) object.Object {
|
||||
if objErr := arg.Require("item.set_attr", 2, args); objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
pathExpr, objErr := object.AsString(args[0])
|
||||
if objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
path, err := queryexpr.Parse(pathExpr)
|
||||
if err != nil {
|
||||
return object.Errorf("arg error: invalid path expression: %v", err)
|
||||
}
|
||||
|
||||
// TODO
|
||||
newValue := args[1]
|
||||
switch v := newValue.(type) {
|
||||
case *object.String:
|
||||
if err := path.SetEvalItem(i.item, &types.AttributeValueMemberS{Value: v.Value()}); err != nil {
|
||||
return object.NewError(err)
|
||||
}
|
||||
default:
|
||||
return object.Errorf("type error: unsupported value type (got %v)", newValue.Type())
|
||||
}
|
||||
|
||||
i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *itemProxy) deleteAttr(ctx context.Context, args ...object.Object) object.Object {
|
||||
if objErr := arg.Require("item.delete_attr", 1, args); objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
str, objErr := object.AsString(args[0])
|
||||
if objErr != nil {
|
||||
return objErr
|
||||
}
|
||||
|
||||
modExpr, err := queryexpr.Parse(str)
|
||||
if err != nil {
|
||||
return object.Errorf("arg error: invalid path expression: %v", err)
|
||||
}
|
||||
if err := modExpr.DeleteAttribute(i.item); err != nil {
|
||||
return object.NewError(errors.Errorf("arg error: path expression evaluate error: %v", err))
|
||||
}
|
||||
|
||||
i.resultSetProxy.resultSet.SetDirty(i.itemIndex, true)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
package scriptmanager_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResultSetProxy(t *testing.T) {
|
||||
t.Run("should property return properties of a resultset and item", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
rs.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
|
||||
})
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
|
||||
// Test properties of the result set
|
||||
assert(res == res, "result_set.equals")
|
||||
assert(res.length == 2, "result_set.length")
|
||||
|
||||
// Test properties of items
|
||||
assert(res[0].index == 0, "res[0].index")
|
||||
assert(res[0].result_set == res, "res[0].result_set")
|
||||
assert(res[0].attr('pk') == 'abc', "res[0].attr('pk')")
|
||||
|
||||
assert(res[1].attr('pk') == '1232', "res[1].attr('pk')")
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResultSetProxy_SetAttr(t *testing.T) {
|
||||
t.Run("should set the value of the item within a result set", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
rs.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "abc"}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
|
||||
})
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
|
||||
mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool {
|
||||
assert.Equal(t, "bla-di-bla", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value)
|
||||
assert.True(t, rs.IsDirty(0))
|
||||
return true
|
||||
}))
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
res[0].set_attr("pk", "bla-di-bla")
|
||||
session.set_result_set(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResultSetProxy_DeleteAttr(t *testing.T) {
|
||||
t.Run("should delete the value of the item within a result set", func(t *testing.T) {
|
||||
rs := &models.ResultSet{}
|
||||
rs.SetItems([]models.Item{
|
||||
{"pk": &types.AttributeValueMemberS{Value: "abc"}, "deleteMe": &types.AttributeValueMemberBOOL{Value: true}},
|
||||
{"pk": &types.AttributeValueMemberS{Value: "1232"}},
|
||||
})
|
||||
|
||||
mockedSessionService := mocks.NewSessionService(t)
|
||||
mockedSessionService.EXPECT().Query(mock.Anything, "some expr", scriptmanager.QueryOptions{}).Return(rs, nil)
|
||||
mockedSessionService.EXPECT().SetResultSet(mock.Anything, mock.MatchedBy(func(rs *models.ResultSet) bool {
|
||||
assert.Equal(t, "abc", rs.Items()[0]["pk"].(*types.AttributeValueMemberS).Value)
|
||||
assert.Nil(t, rs.Items()[0]["deleteMe"])
|
||||
assert.True(t, rs.IsDirty(0))
|
||||
return true
|
||||
}))
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
res := session.query("some expr").unwrap()
|
||||
res[0].delete_attr("deleteMe")
|
||||
session.set_result_set(res)
|
||||
`)
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
Session: mockedSessionService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
mockedSessionService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
}
|
||||
52
internal/dynamo-browse/services/scriptmanager/scrsched.go
Normal file
52
internal/dynamo-browse/services/scriptmanager/scrsched.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type scriptScheduler struct {
|
||||
jobChan chan scriptJob
|
||||
}
|
||||
|
||||
func newScriptScheduler() *scriptScheduler {
|
||||
ss := &scriptScheduler{}
|
||||
ss.start()
|
||||
return ss
|
||||
}
|
||||
|
||||
func (ss *scriptScheduler) start() {
|
||||
ss.jobChan = make(chan scriptJob)
|
||||
go func() {
|
||||
for job := range ss.jobChan {
|
||||
job.job(job.ctx)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// startJobOnceFree will submit a script execution job. The function will wait until the scheduler is free.
|
||||
// The job will then run on the script goroutine and the function will return.
|
||||
func (ss *scriptScheduler) startJobOnceFree(ctx context.Context, job func(ctx context.Context)) error {
|
||||
select {
|
||||
case ss.jobChan <- scriptJob{ctx: ctx, job: job}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// runNow will submit a job for immediate execution. The job will run as long as the scheduler is free.
|
||||
// If the scheduler is not free, an error will be returned and the job will not run.
|
||||
func (ss *scriptScheduler) runNow(ctx context.Context, job func(ctx context.Context)) error {
|
||||
select {
|
||||
case ss.jobChan <- scriptJob{ctx: ctx, job: job}:
|
||||
return nil
|
||||
default:
|
||||
return errors.New("a script is already running")
|
||||
}
|
||||
}
|
||||
|
||||
type scriptJob struct {
|
||||
ctx context.Context
|
||||
job func(ctx context.Context)
|
||||
}
|
||||
185
internal/dynamo-browse/services/scriptmanager/service.go
Normal file
185
internal/dynamo-browse/services/scriptmanager/service.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package scriptmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudcmds/tamarin/exec"
|
||||
"github.com/cloudcmds/tamarin/scope"
|
||||
"github.com/pkg/errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
lookupPaths []fs.FS
|
||||
ifaces Ifaces
|
||||
options Options
|
||||
sched *scriptScheduler
|
||||
plugins []*ScriptPlugin
|
||||
}
|
||||
|
||||
func New(opts ...ServiceOption) *Service {
|
||||
srv := &Service{
|
||||
lookupPaths: nil,
|
||||
sched: newScriptScheduler(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(srv)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *Service) SetLookupPaths(fs []fs.FS) {
|
||||
s.lookupPaths = fs
|
||||
}
|
||||
|
||||
func (s *Service) SetDefaultOptions(options Options) {
|
||||
s.options = options
|
||||
}
|
||||
|
||||
func (s *Service) SetIFaces(ifaces Ifaces) {
|
||||
s.ifaces = ifaces
|
||||
}
|
||||
|
||||
func (s *Service) LoadScript(ctx context.Context, filename string) (*ScriptPlugin, error) {
|
||||
resChan := make(chan loadedScriptResult)
|
||||
|
||||
if err := s.sched.startJobOnceFree(ctx, func(ctx context.Context) {
|
||||
s.loadScript(ctx, filename, resChan)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := <-resChan
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
|
||||
// Look for the previous version. If one is there, replace it, otherwise add it
|
||||
// TODO: this should probably be protected by a mutex
|
||||
newPlugin := res.scriptPlugin
|
||||
for i, p := range s.plugins {
|
||||
if p.name == newPlugin.name {
|
||||
s.plugins[i] = newPlugin
|
||||
return newPlugin, nil
|
||||
}
|
||||
}
|
||||
|
||||
s.plugins = append(s.plugins, newPlugin)
|
||||
return newPlugin, nil
|
||||
}
|
||||
|
||||
func (s *Service) RunAdHocScript(ctx context.Context, filename string) chan error {
|
||||
errChan := make(chan error)
|
||||
go s.startAdHocScript(ctx, filename, errChan)
|
||||
return errChan
|
||||
}
|
||||
|
||||
func (s *Service) StartAdHocScript(ctx context.Context, filename string, errChan chan error) error {
|
||||
return s.sched.startJobOnceFree(ctx, func(ctx context.Context) {
|
||||
s.startAdHocScript(ctx, filename, errChan)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) startAdHocScript(ctx context.Context, filename string, errChan chan error) {
|
||||
defer close(errChan)
|
||||
|
||||
code, err := s.readScript(filename)
|
||||
if err != nil {
|
||||
errChan <- errors.Wrapf(err, "cannot load script file %v", filename)
|
||||
return
|
||||
}
|
||||
|
||||
scp := scope.New(scope.Opts{Parent: s.parentScope()})
|
||||
|
||||
ctx = ctxWithOptions(ctx, s.options)
|
||||
|
||||
if _, err = exec.Execute(ctx, exec.Opts{
|
||||
Input: string(code),
|
||||
File: filename,
|
||||
Scope: scp,
|
||||
}); err != nil {
|
||||
errChan <- errors.Wrapf(err, "script %v", filename)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type loadedScriptResult struct {
|
||||
scriptPlugin *ScriptPlugin
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *Service) loadScript(ctx context.Context, filename string, resChan chan loadedScriptResult) {
|
||||
defer close(resChan)
|
||||
|
||||
code, err := s.readScript(filename)
|
||||
if err != nil {
|
||||
resChan <- loadedScriptResult{err: errors.Wrapf(err, "cannot load script file %v", filename)}
|
||||
return
|
||||
}
|
||||
|
||||
newPlugin := &ScriptPlugin{
|
||||
name: filepath.Base(filename),
|
||||
scriptService: s,
|
||||
}
|
||||
|
||||
scp := scope.New(scope.Opts{Parent: s.parentScope()})
|
||||
|
||||
(&extModule{scriptPlugin: newPlugin}).register(scp)
|
||||
|
||||
ctx = ctxWithOptions(ctx, s.options)
|
||||
|
||||
if _, err = exec.Execute(ctx, exec.Opts{
|
||||
Input: string(code),
|
||||
File: filename,
|
||||
Scope: scp,
|
||||
}); err != nil {
|
||||
resChan <- loadedScriptResult{err: errors.Wrapf(err, "script %v", filename)}
|
||||
return
|
||||
}
|
||||
|
||||
resChan <- loadedScriptResult{scriptPlugin: newPlugin}
|
||||
}
|
||||
|
||||
func (s *Service) readScript(filename string) ([]byte, error) {
|
||||
for _, currFS := range s.lookupPaths {
|
||||
stat, err := fs.Stat(currFS, filename)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
} else if stat.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
code, err := fs.ReadFile(currFS, filename)
|
||||
if err == nil {
|
||||
return code, nil
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// LookupCommand looks up a command defined by a script.
|
||||
// TODO: Command should probably accept/return a chan error to indicate that this will run in a separate goroutine
|
||||
func (s *Service) LookupCommand(name string) *Command {
|
||||
for _, p := range s.plugins {
|
||||
if cmd, hasCmd := p.definedCommands[name]; hasCmd {
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) parentScope() *scope.Scope {
|
||||
scp := scope.New(scope.Opts{})
|
||||
(&uiModule{uiService: s.ifaces.UI}).register(scp)
|
||||
(&sessionModule{sessionService: s.ifaces.Session}).register(scp)
|
||||
(&osModule{}).register(scp)
|
||||
return scp
|
||||
}
|
||||
150
internal/dynamo-browse/services/scriptmanager/service_test.go
Normal file
150
internal/dynamo-browse/services/scriptmanager/service_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package scriptmanager_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/scriptmanager/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestService_RunAdHocScript(t *testing.T) {
|
||||
t.Run("successfully loads and executes a script", func(t *testing.T) {
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
ui.print("Hello, world")
|
||||
`)
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, world")
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
err := <-srv.RunAdHocScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_LoadScript(t *testing.T) {
|
||||
t.Run("successfully loads a script and exposes it as a plugin", func(t *testing.T) {
|
||||
testFS := testScriptFile(t, "test.tm", `
|
||||
ext.command("somewhere", func(a) {
|
||||
ui.print("Hello, " + a)
|
||||
})
|
||||
`)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone")
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
plugin, err := srv.LoadScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, plugin)
|
||||
assert.Equal(t, "test.tm", plugin.Name())
|
||||
|
||||
cmd := srv.LookupCommand("somewhere")
|
||||
assert.NotNil(t, cmd)
|
||||
|
||||
errChan := make(chan error)
|
||||
err = cmd.Invoke(ctx, []string{"someone"}, errChan)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, waitForErr(t, errChan))
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("reloading a script with the same name should remove the old one", func(t *testing.T) {
|
||||
testFS := fstest.MapFS{
|
||||
"test.tm": &fstest.MapFile{
|
||||
Data: []byte(`
|
||||
ext.command("somewhere", func(a) {
|
||||
ui.print("Hello, " + a)
|
||||
})
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
mockedUIService := mocks.NewUIService(t)
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Hello, someone").Once()
|
||||
mockedUIService.EXPECT().PrintMessage(mock.Anything, "Goodbye, someone").Once()
|
||||
|
||||
srv := scriptmanager.New(scriptmanager.WithFS(testFS))
|
||||
srv.SetIFaces(scriptmanager.Ifaces{
|
||||
UI: mockedUIService,
|
||||
})
|
||||
|
||||
// Execute the old script
|
||||
_, err := srv.LoadScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
cmd := srv.LookupCommand("somewhere")
|
||||
assert.NotNil(t, cmd)
|
||||
|
||||
errChan := make(chan error)
|
||||
err = cmd.Invoke(ctx, []string{"someone"}, errChan)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, waitForErr(t, errChan))
|
||||
|
||||
// Change the script and reload
|
||||
testFS["test.tm"] = &fstest.MapFile{
|
||||
Data: []byte(`
|
||||
ext.command("somewhere", func(a) {
|
||||
ui.print("Goodbye, " + a)
|
||||
})
|
||||
`),
|
||||
}
|
||||
|
||||
_, err = srv.LoadScript(ctx, "test.tm")
|
||||
assert.NoError(t, err)
|
||||
|
||||
cmd = srv.LookupCommand("somewhere")
|
||||
assert.NotNil(t, cmd)
|
||||
|
||||
errChan = make(chan error)
|
||||
err = cmd.Invoke(ctx, []string{"someone"}, errChan)
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, waitForErr(t, errChan))
|
||||
|
||||
mockedUIService.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
|
||||
func testScriptFile(t *testing.T, filename, code string) fs.FS {
|
||||
t.Helper()
|
||||
|
||||
testFs := fstest.MapFS{
|
||||
filename: &fstest.MapFile{
|
||||
Data: []byte(code),
|
||||
},
|
||||
}
|
||||
return testFs
|
||||
}
|
||||
|
||||
func waitForErr(t *testing.T, errChan chan error) error {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("timed-out waiting for an error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
11
internal/dynamo-browse/services/scriptmanager/serviceopts.go
Normal file
11
internal/dynamo-browse/services/scriptmanager/serviceopts.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package scriptmanager
|
||||
|
||||
import "io/fs"
|
||||
|
||||
type ServiceOption func(srv *Service)
|
||||
|
||||
func WithFS(fs ...fs.FS) ServiceOption {
|
||||
return func(srv *Service) {
|
||||
srv.lookupPaths = fs
|
||||
}
|
||||
}
|
||||
26
internal/dynamo-browse/services/scriptmanager/types.go
Normal file
26
internal/dynamo-browse/services/scriptmanager/types.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package scriptmanager
|
||||
|
||||
import "context"
|
||||
|
||||
type ScriptPlugin struct {
|
||||
scriptService *Service
|
||||
name string
|
||||
definedCommands map[string]*Command
|
||||
}
|
||||
|
||||
func (sp *ScriptPlugin) Name() string {
|
||||
return sp.name
|
||||
}
|
||||
|
||||
type Command struct {
|
||||
plugin *ScriptPlugin
|
||||
cmdFn func(ctx context.Context, args []string) error
|
||||
}
|
||||
|
||||
// Invoke will schedule the command for invocation. If the script scheduler is free, it will be started immediately.
|
||||
// Otherwise an error will be returned.
|
||||
func (c *Command) Invoke(ctx context.Context, args []string, errChan chan error) error {
|
||||
return c.plugin.scriptService.sched.runNow(ctx, func(ctx context.Context) {
|
||||
errChan <- c.cmdFn(ctx, args)
|
||||
})
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ func (s *ViewSnapshotService) PushSnapshot(details serialisable.ViewSnapshotDeta
|
|||
return errors.Wrap(err, "cannot get snapshot head")
|
||||
}
|
||||
|
||||
if oldHead != nil && oldHead.Details == details {
|
||||
if oldHead != nil && oldHead.Details.Equals(details, false) {
|
||||
// Attempting to push a duplicate
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package viewsnapshot_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/queryexpr"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/models/serialisable"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/providers/workspacestore"
|
||||
"github.com/lmika/audax/internal/dynamo-browse/services/viewsnapshot"
|
||||
|
|
@ -14,11 +17,14 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) {
|
|||
ws := testworkspace.New(t)
|
||||
|
||||
service := viewsnapshot.NewService(workspacestore.NewResultSetSnapshotStore(ws))
|
||||
q, _ := queryexpr.Parse("pk = \"abc\"")
|
||||
qbs, _ := q.SerializeToBytes()
|
||||
|
||||
// Push some snapshots
|
||||
err := service.PushSnapshot(serialisable.ViewSnapshotDetails{
|
||||
TableName: "normal-table",
|
||||
Query: "pk = 'abc'",
|
||||
Query: qbs,
|
||||
QueryHash: q.HashCode(),
|
||||
Filter: "",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -27,9 +33,13 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, cnt)
|
||||
|
||||
q2, _ := queryexpr.Parse("another = \"test\"")
|
||||
qbs2, _ := q.SerializeToBytes()
|
||||
|
||||
err = service.PushSnapshot(serialisable.ViewSnapshotDetails{
|
||||
TableName: "abnormal-table",
|
||||
Query: "pk = 'abc'",
|
||||
Query: qbs2,
|
||||
QueryHash: q2.HashCode(),
|
||||
Filter: "fla",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -41,7 +51,8 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) {
|
|||
// Push a duplicate
|
||||
err = service.PushSnapshot(serialisable.ViewSnapshotDetails{
|
||||
TableName: "abnormal-table",
|
||||
Query: "pk = 'abc'",
|
||||
Query: qbs2,
|
||||
QueryHash: q2.HashCode(),
|
||||
Filter: "fla",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -50,4 +61,34 @@ func TestViewSnapshotService_PushSnapshot(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, cnt)
|
||||
})
|
||||
|
||||
t.Run("should push expression with placeholder", func(t *testing.T) {
|
||||
ws := testworkspace.New(t)
|
||||
service := viewsnapshot.NewService(workspacestore.NewResultSetSnapshotStore(ws))
|
||||
|
||||
q, _ := queryexpr.Parse("another = $one")
|
||||
q = q.WithValueParams(map[string]types.AttributeValue{
|
||||
"one": &types.AttributeValueMemberS{Value: "bla-di-bla"},
|
||||
})
|
||||
qbs, _ := q.SerializeToBytes()
|
||||
|
||||
err := service.PushSnapshot(serialisable.ViewSnapshotDetails{
|
||||
TableName: "abnormal-table",
|
||||
Query: qbs,
|
||||
QueryHash: q.HashCode(),
|
||||
Filter: "fla",
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
vs, err := service.ViewRestore()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "abnormal-table", vs.Details.TableName)
|
||||
assert.Equal(t, "fla", vs.Details.Filter)
|
||||
|
||||
rq, err := queryexpr.DeserializeFrom(bytes.NewReader(vs.Details.Query))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "bla-di-bla", rq.ValueParamOrNil("one").(*types.AttributeValueMemberS).Value)
|
||||
assert.True(t, q.Equal(rq))
|
||||
assert.Equal(t, q.HashCode(), rq.HashCode())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue