feat: support starting store extentsion automatically (#246)
This commit is contained in:
parent
82e91207e9
commit
fac641fef9
2
Makefile
2
Makefile
|
@ -11,6 +11,8 @@ build:
|
||||||
go build ${TOOLEXEC} -a -o bin/atest main.go
|
go build ${TOOLEXEC} -a -o bin/atest main.go
|
||||||
build-ext-git:
|
build-ext-git:
|
||||||
CGO_ENABLED=0 go build -ldflags "-w -s" -o bin/atest-store-git extensions/store-git/main.go
|
CGO_ENABLED=0 go build -ldflags "-w -s" -o bin/atest-store-git extensions/store-git/main.go
|
||||||
|
build-ext-orm:
|
||||||
|
CGO_ENABLED=0 go build -ldflags "-w -s" -o bin/atest-store-orm extensions/store-orm/main.go
|
||||||
embed-ui:
|
embed-ui:
|
||||||
cd console/atest-ui && npm i && npm run build-only
|
cd console/atest-ui && npm i && npm run build-only
|
||||||
cp console/atest-ui/dist/index.html cmd/data/index.html
|
cp console/atest-ui/dist/index.html cmd/data/index.html
|
||||||
|
|
|
@ -119,12 +119,14 @@ func (o *serverOption) runE(cmd *cobra.Command, args []string) (err error) {
|
||||||
template.SetSecretGetter(remote.NewGRPCSecretGetter(secretServer))
|
template.SetSecretGetter(remote.NewGRPCSecretGetter(secretServer))
|
||||||
}
|
}
|
||||||
|
|
||||||
remoteServer := server.NewRemoteServer(loader, remote.NewGRPCloaderFromStore(), secretServer, o.configDir)
|
storeExtMgr := server.NewStoreExtManager(o.execer)
|
||||||
|
|
||||||
|
remoteServer := server.NewRemoteServer(loader, remote.NewGRPCloaderFromStore(), secretServer, storeExtMgr, o.configDir)
|
||||||
kinds, storeKindsErr := remoteServer.GetStoreKinds(ctx, nil)
|
kinds, storeKindsErr := remoteServer.GetStoreKinds(ctx, nil)
|
||||||
if storeKindsErr != nil {
|
if storeKindsErr != nil {
|
||||||
cmd.PrintErrf("failed to get store kinds, error: %p\n", storeKindsErr)
|
cmd.PrintErrf("failed to get store kinds, error: %p\n", storeKindsErr)
|
||||||
} else {
|
} else {
|
||||||
if err = startPlugins(o.execer, kinds); err != nil {
|
if err = startPlugins(storeExtMgr, kinds); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -146,11 +148,7 @@ func (o *serverOption) runE(cmd *cobra.Command, args []string) (err error) {
|
||||||
<-clean
|
<-clean
|
||||||
_ = lis.Close()
|
_ = lis.Close()
|
||||||
_ = o.httpServer.Shutdown(ctx)
|
_ = o.httpServer.Shutdown(ctx)
|
||||||
for _, file := range filesNeedToBeRemoved {
|
_ = storeExtMgr.StopAll()
|
||||||
if err = os.RemoveAll(file); err != nil {
|
|
||||||
log.Printf("failed to remove %s, error: %v", file, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
mux := runtime.NewServeMux(runtime.WithMetadata(server.MetadataStoreFunc)) // runtime.WithIncomingHeaderMatcher(func(key string) (s string, b bool) {
|
mux := runtime.NewServeMux(runtime.WithMetadata(server.MetadataStoreFunc)) // runtime.WithIncomingHeaderMatcher(func(key string) (s string, b bool) {
|
||||||
|
@ -200,22 +198,13 @@ func postRequestProxy(proxy string) func(w http.ResponseWriter, r *http.Request,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startPlugins(execer fakeruntime.Execer, kinds *server.StoreKinds) (err error) {
|
func startPlugins(storeExtMgr server.ExtManager, kinds *server.StoreKinds) (err error) {
|
||||||
const socketPrefix = "unix://"
|
const socketPrefix = "unix://"
|
||||||
|
|
||||||
for _, kind := range kinds.Data {
|
for _, kind := range kinds.Data {
|
||||||
if kind.Enabled && strings.HasPrefix(kind.Url, socketPrefix) {
|
if kind.Enabled && strings.HasPrefix(kind.Url, socketPrefix) {
|
||||||
binaryPath, lookErr := execer.LookPath(kind.Name)
|
if err = storeExtMgr.Start(kind.Name, kind.Url); err != nil {
|
||||||
if lookErr != nil {
|
break
|
||||||
log.Printf("failed to find %s, error: %v", kind.Name, lookErr)
|
|
||||||
} else {
|
|
||||||
go func(socketURL, plugin string) {
|
|
||||||
socketFile := strings.TrimPrefix(socketURL, socketPrefix)
|
|
||||||
filesNeedToBeRemoved = append(filesNeedToBeRemoved, socketFile)
|
|
||||||
if err = execer.RunCommand(plugin, "--socket", socketFile); err != nil {
|
|
||||||
log.Printf("failed to start %s, error: %v", socketURL, err)
|
|
||||||
}
|
|
||||||
}(kind.Url, binaryPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,27 @@
|
||||||
|
/**
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 API Testing Authors.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { reactive, ref } from 'vue'
|
||||||
import { Edit, Delete } from '@element-plus/icons-vue'
|
import { Edit, Delete } from '@element-plus/icons-vue'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import type { Pair } from './types'
|
import type { Pair } from './types'
|
||||||
|
import { SupportedExtensions } from './store'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
@ -266,7 +267,19 @@ function updateKeys() {
|
||||||
<el-input v-model="storeForm.password" type="password" test-id="store-form-password" />
|
<el-input v-model="storeForm.password" type="password" test-id="store-form-password" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('field.pluginName')" prop="pluginName">
|
<el-form-item :label="t('field.pluginName')" prop="pluginName">
|
||||||
<el-input v-model="storeForm.kind.name" test-id="store-form-plugin-name" />
|
<el-select
|
||||||
|
v-model="storeForm.kind.name"
|
||||||
|
test-id="store-form-plugin-name"
|
||||||
|
class="m-2"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in SupportedExtensions()"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.key"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('field.pluginURL')" prop="plugin">
|
<el-form-item :label="t('field.pluginURL')" prop="plugin">
|
||||||
<el-input v-model="storeForm.kind.url" test-id="store-form-plugin" />
|
<el-input v-model="storeForm.kind.url" test-id="store-form-plugin" />
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { Pair } from './types'
|
||||||
|
|
||||||
|
export function SupportedExtensions() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: 'atest-store-git',
|
||||||
|
key: 'atest-store-git'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'atest-store-s3',
|
||||||
|
key: 'atest-store-s3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'atest-store-orm',
|
||||||
|
key: 'atest-store-orm'
|
||||||
|
}
|
||||||
|
] as Pair[]
|
||||||
|
}
|
|
@ -57,6 +57,7 @@ type server struct {
|
||||||
loader testing.Writer
|
loader testing.Writer
|
||||||
storeWriterFactory testing.StoreWriterFactory
|
storeWriterFactory testing.StoreWriterFactory
|
||||||
configDir string
|
configDir string
|
||||||
|
storeExtMgr ExtManager
|
||||||
|
|
||||||
secretServer SecretServiceServer
|
secretServer SecretServiceServer
|
||||||
}
|
}
|
||||||
|
@ -97,7 +98,7 @@ func (f *fakeSecretServer) UpdateSecret(ctx context.Context, in *Secret) (reply
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRemoteServer creates a remote server instance
|
// NewRemoteServer creates a remote server instance
|
||||||
func NewRemoteServer(loader testing.Writer, storeWriterFactory testing.StoreWriterFactory, secretServer SecretServiceServer, configDir string) RunnerServer {
|
func NewRemoteServer(loader testing.Writer, storeWriterFactory testing.StoreWriterFactory, secretServer SecretServiceServer, storeExtMgr ExtManager, configDir string) RunnerServer {
|
||||||
if secretServer == nil {
|
if secretServer == nil {
|
||||||
secretServer = &fakeSecretServer{}
|
secretServer = &fakeSecretServer{}
|
||||||
}
|
}
|
||||||
|
@ -107,6 +108,7 @@ func NewRemoteServer(loader testing.Writer, storeWriterFactory testing.StoreWrit
|
||||||
storeWriterFactory: storeWriterFactory,
|
storeWriterFactory: storeWriterFactory,
|
||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
secretServer: secretServer,
|
secretServer: secretServer,
|
||||||
|
storeExtMgr: storeExtMgr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -829,13 +831,19 @@ func (s *server) GetStores(ctx context.Context, in *Empty) (reply *Stores, err e
|
||||||
func (s *server) CreateStore(ctx context.Context, in *Store) (reply *Store, err error) {
|
func (s *server) CreateStore(ctx context.Context, in *Store) (reply *Store, err error) {
|
||||||
reply = &Store{}
|
reply = &Store{}
|
||||||
storeFactory := testing.NewStoreFactory(s.configDir)
|
storeFactory := testing.NewStoreFactory(s.configDir)
|
||||||
err = storeFactory.CreateStore(ToNormalStore(in))
|
store := ToNormalStore(in)
|
||||||
|
if err = storeFactory.CreateStore(store); err == nil && s.storeExtMgr != nil {
|
||||||
|
err = s.storeExtMgr.Start(store.Kind.Name, store.Kind.URL)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
func (s *server) UpdateStore(ctx context.Context, in *Store) (reply *Store, err error) {
|
func (s *server) UpdateStore(ctx context.Context, in *Store) (reply *Store, err error) {
|
||||||
reply = &Store{}
|
reply = &Store{}
|
||||||
storeFactory := testing.NewStoreFactory(s.configDir)
|
storeFactory := testing.NewStoreFactory(s.configDir)
|
||||||
err = storeFactory.UpdateStore(ToNormalStore(in))
|
store := ToNormalStore(in)
|
||||||
|
if err = storeFactory.UpdateStore(store); err == nil && s.storeExtMgr != nil {
|
||||||
|
err = s.storeExtMgr.Start(store.Kind.Name, store.Kind.URL)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
func (s *server) DeleteStore(ctx context.Context, in *Store) (reply *Store, err error) {
|
func (s *server) DeleteStore(ctx context.Context, in *Store) (reply *Store, err error) {
|
||||||
|
|
|
@ -54,7 +54,7 @@ func TestRemoteServer(t *testing.T) {
|
||||||
|
|
||||||
loader := atesting.NewFileWriter("")
|
loader := atesting.NewFileWriter("")
|
||||||
loader.Put("testdata/simple.yaml")
|
loader.Put("testdata/simple.yaml")
|
||||||
server := NewRemoteServer(loader, nil, nil, "")
|
server := NewRemoteServer(loader, nil, nil, nil, "")
|
||||||
_, err := server.Run(ctx, &TestTask{
|
_, err := server.Run(ctx, &TestTask{
|
||||||
Kind: "fake",
|
Kind: "fake",
|
||||||
})
|
})
|
||||||
|
@ -138,7 +138,7 @@ func TestRemoteServer(t *testing.T) {
|
||||||
func TestRunTestCase(t *testing.T) {
|
func TestRunTestCase(t *testing.T) {
|
||||||
loader := atesting.NewFileWriter("")
|
loader := atesting.NewFileWriter("")
|
||||||
loader.Put("testdata/simple.yaml")
|
loader.Put("testdata/simple.yaml")
|
||||||
server := NewRemoteServer(loader, nil, nil, "")
|
server := NewRemoteServer(loader, nil, nil, nil, "")
|
||||||
|
|
||||||
defer gock.Clean()
|
defer gock.Clean()
|
||||||
gock.New(urlFoo).Get("/").MatchHeader("key", "value").
|
gock.New(urlFoo).Get("/").MatchHeader("key", "value").
|
||||||
|
@ -313,7 +313,7 @@ func TestUpdateTestCase(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
server := NewRemoteServer(writer, nil, nil, "")
|
server := NewRemoteServer(writer, nil, nil, nil, "")
|
||||||
_, err = server.UpdateTestCase(ctx, &TestCaseWithSuite{
|
_, err = server.UpdateTestCase(ctx, &TestCaseWithSuite{
|
||||||
SuiteName: "simple",
|
SuiteName: "simple",
|
||||||
Data: &TestCase{
|
Data: &TestCase{
|
||||||
|
@ -385,7 +385,7 @@ func TestListTestCase(t *testing.T) {
|
||||||
writer := atesting.NewFileWriter(os.TempDir())
|
writer := atesting.NewFileWriter(os.TempDir())
|
||||||
writer.Put(tmpFile.Name())
|
writer.Put(tmpFile.Name())
|
||||||
|
|
||||||
server := NewRemoteServer(writer, nil, nil, "")
|
server := NewRemoteServer(writer, nil, nil, nil, "")
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
t.Run("get two testcases", func(t *testing.T) {
|
t.Run("get two testcases", func(t *testing.T) {
|
||||||
|
@ -813,7 +813,7 @@ func getRemoteServerInTempDir() (server RunnerServer, call func()) {
|
||||||
call = func() { os.RemoveAll(dir) }
|
call = func() { os.RemoveAll(dir) }
|
||||||
|
|
||||||
writer := atesting.NewFileWriter(dir)
|
writer := atesting.NewFileWriter(dir)
|
||||||
server = NewRemoteServer(writer, newLocalloaderFromStore(), nil, dir)
|
server = NewRemoteServer(writer, newLocalloaderFromStore(), nil, nil, dir)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 API Testing Authors.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
fakeruntime "github.com/linuxsuren/go-fake-runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtManager interface {
|
||||||
|
Start(name, socket string) (err error)
|
||||||
|
StopAll() (err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type storeExtManager struct {
|
||||||
|
stopSignal chan struct{}
|
||||||
|
execer fakeruntime.Execer
|
||||||
|
socketPrefix string
|
||||||
|
filesNeedToBeRemoved []string
|
||||||
|
extStatusMap map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var s *storeExtManager
|
||||||
|
|
||||||
|
func NewStoreExtManager(execer fakeruntime.Execer) ExtManager {
|
||||||
|
if s == nil {
|
||||||
|
s = &storeExtManager{}
|
||||||
|
s.execer = execer
|
||||||
|
s.socketPrefix = "unix://"
|
||||||
|
s.extStatusMap = map[string]bool{}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storeExtManager) Start(name, socket string) (err error) {
|
||||||
|
if v, ok := s.extStatusMap[name]; ok && v {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binaryPath, lookErr := s.execer.LookPath(name)
|
||||||
|
if lookErr != nil {
|
||||||
|
err = fmt.Errorf("failed to find %s, error: %v", name, lookErr)
|
||||||
|
} else {
|
||||||
|
go func(socketURL, plugin string) {
|
||||||
|
socketFile := strings.TrimPrefix(socketURL, s.socketPrefix)
|
||||||
|
s.filesNeedToBeRemoved = append(s.filesNeedToBeRemoved, socketFile)
|
||||||
|
s.extStatusMap[name] = true
|
||||||
|
if err = s.execer.RunCommand(plugin, "--socket", socketFile); err != nil {
|
||||||
|
log.Printf("failed to start %s, error: %v", socketURL, err)
|
||||||
|
}
|
||||||
|
}(socket, binaryPath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *storeExtManager) StopAll() error {
|
||||||
|
for _, file := range s.filesNeedToBeRemoved {
|
||||||
|
if err := os.RemoveAll(file); err != nil {
|
||||||
|
log.Printf("failed to remove %s, error: %v", file, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 API Testing Authors.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
fakeruntime "github.com/linuxsuren/go-fake-runtime"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStoreExtManager(t *testing.T) {
|
||||||
|
mgr := NewStoreExtManager(fakeruntime.DefaultExecer{})
|
||||||
|
|
||||||
|
t.Run("not found", func(t *testing.T) {
|
||||||
|
err := mgr.Start("fake", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exist executable file", func(t *testing.T) {
|
||||||
|
err := mgr.Start("go", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
err = mgr.StopAll()
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in New Issue