-
Notifications
You must be signed in to change notification settings - Fork 39
/
Copy pathdevcontainer.go
362 lines (331 loc) · 11.1 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
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
package devcontainer
import (
"crypto/md5"
"encoding/json"
"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"
"github.com/tailscale/hujson"
)
// Parse parses a devcontainer.json file.
func Parse(content []byte) (*Spec, error) {
content, err := hujson.Standardize(content)
if err != nil {
return nil, fmt.Errorf("standardize json: %w", err)
}
var schema Spec
return &schema, json.Unmarshal(content, &schema)
}
type Spec struct {
Image string `json:"image"`
Build BuildSpec `json:"build"`
RemoteUser string `json:"remoteUser"`
ContainerUser string `json:"containerUser"`
ContainerEnv map[string]string `json:"containerEnv"`
RemoteEnv map[string]string `json:"remoteEnv"`
// Features is a map of feature names to feature configurations.
Features map[string]any `json:"features"`
LifecycleScripts
// Deprecated but still frequently used...
Dockerfile string `json:"dockerFile"`
Context string `json:"context"`
}
type LifecycleScripts struct {
OnCreateCommand LifecycleScript `json:"onCreateCommand"`
UpdateContentCommand LifecycleScript `json:"updateContentCommand"`
PostCreateCommand LifecycleScript `json:"postCreateCommand"`
PostStartCommand LifecycleScript `json:"postStartCommand"`
}
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
ContainerEnv map[string]string
RemoteEnv map[string]string
}
func SubstituteVars(s string, workspaceFolder string) string {
var buf string
for {
beforeOpen, afterOpen, ok := strings.Cut(s, "${")
if !ok {
return buf + s
}
varExpr, afterClose, ok := strings.Cut(afterOpen, "}")
if !ok {
return buf + s
}
buf += beforeOpen + substitute(varExpr, workspaceFolder)
s = afterClose
}
}
// Spec for variable substitutions:
// https://containers.dev/implementors/json_reference/#variables-in-devcontainerjson
func substitute(varExpr string, workspaceFolder string) string {
parts := strings.Split(varExpr, ":")
if len(parts) == 1 {
switch varExpr {
case "localWorkspaceFolder", "containerWorkspaceFolder":
return workspaceFolder
case "localWorkspaceFolderBasename", "containerWorkspaceFolderBasename":
return filepath.Base(workspaceFolder)
default:
return os.Getenv(varExpr)
}
}
switch parts[0] {
case "env", "localEnv", "containerEnv":
if val, ok := os.LookupEnv(parts[1]); ok {
return val
}
if len(parts) == 3 {
return parts[2]
}
}
return ""
}
// HasImage returns true if the devcontainer.json specifies an image.
func (s Spec) HasImage() bool {
return s.Image != ""
}
// HasDockerfile 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, workspaceFolder string) (*Compiled, error) {
params := &Compiled{
User: s.ContainerUser,
ContainerEnv: s.ContainerEnv,
RemoteEnv: s.RemoteEnv,
}
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 {
val := SubstituteVars(s.Build.Args[key], workspaceFolder)
buildArgs = append(buildArgs, key+"="+val)
}
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)
}
}
remoteUser := s.RemoteUser
if remoteUser == "" {
remoteUser = params.User
}
params.DockerfileContent, err = s.compileFeatures(fs, devcontainerDir, scratchDir, params.User, remoteUser, params.DockerfileContent)
if err != nil {
return nil, err
}
return params, nil
}
func (s *Spec) compileFeatures(fs billy.Filesystem, devcontainerDir, scratchDir, containerUser, 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 _, featureRefRaw := range featureOrder {
var (
featureRef string
ok bool
)
if _, featureRef, ok = strings.Cut(featureRefRaw, "./"); !ok {
featureRefParsed, err := name.NewTag(featureRefRaw)
if err != nil {
return "", fmt.Errorf("parse feature ref %s: %w", featureRefRaw, err)
}
featureRef = featureRefParsed.Repository.Name()
}
featureOpts := map[string]any{}
switch t := s.Features[featureRefRaw].(type) {
case string:
// As a shorthand, the value of the `features`` property can be provided as a
// single string. This string is mapped to an option called version.
// https://containers.dev/implementors/features/#devcontainer-json-properties
featureOpts["version"] = t
case map[string]any:
featureOpts = t
}
// 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(featureRefRaw))
featureName := filepath.Base(featureRef)
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, devcontainerDir, featureDir, featureRefRaw)
if err != nil {
return "", fmt.Errorf("extract feature %s: %w", featureRefRaw, err)
}
directive, err := spec.Compile(containerUser, remoteUser, featureOpts)
if err != nil {
return "", fmt.Errorf("compile feature %s: %w", featureRefRaw, 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) {
args := map[string]string{}
var imageRef 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, "ARG ") {
arg := strings.TrimSpace(strings.TrimPrefix(line, "ARG "))
if strings.Contains(arg, "=") {
parts := strings.SplitN(arg, "=", 2)
args[parts[0]] = parts[1]
}
continue
}
if imageRef == "" && strings.HasPrefix(line, "FROM ") {
imageRef = strings.TrimPrefix(line, "FROM ")
}
}
if imageRef == "" {
return nil, fmt.Errorf("no FROM directive found")
}
image, err := name.ParseReference(os.Expand(imageRef, func(s string) string {
return args[s]
}))
if err != nil {
return nil, fmt.Errorf("parse image ref %q: %w", imageRef, err)
}
return image, nil
}
// 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
}