Skip to content

Commit c319906

Browse files
committed
Fixes git file ownership issues in a non-root container
1 parent 7429b48 commit c319906

10 files changed

Lines changed: 571 additions & 62 deletions

File tree

internal/apple/container.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package apple
22

33
import (
4+
"bytes"
45
"context"
56
"fmt"
67
"io"
78
"os"
89
"os/signal"
910
"strconv"
11+
"strings"
1012
"syscall"
1113
"time"
1214

@@ -36,6 +38,51 @@ type Container struct {
3638
readyBaseDelay time.Duration
3739
}
3840

41+
// InspectUser returns the uid and gid of the container's default user.
42+
// It starts the container if not already running, then execs `id -u` and `id -g`.
43+
func (c *Container) InspectUser(ctx context.Context) (runtime.ImageUser, error) {
44+
if !c.started {
45+
err := c.runner.Run(ctx, nil, os.Stdout, os.Stderr,
46+
"container", "start", c.name,
47+
)
48+
if err != nil {
49+
return runtime.ImageUser{}, fmt.Errorf("failed to start container %q for user inspection: %w", c.name, err)
50+
}
51+
52+
if err := c.waitForRunning(ctx); err != nil {
53+
return runtime.ImageUser{}, fmt.Errorf("container %q failed to become ready for user inspection: %w", c.name, err)
54+
}
55+
56+
c.started = true
57+
}
58+
59+
var uidBuf, gidBuf bytes.Buffer
60+
61+
if err := c.runner.Run(ctx, nil, &uidBuf, os.Stderr,
62+
"container", "exec", c.name, "id", "-u",
63+
); err != nil {
64+
return runtime.ImageUser{}, fmt.Errorf("failed to get uid from container %q: %w", c.name, err)
65+
}
66+
67+
if err := c.runner.Run(ctx, nil, &gidBuf, os.Stderr,
68+
"container", "exec", c.name, "id", "-g",
69+
); err != nil {
70+
return runtime.ImageUser{}, fmt.Errorf("failed to get gid from container %q: %w", c.name, err)
71+
}
72+
73+
uid, err := strconv.Atoi(strings.TrimSpace(uidBuf.String()))
74+
if err != nil {
75+
return runtime.ImageUser{}, fmt.Errorf("unexpected output from id -u in container %q: %w", c.name, err)
76+
}
77+
78+
gid, err := strconv.Atoi(strings.TrimSpace(gidBuf.String()))
79+
if err != nil {
80+
return runtime.ImageUser{}, fmt.Errorf("unexpected output from id -g in container %q: %w", c.name, err)
81+
}
82+
83+
return runtime.ImageUser{UID: uid, GID: gid}, nil
84+
}
85+
3986
// CopyTo starts the container and copies content via `container exec tar`.
4087
// Apple Container cannot copy files into a stopped container, so we start it first
4188
// with `sleep infinity`, then pipe the tar archive via exec.

internal/docker/container.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,23 @@ type Container struct {
3333
RetryDelay time.Duration
3434
}
3535

36+
// InspectUser returns the default user for the container's image by inspecting the container
37+
// config. If the user is specified as a name rather than a numeric ID, it resolves the name
38+
// via /etc/passwd and /etc/group copied from the stopped container.
39+
func (c Container) InspectUser(ctx context.Context) (runtime.ImageUser, error) {
40+
result, err := c.client.ContainerInspect(ctx, c.ID, client.ContainerInspectOptions{})
41+
if err != nil {
42+
return runtime.ImageUser{}, fmt.Errorf("failed to inspect container %q: %w", c.Name, err)
43+
}
44+
45+
var userStr string
46+
if result.Container.Config != nil {
47+
userStr = result.Container.Config.User
48+
}
49+
50+
return resolveImageUser(ctx, c.client, c.ID, userStr)
51+
}
52+
3653
// Start starts the container. Returns an error if the container fails to start,
3754
// which may indicate a misconfiguration or an unhealthy Docker daemon.
3855
func (c Container) Start(ctx context.Context) error {

internal/docker/interface.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ import (
3131
// c := docker.NewClient(&mockDockerClient{})
3232
type DockerClient interface {
3333
ImageBuild(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error)
34+
ContainerInspect(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error)
3435
ContainerCreate(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error)
36+
CopyFromContainer(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error)
3537
ContainerStart(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error)
3638
ContainerAttach(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error)
3739
ContainerWait(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult

internal/docker/mock_client_test.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111

1212
// mockDockerClient is a mock implementation of docker.DockerClient for testing
1313
type mockDockerClient struct {
14-
imageBuildFunc func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error)
15-
containerCreateFunc func(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error)
14+
imageBuildFunc func(ctx context.Context, buildContext io.Reader, options client.ImageBuildOptions) (client.ImageBuildResult, error)
15+
containerInspectFunc func(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error)
16+
containerCreateFunc func(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error)
17+
copyFromContainerFunc func(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error)
1618
containerStartFunc func(ctx context.Context, containerID string, options client.ContainerStartOptions) (client.ContainerStartResult, error)
1719
containerAttachFunc func(ctx context.Context, containerID string, options client.ContainerAttachOptions) (client.ContainerAttachResult, error)
1820
containerWaitFunc func(ctx context.Context, containerID string, options client.ContainerWaitOptions) client.ContainerWaitResult
@@ -32,6 +34,13 @@ func (m *mockDockerClient) ImageBuild(ctx context.Context, buildContext io.Reade
3234
return client.ImageBuildResult{}, errors.New("not implemented")
3335
}
3436

37+
func (m *mockDockerClient) ContainerInspect(ctx context.Context, containerID string, options client.ContainerInspectOptions) (client.ContainerInspectResult, error) {
38+
if m.containerInspectFunc != nil {
39+
return m.containerInspectFunc(ctx, containerID, options)
40+
}
41+
return client.ContainerInspectResult{}, errors.New("not implemented")
42+
}
43+
3544
func (m *mockDockerClient) ContainerCreate(ctx context.Context, options client.ContainerCreateOptions) (client.ContainerCreateResult, error) {
3645
if m.containerCreateFunc != nil {
3746
return m.containerCreateFunc(ctx, options)
@@ -91,6 +100,13 @@ func (m *mockDockerClient) CopyToContainer(ctx context.Context, containerID stri
91100
return client.CopyToContainerResult{}, errors.New("not implemented")
92101
}
93102

103+
func (m *mockDockerClient) CopyFromContainer(ctx context.Context, containerID string, options client.CopyFromContainerOptions) (client.CopyFromContainerResult, error) {
104+
if m.copyFromContainerFunc != nil {
105+
return m.copyFromContainerFunc(ctx, containerID, options)
106+
}
107+
return client.CopyFromContainerResult{}, errors.New("not implemented")
108+
}
109+
94110
func (m *mockDockerClient) Ping(ctx context.Context, options client.PingOptions) (client.PingResult, error) {
95111
if m.pingFunc != nil {
96112
return m.pingFunc(ctx, options)

internal/docker/user.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package docker
2+
3+
import (
4+
"archive/tar"
5+
"bufio"
6+
"context"
7+
"fmt"
8+
"io"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/moby/moby/client"
13+
"github.com/ryanmoran/contagent/internal/runtime"
14+
)
15+
16+
// resolveImageUser parses the Docker USER field and resolves it to uid/gid.
17+
// The USER field format is "[user][:group]" where each part can be a name or numeric ID.
18+
// If names are present, they are resolved via /etc/passwd and /etc/group copied from
19+
// the container with the given containerID (which may be stopped).
20+
func resolveImageUser(ctx context.Context, dockerClient DockerClient, containerID, userStr string) (runtime.ImageUser, error) {
21+
if userStr == "" {
22+
return runtime.ImageUser{}, nil
23+
}
24+
25+
userPart, groupPart, hasGroup := strings.Cut(userStr, ":")
26+
27+
userUID, userIsNumeric := tryParseInt(userPart)
28+
groupGID, groupIsNumeric := tryParseInt(groupPart)
29+
30+
// Fast path: both parts are numeric (or group is absent with a numeric user)
31+
if userIsNumeric && (!hasGroup || groupIsNumeric) {
32+
gid := userUID // default gid = uid when no group specified
33+
if hasGroup {
34+
gid = groupGID
35+
}
36+
return runtime.ImageUser{UID: userUID, GID: gid}, nil
37+
}
38+
39+
// Slow path: resolve names via /etc/passwd and /etc/group from the container
40+
var uid, gid int
41+
var err error
42+
43+
if userIsNumeric {
44+
uid = userUID
45+
gid = userUID // fallback; may be overridden below
46+
} else {
47+
uid, gid, err = lookupUser(ctx, dockerClient, containerID, userPart)
48+
if err != nil {
49+
return runtime.ImageUser{}, fmt.Errorf("failed to resolve user %q: %w", userPart, err)
50+
}
51+
}
52+
53+
if hasGroup {
54+
if groupIsNumeric {
55+
gid = groupGID
56+
} else {
57+
gid, err = lookupGroup(ctx, dockerClient, containerID, groupPart)
58+
if err != nil {
59+
return runtime.ImageUser{}, fmt.Errorf("failed to resolve group %q: %w", groupPart, err)
60+
}
61+
}
62+
}
63+
64+
return runtime.ImageUser{UID: uid, GID: gid}, nil
65+
}
66+
67+
func tryParseInt(s string) (int, bool) {
68+
if s == "" {
69+
return 0, false
70+
}
71+
n, err := strconv.Atoi(s)
72+
return n, err == nil
73+
}
74+
75+
func copyFileFromContainer(ctx context.Context, dockerClient DockerClient, containerID, srcPath string) (string, error) {
76+
result, err := dockerClient.CopyFromContainer(ctx, containerID, client.CopyFromContainerOptions{
77+
SourcePath: srcPath,
78+
})
79+
if err != nil {
80+
return "", fmt.Errorf("failed to copy %q from container: %w", srcPath, err)
81+
}
82+
defer result.Content.Close()
83+
84+
tr := tar.NewReader(result.Content)
85+
if _, err = tr.Next(); err != nil {
86+
return "", fmt.Errorf("failed to read tar entry from container copy: %w", err)
87+
}
88+
89+
content, err := io.ReadAll(tr)
90+
if err != nil {
91+
return "", fmt.Errorf("failed to read file content: %w", err)
92+
}
93+
94+
return string(content), nil
95+
}
96+
97+
// lookupUser finds a username in /etc/passwd and returns its uid and primary gid.
98+
func lookupUser(ctx context.Context, dockerClient DockerClient, containerID, username string) (uid, gid int, err error) {
99+
content, err := copyFileFromContainer(ctx, dockerClient, containerID, "/etc/passwd")
100+
if err != nil {
101+
return 0, 0, err
102+
}
103+
104+
scanner := bufio.NewScanner(strings.NewReader(content))
105+
for scanner.Scan() {
106+
line := strings.TrimSpace(scanner.Text())
107+
if line == "" || strings.HasPrefix(line, "#") {
108+
continue
109+
}
110+
fields := strings.SplitN(line, ":", 7)
111+
if len(fields) < 4 || fields[0] != username {
112+
continue
113+
}
114+
uid, err = strconv.Atoi(fields[2])
115+
if err != nil {
116+
return 0, 0, fmt.Errorf("invalid uid for user %q in /etc/passwd: %w", username, err)
117+
}
118+
gid, err = strconv.Atoi(fields[3])
119+
if err != nil {
120+
return 0, 0, fmt.Errorf("invalid gid for user %q in /etc/passwd: %w", username, err)
121+
}
122+
return uid, gid, nil
123+
}
124+
return 0, 0, fmt.Errorf("user %q not found in /etc/passwd", username)
125+
}
126+
127+
// lookupGroup finds a group name in /etc/group and returns its gid.
128+
func lookupGroup(ctx context.Context, dockerClient DockerClient, containerID, groupName string) (gid int, err error) {
129+
content, err := copyFileFromContainer(ctx, dockerClient, containerID, "/etc/group")
130+
if err != nil {
131+
return 0, err
132+
}
133+
134+
scanner := bufio.NewScanner(strings.NewReader(content))
135+
for scanner.Scan() {
136+
line := strings.TrimSpace(scanner.Text())
137+
if line == "" || strings.HasPrefix(line, "#") {
138+
continue
139+
}
140+
fields := strings.SplitN(line, ":", 4)
141+
if len(fields) < 3 || fields[0] != groupName {
142+
continue
143+
}
144+
gid, err = strconv.Atoi(fields[2])
145+
if err != nil {
146+
return 0, fmt.Errorf("invalid gid for group %q in /etc/group: %w", groupName, err)
147+
}
148+
return gid, nil
149+
}
150+
return 0, fmt.Errorf("group %q not found in /etc/group", groupName)
151+
}

0 commit comments

Comments
 (0)