Merge pull request #43622 from vvoland/3554-exec-size
container/exec: Support ConsoleSize
This commit is contained in:
commit
4eb1c5bd52
14 changed files with 142 additions and 11 deletions
|
@ -17,7 +17,7 @@ type execBackend interface {
|
||||||
ContainerExecCreate(name string, config *types.ExecConfig) (string, error)
|
ContainerExecCreate(name string, config *types.ExecConfig) (string, error)
|
||||||
ContainerExecInspect(id string) (*backend.ExecInspect, error)
|
ContainerExecInspect(id string) (*backend.ExecInspect, error)
|
||||||
ContainerExecResize(name string, height, width int) error
|
ContainerExecResize(name string, height, width int) error
|
||||||
ContainerExecStart(ctx context.Context, name string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error
|
ContainerExecStart(ctx context.Context, name string, options container.ExecStartOptions) error
|
||||||
ExecExists(name string) (bool, error)
|
ExecExists(name string) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
|
|
||||||
"github.com/docker/docker/api/server/httputils"
|
"github.com/docker/docker/api/server/httputils"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/versions"
|
"github.com/docker/docker/api/types/versions"
|
||||||
"github.com/docker/docker/errdefs"
|
"github.com/docker/docker/errdefs"
|
||||||
"github.com/docker/docker/pkg/stdcopy"
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
|
@ -46,6 +47,12 @@ func (s *containerRouter) postContainerExecCreate(ctx context.Context, w http.Re
|
||||||
return execCommandError{}
|
return execCommandError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version := httputils.VersionFromContext(ctx)
|
||||||
|
if versions.LessThan(version, "1.42") {
|
||||||
|
// Not supported by API versions before 1.42
|
||||||
|
execConfig.ConsoleSize = nil
|
||||||
|
}
|
||||||
|
|
||||||
// Register an instance of Exec in container.
|
// Register an instance of Exec in container.
|
||||||
id, err := s.backend.ContainerExecCreate(vars["name"], execConfig)
|
id, err := s.backend.ContainerExecCreate(vars["name"], execConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -88,6 +95,18 @@ func (s *containerRouter) postContainerExecStart(ctx context.Context, w http.Res
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if execStartCheck.ConsoleSize != nil {
|
||||||
|
// Not supported before 1.42
|
||||||
|
if versions.LessThan(version, "1.42") {
|
||||||
|
execStartCheck.ConsoleSize = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// No console without tty
|
||||||
|
if !execStartCheck.Tty {
|
||||||
|
execStartCheck.ConsoleSize = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !execStartCheck.Detach {
|
if !execStartCheck.Detach {
|
||||||
var err error
|
var err error
|
||||||
// Setting up the streaming http interface.
|
// Setting up the streaming http interface.
|
||||||
|
@ -121,9 +140,16 @@ func (s *containerRouter) postContainerExecStart(ctx context.Context, w http.Res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options := container.ExecStartOptions{
|
||||||
|
Stdin: stdin,
|
||||||
|
Stdout: stdout,
|
||||||
|
Stderr: stderr,
|
||||||
|
ConsoleSize: execStartCheck.ConsoleSize,
|
||||||
|
}
|
||||||
|
|
||||||
// Now run the user process in container.
|
// Now run the user process in container.
|
||||||
// Maybe we should we pass ctx here if we're not detaching?
|
// Maybe we should we pass ctx here if we're not detaching?
|
||||||
if err := s.backend.ContainerExecStart(context.Background(), execName, stdin, stdout, stderr); err != nil {
|
if err := s.backend.ContainerExecStart(context.Background(), execName, options); err != nil {
|
||||||
if execStartCheck.Detach {
|
if execStartCheck.Detach {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -966,6 +966,7 @@ definitions:
|
||||||
type: "array"
|
type: "array"
|
||||||
description: |
|
description: |
|
||||||
Initial console size, as an `[height, width]` array.
|
Initial console size, as an `[height, width]` array.
|
||||||
|
x-nullable: true
|
||||||
minItems: 2
|
minItems: 2
|
||||||
maxItems: 2
|
maxItems: 2
|
||||||
items:
|
items:
|
||||||
|
@ -9207,6 +9208,15 @@ paths:
|
||||||
AttachStderr:
|
AttachStderr:
|
||||||
type: "boolean"
|
type: "boolean"
|
||||||
description: "Attach to `stderr` of the exec command."
|
description: "Attach to `stderr` of the exec command."
|
||||||
|
ConsoleSize:
|
||||||
|
type: "array"
|
||||||
|
description: "Initial console size, as an `[height, width]` array."
|
||||||
|
x-nullable: true
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items:
|
||||||
|
type: "integer"
|
||||||
|
minimum: 0
|
||||||
DetachKeys:
|
DetachKeys:
|
||||||
type: "string"
|
type: "string"
|
||||||
description: |
|
description: |
|
||||||
|
@ -9296,9 +9306,19 @@ paths:
|
||||||
Tty:
|
Tty:
|
||||||
type: "boolean"
|
type: "boolean"
|
||||||
description: "Allocate a pseudo-TTY."
|
description: "Allocate a pseudo-TTY."
|
||||||
|
ConsoleSize:
|
||||||
|
type: "array"
|
||||||
|
description: "Initial console size, as an `[height, width]` array."
|
||||||
|
x-nullable: true
|
||||||
|
minItems: 2
|
||||||
|
maxItems: 2
|
||||||
|
items:
|
||||||
|
type: "integer"
|
||||||
|
minimum: 0
|
||||||
example:
|
example:
|
||||||
Detach: false
|
Detach: false
|
||||||
Tty: false
|
Tty: true
|
||||||
|
ConsoleSize: [80, 64]
|
||||||
- name: "id"
|
- name: "id"
|
||||||
in: "path"
|
in: "path"
|
||||||
description: "Exec instance ID"
|
description: "Exec instance ID"
|
||||||
|
|
|
@ -33,6 +33,7 @@ type ExecConfig struct {
|
||||||
User string // User that will run the command
|
User string // User that will run the command
|
||||||
Privileged bool // Is the container in privileged mode
|
Privileged bool // Is the container in privileged mode
|
||||||
Tty bool // Attach standard streams to a tty.
|
Tty bool // Attach standard streams to a tty.
|
||||||
|
ConsoleSize *[2]uint `json:",omitempty"` // Initial console size [height, width]
|
||||||
AttachStdin bool // Attach the standard input, makes possible user interaction
|
AttachStdin bool // Attach the standard input, makes possible user interaction
|
||||||
AttachStderr bool // Attach the standard error
|
AttachStderr bool // Attach the standard error
|
||||||
AttachStdout bool // Attach the standard output
|
AttachStdout bool // Attach the standard output
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package container // import "github.com/docker/docker/api/types/container"
|
package container // import "github.com/docker/docker/api/types/container"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/strslice"
|
"github.com/docker/docker/api/types/strslice"
|
||||||
|
@ -52,6 +53,14 @@ type HealthConfig struct {
|
||||||
Retries int `json:",omitempty"`
|
Retries int `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecStartOptions holds the options to start container's exec.
|
||||||
|
type ExecStartOptions struct {
|
||||||
|
Stdin io.Reader
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
ConsoleSize *[2]uint `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// Config contains the configuration data about a container.
|
// Config contains the configuration data about a container.
|
||||||
// It should hold only portable information about the container.
|
// It should hold only portable information about the container.
|
||||||
// Here, "portable" means "independent from the host we are running on".
|
// Here, "portable" means "independent from the host we are running on".
|
||||||
|
|
|
@ -390,6 +390,8 @@ type ExecStartCheck struct {
|
||||||
Detach bool
|
Detach bool
|
||||||
// Check if there's a tty
|
// Check if there's a tty
|
||||||
Tty bool
|
Tty bool
|
||||||
|
// Terminal size [height, width], unused if Tty == false
|
||||||
|
ConsoleSize *[2]uint `json:",omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HealthcheckResult stores information about a single run of a healthcheck probe
|
// HealthcheckResult stores information about a single run of a healthcheck probe
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/versions"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ContainerExecCreate creates a new exec configuration to run an exec process.
|
// ContainerExecCreate creates a new exec configuration to run an exec process.
|
||||||
|
@ -14,6 +15,9 @@ func (cli *Client) ContainerExecCreate(ctx context.Context, container string, co
|
||||||
if err := cli.NewVersionError("1.25", "env"); len(config.Env) != 0 && err != nil {
|
if err := cli.NewVersionError("1.25", "env"); len(config.Env) != 0 && err != nil {
|
||||||
return response, err
|
return response, err
|
||||||
}
|
}
|
||||||
|
if versions.LessThan(cli.ClientVersion(), "1.42") {
|
||||||
|
config.ConsoleSize = nil
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := cli.post(ctx, "/containers/"+container+"/exec", nil, config, nil)
|
resp, err := cli.post(ctx, "/containers/"+container+"/exec", nil, config, nil)
|
||||||
defer ensureReaderClosed(resp)
|
defer ensureReaderClosed(resp)
|
||||||
|
@ -26,6 +30,9 @@ func (cli *Client) ContainerExecCreate(ctx context.Context, container string, co
|
||||||
|
|
||||||
// ContainerExecStart starts an exec process already created in the docker host.
|
// ContainerExecStart starts an exec process already created in the docker host.
|
||||||
func (cli *Client) ContainerExecStart(ctx context.Context, execID string, config types.ExecStartCheck) error {
|
func (cli *Client) ContainerExecStart(ctx context.Context, execID string, config types.ExecStartCheck) error {
|
||||||
|
if versions.LessThan(cli.ClientVersion(), "1.42") {
|
||||||
|
config.ConsoleSize = nil
|
||||||
|
}
|
||||||
resp, err := cli.post(ctx, "/exec/"+execID+"/start", nil, config, nil)
|
resp, err := cli.post(ctx, "/exec/"+execID+"/start", nil, config, nil)
|
||||||
ensureReaderClosed(resp)
|
ensureReaderClosed(resp)
|
||||||
return err
|
return err
|
||||||
|
@ -36,6 +43,9 @@ func (cli *Client) ContainerExecStart(ctx context.Context, execID string, config
|
||||||
// and the a reader to get output. It's up to the called to close
|
// and the a reader to get output. It's up to the called to close
|
||||||
// the hijacked connection by calling types.HijackedResponse.Close.
|
// the hijacked connection by calling types.HijackedResponse.Close.
|
||||||
func (cli *Client) ContainerExecAttach(ctx context.Context, execID string, config types.ExecStartCheck) (types.HijackedResponse, error) {
|
func (cli *Client) ContainerExecAttach(ctx context.Context, execID string, config types.ExecStartCheck) (types.HijackedResponse, error) {
|
||||||
|
if versions.LessThan(cli.ClientVersion(), "1.42") {
|
||||||
|
config.ConsoleSize = nil
|
||||||
|
}
|
||||||
headers := map[string][]string{
|
headers := map[string][]string{
|
||||||
"Content-Type": {"application/json"},
|
"Content-Type": {"application/json"},
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
|
containertypes "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/strslice"
|
"github.com/docker/docker/api/types/strslice"
|
||||||
"github.com/docker/docker/container"
|
"github.com/docker/docker/container"
|
||||||
"github.com/docker/docker/container/stream"
|
"github.com/docker/docker/container/stream"
|
||||||
|
@ -121,6 +122,7 @@ func (daemon *Daemon) ContainerExecCreate(name string, config *types.ExecConfig)
|
||||||
execConfig.Entrypoint = entrypoint
|
execConfig.Entrypoint = entrypoint
|
||||||
execConfig.Args = args
|
execConfig.Args = args
|
||||||
execConfig.Tty = config.Tty
|
execConfig.Tty = config.Tty
|
||||||
|
execConfig.ConsoleSize = config.ConsoleSize
|
||||||
execConfig.Privileged = config.Privileged
|
execConfig.Privileged = config.Privileged
|
||||||
execConfig.User = config.User
|
execConfig.User = config.User
|
||||||
execConfig.WorkingDir = config.WorkingDir
|
execConfig.WorkingDir = config.WorkingDir
|
||||||
|
@ -150,7 +152,7 @@ func (daemon *Daemon) ContainerExecCreate(name string, config *types.ExecConfig)
|
||||||
// ContainerExecStart starts a previously set up exec instance. The
|
// ContainerExecStart starts a previously set up exec instance. The
|
||||||
// std streams are set up.
|
// std streams are set up.
|
||||||
// If ctx is cancelled, the process is terminated.
|
// If ctx is cancelled, the process is terminated.
|
||||||
func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (err error) {
|
func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, options containertypes.ExecStartOptions) (err error) {
|
||||||
var (
|
var (
|
||||||
cStdin io.ReadCloser
|
cStdin io.ReadCloser
|
||||||
cStdout, cStderr io.Writer
|
cStdout, cStderr io.Writer
|
||||||
|
@ -199,20 +201,20 @@ func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, stdin
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if ec.OpenStdin && stdin != nil {
|
if ec.OpenStdin && options.Stdin != nil {
|
||||||
r, w := io.Pipe()
|
r, w := io.Pipe()
|
||||||
go func() {
|
go func() {
|
||||||
defer w.Close()
|
defer w.Close()
|
||||||
defer logrus.Debug("Closing buffered stdin pipe")
|
defer logrus.Debug("Closing buffered stdin pipe")
|
||||||
pools.Copy(w, stdin)
|
pools.Copy(w, options.Stdin)
|
||||||
}()
|
}()
|
||||||
cStdin = r
|
cStdin = r
|
||||||
}
|
}
|
||||||
if ec.OpenStdout {
|
if ec.OpenStdout {
|
||||||
cStdout = stdout
|
cStdout = options.Stdout
|
||||||
}
|
}
|
||||||
if ec.OpenStderr {
|
if ec.OpenStderr {
|
||||||
cStderr = stderr
|
cStderr = options.Stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
if ec.OpenStdin {
|
if ec.OpenStdin {
|
||||||
|
@ -238,6 +240,18 @@ func (daemon *Daemon) ContainerExecStart(ctx context.Context, name string, stdin
|
||||||
p.Cwd = ec.WorkingDir
|
p.Cwd = ec.WorkingDir
|
||||||
p.Terminal = ec.Tty
|
p.Terminal = ec.Tty
|
||||||
|
|
||||||
|
consoleSize := options.ConsoleSize
|
||||||
|
// If size isn't specified for start, use the one provided for create
|
||||||
|
if consoleSize == nil {
|
||||||
|
consoleSize = ec.ConsoleSize
|
||||||
|
}
|
||||||
|
if p.Terminal && consoleSize != nil {
|
||||||
|
p.ConsoleSize = &specs.Box{
|
||||||
|
Height: consoleSize[0],
|
||||||
|
Width: consoleSize[1],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if p.Cwd == "" {
|
if p.Cwd == "" {
|
||||||
p.Cwd = "/"
|
p.Cwd = "/"
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ type Config struct {
|
||||||
WorkingDir string
|
WorkingDir string
|
||||||
Env []string
|
Env []string
|
||||||
Pid int
|
Pid int
|
||||||
|
ConsoleSize *[2]uint
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig initializes the a new exec configuration
|
// NewConfig initializes the a new exec configuration
|
||||||
|
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
|
containertypes "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/strslice"
|
"github.com/docker/docker/api/types/strslice"
|
||||||
"github.com/docker/docker/container"
|
"github.com/docker/docker/container"
|
||||||
"github.com/docker/docker/daemon/exec"
|
"github.com/docker/docker/daemon/exec"
|
||||||
|
@ -97,7 +98,13 @@ func (p *cmdProbe) run(ctx context.Context, d *Daemon, cntr *container.Container
|
||||||
probeCtx, cancelProbe := context.WithCancel(ctx)
|
probeCtx, cancelProbe := context.WithCancel(ctx)
|
||||||
defer cancelProbe()
|
defer cancelProbe()
|
||||||
execErr := make(chan error, 1)
|
execErr := make(chan error, 1)
|
||||||
go func() { execErr <- d.ContainerExecStart(probeCtx, execConfig.ID, nil, output, output) }()
|
|
||||||
|
options := containertypes.ExecStartOptions{
|
||||||
|
Stdout: output,
|
||||||
|
Stderr: output,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() { execErr <- d.ContainerExecStart(probeCtx, execConfig.ID, options) }()
|
||||||
|
|
||||||
// Starting an exec can take a significant amount of time: on the order
|
// Starting an exec can take a significant amount of time: on the order
|
||||||
// of 1s in extreme cases. The time it takes dockerd and containerd to
|
// of 1s in extreme cases. The time it takes dockerd and containerd to
|
||||||
|
|
|
@ -101,6 +101,8 @@ keywords: "API, Docker, rcli, REST, documentation"
|
||||||
created if missing. This brings parity with `Binds`
|
created if missing. This brings parity with `Binds`
|
||||||
* `POST /containers/create` rejects request if BindOptions|VolumeOptions|TmpfsOptions
|
* `POST /containers/create` rejects request if BindOptions|VolumeOptions|TmpfsOptions
|
||||||
is set with a non-matching mount Type.
|
is set with a non-matching mount Type.
|
||||||
|
* `POST /containers/{id}/exec` now accepts an optional `ConsoleSize` parameter.
|
||||||
|
It allows to set the console size of the executed process immediately when it's created.
|
||||||
|
|
||||||
## v1.41 API changes
|
## v1.41 API changes
|
||||||
|
|
||||||
|
|
34
integration/container/exec_linux_test.go
Normal file
34
integration/container/exec_linux_test.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package container // import "github.com/docker/docker/integration/container"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/versions"
|
||||||
|
"github.com/docker/docker/integration/internal/container"
|
||||||
|
"gotest.tools/v3/assert"
|
||||||
|
"gotest.tools/v3/skip"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecConsoleSize(t *testing.T) {
|
||||||
|
skip.If(t, testEnv.DaemonInfo.OSType != "linux")
|
||||||
|
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.42"), "skip test from new feature")
|
||||||
|
|
||||||
|
defer setupTest(t)()
|
||||||
|
client := testEnv.APIClient()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cID := container.Run(ctx, t, client, container.WithImage("busybox"))
|
||||||
|
|
||||||
|
result, err := container.Exec(ctx, client, cID, []string{"stty", "size"},
|
||||||
|
func(ec *types.ExecConfig) {
|
||||||
|
ec.Tty = true
|
||||||
|
ec.ConsoleSize = &[2]uint{57, 123}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.NilError(t, err)
|
||||||
|
assert.Equal(t, strings.TrimSpace(result.Stdout()), "57 123")
|
||||||
|
}
|
|
@ -187,7 +187,7 @@ func TestPrivilegedHostDevices(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsoleSize(t *testing.T) {
|
func TestRunConsoleSize(t *testing.T) {
|
||||||
skip.If(t, testEnv.DaemonInfo.OSType != "linux")
|
skip.If(t, testEnv.DaemonInfo.OSType != "linux")
|
||||||
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.42"), "skip test from new feature")
|
skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.42"), "skip test from new feature")
|
||||||
|
|
||||||
|
|
|
@ -35,13 +35,18 @@ func (res *ExecResult) Combined() string {
|
||||||
// containing stdout, stderr, and exit code. Note:
|
// containing stdout, stderr, and exit code. Note:
|
||||||
// - this is a synchronous operation;
|
// - this is a synchronous operation;
|
||||||
// - cmd stdin is closed.
|
// - cmd stdin is closed.
|
||||||
func Exec(ctx context.Context, cli client.APIClient, id string, cmd []string) (ExecResult, error) {
|
func Exec(ctx context.Context, cli client.APIClient, id string, cmd []string, ops ...func(*types.ExecConfig)) (ExecResult, error) {
|
||||||
// prepare exec
|
// prepare exec
|
||||||
execConfig := types.ExecConfig{
|
execConfig := types.ExecConfig{
|
||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, op := range ops {
|
||||||
|
op(&execConfig)
|
||||||
|
}
|
||||||
|
|
||||||
cresp, err := cli.ContainerExecCreate(ctx, id, execConfig)
|
cresp, err := cli.ContainerExecCreate(ctx, id, execConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExecResult{}, err
|
return ExecResult{}, err
|
||||||
|
|
Loading…
Reference in a new issue