Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
47 changes: 47 additions & 0 deletions internal/apple/container.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package apple

import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -36,6 +38,51 @@ type Container struct {
readyBaseDelay time.Duration
}

// InspectUser returns the uid and gid of the container's default user.
// It starts the container if not already running, then execs `id -u` and `id -g`.
func (c *Container) InspectUser(ctx context.Context) (runtime.ImageUser, error) {
if !c.started {
err := c.runner.Run(ctx, nil, os.Stdout, os.Stderr,
"container", "start", c.name,
)
if err != nil {
return runtime.ImageUser{}, fmt.Errorf("failed to start container %q for user inspection: %w", c.name, err)
}

if err := c.waitForRunning(ctx); err != nil {
return runtime.ImageUser{}, fmt.Errorf("container %q failed to become ready for user inspection: %w", c.name, err)
}

c.started = true
}

var uidBuf, gidBuf bytes.Buffer

if err := c.runner.Run(ctx, nil, &uidBuf, os.Stderr,
"container", "exec", c.name, "id", "-u",
); err != nil {
return runtime.ImageUser{}, fmt.Errorf("failed to get uid from container %q: %w", c.name, err)
}

if err := c.runner.Run(ctx, nil, &gidBuf, os.Stderr,
"container", "exec", c.name, "id", "-g",
); err != nil {
return runtime.ImageUser{}, fmt.Errorf("failed to get gid from container %q: %w", c.name, err)
}

uid, err := strconv.Atoi(strings.TrimSpace(uidBuf.String()))
if err != nil {
return runtime.ImageUser{}, fmt.Errorf("unexpected output from id -u in container %q: %w", c.name, err)
}

gid, err := strconv.Atoi(strings.TrimSpace(gidBuf.String()))
if err != nil {
return runtime.ImageUser{}, fmt.Errorf("unexpected output from id -g in container %q: %w", c.name, err)
}

return runtime.ImageUser{UID: uid, GID: gid}, nil
}

// CopyTo starts the container and copies content via `container exec tar`.
// Apple Container cannot copy files into a stopped container, so we start it first
// with `sleep infinity`, then pipe the tar archive via exec.
Expand Down
17 changes: 17 additions & 0 deletions internal/docker/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ type Container struct {
RetryDelay time.Duration
}

// InspectUser returns the default user for the container's image by inspecting the container
// config. If the user is specified as a name rather than a numeric ID, it resolves the name
// via /etc/passwd and /etc/group copied from the stopped container.
func (c Container) InspectUser(ctx context.Context) (runtime.ImageUser, error) {
result, err := c.client.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{})
if err != nil {
return runtime.ImageUser{}, fmt.Errorf("failed to inspect container %q: %w", c.Name, err)
}

var userStr string
if result.Container.Config != nil {
userStr = result.Container.Config.User
}

return resolveImageUser(ctx, c.client, c.ID, userStr)
}

// Start starts the container. Returns an error if the container fails to start,
// which may indicate a misconfiguration or an unhealthy Docker daemon.
func (c Container) Start(ctx context.Context) error {
Expand Down
2 changes: 2 additions & 0 deletions internal/docker/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import (
// c := docker.NewClient(&mockDockerClient{})
type DockerClient interface {
ImageBuild(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error)
ContainerInspect(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error)
ContainerCreate(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error)
CopyFromContainer(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error)
ContainerStart(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error)
ContainerAttach(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error)
ContainerWait(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult
Expand Down
40 changes: 28 additions & 12 deletions internal/docker/mock_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,20 @@ import (

// mockDockerClient is a mock implementation of docker.DockerClient for testing
type mockDockerClient struct {
imageBuildFunc func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error)
containerCreateFunc func(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error)
containerStartFunc func(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error)
containerAttachFunc func(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error)
containerWaitFunc func(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult
containerStopFunc func(ctx context.Context, containerID string, options client.ContainerStopOptions) (client.ContainerStopResult, error)
containerRemoveFunc func(ctx context.Context, containerID string, options client.ContainerRemoveOptions) (client.ContainerRemoveResult, error)
containerResizeFunc func(ctx context.Context, containerID string, options client.ContainerResizeOptions) (client.ContainerResizeResult, error)
copyToContainerFunc func(ctx context.Context, containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error)
pingFunc func(ctx context.Context, options client.PingOptions) (client.PingResult, error)
containerListFunc func(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error)
closeFunc func() error
imageBuildFunc func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error)
containerInspectFunc func(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error)
containerCreateFunc func(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error)
copyFromContainerFunc func(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error)
containerStartFunc func(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error)
containerAttachFunc func(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error)
containerWaitFunc func(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult
containerStopFunc func(ctx context.Context, containerID string, options client.ContainerStopOptions) (client.ContainerStopResult, error)
containerRemoveFunc func(ctx context.Context, containerID string, options client.ContainerRemoveOptions) (client.ContainerRemoveResult, error)
containerResizeFunc func(ctx context.Context, containerID string, options client.ContainerResizeOptions) (client.ContainerResizeResult, error)
copyToContainerFunc func(ctx context.Context, containerID string, options client.CopyToContainerOptions) (client.CopyToContainerResult, error)
pingFunc func(ctx context.Context, options client.PingOptions) (client.PingResult, error)
containerListFunc func(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error)
closeFunc func() error
}

func (m *mockDockerClient) ImageBuild(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error) {
Expand All @@ -32,6 +34,13 @@ func (m *mockDockerClient) ImageBuild(ctx context.Context, buildContext io.Reade
return client.ImageBuildResult{}, errors.New("not implemented")
}

func (m *mockDockerClient) ContainerInspect(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error) {
if m.containerInspectFunc != nil {
return m.containerInspectFunc(ctx, containerID, options)
}
return client.ContainerInspectResult{}, errors.New("not implemented")
}

func (m *mockDockerClient) ContainerCreate(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error) {
if m.containerCreateFunc != nil {
return m.containerCreateFunc(ctx, options)
Expand Down Expand Up @@ -91,6 +100,13 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, containerID stri
return client.CopyToContainerResult{}, errors.New("not implemented")
}

func (m *mockDockerClient) CopyFromContainer(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error) {
if m.copyFromContainerFunc != nil {
return m.copyFromContainerFunc(ctx, containerID, options)
}
return client.CopyFromContainerResult{}, errors.New("not implemented")
}

func (m *mockDockerClient) Ping(ctx context.Context, options client.PingOptions) (client.PingResult, error) {
if m.pingFunc != nil {
return m.pingFunc(ctx, options)
Expand Down
151 changes: 151 additions & 0 deletions internal/docker/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package docker

import (
"archive/tar"
"bufio"
"context"
"fmt"
"io"
"strconv"
"strings"

"github.com/moby/moby/client"
"github.com/ryanmoran/contagent/internal/runtime"
)

// resolveImageUser parses the Docker USER field and resolves it to uid/gid.
// The USER field format is "[user][:group]" where each part can be a name or numeric ID.
// If names are present, they are resolved via /etc/passwd and /etc/group copied from
// the container with the given containerID (which may be stopped).
func resolveImageUser(ctx context.Context, dockerClient DockerClient, containerID, userStr string) (runtime.ImageUser, error) {
if userStr == "" {
return runtime.ImageUser{UID: 0, GID: 0}, nil
}

userPart, groupPart, hasGroup := strings.Cut(userStr, ":")

userUID, userIsNumeric := tryParseInt(userPart)
groupGID, groupIsNumeric := tryParseInt(groupPart)

// Fast path: both parts are numeric (or group is absent with a numeric user)
if userIsNumeric && (!hasGroup || groupIsNumeric) {
gid := userUID // default gid = uid when no group specified
if hasGroup {
gid = groupGID
}
return runtime.ImageUser{UID: userUID, GID: gid}, nil
}

// Slow path: resolve names via /etc/passwd and /etc/group from the container
var uid, gid int
var err error

if userIsNumeric {
uid = userUID
gid = userUID // fallback; may be overridden below
} else {
uid, gid, err = lookupUser(ctx, dockerClient, containerID, userPart)
if err != nil {
return runtime.ImageUser{}, fmt.Errorf("failed to resolve user %q: %w", userPart, err)
}
}

if hasGroup {
if groupIsNumeric {
gid = groupGID
} else {
gid, err = lookupGroup(ctx, dockerClient, containerID, groupPart)
if err != nil {
return runtime.ImageUser{}, fmt.Errorf("failed to resolve group %q: %w", groupPart, err)
}
}
}

return runtime.ImageUser{UID: uid, GID: gid}, nil
}

func tryParseInt(s string) (int, bool) {
if s == "" {
return 0, false
}
n, err := strconv.Atoi(s)
return n, err == nil
}

func copyFileFromContainer(ctx context.Context, dockerClient DockerClient, containerID, srcPath string) (string, error) {
result, err := dockerClient.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{
SourcePath: srcPath,
})
if err != nil {
return "", fmt.Errorf("failed to copy %q from container: %w", srcPath, err)
}
defer result.Content.Close()

tr := tar.NewReader(result.Content)
if _, err = tr.Next(); err != nil {
return "", fmt.Errorf("failed to read tar entry from container copy: %w", err)
}

content, err := io.ReadAll(tr)
if err != nil {
return "", fmt.Errorf("failed to read file content: %w", err)
}

return string(content), nil
}

// lookupUser finds a username in /etc/passwd and returns its uid and primary gid.
func lookupUser(ctx context.Context, dockerClient DockerClient, containerID, username string) (uid, gid int, err error) {
content, err := copyFileFromContainer(ctx, dockerClient, containerID, "/etc/passwd")
if err != nil {
return 0, 0, err
}

scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.SplitN(line, ":", 7)
if len(fields) < 4 || fields[0] != username {
continue
}
uid, err = strconv.Atoi(fields[2])
if err != nil {
return 0, 0, fmt.Errorf("invalid uid for user %q in /etc/passwd: %w", username, err)
}
gid, err = strconv.Atoi(fields[3])
if err != nil {
return 0, 0, fmt.Errorf("invalid gid for user %q in /etc/passwd: %w", username, err)
}
return uid, gid, nil
}
return 0, 0, fmt.Errorf("user %q not found in /etc/passwd", username)
}

// lookupGroup finds a group name in /etc/group and returns its gid.
func lookupGroup(ctx context.Context, dockerClient DockerClient, containerID, groupName string) (gid int, err error) {
content, err := copyFileFromContainer(ctx, dockerClient, containerID, "/etc/group")
if err != nil {
return 0, err
}

scanner := bufio.NewScanner(strings.NewReader(content))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
fields := strings.SplitN(line, ":", 4)
if len(fields) < 3 || fields[0] != groupName {
continue
}
gid, err = strconv.Atoi(fields[2])
if err != nil {
return 0, fmt.Errorf("invalid gid for group %q in /etc/group: %w", groupName, err)
}
return gid, nil
}
return 0, fmt.Errorf("group %q not found in /etc/group", groupName)
}
Loading
Loading