Skip to content

Commit d6ec3d5

Browse files
Merge branch 'main' into 74-git-submodule-support
2 parents b82aee0 + da95f80 commit d6ec3d5

7 files changed

Lines changed: 1168 additions & 583 deletions

File tree

README.md

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,20 +101,5 @@ Issue: [#113](https://github.com/coder/envbuilder/issues/113)
101101

102102
## Contributing
103103

104-
Building `envbuilder` currently **requires** a Linux system.
105-
106-
On macOS or Windows systems, we recommend using a VM or the provided `.devcontainer` for development.
107-
108-
**Additional Requirements:**
109-
110-
- `go 1.22`
111-
- `make`
112-
- Docker daemon (for running tests)
113-
114-
**Makefile targets:**
115-
116-
- `build`: Builds and tags `envbuilder:latest` for your current architecture.
117-
- `develop`: Runs `envbuilder:latest` against a sample Git repository.
118-
- `test`: Runs tests.
119-
- `test-registry`: Stands up a local registry for caching images used in tests.
120-
- `docs/env-variables.md`: Updated the [environment variables documentation](./docs/env-variables.md).
104+
See [Development](./docs/development.md) for build instructions, dependency
105+
management notes, and common troubleshooting.

devcontainer/devcontainer.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir string,
204204
// We should make a best-effort attempt to find the user.
205205
// Features must be executed as root, so we need to swap back
206206
// to the running user afterwards.
207-
params.User, err = UserFromDockerfile(params.DockerfileContent)
207+
params.User, err = UserFromDockerfile(params.DockerfileContent, BuildArgsMap(params.BuildArgs))
208208
if err != nil {
209209
return nil, fmt.Errorf("user from dockerfile: %w", err)
210210
}
@@ -306,14 +306,46 @@ func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir
306306
return strings.Join(lines, "\n"), featureContexts, err
307307
}
308308

309+
// BuildArgsMap converts a slice of "KEY=VALUE" strings to a map.
310+
func BuildArgsMap(buildArgs []string) map[string]string {
311+
m := make(map[string]string, len(buildArgs))
312+
for _, arg := range buildArgs {
313+
if key, val, ok := strings.Cut(arg, "="); ok {
314+
m[key] = val
315+
}
316+
}
317+
return m
318+
}
319+
309320
// UserFromDockerfile inspects the contents of a provided Dockerfile
310321
// and returns the user that will be used to run the container.
311-
func UserFromDockerfile(dockerfileContent string) (user string, err error) {
322+
func UserFromDockerfile(dockerfileContent string, buildArgs map[string]string) (user string, err error) {
312323
res, err := parser.Parse(strings.NewReader(dockerfileContent))
313324
if err != nil {
314325
return "", fmt.Errorf("parse dockerfile: %w", err)
315326
}
316327

328+
// Collect ARG values (defaults + overrides from buildArgs) for
329+
// substitution into FROM image refs.
330+
lexer := shell.NewLex('\\')
331+
var argEnvs []string
332+
for _, child := range res.AST.Children {
333+
if !strings.EqualFold(child.Value, "arg") || child.Next == nil {
334+
continue
335+
}
336+
if key, val, ok := strings.Cut(child.Next.Value, "="); ok {
337+
if override, has := buildArgs[key]; has {
338+
val = override
339+
}
340+
argEnvs = append(argEnvs, key+"="+val)
341+
} else {
342+
arg := child.Next.Value
343+
if val, has := buildArgs[arg]; has {
344+
argEnvs = append(argEnvs, arg+"="+val)
345+
}
346+
}
347+
}
348+
317349
// Parse stages and user commands to determine the relevant user
318350
// from the final stage.
319351
var (
@@ -330,6 +362,12 @@ func UserFromDockerfile(dockerfileContent string) (user string, err error) {
330362

331363
switch i := inst.(type) {
332364
case *instructions.Stage:
365+
// Substitute ARG values in the base image name.
366+
baseName, _, err := lexer.ProcessWord(i.BaseName, shell.EnvsFromSlice(argEnvs))
367+
if err != nil {
368+
return "", fmt.Errorf("processing ARG substitution in FROM %q: %w", i.BaseName, err)
369+
}
370+
i.BaseName = baseName
333371
stages = append(stages, i)
334372
if i.Name != "" {
335373
stageNames[i.Name] = i
@@ -388,7 +426,7 @@ func UserFromDockerfile(dockerfileContent string) (user string, err error) {
388426

389427
// ImageFromDockerfile inspects the contents of a provided Dockerfile
390428
// and returns the image that will be used to run the container.
391-
func ImageFromDockerfile(dockerfileContent string) (name.Reference, error) {
429+
func ImageFromDockerfile(dockerfileContent string, buildArgs map[string]string) (name.Reference, error) {
392430
lexer := shell.NewLex('\\')
393431
var args []string
394432
var imageRef string
@@ -398,17 +436,25 @@ func ImageFromDockerfile(dockerfileContent string) (name.Reference, error) {
398436
line := lines[i]
399437
if arg, ok := strings.CutPrefix(line, "ARG "); ok {
400438
arg = strings.TrimSpace(arg)
401-
if strings.Contains(arg, "=") {
402-
parts := strings.SplitN(arg, "=", 2)
403-
key, _, err := lexer.ProcessWord(parts[0], shell.EnvsFromSlice(args))
439+
if key, val, ok := strings.Cut(arg, "="); ok {
440+
key, _, err := lexer.ProcessWord(key, shell.EnvsFromSlice(args))
404441
if err != nil {
405442
return nil, fmt.Errorf("processing %q: %w", line, err)
406443
}
407-
val, _, err := lexer.ProcessWord(parts[1], shell.EnvsFromSlice(args))
444+
val, _, err := lexer.ProcessWord(val, shell.EnvsFromSlice(args))
408445
if err != nil {
409446
return nil, fmt.Errorf("processing %q: %w", line, err)
410447
}
448+
// Allow buildArgs to override Dockerfile ARG defaults.
449+
if override, has := buildArgs[key]; has {
450+
val = override
451+
}
411452
args = append(args, key+"="+val)
453+
} else {
454+
// ARG without a default — look up in buildArgs.
455+
if val, has := buildArgs[arg]; has {
456+
args = append(args, arg+"="+val)
457+
}
412458
}
413459
continue
414460
}

devcontainer/devcontainer_test.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,13 +213,61 @@ func TestImageFromDockerfile(t *testing.T) {
213213
tc := tc
214214
t.Run(tc.image, func(t *testing.T) {
215215
t.Parallel()
216-
ref, err := devcontainer.ImageFromDockerfile(tc.content)
216+
ref, err := devcontainer.ImageFromDockerfile(tc.content, nil)
217217
require.NoError(t, err)
218218
require.Equal(t, tc.image, ref.Name())
219219
})
220220
}
221221
}
222222

223+
func TestImageFromDockerfile_BuildArgs(t *testing.T) {
224+
t.Parallel()
225+
226+
// Test that build args override ARG defaults.
227+
t.Run("OverridesDefault", func(t *testing.T) {
228+
t.Parallel()
229+
content := "ARG VARIANT=3.10\nFROM mcr.microsoft.com/devcontainers/python:0-${VARIANT}"
230+
ref, err := devcontainer.ImageFromDockerfile(content, map[string]string{"VARIANT": "3.11-bookworm"})
231+
require.NoError(t, err)
232+
require.Equal(t, "mcr.microsoft.com/devcontainers/python:0-3.11-bookworm", ref.Name())
233+
})
234+
235+
// Test that build args supply values for ARGs without defaults.
236+
t.Run("SuppliesArgWithoutDefault", func(t *testing.T) {
237+
t.Parallel()
238+
content := "ARG VARIANT\nFROM mcr.microsoft.com/devcontainers/python:1-${VARIANT}"
239+
ref, err := devcontainer.ImageFromDockerfile(content, map[string]string{"VARIANT": "3.11-bookworm"})
240+
require.NoError(t, err)
241+
require.Equal(t, "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", ref.Name())
242+
})
243+
}
244+
245+
func TestUserFromDockerfile_BuildArgs(t *testing.T) {
246+
t.Parallel()
247+
248+
t.Run("SubstitutesARGInFROM", func(t *testing.T) {
249+
t.Parallel()
250+
registry := registrytest.New(t)
251+
image, err := partial.UncompressedToImage(emptyImage{configFile: &v1.ConfigFile{
252+
Config: v1.Config{
253+
User: "testuser",
254+
},
255+
}})
256+
require.NoError(t, err)
257+
ref := strings.TrimPrefix(registry, "http://") + "/coder/test:latest"
258+
parsed, err := name.ParseReference(ref)
259+
require.NoError(t, err)
260+
err = remote.Write(parsed, image)
261+
require.NoError(t, err)
262+
263+
// Dockerfile uses ARG without default for the image ref.
264+
content := fmt.Sprintf("ARG TAG\nFROM %s/coder/test:${TAG}", strings.TrimPrefix(registry, "http://"))
265+
user, err := devcontainer.UserFromDockerfile(content, map[string]string{"TAG": "latest"})
266+
require.NoError(t, err)
267+
require.Equal(t, "testuser", user)
268+
})
269+
}
270+
223271
func TestUserFrom(t *testing.T) {
224272
t.Parallel()
225273

@@ -287,7 +335,7 @@ func TestUserFrom(t *testing.T) {
287335
for _, tt := range tests {
288336
t.Run(tt.name, func(t *testing.T) {
289337
t.Parallel()
290-
user, err := devcontainer.UserFromDockerfile(tt.content)
338+
user, err := devcontainer.UserFromDockerfile(tt.content, nil)
291339
require.NoError(t, err)
292340
require.Equal(t, tt.user, user)
293341
})
@@ -364,7 +412,7 @@ FROM a`,
364412

365413
content := strings.ReplaceAll(tt.content, "coder/test", strings.TrimPrefix(registry, "http://")+"/coder/test")
366414

367-
user, err := devcontainer.UserFromDockerfile(content)
415+
user, err := devcontainer.UserFromDockerfile(content, nil)
368416
require.NoError(t, err)
369417
require.Equal(t, tt.user, user)
370418
})

docs/development.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Development
2+
3+
Building envbuilder currently **requires** a Linux system.
4+
5+
On macOS or Windows systems, we recommend using a VM or the provided
6+
`.devcontainer` for development.
7+
8+
## Requirements
9+
10+
- Go (see the version in `go.mod`)
11+
- `make`
12+
- Docker daemon (for running tests)
13+
14+
## Makefile targets
15+
16+
- `build`: Builds and tags `envbuilder:latest` for your current architecture.
17+
- `develop`: Runs `envbuilder:latest` against a sample Git repository.
18+
- `test`: Runs tests.
19+
- `test-registry`: Stands up a local registry for caching images used in tests.
20+
- `docs/env-variables.md`: Updates the environment variables documentation.
21+
22+
## Dependency management
23+
24+
Envbuilder has several forked and interrelated dependencies that require care
25+
when upgrading. This section documents known constraints and pitfalls.
26+
27+
### Kaniko
28+
29+
Envbuilder uses a [fork of Kaniko](https://github.com/coder/kaniko) for
30+
container image building. The replace directive in `go.mod` points to this fork:
31+
32+
```
33+
replace github.com/GoogleContainerTools/kaniko => github.com/coder/kaniko <version>
34+
```
35+
36+
The Kaniko fork pins its own versions of `docker/docker`,
37+
`containerd/containerd`, and related packages. When upgrading deps, be aware
38+
that the versions resolved by Go's MVS (minimum version selection) may be higher
39+
than what the Kaniko fork expects if other dependencies (like `coder/coder/v2`)
40+
require newer versions.
41+
42+
### Tailscale
43+
44+
Envbuilder uses a [Coder fork of Tailscale](https://github.com/coder/tailscale)
45+
via a replace directive:
46+
47+
```
48+
replace tailscale.com => github.com/coder/tailscale <version>
49+
```
50+
51+
The `coder/coder/v2` module depends on symbols that only exist in this fork
52+
(e.g. `netns.SetCoderSoftIsolation`, `tsaddr.CoderServiceIPv6`). When upgrading
53+
`coder/coder/v2`, you **must** also update the Tailscale replace to match the
54+
version used in `coder/coder/v2`'s own `go.mod`. You can find it with:
55+
56+
```bash
57+
grep 'replace tailscale.com' /path/to/coder/coder/go.mod
58+
```
59+
60+
### vishvananda/netlink and tailscale/netlink
61+
62+
`tailscale/netlink` is a fork of `vishvananda/netlink` from 2021. Starting from
63+
`vishvananda/netlink` v1.3.0, the `XfrmAddress.ToIPNet` method gained an
64+
additional `family uint16` parameter, which breaks `tailscale/netlink`.
65+
66+
If a transitive dependency (e.g. `containerd/containerd/v2`) pulls in
67+
`vishvananda/netlink` >= v1.3.0, you will see build errors like:
68+
69+
```
70+
not enough arguments in call to msg.Sel.Daddr.ToIPNet
71+
have (uint8)
72+
want (uint8, uint16)
73+
```
74+
75+
The fix is to add `exclude` directives in `go.mod` for the incompatible
76+
versions, forcing Go to select v1.2.x:
77+
78+
```
79+
exclude (
80+
github.com/vishvananda/netlink v1.3.0
81+
github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8
82+
)
83+
```
84+
85+
You may need to add additional exclude directives if new versions are released.
86+
87+
### moby/go-archive
88+
89+
`docker/docker` (the `+incompatible` module) imports `github.com/moby/go-archive`
90+
and expects certain symbols (`archive.Uncompressed`, `archive.Compression`) at the
91+
package root. In `moby/go-archive` v0.2.0, these were moved to a `compression`
92+
subpackage and the top-level aliases were removed.
93+
94+
If you see errors like:
95+
96+
```
97+
undefined: archive.Uncompressed
98+
undefined: archive.Compression
99+
```
100+
101+
Pin `moby/go-archive` to v0.1.0 in `go.mod`:
102+
103+
```bash
104+
go mod edit -require 'github.com/moby/go-archive@v0.1.0'
105+
go mod tidy
106+
```
107+
108+
### General upgrade workflow
109+
110+
When upgrading `coder/coder/v2` or other major dependencies:
111+
112+
1. Update the dependency version in `go.mod`.
113+
2. Compare replace directives (especially `tailscale.com`) against the
114+
upstream module's `go.mod` and update to match.
115+
3. Run `go mod tidy`.
116+
4. Run `go build ./...` and check for compilation errors.
117+
5. If you see errors from transitive dependencies, check whether version
118+
conflicts can be resolved with `exclude` directives or by pinning
119+
specific versions with `go mod edit -require`.
120+
6. Run `make test` to verify everything works end-to-end.

envbuilder.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
493493
defer cleanupBuildContext()
494494
if runtimeData.Built && opts.SkipRebuild {
495495
endStage := startStage("🏗️ Skipping build because of cache...")
496-
imageRef, err := devcontainer.ImageFromDockerfile(buildParams.DockerfileContent)
496+
imageRef, err := devcontainer.ImageFromDockerfile(buildParams.DockerfileContent, devcontainer.BuildArgsMap(buildParams.BuildArgs))
497497
if err != nil {
498498
return nil, fmt.Errorf("image from dockerfile: %w", err)
499499
}
@@ -812,10 +812,16 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
812812
// We need to change the ownership of the files to the user that will
813813
// be running the init script.
814814
if chownErr := filepath.Walk(opts.WorkspaceFolder, func(path string, _ os.FileInfo, err error) error {
815+
if errors.Is(err, fs.ErrNotExist) {
816+
return nil
817+
}
815818
if err != nil {
816819
return err
817820
}
818-
return os.Lchown(path, execArgs.UserInfo.uid, execArgs.UserInfo.gid)
821+
if err := os.Lchown(path, execArgs.UserInfo.uid, execArgs.UserInfo.gid); err != nil && !errors.Is(err, fs.ErrNotExist) {
822+
return err
823+
}
824+
return nil
819825
}); chownErr != nil {
820826
opts.Logger(log.LevelError, "chown %q: %s", execArgs.UserInfo.user.HomeDir, chownErr.Error())
821827
endStage("⚠️ Failed to the ownership of the workspace, you may need to fix this manually!")
@@ -829,10 +835,16 @@ func run(ctx context.Context, opts options.Options, execArgs *execArgsInfo) erro
829835
if execArgs.UserInfo.uid != 0 {
830836
endStage := startStage("🔄 Updating ownership of %s...", execArgs.UserInfo.user.HomeDir)
831837
if chownErr := filepath.Walk(execArgs.UserInfo.user.HomeDir, func(path string, _ fs.FileInfo, err error) error {
838+
if errors.Is(err, fs.ErrNotExist) {
839+
return nil
840+
}
832841
if err != nil {
833842
return err
834843
}
835-
return os.Lchown(path, execArgs.UserInfo.uid, execArgs.UserInfo.gid)
844+
if err := os.Lchown(path, execArgs.UserInfo.uid, execArgs.UserInfo.gid); err != nil && !errors.Is(err, fs.ErrNotExist) {
845+
return err
846+
}
847+
return nil
836848
}); chownErr != nil {
837849
opts.Logger(log.LevelError, "chown %q: %s", execArgs.UserInfo.user.HomeDir, chownErr.Error())
838850
endStage("⚠️ Failed to update ownership of %s, you may need to fix this manually!", execArgs.UserInfo.user.HomeDir)

0 commit comments

Comments
 (0)