feat(agent/agentcontainers): add ContainerEnvInfoer (#16623)

This PR adds an alternative implementation of EnvInfo
(https://github.com/coder/coder/pull/16603) that reads information from
a running container.

---------

Co-authored-by: Mathias Fredriksson <mafredri@gmail.com>
This commit is contained in:
Cian Johnston
2025-02-24 15:05:15 +00:00
committed by GitHub
parent ac88c9ba17
commit 304007b5ea
3 changed files with 462 additions and 27 deletions

View File

@ -6,6 +6,9 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"os/user"
"slices"
"sort"
"strconv"
"strings"
@ -31,6 +34,210 @@ func NewDocker(execer agentexec.Execer) Lister {
}
}
// DockerEnvInfoer is an implementation of agentssh.EnvInfoer that returns
// information about a container.
type DockerEnvInfoer 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) (*DockerEnvInfoer, error) {
var dei DockerEnvInfoer
dei.container = container
if containerUser == "" {
// Get the "default" user of the container if no user is specified.
// TODO: handle different container runtimes.
cmd, args := wrapDockerExec(container, "", "whoami")
stdout, stderr, err := run(ctx, execer, cmd, args...)
if err != nil {
return nil, xerrors.Errorf("get container user: run whoami: %w: %s", err, stderr)
}
if len(stdout) == 0 {
return nil, xerrors.Errorf("get container user: run whoami: empty output")
}
containerUser = stdout
}
// 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")
stdout, stderr, err := run(ctx, execer, cmd, args...)
if err != nil {
return nil, xerrors.Errorf("get container user: read /etc/passwd: %w: %q", err, stderr)
}
scanner := bufio.NewScanner(strings.NewReader(stdout))
var foundLine string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, containerUser+":") {
continue
}
foundLine = line
break
}
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 fifth 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]
// We need to inspect the container labels for remoteEnv and append these to
// the resulting docker exec command.
// ref: https://code.visualstudio.com/docs/devcontainers/attach-container
env, err := devcontainerEnv(ctx, execer, container)
if err != nil { // best effort.
return nil, xerrors.Errorf("read devcontainer remoteEnv: %w", err)
}
dei.env = env
return &dei, nil
}
func (dei *DockerEnvInfoer) CurrentUser() (*user.User, error) {
// Clone the user so that the caller can't modify it
u := *dei.user
return &u, nil
}
func (*DockerEnvInfoer) Environ() []string {
// Return a clone of the environment so that the caller can't modify it
return os.Environ()
}
func (*DockerEnvInfoer) UserHomeDir() (string, error) {
// We default the working directory of the command to the user's home
// directory. Since this came from inside the container, we cannot guarantee
// that this exists on the host. Return the "real" home directory of the user
// instead.
return os.UserHomeDir()
}
func (dei *DockerEnvInfoer) UserShell(string) (string, error) {
return dei.userShell, nil
}
func (dei *DockerEnvInfoer) ModifyCommand(cmd string, args ...string) (string, []string) {
// Wrap the command with `docker exec` and run it as the container user.
// There is some additional munging here regarding the container user and environment.
dockerArgs := []string{
"exec",
// The assumption is that this command will be a shell command, so allocate a PTY.
"--interactive",
"--tty",
// Run the command as the user in the container.
"--user",
dei.user.Username,
// Set the working directory to the user's home directory as a sane default.
"--workdir",
dei.user.HomeDir,
}
// Append the environment variables from the container.
for _, e := range dei.env {
dockerArgs = append(dockerArgs, "--env", e)
}
// Append the container name and the command.
dockerArgs = append(dockerArgs, dei.container, cmd)
return "docker", append(dockerArgs, args...)
}
// devcontainerEnv is a helper function that inspects the container labels to
// find the required environment variables for running a command in the container.
func devcontainerEnv(ctx context.Context, execer agentexec.Execer, container string) ([]string, error) {
ins, stderr, err := runDockerInspect(ctx, execer, container)
if err != nil {
return nil, xerrors.Errorf("inspect container: %w: %q", err, stderr)
}
if len(ins) != 1 {
return nil, xerrors.Errorf("inspect container: expected 1 container, got %d", len(ins))
}
in := ins[0]
if in.Config.Labels == nil {
return nil, nil
}
// We want to look for the devcontainer metadata, which is in the
// value of the label `devcontainer.metadata`.
rawMeta, ok := in.Config.Labels["devcontainer.metadata"]
if !ok {
return nil, nil
}
meta := struct {
RemoteEnv map[string]string `json:"remoteEnv"`
}{}
if err := json.Unmarshal([]byte(rawMeta), &meta); err != nil {
return nil, xerrors.Errorf("unmarshal devcontainer.metadata: %w", err)
}
// The environment variables are stored in the `remoteEnv` key.
env := make([]string, 0, len(meta.RemoteEnv))
for k, v := range meta.RemoteEnv {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
slices.Sort(env)
return env, nil
}
// wrapDockerExec is a helper function that wraps the given command and arguments
// with a docker exec command that runs as the given user in the given
// container. This is used to fetch information about a container prior to
// running the actual command.
func wrapDockerExec(containerName, userName, 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...)
}
// Helper function to run a command and return its stdout and stderr.
// We want to differentiate stdout and stderr instead of using CombinedOutput.
// We also want to differentiate between a command running successfully with
// output to stderr and a non-zero exit code.
func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr string, err error) {
var stdoutBuf, stderrBuf strings.Builder
execCmd := execer.CommandContext(ctx, cmd, args...)
execCmd.Stdout = &stdoutBuf
execCmd.Stderr = &stderrBuf
err = execCmd.Run()
stdout = strings.TrimSpace(stdoutBuf.String())
stderr = strings.TrimSpace(stderrBuf.String())
return stdout, stderr, err
}
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
@ -66,30 +273,16 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
}
// now we can get the detailed information for each container
// Run `docker inspect` on each container ID
stdoutBuf.Reset()
stderrBuf.Reset()
// nolint: gosec // We are not executing user input, these IDs come from
// `docker ps`.
cmd = dcl.execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
if err := cmd.Run(); err != nil {
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, strings.TrimSpace(stderrBuf.String()))
}
dockerInspectStderr := strings.TrimSpace(stderrBuf.String())
// Run `docker inspect` on each container ID.
// NOTE: There is an unavoidable potential race condition where a
// container is removed between `docker ps` and `docker inspect`.
// In this case, stderr will contain an error message but stdout
// will still contain valid JSON. We will just end up missing
// information about the removed container. We could potentially
// log this error, but I'm not sure it's worth it.
ins := make([]dockerInspect, 0, len(ids))
if err := json.NewDecoder(&stdoutBuf).Decode(&ins); err != nil {
// However, if we just get invalid JSON, we should absolutely return an error.
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("decode docker inspect output: %w", err)
ins, dockerInspectStderr, err := runDockerInspect(ctx, dcl.execer, ids...)
if err != nil {
return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w", err)
}
res := codersdk.WorkspaceAgentListContainersResponse{
@ -111,6 +304,28 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi
return res, nil
}
// runDockerInspect is a helper function that runs `docker inspect` on the given
// container IDs and returns the parsed output.
// The stderr output is also returned for logging purposes.
func runDockerInspect(ctx context.Context, execer agentexec.Execer, ids ...string) ([]dockerInspect, string, error) {
var stdoutBuf, stderrBuf bytes.Buffer
cmd := execer.CommandContext(ctx, "docker", append([]string{"inspect"}, ids...)...)
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
err := cmd.Run()
stderr := strings.TrimSpace(stderrBuf.String())
if err != nil {
return nil, stderr, err
}
var ins []dockerInspect
if err := json.NewDecoder(&stdoutBuf).Decode(&ins); err != nil {
return nil, stderr, xerrors.Errorf("decode docker inspect output: %w", err)
}
return ins, stderr, nil
}
// To avoid a direct dependency on the Docker API, we use the docker CLI
// to fetch information about containers.
type dockerInspect struct {