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