* Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package clusterctrl
import (
"errors"
"fmt"
"io"
"strings"
"sync"
"testing"
"github.com/agiledragon/gomonkey/v2"
. "github.com/smartystreets/goconvey/convey"
)
type mockExecutor struct {
runPipeOutput string
runPipeError error
waitFnError error
mutex sync.Mutex
closed bool
}
func newMockExecutor(output string, runErr, waitErr error) *mockExecutor {
return &mockExecutor{
runPipeOutput: output,
runPipeError: runErr,
waitFnError: waitErr,
}
}
func (m *mockExecutor) RunCommandWithPipe(_, _ string, _ []string) (io.ReadCloser, func() error, error) {
if m.runPipeError != nil {
return nil, nil, m.runPipeError
}
reader := strings.NewReader(m.runPipeOutput)
rc := &mockReadCloser{Reader: reader, closer: m}
waitFn := func() error {
return m.waitFnError
}
return rc, waitFn, nil
}
func (m *mockExecutor) RunCommandSync(_, _ string, _ []string) ([]byte, error) {
return nil, errors.New("not implemented")
}
func (m *mockExecutor) RunCommandsSync(_ string, _ []string, _ []string) ([]byte, error) {
return nil, errors.New("not implemented")
}
func (m *mockExecutor) RunCommandsOnHostsParallelSync(_ []string, _ []string, _ []string) ([]result, error) {
return nil, errors.New("not implemented")
}
func (m *mockExecutor) RunCommandStream(_, _ string, _ []string) (chan result, error) {
return nil, errors.New("not implemented")
}
type mockReadCloser struct {
io.Reader
closer *mockExecutor
}
func (m *mockReadCloser) Close() error {
m.closer.mutex.Lock()
defer m.closer.mutex.Unlock()
if !m.closer.closed {
m.closer.closed = true
}
return nil
}
func TestBaseRemoteExecutor(t *testing.T) {
Convey("Test baseRemoteExecutor", t, func() {
Convey("RunCommandSync", func() {
Convey("Given a successful command execution", func() {
mock := newMockExecutor("hello\nworld\n", nil, nil)
b := &baseRemoteExecutor{Impl: mock}
Convey("When RunCommandSync is called", func() {
output, err := b.RunCommandSync("test-host", "echo hello", []string{})
Convey("Then it should return the output without error", func() {
So(err, ShouldBeNil)
So(string(output), ShouldEqual, "hello\nworld\n")
})
})
})
Convey("Given a command execution with wait error", func() {
mock := newMockExecutor("partial output", nil, errors.New("command failed"))
b := &baseRemoteExecutor{Impl: mock}
Convey("When RunCommandSync is called", func() {
output, err := b.RunCommandSync("test-host", "fail", []string{})
Convey("Then it should return partial output with error", func() {
So(err, ShouldNotBeNil)
So(string(output), ShouldEqual, "partial output")
So(err.Error(), ShouldContainSubstring, "command failed")
})
})
})
Convey("Given a command execution with initial error", func() {
mock := newMockExecutor("", errors.New("connection failed"), nil)
b := &baseRemoteExecutor{Impl: mock}
Convey("When RunCommandSync is called", func() {
output, err := b.RunCommandSync("test-host", "test", []string{})
Convey("Then it should return error without output", func() {
So(err, ShouldResemble, errors.New("connection failed"))
So(output, ShouldBeNil)
})
})
})
})
Convey("RunCommandsSync", func() {
Convey("Given multiple successful commands", func() {
mock := newMockExecutor("hello\nworld\n", nil, nil)
b := &baseRemoteExecutor{Impl: mock}
patches := gomonkey.ApplyMethodFunc(mock, "RunCommandSync",
func(host, cmd string) ([]byte, error) {
return []byte("hello\nworld\n"), nil
})
defer patches.Reset()
Convey("When RunCommandsSync is called", func() {
output, err := b.RunCommandsSync("test-host", []string{"echo hello", "echo world"}, []string{})
Convey("Then it should return concatenated output", func() {
So(err, ShouldBeNil)
So(string(output), ShouldEqual, "hello\nworld\n")
})
})
})
Convey("Given multiple commands with error", func() {
mock := newMockExecutor("", nil, nil)
b := &baseRemoteExecutor{Impl: mock}
patches := gomonkey.ApplyMethodFunc(mock, "RunCommandSync",
func(host, cmd string) ([]byte, error) {
return nil, errors.New("execution failed")
})
defer patches.Reset()
Convey("When RunCommandsSync is called", func() {
output, err := b.RunCommandsSync("test-host", []string{"fail"}, []string{})
Convey("Then it should return error", func() {
So(err, ShouldNotBeNil)
So(output, ShouldBeNil)
})
})
})
})
Convey("RunCommandsOnHostsParallelSync", func() {
Convey("Given multiple hosts with successful commands", func() {
mock := newMockExecutor("", nil, nil)
b := &baseRemoteExecutor{Impl: mock}
patches := gomonkey.ApplyMethodFunc(mock, "RunCommandsSync",
func(host string, _ []string) ([]byte, error) {
return []byte(fmt.Sprintf("test from %s", host)), nil
})
defer patches.Reset()
Convey("When RunCommandsOnHostsParallelSync is called", func() {
results, err := b.RunCommandsOnHostsParallelSync([]string{"host1", "host2"}, []string{"echo test"}, []string{})
Convey("Then it should return results for all hosts", func() {
So(err, ShouldBeNil)
So(len(results), ShouldEqual, 2)
So(string(results[0].Output), ShouldEqual, "test from host1")
So(string(results[1].Output), ShouldEqual, "test from host2")
So(results[0].Error, ShouldBeNil)
So(results[1].Error, ShouldBeNil)
})
})
})
Convey("Given multiple hosts with one failure", func() {
mock := newMockExecutor("", nil, nil)
b := &baseRemoteExecutor{Impl: mock}
patches := gomonkey.ApplyMethodFunc(mock, "RunCommandsSync",
func(host string, _ []string) ([]byte, error) {
if host == "host2" {
return nil, errors.New("failed on host2")
}
return []byte("test from host1"), nil
})
defer patches.Reset()
Convey("When RunCommandsOnHostsParallelSync is called", func() {
results, err := b.RunCommandsOnHostsParallelSync([]string{"host1", "host2"}, []string{"echo test"}, []string{})
Convey("Then it should return partial results with error", func() {
So(err, ShouldNotBeNil)
So(len(results), ShouldEqual, 2)
So(string(results[0].Output), ShouldEqual, "test from host1")
So(results[0].Error, ShouldBeNil)
So(results[1].Error, ShouldNotBeNil)
})
})
})
})
Convey("RunCommandStream", func() {
Convey("Given a streaming command", func() {
mock := newMockExecutor("chunk1\nchunk2\n", nil, nil)
b := &baseRemoteExecutor{Impl: mock}
Convey("When RunCommandStream is called", func() {
ch, err := b.RunCommandStream("test-host", "stream data", []string{})
Convey("Then it should stream the output in chunks", func() {
So(err, ShouldBeNil)
var outputs []string
for r := range ch {
So(r.Error, ShouldBeNil)
outputs = append(outputs, string(r.Output))
}
So(len(outputs), ShouldBeGreaterThan, 0)
So(strings.Join(outputs, ""), ShouldEqual, "chunk1\nchunk2\n")
})
})
})
Convey("Given a streaming command with error", func() {
mock := newMockExecutor("partial", nil, errors.New("stream failed"))
b := &baseRemoteExecutor{Impl: mock}
Convey("When RunCommandStream is called", func() {
ch, err := b.RunCommandStream("test-host", "fail stream", []string{})
Convey("Then it should return partial output and an error", func() {
So(err, ShouldBeNil)
var outputs []string
var lastErr error
for r := range ch {
if r.Error != nil {
lastErr = r.Error
} else {
outputs = append(outputs, string(r.Output))
}
}
So(len(outputs), ShouldEqual, 1)
So(outputs[0], ShouldEqual, "partial")
So(lastErr, ShouldResemble, errors.New("stream failed"))
})
})
})
})
})
}