Skip to content

Commit ba8a3bf

Browse files
authored
feat: add support for devcontainer features (#19)
1 parent 66aa804 commit ba8a3bf

File tree

15 files changed

+1034
-363
lines changed

15 files changed

+1034
-363
lines changed

.github/workflows/ci.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ name: ci
33
on:
44
push:
55

6-
pull_request:
7-
86
workflow_dispatch:
97

108
permissions:

cmd/envbuilder/main.go

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"crypto/tls"
56
"errors"
67
"fmt"
@@ -9,6 +10,7 @@ import (
910
"os"
1011
"time"
1112

13+
"cdr.dev/slog"
1214
"github.com/coder/coder/codersdk"
1315
"github.com/coder/coder/codersdk/agentsdk"
1416
"github.com/coder/envbuilder"
@@ -30,7 +32,7 @@ func main() {
3032
RunE: func(cmd *cobra.Command, args []string) error {
3133
options := envbuilder.OptionsFromEnv(os.Getenv)
3234

33-
var sendLogs func(log agentsdk.StartupLog)
35+
var sendLogs func(ctx context.Context, log ...agentsdk.StartupLog) error
3436
agentURL := os.Getenv("CODER_AGENT_URL")
3537
agentToken := os.Getenv("CODER_AGENT_TOKEN")
3638
if agentToken != "" {
@@ -50,21 +52,16 @@ func main() {
5052
},
5153
},
5254
}
53-
var flushAndClose func()
54-
sendLogs, flushAndClose, err = envbuilder.SendLogsToCoder(cmd.Context(), client, func(format string, args ...any) {
55-
fmt.Fprintf(cmd.ErrOrStderr(), format, args...)
56-
})
57-
if err != nil {
58-
return err
59-
}
60-
defer flushAndClose()
55+
var flushAndClose func(ctx context.Context) error
56+
sendLogs, flushAndClose = agentsdk.StartupLogsSender(client.PatchStartupLogs, slog.Logger{})
57+
defer flushAndClose(cmd.Context())
6158
}
6259

6360
options.Logger = func(level codersdk.LogLevel, format string, args ...interface{}) {
6461
output := fmt.Sprintf(format, args...)
6562
fmt.Fprintln(cmd.ErrOrStderr(), output)
6663
if sendLogs != nil {
67-
sendLogs(agentsdk.StartupLog{
64+
sendLogs(cmd.Context(), agentsdk.StartupLog{
6865
CreatedAt: time.Now(),
6966
Output: output,
7067
Level: level,

devcontainer.go

Lines changed: 0 additions & 85 deletions
This file was deleted.

devcontainer/devcontainer.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package devcontainer
2+
3+
import (
4+
"crypto/md5"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/GoogleContainerTools/kaniko/pkg/creds"
12+
"github.com/coder/envbuilder/devcontainer/features"
13+
"github.com/go-git/go-billy/v5"
14+
"github.com/google/go-containerregistry/pkg/name"
15+
"github.com/google/go-containerregistry/pkg/v1/remote"
16+
"muzzammil.xyz/jsonc"
17+
)
18+
19+
// Parse parses a devcontainer.json file.
20+
func Parse(content []byte) (*Spec, error) {
21+
content = jsonc.ToJSON(content)
22+
var schema Spec
23+
return &schema, jsonc.Unmarshal(content, &schema)
24+
}
25+
26+
type Spec struct {
27+
Image string `json:"image"`
28+
Build BuildSpec `json:"build"`
29+
RemoteUser string `json:"remoteUser"`
30+
RemoteEnv map[string]string `json:"remoteEnv"`
31+
// Features is a map of feature names to feature configurations.
32+
Features map[string]map[string]any `json:"features"`
33+
34+
// Deprecated but still frequently used...
35+
Dockerfile string `json:"dockerFile"`
36+
Context string `json:"context"`
37+
}
38+
39+
type BuildSpec struct {
40+
Dockerfile string `json:"dockerfile"`
41+
Context string `json:"context"`
42+
Args map[string]string `json:"args"`
43+
Target string `json:"target"`
44+
CacheFrom string `json:"cache_from"`
45+
}
46+
47+
// Compiled is the result of compiling a devcontainer.json file.
48+
type Compiled struct {
49+
DockerfilePath string
50+
DockerfileContent string
51+
BuildContext string
52+
BuildArgs []string
53+
54+
User string
55+
Env []string
56+
}
57+
58+
// Compile returns the build parameters for the workspace.
59+
// devcontainerDir is the path to the directory where the devcontainer.json file
60+
// is located. scratchDir is the path to the directory where the Dockerfile will
61+
// be written to if one doesn't exist.
62+
func (s *Spec) Compile(fs billy.Filesystem, devcontainerDir, scratchDir string) (*Compiled, error) {
63+
env := make([]string, 0)
64+
for key, value := range s.RemoteEnv {
65+
env = append(env, key+"="+value)
66+
}
67+
params := &Compiled{
68+
User: s.RemoteUser,
69+
Env: env,
70+
}
71+
72+
if s.Image != "" {
73+
// We just write the image to a file and return it.
74+
dockerfilePath := filepath.Join(scratchDir, "Dockerfile")
75+
file, err := fs.OpenFile(dockerfilePath, os.O_CREATE|os.O_WRONLY, 0644)
76+
if err != nil {
77+
return nil, fmt.Errorf("open dockerfile: %w", err)
78+
}
79+
defer file.Close()
80+
_, err = file.Write([]byte("FROM " + s.Image))
81+
if err != nil {
82+
return nil, err
83+
}
84+
params.DockerfilePath = dockerfilePath
85+
params.BuildContext = scratchDir
86+
} else {
87+
// Deprecated values!
88+
if s.Dockerfile != "" {
89+
s.Build.Dockerfile = s.Dockerfile
90+
}
91+
if s.Context != "" {
92+
s.Build.Context = s.Context
93+
}
94+
95+
params.DockerfilePath = filepath.Join(devcontainerDir, s.Build.Dockerfile)
96+
params.BuildContext = filepath.Join(devcontainerDir, s.Build.Context)
97+
}
98+
buildArgs := make([]string, 0)
99+
for key, value := range s.Build.Args {
100+
buildArgs = append(buildArgs, key+"="+value)
101+
}
102+
params.BuildArgs = buildArgs
103+
104+
dockerfile, err := fs.Open(params.DockerfilePath)
105+
if err != nil {
106+
return nil, fmt.Errorf("open dockerfile %q: %w", params.DockerfilePath, err)
107+
}
108+
defer dockerfile.Close()
109+
dockerfileContent, err := io.ReadAll(dockerfile)
110+
if err != nil {
111+
return nil, err
112+
}
113+
params.DockerfileContent = string(dockerfileContent)
114+
115+
if params.User == "" {
116+
// We should make a best-effort attempt to find the user.
117+
// Features must be executed as root, so we need to swap back
118+
// to the running user afterwards.
119+
params.User = UserFromDockerfile(params.DockerfileContent)
120+
}
121+
if params.User == "" {
122+
image := ImageFromDockerfile(params.DockerfileContent)
123+
imageRef, err := name.ParseReference(image)
124+
if err != nil {
125+
return nil, fmt.Errorf("parse image from dockerfile %q: %w", image, err)
126+
}
127+
params.User, err = UserFromImage(imageRef)
128+
if err != nil {
129+
return nil, fmt.Errorf("get user from image %q: %w", image, err)
130+
}
131+
}
132+
params.DockerfileContent, err = s.compileFeatures(fs, scratchDir, params.User, params.DockerfileContent)
133+
if err != nil {
134+
return nil, err
135+
}
136+
return params, nil
137+
}
138+
139+
func (s *Spec) compileFeatures(fs billy.Filesystem, scratchDir, remoteUser, dockerfileContent string) (string, error) {
140+
// If there are no features, we don't need to do anything!
141+
if len(s.Features) == 0 {
142+
return dockerfileContent, nil
143+
}
144+
145+
featuresDir := filepath.Join(scratchDir, "features")
146+
err := fs.MkdirAll(featuresDir, 0644)
147+
if err != nil {
148+
return "", fmt.Errorf("create features directory: %w", err)
149+
}
150+
featureDirectives := []string{}
151+
for featureRef, featureOpts := range s.Features {
152+
// It's important for caching that this directory is static.
153+
// If it changes on each run then the container will not be cached.
154+
//
155+
// devcontainers/cli has a very complex method of computing the feature
156+
// name from the feature reference. We're just going to hash it for simplicity.
157+
featureSha := md5.Sum([]byte(featureRef))
158+
featureName := strings.Split(filepath.Base(featureRef), ":")[0]
159+
featureDir := filepath.Join(featuresDir, fmt.Sprintf("%s-%x", featureName, featureSha[:4]))
160+
err = fs.MkdirAll(featureDir, 0644)
161+
if err != nil {
162+
return "", err
163+
}
164+
spec, err := features.Extract(fs, featureDir, featureRef)
165+
if err != nil {
166+
return "", fmt.Errorf("extract feature %s: %w", featureRef, err)
167+
}
168+
directive, err := spec.Compile(featureOpts)
169+
if err != nil {
170+
return "", fmt.Errorf("compile feature %s: %w", featureRef, err)
171+
}
172+
featureDirectives = append(featureDirectives, directive)
173+
}
174+
175+
lines := []string{"\nUSER root"}
176+
lines = append(lines, featureDirectives...)
177+
if remoteUser != "" {
178+
// TODO: We should warn that because we were unable to find the remote user,
179+
// we're going to run as root.
180+
lines = append(lines, fmt.Sprintf("USER %s", remoteUser))
181+
}
182+
return strings.Join(append([]string{dockerfileContent}, lines...), "\n"), err
183+
}
184+
185+
// UserFromDockerfile inspects the contents of a provided Dockerfile
186+
// and returns the user that will be used to run the container.
187+
func UserFromDockerfile(dockerfileContent string) string {
188+
lines := strings.Split(dockerfileContent, "\n")
189+
// Iterate over lines in reverse
190+
for i := len(lines) - 1; i >= 0; i-- {
191+
line := lines[i]
192+
if !strings.HasPrefix(line, "USER ") {
193+
continue
194+
}
195+
return strings.TrimSpace(strings.TrimPrefix(line, "USER "))
196+
}
197+
return ""
198+
}
199+
200+
// ImageFromDockerfile inspects the contents of a provided Dockerfile
201+
// and returns the image that will be used to run the container.
202+
func ImageFromDockerfile(dockerfileContent string) string {
203+
lines := strings.Split(dockerfileContent, "\n")
204+
// Iterate over lines in reverse
205+
for i := len(lines) - 1; i >= 0; i-- {
206+
line := lines[i]
207+
if !strings.HasPrefix(line, "FROM ") {
208+
continue
209+
}
210+
return strings.TrimSpace(strings.TrimPrefix(line, "FROM "))
211+
}
212+
return ""
213+
}
214+
215+
// UserFromImage inspects the remote reference and returns the user
216+
// that will be used to run the container.
217+
func UserFromImage(ref name.Reference) (string, error) {
218+
image, err := remote.Image(ref, remote.WithAuthFromKeychain(creds.GetKeychain()))
219+
if err != nil {
220+
return "", fmt.Errorf("fetch image %s: %w", ref.Name(), err)
221+
}
222+
config, err := image.ConfigFile()
223+
if err != nil {
224+
return "", fmt.Errorf("fetch config %s: %w", ref.Name(), err)
225+
}
226+
return config.Config.User, nil
227+
}

0 commit comments

Comments
 (0)