diff --git a/runner/ansiblerunner.go b/runner/ansiblerunner.go index 99375cfe4..8a4e69743 100644 --- a/runner/ansiblerunner.go +++ b/runner/ansiblerunner.go @@ -22,60 +22,58 @@ const ( AraApiServer = "ARA_API_SERVER" ) +//go:generate mockery --name=CustomCommand + +type CustomCommand func(name string, arg ...string) *exec.Cmd + +var customExecCommand CustomCommand = exec.Command + type AnsibleRunner struct { Playbook string Inventory string Envs map[string]string + Check bool } -func runPlaybook(playbook, inventory string, envs map[string]string) error { - log.Infof("Running ansible playbook %s, with inventory %s...", playbook, inventory) - - cmd := exec.Command("ansible-playbook", playbook, fmt.Sprintf("--inventory=%s", inventory)) - - cmd.Env = os.Environ() - for key, value := range envs { - newEnv := fmt.Sprintf("%s=%s", key, value) - log.Debugf("New environment variable: %s", newEnv) - cmd.Env = append(cmd.Env, newEnv) +func DefaultAnsibleRunner() *AnsibleRunner { + return &AnsibleRunner{ + Playbook: "main.yml", + Envs: make(map[string]string), + Check: false, } +} - output, err := cmd.CombinedOutput() - - log.Debugf("Ansible output:\n%s:", output) - - if err != nil { - log.Errorf("An error occurred while running ansible: %s", err) - return err +func DefaultAnsibleRunnerWithAra() (*AnsibleRunner, error) { + a := DefaultAnsibleRunner() + if err := a.LoadAraPlugins(); err != nil { + return a, err } - log.Info("Ansible playbook execution finished successfully") - - return nil + return a, nil } -func NewAnsibleRunner(playbook, inventory string) (*AnsibleRunner, error) { - r := &AnsibleRunner{Envs: make(map[string]string)} +func (a *AnsibleRunner) setEnv(name, value string) { + a.Envs[name] = value +} +func (a *AnsibleRunner) SetPlaybook(playbook string) error { if _, err := os.Stat(playbook); os.IsNotExist(err) { log.Errorf("Playbook file %s does not exist", playbook) - return r, err + return err } - r.Playbook = playbook + a.Playbook = playbook + return nil +} +func (a *AnsibleRunner) SetInventory(inventory string) error { if _, err := os.Stat(inventory); os.IsNotExist(err) { log.Errorf("Inventory file %s does not exist", inventory) - return r, err + return err } - r.Inventory = inventory - - return r, nil -} - -func (a *AnsibleRunner) setEnv(name, value string) { - a.Envs[name] = value + a.Inventory = inventory + return nil } // ARA_API_CLIENT is always set to "http" to ensure the usage of the REST API @@ -89,7 +87,7 @@ func (a *AnsibleRunner) SetAraServer(host string) { func (a *AnsibleRunner) LoadAraPlugins() error { log.Info("Loading ARA plugins...") - araCallback := exec.Command("python3", "-m", "ara.setup.callback_plugins") + araCallback := customExecCommand("python3", "-m", "ara.setup.callback_plugins") araCallbackPath, err := araCallback.Output() if err != nil { log.Errorf("An error occurred getting the ARA callback plugin path: %s", err) @@ -100,7 +98,7 @@ func (a *AnsibleRunner) LoadAraPlugins() error { a.setEnv(AnsibleCallbackPlugins, araCallbackPathStr) - araAction := exec.Command("python3", "-m", "ara.setup.action_plugins") + araAction := customExecCommand("python3", "-m", "ara.setup.action_plugins") araActionPath, err := araAction.Output() if err != nil { log.Errorf("An error occurred getting the ARA actions plugin path: %s", err) @@ -116,7 +114,7 @@ func (a *AnsibleRunner) LoadAraPlugins() error { return nil } -func (a *AnsibleRunner) isAraServerUp() bool { +func (a *AnsibleRunner) IsAraServerUp() bool { server, ok := a.Envs[AraApiServer] if !ok { log.Warn("ARA server usage not configured") @@ -143,5 +141,40 @@ func (a *AnsibleRunner) isAraServerUp() bool { } func (a *AnsibleRunner) RunPlaybook() error { - return runPlaybook(a.Playbook, a.Inventory, a.Envs) + var cmdItems []string + + log.Infof("Ansible playbook %s", a.Playbook) + cmdItems = append(cmdItems, a.Playbook) + + if a.Inventory != "" { + log.Infof("Inventory %s", a.Inventory) + cmdItems = append(cmdItems, fmt.Sprintf("--inventory=%s", a.Inventory)) + } + + if a.Check { + log.Info("Running in check mode") + cmdItems = append(cmdItems, "--check") + } + + cmd := customExecCommand("ansible-playbook", cmdItems...) + + cmd.Env = os.Environ() + for key, value := range a.Envs { + newEnv := fmt.Sprintf("%s=%s", key, value) + log.Debugf("New environment variable: %s", newEnv) + cmd.Env = append(cmd.Env, newEnv) + } + + output, err := cmd.CombinedOutput() + + log.Debugf("Ansible output:\n%s:", output) + + if err != nil { + log.Errorf("An error occurred while running ansible: %s", err) + return err + } + + log.Info("Ansible playbook execution finished successfully") + + return nil } diff --git a/runner/ansiblerunner_test.go b/runner/ansiblerunner_test.go new file mode 100644 index 000000000..65286e77a --- /dev/null +++ b/runner/ansiblerunner_test.go @@ -0,0 +1,121 @@ +package runner + +import ( + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/trento-project/trento/runner/mocks" +) + +func TestLoadAraPlugins(t *testing.T) { + + a := DefaultAnsibleRunner() + + cmdCallback := exec.Command("echo", "callback") + cmdAction := exec.Command("echo", "action") + + mockCommand := new(mocks.CustomCommand) + customExecCommand = mockCommand.Execute + mockCommand.On("Execute", "python3", "-m", "ara.setup.callback_plugins").Return( + cmdCallback, + ) + mockCommand.On("Execute", "python3", "-m", "ara.setup.action_plugins").Return( + cmdAction, + ) + + err := a.LoadAraPlugins() + a.SetAraServer("127.0.0.1") + + expectedMetaRunner := &AnsibleRunner{ + Playbook: "main.yml", + Envs: map[string]string{ + "ANSIBLE_CALLBACK_PLUGINS": "callback", + "ANSIBLE_ACTION_PLUGINS": "action", + "ARA_API_CLIENT": "http", + "ARA_API_SERVER": "127.0.0.1", + }, + Check: false, + } + + assert.NoError(t, err) + assert.Equal(t, expectedMetaRunner, a) + + mockCommand.AssertExpectations(t) +} + +func TestRunPlaybookSimple(t *testing.T) { + + runnerInst := &AnsibleRunner{ + Playbook: "superplay.yml", + } + + cmd := exec.Command("echo", "stdout", "&&", ">&2", "echo", "stderr") + + mockCommand := new(mocks.CustomCommand) + customExecCommand = mockCommand.Execute + mockCommand.On("Execute", "ansible-playbook", "superplay.yml").Return( + cmd, + ) + + err := runnerInst.RunPlaybook() + + assert.Equal(t, os.Environ(), cmd.Env) + assert.NoError(t, err) + + mockCommand.AssertExpectations(t) +} + +func TestRunPlaybookError(t *testing.T) { + + runnerInst := &AnsibleRunner{ + Playbook: "superplay.yml", + } + + cmd := exec.Command("error") + + mockCommand := new(mocks.CustomCommand) + customExecCommand = mockCommand.Execute + mockCommand.On("Execute", "ansible-playbook", "superplay.yml").Return( + cmd, + ) + + err := runnerInst.RunPlaybook() + + assert.Equal(t, os.Environ(), cmd.Env) + assert.EqualError(t, err, "exec: \"error\": executable file not found in $PATH") + + mockCommand.AssertExpectations(t) +} + +func TestRunPlaybookComplex(t *testing.T) { + + runnerInst := &AnsibleRunner{ + Playbook: "superplay.yml", + Inventory: "inventory.yml", + Envs: map[string]string{ + "env1": "value1", + "env2": "value2", + }, + Check: true, + } + + cmd := exec.Command("echo", "stdout", "&&", ">&2", "echo", "stderr") + + mockCommand := new(mocks.CustomCommand) + customExecCommand = mockCommand.Execute + mockCommand.On( + "Execute", "ansible-playbook", "superplay.yml", + "--inventory=inventory.yml", "--check").Return( + cmd, + ) + + err := runnerInst.RunPlaybook() + + assert.Contains(t, cmd.Env, "env1=value1") + assert.Contains(t, cmd.Env, "env2=value2") + assert.NoError(t, err) + + mockCommand.AssertExpectations(t) +} diff --git a/runner/mocks/CustomCommand.go b/runner/mocks/CustomCommand.go new file mode 100644 index 000000000..f8e8dc845 --- /dev/null +++ b/runner/mocks/CustomCommand.go @@ -0,0 +1,37 @@ +// Code generated by mockery v0.0.0-dev. DO NOT EDIT. + +package mocks + +import ( + exec "os/exec" + + mock "github.com/stretchr/testify/mock" +) + +// CustomCommand is an autogenerated mock type for the CustomCommand type +type CustomCommand struct { + mock.Mock +} + +// Execute provides a mock function with given fields: name, arg +func (_m *CustomCommand) Execute(name string, arg ...string) *exec.Cmd { + _va := make([]interface{}, len(arg)) + for _i := range arg { + _va[_i] = arg[_i] + } + var _ca []interface{} + _ca = append(_ca, name) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *exec.Cmd + if rf, ok := ret.Get(0).(func(string, ...string) *exec.Cmd); ok { + r0 = rf(name, arg...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*exec.Cmd) + } + } + + return r0 +} diff --git a/runner/runner.go b/runner/runner.go index e645ca440..5eac769c5 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -19,7 +19,8 @@ import ( var ansibleFS embed.FS const ( - AnsibleMain = "ansible/main.yaml" + AnsibleMain = "ansible/check.yml" + AnsibleMeta = "ansible/meta.yml" ) type Runner struct { @@ -68,11 +69,28 @@ func DefaultConfig() (Config, error) { func (c *Runner) Start() error { var wg sync.WaitGroup + if err := createAnsibleFiles(c.cfg.AnsibleFolder); err != nil { + return err + } + + metaRunner, err := NewAnsibleMetaRunner(&c.cfg) + if err != nil { + return err + } + + if !metaRunner.IsAraServerUp() { + return fmt.Errorf("ARA server not available") + } + + if err = metaRunner.RunPlaybook(); err != nil { + return err + } + wg.Add(1) go func(wg *sync.WaitGroup) { log.Println("Starting the runner loop...") defer wg.Done() - c.startRunnerTicker() + c.startCheckRunnerTicker() log.Println("Runner loop stopped.") }(&wg) @@ -95,7 +113,7 @@ func createAnsibleFiles(folder string) error { return err } - err = os.MkdirAll(ansibleFolder, 0644) + err = os.MkdirAll(ansibleFolder, 0755) if err != nil { log.Error(err) return err @@ -119,7 +137,7 @@ func createAnsibleFiles(folder string) error { } fmt.Fprintf(f, "%s", content) } else { - os.Mkdir(path.Join(folder, fileName), 0644) + os.Mkdir(path.Join(folder, fileName), 0755) } return nil }) @@ -134,28 +152,54 @@ func createAnsibleFiles(folder string) error { return nil } -func (c *Runner) startRunnerTicker() { - err := createAnsibleFiles(c.cfg.AnsibleFolder) +func NewAnsibleMetaRunner(cfg *Config) (*AnsibleRunner, error) { + playbookPath := path.Join(cfg.AnsibleFolder, AnsibleMeta) + ansibleRunner, err := DefaultAnsibleRunnerWithAra() if err != nil { - return + return ansibleRunner, err } - c.startConsulTemplate() + if err = ansibleRunner.SetPlaybook(playbookPath); err != nil { + return ansibleRunner, err + } + + ansibleRunner.SetAraServer(cfg.AraServer) - ansibleRunner, err := NewAnsibleRunner( - path.Join(c.cfg.AnsibleFolder, AnsibleMain), - path.Join(c.cfg.AnsibleFolder, ansibleHostFile)) + return ansibleRunner, err +} + +func NewAnsibleCheckRunner(cfg *Config) (*AnsibleRunner, error) { + playbookPath := path.Join(cfg.AnsibleFolder, AnsibleMain) + inventoryPath := path.Join(cfg.AnsibleFolder, ansibleHostFile) + + ansibleRunner, err := DefaultAnsibleRunnerWithAra() if err != nil { - return + return ansibleRunner, err + } + + if err = ansibleRunner.SetPlaybook(playbookPath); err != nil { + return ansibleRunner, err + } + + if err = ansibleRunner.SetInventory(inventoryPath); err != nil { + return ansibleRunner, err } - err = ansibleRunner.LoadAraPlugins() + ansibleRunner.Check = true + ansibleRunner.SetAraServer(cfg.AraServer) + + return ansibleRunner, nil +} + +func (c *Runner) startCheckRunnerTicker() { + + c.startConsulTemplate() + + checkRunner, err := NewAnsibleCheckRunner(&c.cfg) if err != nil { return } - ansibleRunner.SetAraServer(c.cfg.AraServer) - tick := func() { // As consul-template is executed as run-once, we need to create the runner everytime tmpRunner, err := NewTemplateRunner(&c.cfg) @@ -165,11 +209,11 @@ func (c *Runner) startRunnerTicker() { c.templateRunner = tmpRunner c.startConsulTemplate() - if !ansibleRunner.isAraServerUp() { + if !checkRunner.IsAraServerUp() { log.Error("ARA server not found. Skipping ansible execution as the data is not recorded") return } - ansibleRunner.RunPlaybook() + checkRunner.RunPlaybook() } interval := c.cfg.Interval diff --git a/runner/runner_test.go b/runner/runner_test.go new file mode 100644 index 000000000..f18176088 --- /dev/null +++ b/runner/runner_test.go @@ -0,0 +1,105 @@ +package runner + +import ( + "io/ioutil" + "os" + "os/exec" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/trento-project/trento/runner/mocks" +) + +const ( + TestAnsibleFolder string = "../test/ansible_test" +) + +// TODO: This test could be improved to check the definitve ansible files structure +// once we have something fixed +func TestCreateAnsibleFiles(t *testing.T) { + tmpDir, _ := ioutil.TempDir(os.TempDir(), "trentotest") + err := createAnsibleFiles(tmpDir) + + assert.DirExists(t, path.Join(tmpDir, "ansible")) + assert.NoError(t, err) + + os.RemoveAll(tmpDir) +} + +func TestNewAnsibleMetaRunner(t *testing.T) { + + cfg := &Config{ + AnsibleFolder: TestAnsibleFolder, + AraServer: "127.0.0.1", + } + + cmdCallback := exec.Command("echo", "callback") + cmdAction := exec.Command("echo", "action") + + mockCommand := new(mocks.CustomCommand) + customExecCommand = mockCommand.Execute + mockCommand.On("Execute", "python3", "-m", "ara.setup.callback_plugins").Return( + cmdCallback, + ) + mockCommand.On("Execute", "python3", "-m", "ara.setup.action_plugins").Return( + cmdAction, + ) + + a, err := NewAnsibleMetaRunner(cfg) + + expectedMetaRunner := &AnsibleRunner{ + Playbook: path.Join(TestAnsibleFolder, "ansible/meta.yml"), + Envs: map[string]string{ + "ANSIBLE_CALLBACK_PLUGINS": "callback", + "ANSIBLE_ACTION_PLUGINS": "action", + "ARA_API_CLIENT": "http", + "ARA_API_SERVER": "127.0.0.1", + }, + Check: false, + } + + assert.NoError(t, err) + assert.Equal(t, expectedMetaRunner, a) + + mockCommand.AssertExpectations(t) +} + +func TestNewAnsibleCheckRunner(t *testing.T) { + + cfg := &Config{ + AnsibleFolder: TestAnsibleFolder, + AraServer: "127.0.0.1", + } + + cmdCallback := exec.Command("echo", "callback") + cmdAction := exec.Command("echo", "action") + + mockCommand := new(mocks.CustomCommand) + customExecCommand = mockCommand.Execute + mockCommand.On("Execute", "python3", "-m", "ara.setup.callback_plugins").Return( + cmdCallback, + ) + mockCommand.On("Execute", "python3", "-m", "ara.setup.action_plugins").Return( + cmdAction, + ) + + a, err := NewAnsibleCheckRunner(cfg) + + expectedMetaRunner := &AnsibleRunner{ + Playbook: path.Join(TestAnsibleFolder, "ansible/check.yml"), + Inventory: path.Join(TestAnsibleFolder, "ansible_hosts"), + Envs: map[string]string{ + "ANSIBLE_CALLBACK_PLUGINS": "callback", + "ANSIBLE_ACTION_PLUGINS": "action", + "ARA_API_CLIENT": "http", + "ARA_API_SERVER": "127.0.0.1", + }, + Check: true, + } + + assert.NoError(t, err) + assert.Equal(t, expectedMetaRunner, a) + + mockCommand.AssertExpectations(t) +} diff --git a/test/ansible_test/ansible/check.yml b/test/ansible_test/ansible/check.yml new file mode 100644 index 000000000..d13dc83eb --- /dev/null +++ b/test/ansible_test/ansible/check.yml @@ -0,0 +1 @@ +Dummy check.yml for UT purpose diff --git a/test/ansible_test/ansible/meta.yml b/test/ansible_test/ansible/meta.yml new file mode 100644 index 000000000..fc1b1b219 --- /dev/null +++ b/test/ansible_test/ansible/meta.yml @@ -0,0 +1 @@ +Dummy meta.yml for UT purpose diff --git a/test/ansible_test/ansible_hosts b/test/ansible_test/ansible_hosts new file mode 100644 index 000000000..3744a577d --- /dev/null +++ b/test/ansible_test/ansible_hosts @@ -0,0 +1 @@ +Dummy ansible_hosts for UT purpose