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:
Leon Mika 2023-01-10 22:27:13 +11:00 committed by GitHub
parent cd9700569c
commit c89b09447c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 4588 additions and 281 deletions

View 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
}

View file

@ -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
}

View 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
}

View 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
}

View 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)
}

View 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)
})
}

View 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)
}

View 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)
})
}

View 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)
}

View 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)
})
}

View 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)
}

View 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
}

View file

@ -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)
})
}

View 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)
}

View 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
}

View 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
}

View 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
}
}

View 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)
})
}

View file

@ -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
}

View file

@ -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())
})
}