spiegel_podman/cmd/podman/containers/exec.go
Shuai Yuan f5efef5043 exec: honor ConsoleSize so the terminal is sized at creation
The exec API accepts a ConsoleSize but it is dropped: the exec
pseudo-terminal is created at its default size and only corrected
afterwards by an asynchronous resize. A short-lived exec that reads its
window size at startup (e.g. `stty size`) can therefore observe the wrong
size, because the resize may arrive after the process has already read it.
docker applies the size at creation.

Carry the requested ConsoleSize through ExecConfig and into the exec OCI
process spec (process.consoleSize) so the runtime sizes the terminal
before the process starts, removing the race. The local and remote CLIs
capture the caller's terminal size when -t is given and pass it through
ExecOptions, matching the behavior of `podman run`.

Re-enable the previously flaky `podman exec` case in the interactive
system test, which this change makes deterministic.

Signed-off-by: Shuai Yuan <shuaiyuanzju@gmail.com>
2026-06-22 10:36:10 +08:00

280 lines
9.3 KiB
Go

package containers
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"go.podman.io/common/pkg/completion"
"go.podman.io/common/pkg/resize"
"go.podman.io/podman/v6/cmd/podman/common"
"go.podman.io/podman/v6/cmd/podman/registry"
"go.podman.io/podman/v6/cmd/podman/validate"
"go.podman.io/podman/v6/libpod/define"
"go.podman.io/podman/v6/pkg/domain/entities"
envLib "go.podman.io/podman/v6/pkg/env"
"go.podman.io/podman/v6/pkg/rootless"
"golang.org/x/term"
)
var (
execDescription = `Execute the specified command inside a running container.
`
execCommand = &cobra.Command{
Use: "exec [options] CONTAINER COMMAND [ARG...]",
Short: "Run a process in a running container",
Long: execDescription,
RunE: exec,
ValidArgsFunction: common.AutocompleteExecCommand,
Example: `podman exec -it ctrID ls
podman exec -it -w /tmp myCtr pwd
podman exec --user root ctrID ls`,
}
containerExecCommand = &cobra.Command{
Use: execCommand.Use,
Short: execCommand.Short,
Long: execCommand.Long,
RunE: execCommand.RunE,
ValidArgsFunction: execCommand.ValidArgsFunction,
Example: `podman container exec -it ctrID ls
podman container exec -it -w /tmp myCtr pwd
podman container exec --user root ctrID ls`,
}
)
var (
envInput, envFile []string
execOpts entities.ExecOptions
execDetach bool
execCidFile string
execNoSession bool
)
func execFlags(cmd *cobra.Command) {
podmanConfig := registry.PodmanConfig()
flags := cmd.Flags()
flags.SetInterspersed(false)
flags.BoolVarP(&execDetach, "detach", "d", false, "Run the exec session in detached mode (backgrounded)")
detachKeysFlagName := "detach-keys"
flags.StringVar(&execOpts.DetachKeys, detachKeysFlagName, containerConfig.DetachKeys(), "Select the key sequence for detaching a container. Format is a single character [a-Z] or ctrl-<value> where <value> is one of: a-z, @, ^, [, , or _")
_ = cmd.RegisterFlagCompletionFunc(detachKeysFlagName, common.AutocompleteDetachKeys)
cidfileFlagName := "cidfile"
flags.StringVar(&execCidFile, cidfileFlagName, "", "File to read the container ID from")
_ = cmd.RegisterFlagCompletionFunc(cidfileFlagName, completion.AutocompleteDefault)
envFlagName := "env"
flags.StringArrayVarP(&envInput, envFlagName, "e", []string{}, "Set environment variables")
_ = cmd.RegisterFlagCompletionFunc(envFlagName, completion.AutocompleteNone)
envFileFlagName := "env-file"
flags.StringArrayVar(&envFile, envFileFlagName, []string{}, "Read in a file of environment variables")
_ = cmd.RegisterFlagCompletionFunc(envFileFlagName, completion.AutocompleteDefault)
flags.BoolVarP(&execOpts.Interactive, "interactive", "i", false, "Make STDIN available to the contained process")
flags.BoolVar(&execOpts.Privileged, "privileged", podmanConfig.ContainersConfDefaultsRO.Containers.Privileged, "Give the process extended Linux capabilities inside the container. The default is false")
flags.BoolVarP(&execOpts.Tty, "tty", "t", false, "Allocate a pseudo-TTY. The default is false")
userFlagName := "user"
flags.StringVarP(&execOpts.User, userFlagName, "u", "", "Sets the username or UID used and optionally the groupname or GID for the specified command")
_ = cmd.RegisterFlagCompletionFunc(userFlagName, common.AutocompleteUserFlag)
preserveFdsFlagName := "preserve-fds"
flags.UintVar(&execOpts.PreserveFDs, preserveFdsFlagName, 0, "Pass N additional file descriptors to the container")
_ = cmd.RegisterFlagCompletionFunc(preserveFdsFlagName, completion.AutocompleteNone)
preserveFdFlagName := "preserve-fd"
flags.UintSliceVar(&execOpts.PreserveFD, preserveFdFlagName, nil, "Pass a list of additional file descriptors to the container")
_ = cmd.RegisterFlagCompletionFunc(preserveFdFlagName, completion.AutocompleteNone)
workdirFlagName := "workdir"
flags.StringVarP(&execOpts.WorkDir, workdirFlagName, "w", "", "Working directory inside the container")
_ = cmd.RegisterFlagCompletionFunc(workdirFlagName, completion.AutocompleteDefault)
waitFlagName := "wait"
flags.Int32(waitFlagName, 0, "Total seconds to wait for container to start")
_ = flags.MarkHidden(waitFlagName)
if !registry.IsRemote() {
flags.BoolVar(&execNoSession, "no-session", false, "Do not create a database session for the exec process")
}
if registry.IsRemote() {
_ = flags.MarkHidden("preserve-fds")
}
}
func init() {
registry.Commands = append(registry.Commands, registry.CliCommand{
Command: execCommand,
})
execFlags(execCommand)
validate.AddLatestFlag(execCommand, &execOpts.Latest)
registry.Commands = append(registry.Commands, registry.CliCommand{
Command: containerExecCommand,
Parent: containerCmd,
})
execFlags(containerExecCommand)
validate.AddLatestFlag(containerExecCommand, &execOpts.Latest)
}
func exec(cmd *cobra.Command, args []string) error {
if execNoSession {
if execDetach || cmd.Flags().Changed("detach-keys") {
return errors.New("--no-session cannot be used with --detach or --detach-keys")
}
}
nameOrID, command, err := determineTargetCtrAndCmd(args, execOpts.Latest, execCidFile != "")
if err != nil {
return err
}
execOpts.Cmd = command
// Validate given environment variables
execOpts.Envs = make(map[string]string)
for _, f := range envFile {
fileEnv, err := envLib.ParseFile(f)
if err != nil {
return err
}
execOpts.Envs = envLib.Join(execOpts.Envs, fileEnv)
}
cliEnv, err := envLib.ParseSlice(envInput)
if err != nil {
return fmt.Errorf("parsing environment variables: %w", err)
}
execOpts.Envs = envLib.Join(execOpts.Envs, cliEnv)
for _, fd := range execOpts.PreserveFD {
if !rootless.IsFdInherited(int(fd)) {
return fmt.Errorf("file descriptor %d is not available - the preserve-fd option requires that file descriptors must be passed", fd)
}
}
for fd := 3; fd < int(3+execOpts.PreserveFDs); fd++ {
if !rootless.IsFdInherited(fd) {
return fmt.Errorf("file descriptor %d is not available - the preserve-fds option requires that file descriptors must be passed", fd)
}
}
if cmd.Flags().Changed("wait") {
seconds, err := cmd.Flags().GetInt32("wait")
if err != nil {
return err
}
if err := execWait(nameOrID, seconds); err != nil {
if errors.Is(err, define.ErrCanceled) {
return fmt.Errorf("timed out waiting for container: %s", nameOrID)
}
}
}
streams := define.AttachStreams{}
streams.OutputStream = os.Stdout
streams.ErrorStream = os.Stderr
if execOpts.Interactive {
streams.InputStream = bufio.NewReader(os.Stdin)
streams.AttachInput = true
}
streams.AttachOutput = true
streams.AttachError = true
// When allocating a TTY, capture the current terminal size up front so the
// exec pseudo-terminal is created at the right size, rather than relying on
// the asynchronous resize that follows attach. This matters for short-lived
// commands that read their window size at startup (e.g. `stty size`).
if execOpts.Tty {
if w, h, err := term.GetSize(int(os.Stdin.Fd())); err == nil {
execOpts.ConsoleSize = &resize.TerminalSize{Width: uint16(w), Height: uint16(h)}
}
}
if execNoSession {
exitCode, err := registry.ContainerEngine().ContainerExecNoSession(registry.Context(), nameOrID, execOpts, streams)
registry.SetExitCode(exitCode)
return err
} else if !execDetach {
exitCode, err := registry.ContainerEngine().ContainerExec(registry.Context(), nameOrID, execOpts, streams)
registry.SetExitCode(exitCode)
return err
}
id, err := registry.ContainerEngine().ContainerExecDetached(registry.Context(), nameOrID, execOpts)
if err != nil {
return err
}
fmt.Println(id)
return nil
}
// determineTargetCtrAndCmd determines which command exec should run in which container
func determineTargetCtrAndCmd(args []string, latestSpecified bool, execCidFileProvided bool) (string, []string, error) {
var nameOrID string
var command []string
if len(args) == 0 && !latestSpecified && !execCidFileProvided {
return "", nil, errors.New("exec requires the name or ID of a container or the --latest or --cidfile flag")
} else if latestSpecified && execCidFileProvided {
return "", nil, errors.New("--latest and --cidfile can not be used together")
}
command = args
if !latestSpecified {
if !execCidFileProvided {
// assume first arg to be name or ID
command = args[1:]
nameOrID = strings.TrimPrefix(args[0], "/")
} else {
content, err := os.ReadFile(execCidFile)
if err != nil {
return "", nil, fmt.Errorf("reading CIDFile: %w", err)
}
nameOrID, _, _ = strings.Cut(string(content), "\n")
}
}
return nameOrID, command, nil
}
func execWait(ctr string, seconds int32) error {
maxDuration := time.Duration(seconds) * time.Second
interval := 100 * time.Millisecond
ctx, cancel := context.WithTimeout(registry.Context(), maxDuration)
defer cancel()
waitOptions.Conditions = []string{define.ContainerStateRunning.String()}
startTime := time.Now()
for time.Since(startTime) < maxDuration {
_, err := registry.ContainerEngine().ContainerWait(ctx, []string{ctr}, waitOptions)
if err == nil {
return nil
}
if !errors.Is(err, define.ErrNoSuchCtr) {
return err
}
interval *= 2
since := time.Since(startTime)
if since+interval > maxDuration {
interval = maxDuration - since
if interval <= 0 {
break
}
}
time.Sleep(interval)
}
return define.ErrCanceled
}