Skip to content

feat(agent/agentcontainers): add ContainerEnvInfoer #16623

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent/agentcontainers/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ type Lister interface {
// NoopLister is a Lister interface that never returns any containers.
type NoopLister struct{}

var _ Lister = NoopLister{}

func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
return codersdk.WorkspaceAgentListContainersResponse{}, nil
}
168 changes: 168 additions & 0 deletions agent/agentcontainers/containers_dockercli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"context"
"encoding/json"
"fmt"
"os/user"
"slices"
"sort"
"strconv"
"strings"
Expand All @@ -31,6 +33,172 @@ func NewDocker(execer agentexec.Execer) Lister {
}
}

// ContainerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
// information about a container.
type ContainerEnvInfoer struct {
container string
user *user.User
userShell string
env []string
}

// EnvInfo returns information about the environment of a container.
func EnvInfo(ctx context.Context, execer agentexec.Execer, container, containerUser string) (*ContainerEnvInfoer, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where will this be called? Right now it's only used in tests.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See here: #16638

var dei ContainerEnvInfoer
dei.container = container

var stdoutBuf, stderrBuf bytes.Buffer
if containerUser == "" {
// Get the "default" user of the container if no user is specified.
// TODO: handle different container runtimes.
cmd, args := WrapDockerExec(container, "")("whoami")
execCmd := execer.CommandContext(ctx, cmd, args...)
execCmd.Stdout = &stdoutBuf
execCmd.Stderr = &stderrBuf
if err := execCmd.Run(); err != nil {
return nil, xerrors.Errorf("get container user: run whoami: %w: stderr: %q", err, strings.TrimSpace(stderrBuf.String()))
}
out := strings.TrimSpace(stdoutBuf.String())
if len(out) == 0 {
return nil, xerrors.Errorf("get container user: run whoami: empty output: stderr: %q", strings.TrimSpace(stderrBuf.String()))
}
containerUser = out
stdoutBuf.Reset()
stderrBuf.Reset()
}
// Now that we know the username, get the required info from the container.
// We can't assume the presence of `getent` so we'll just have to sniff /etc/passwd.
cmd, args := WrapDockerExec(container, containerUser)("cat", "/etc/passwd")
execCmd := execer.CommandContext(ctx, cmd, args...)
execCmd.Stdout = &stdoutBuf
execCmd.Stderr = &stderrBuf
if err := execCmd.Run(); err != nil {
return nil, xerrors.Errorf("get container user: read /etc/passwd: %w stderr: %q", err, strings.TrimSpace(stderrBuf.String()))
}

scanner := bufio.NewScanner(&stdoutBuf)
var foundLine string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) == 0 {
continue
}
if !strings.HasPrefix(line, containerUser+":") {
continue
}
foundLine = line
}
if err := scanner.Err(); err != nil {
return nil, xerrors.Errorf("get container user: scan /etc/passwd: %w", err)
}
if foundLine == "" {
return nil, xerrors.Errorf("get container user: no matching entry for %q found in /etc/passwd", containerUser)
}

// Parse the output of /etc/passwd. It looks like this:
// postgres:x:999:999::/var/lib/postgresql:/bin/bash
passwdFields := strings.Split(foundLine, ":")
if len(passwdFields) < 7 {
return nil, xerrors.Errorf("get container user: invalid line in /etc/passwd: %q", foundLine)
}

// The fourth entry in /etc/passwd contains GECOS information, which is a
// comma-separated list of fields. The first field is the user's full name.
gecos := strings.Split(passwdFields[4], ",")
fullName := ""
if len(gecos) > 1 {
fullName = gecos[0]
}

dei.user = &user.User{
Gid: passwdFields[3],
HomeDir: passwdFields[5],
Name: fullName,
Uid: passwdFields[2],
Username: containerUser,
}
dei.userShell = passwdFields[6]

// Finally, get the environment of the container.
stdoutBuf.Reset()
stderrBuf.Reset()
cmd, args = WrapDockerExec(container, containerUser)("env")
execCmd = execer.CommandContext(ctx, cmd, args...)
execCmd.Stdout = &stdoutBuf
execCmd.Stderr = &stderrBuf
if err := execCmd.Run(); err != nil {
return nil, xerrors.Errorf("get container environment: run env: %w stderr: %q", err, strings.TrimSpace(stderrBuf.String()))
}

scanner = bufio.NewScanner(&stdoutBuf)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
dei.env = append(dei.env, line)
}
if err := scanner.Err(); err != nil {
return nil, xerrors.Errorf("get container environment: scan env output: %w", err)
}

return &dei, nil
}

func (dei *ContainerEnvInfoer) CurrentUser() (*user.User, error) {
// Clone the user so that the caller can't modify it
u := &user.User{
Gid: dei.user.Gid,
HomeDir: dei.user.HomeDir,
Name: dei.user.Name,
Uid: dei.user.Uid,
Username: dei.user.Username,
}
return u, nil
}

func (dei *ContainerEnvInfoer) Environ() []string {
// Return a clone of the environment so that the caller can't modify it
return slices.Clone(dei.env)
}

func (dei *ContainerEnvInfoer) UserHomeDir() (string, error) {
return dei.user.HomeDir, nil
}

func (dei *ContainerEnvInfoer) UserShell(string) (string, error) {
return dei.userShell, nil
}

func (dei *ContainerEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
return WrapDockerExecPTY(dei.container, dei.user.Username)(cmd, args...)
}

// WrapFn is a function that wraps a command and its arguments with another command and arguments.
type WrapFn func(cmd string, args ...string) (string, []string)

// WrapDockerExec returns a WrapFn that wraps the given command and arguments
// with a docker exec command that runs as the given user in the given container.
func WrapDockerExec(containerName, userName string) WrapFn {
return func(cmd string, args ...string) (string, []string) {
dockerArgs := []string{"exec", "--interactive"}
if userName != "" {
dockerArgs = append(dockerArgs, "--user", userName)
}
dockerArgs = append(dockerArgs, containerName, cmd)
return "docker", append(dockerArgs, args...)
}
}

// WrapDockerExecPTY is similar to WrapDockerExec but also allocates a PTY.
func WrapDockerExecPTY(containerName, userName string) WrapFn {
return func(cmd string, args ...string) (string, []string) {
dockerArgs := []string{"exec", "--interactive", "--tty"}
if userName != "" {
dockerArgs = append(dockerArgs, "--user", userName)
}
dockerArgs = append(dockerArgs, containerName, cmd)
return "docker", append(dockerArgs, args...)
}
}

func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) {
var stdoutBuf, stderrBuf bytes.Buffer
// List all container IDs, one per line, with no truncation
Expand Down
Loading
Loading