Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/content-service/pkg/initializer/initializer.go
2499 views
1
// Copyright (c) 2020 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package initializer
6
7
import (
8
"context"
9
"encoding/json"
10
"errors"
11
"fmt"
12
"io"
13
"io/fs"
14
"net/http"
15
"os"
16
"path/filepath"
17
"strings"
18
"syscall"
19
"time"
20
21
"github.com/opencontainers/go-digest"
22
"github.com/opentracing/opentracing-go"
23
"golang.org/x/xerrors"
24
"google.golang.org/grpc/codes"
25
"google.golang.org/grpc/status"
26
27
"github.com/gitpod-io/gitpod/common-go/log"
28
"github.com/gitpod-io/gitpod/common-go/tracing"
29
csapi "github.com/gitpod-io/gitpod/content-service/api"
30
"github.com/gitpod-io/gitpod/content-service/pkg/archive"
31
"github.com/gitpod-io/gitpod/content-service/pkg/git"
32
"github.com/gitpod-io/gitpod/content-service/pkg/storage"
33
)
34
35
const (
36
// WorkspaceReadyFile is the name of the ready file we're placing in a workspace
37
WorkspaceReadyFile = ".gitpod/ready"
38
39
// GitpodUID is the user ID of the gitpod user
40
GitpodUID = 33333
41
42
// GitpodGID is the group ID of the gitpod user group
43
GitpodGID = 33333
44
45
// otsDownloadAttempts is the number of times we'll attempt to download the one-time secret
46
otsDownloadAttempts = 20
47
)
48
49
// Initializer can initialize a workspace with content
50
type Initializer interface {
51
Run(ctx context.Context, mappings []archive.IDMapping) (csapi.WorkspaceInitSource, csapi.InitializerMetrics, error)
52
}
53
54
// EmptyInitializer does nothing
55
type EmptyInitializer struct{}
56
57
// Run does nothing
58
func (e *EmptyInitializer) Run(ctx context.Context, mappings []archive.IDMapping) (csapi.WorkspaceInitSource, csapi.InitializerMetrics, error) {
59
return csapi.WorkspaceInitFromOther, nil, nil
60
}
61
62
// CompositeInitializer does nothing
63
type CompositeInitializer []Initializer
64
65
// Run calls run on all child initializers
66
func (e CompositeInitializer) Run(ctx context.Context, mappings []archive.IDMapping) (_ csapi.WorkspaceInitSource, _ csapi.InitializerMetrics, err error) {
67
span, ctx := opentracing.StartSpanFromContext(ctx, "CompositeInitializer.Run")
68
defer tracing.FinishSpan(span, &err)
69
start := time.Now()
70
initialSize, fsErr := getFsUsage()
71
if fsErr != nil {
72
log.WithError(fsErr).Error("could not get disk usage")
73
}
74
75
total := []csapi.InitializerMetric{}
76
for _, init := range e {
77
_, stats, err := init.Run(ctx, mappings)
78
if err != nil {
79
return csapi.WorkspaceInitFromOther, nil, err
80
}
81
total = append(total, stats...)
82
}
83
84
if fsErr == nil {
85
currentSize, fsErr := getFsUsage()
86
if fsErr != nil {
87
log.WithError(fsErr).Error("could not get disk usage")
88
}
89
90
total = append(total, csapi.InitializerMetric{
91
Type: "composite",
92
Duration: time.Since(start),
93
Size: currentSize - initialSize,
94
})
95
}
96
97
return csapi.WorkspaceInitFromOther, total, nil
98
}
99
100
// NewFromRequestOpts configures the initializer produced from a content init request
101
type NewFromRequestOpts struct {
102
// ForceGitpodUserForGit forces gitpod:gitpod ownership on all files produced by the Git initializer.
103
// For FWB workspaces the content init is run from supervisor which runs as UID 0. Using this flag, the
104
// Git content is forced to the Gitpod user. All other content (backup, prebuild, snapshot) will already
105
// have the correct user.
106
ForceGitpodUserForGit bool
107
}
108
109
// NewFromRequest picks the initializer from the request but does not execute it.
110
// Returns gRPC errors.
111
func NewFromRequest(ctx context.Context, loc string, rs storage.DirectDownloader, req *csapi.WorkspaceInitializer, opts NewFromRequestOpts) (i Initializer, err error) {
112
//nolint:ineffassign,staticcheck
113
span, ctx := opentracing.StartSpanFromContext(ctx, "NewFromRequest")
114
defer tracing.FinishSpan(span, &err)
115
span.LogKV("opts", opts)
116
117
spec := req.Spec
118
var initializer Initializer
119
if _, ok := spec.(*csapi.WorkspaceInitializer_Empty); ok {
120
initializer = &EmptyInitializer{}
121
} else if ir, ok := spec.(*csapi.WorkspaceInitializer_Composite); ok {
122
initializers := make([]Initializer, len(ir.Composite.Initializer))
123
for i, init := range ir.Composite.Initializer {
124
initializers[i], err = NewFromRequest(ctx, loc, rs, init, opts)
125
if err != nil {
126
return nil, err
127
}
128
}
129
initializer = CompositeInitializer(initializers)
130
} else if ir, ok := spec.(*csapi.WorkspaceInitializer_Git); ok {
131
if ir.Git == nil {
132
return nil, status.Error(codes.InvalidArgument, "missing Git initializer spec")
133
}
134
135
initializer, err = newGitInitializer(ctx, loc, ir.Git, opts.ForceGitpodUserForGit)
136
} else if ir, ok := spec.(*csapi.WorkspaceInitializer_Prebuild); ok {
137
if ir.Prebuild == nil {
138
return nil, status.Error(codes.InvalidArgument, "missing prebuild initializer spec")
139
}
140
var snapshot *SnapshotInitializer
141
if ir.Prebuild.Prebuild != nil {
142
snapshot, err = newSnapshotInitializer(loc, rs, ir.Prebuild.Prebuild)
143
if err != nil {
144
return nil, status.Error(codes.Internal, fmt.Sprintf("cannot setup prebuild init: %v", err))
145
}
146
}
147
var gits []*GitInitializer
148
for _, gi := range ir.Prebuild.Git {
149
gitinit, err := newGitInitializer(ctx, loc, gi, opts.ForceGitpodUserForGit)
150
if err != nil {
151
return nil, err
152
}
153
gits = append(gits, gitinit)
154
}
155
initializer = &PrebuildInitializer{
156
Prebuild: snapshot,
157
Git: gits,
158
}
159
} else if ir, ok := spec.(*csapi.WorkspaceInitializer_Snapshot); ok {
160
initializer, err = newSnapshotInitializer(loc, rs, ir.Snapshot)
161
} else if ir, ok := spec.(*csapi.WorkspaceInitializer_Download); ok {
162
initializer, err = newFileDownloadInitializer(loc, ir.Download)
163
} else if ir, ok := spec.(*csapi.WorkspaceInitializer_Backup); ok {
164
initializer, err = newFromBackupInitializer(loc, rs, ir.Backup)
165
} else {
166
initializer = &EmptyInitializer{}
167
}
168
if err != nil {
169
return nil, status.Error(codes.InvalidArgument, err.Error())
170
}
171
return initializer, nil
172
}
173
174
// newFileDownloadInitializer creates a download initializer for a request
175
func newFileDownloadInitializer(loc string, req *csapi.FileDownloadInitializer) (*fileDownloadInitializer, error) {
176
fileInfos := make([]fileInfo, len(req.Files))
177
for i, f := range req.Files {
178
dgst, err := digest.Parse(f.Digest)
179
if err != nil {
180
return nil, xerrors.Errorf("invalid digest %s: %w", f.Digest, err)
181
}
182
fileInfos[i] = fileInfo{
183
URL: f.Url,
184
Path: f.FilePath,
185
Digest: dgst,
186
}
187
}
188
initializer := &fileDownloadInitializer{
189
FilesInfos: fileInfos,
190
TargetLocation: filepath.Join(loc, req.TargetLocation),
191
HTTPClient: http.DefaultClient,
192
RetryTimeout: 1 * time.Second,
193
}
194
return initializer, nil
195
}
196
197
// newFromBackupInitializer creates a backup restoration initializer for a request
198
func newFromBackupInitializer(loc string, rs storage.DirectDownloader, req *csapi.FromBackupInitializer) (*fromBackupInitializer, error) {
199
return &fromBackupInitializer{
200
Location: loc,
201
RemoteStorage: rs,
202
FromVolumeSnapshot: req.FromVolumeSnapshot,
203
}, nil
204
}
205
206
type fromBackupInitializer struct {
207
Location string
208
RemoteStorage storage.DirectDownloader
209
FromVolumeSnapshot bool
210
}
211
212
func (bi *fromBackupInitializer) Run(ctx context.Context, mappings []archive.IDMapping) (src csapi.WorkspaceInitSource, stats csapi.InitializerMetrics, err error) {
213
if bi.FromVolumeSnapshot {
214
return csapi.WorkspaceInitFromBackup, nil, nil
215
}
216
217
start := time.Now()
218
initialSize, fsErr := getFsUsage()
219
if fsErr != nil {
220
log.WithError(fsErr).Error("could not get disk usage")
221
}
222
223
hasBackup, err := bi.RemoteStorage.Download(ctx, bi.Location, storage.DefaultBackup, mappings)
224
if !hasBackup {
225
if err != nil {
226
return src, nil, xerrors.Errorf("no backup found, error: %w", err)
227
}
228
return src, nil, xerrors.Errorf("no backup found")
229
}
230
if err != nil {
231
return src, nil, xerrors.Errorf("cannot restore backup: %w", err)
232
}
233
234
if fsErr == nil {
235
currentSize, fsErr := getFsUsage()
236
if fsErr != nil {
237
log.WithError(fsErr).Error("could not get disk usage")
238
}
239
240
stats = csapi.InitializerMetrics{csapi.InitializerMetric{
241
Type: "fromBackup",
242
Duration: time.Since(start),
243
Size: currentSize - initialSize,
244
}}
245
}
246
247
return csapi.WorkspaceInitFromBackup, stats, nil
248
}
249
250
// newGitInitializer creates a Git initializer based on the request.
251
// Returns gRPC errors.
252
func newGitInitializer(ctx context.Context, loc string, req *csapi.GitInitializer, forceGitpodUser bool) (*GitInitializer, error) {
253
if req.Config == nil {
254
return nil, status.Error(codes.InvalidArgument, "Git initializer misses config")
255
}
256
257
var targetMode CloneTargetMode
258
switch req.TargetMode {
259
case csapi.CloneTargetMode_LOCAL_BRANCH:
260
targetMode = LocalBranch
261
case csapi.CloneTargetMode_REMOTE_BRANCH:
262
targetMode = RemoteBranch
263
case csapi.CloneTargetMode_REMOTE_COMMIT:
264
targetMode = RemoteCommit
265
case csapi.CloneTargetMode_REMOTE_HEAD:
266
targetMode = RemoteHead
267
default:
268
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("invalid target mode: %v", req.TargetMode))
269
}
270
271
var authMethod = git.BasicAuth
272
if req.Config.Authentication == csapi.GitAuthMethod_NO_AUTH {
273
authMethod = git.NoAuth
274
}
275
276
// the auth provider must cache the OTS because it may be used several times,
277
// but can download the one-time-secret only once.
278
authProvider := git.CachingAuthProvider(func() (user string, pwd string, err error) {
279
switch req.Config.Authentication {
280
case csapi.GitAuthMethod_BASIC_AUTH:
281
user = req.Config.AuthUser
282
pwd = req.Config.AuthPassword
283
case csapi.GitAuthMethod_BASIC_AUTH_OTS:
284
user, pwd, err = downloadOTS(ctx, req.Config.AuthOts)
285
if err != nil {
286
log.WithField("location", loc).WithError(err).Error("cannot download Git auth OTS")
287
return "", "", status.Error(codes.InvalidArgument, "cannot get OTS")
288
}
289
case csapi.GitAuthMethod_NO_AUTH:
290
default:
291
return "", "", status.Error(codes.InvalidArgument, fmt.Sprintf("invalid Git authentication method: %v", req.Config.Authentication))
292
}
293
294
return
295
})
296
297
log.WithField("location", loc).Debug("using Git initializer")
298
return &GitInitializer{
299
Client: git.Client{
300
Location: filepath.Join(loc, req.CheckoutLocation),
301
RemoteURI: req.RemoteUri,
302
UpstreamRemoteURI: req.Upstream_RemoteUri,
303
Config: req.Config.CustomConfig,
304
AuthMethod: authMethod,
305
AuthProvider: authProvider,
306
RunAsGitpodUser: forceGitpodUser,
307
FullClone: req.FullClone,
308
},
309
TargetMode: targetMode,
310
CloneTarget: req.CloneTaget,
311
Chown: false,
312
}, nil
313
}
314
315
func newSnapshotInitializer(loc string, rs storage.DirectDownloader, req *csapi.SnapshotInitializer) (*SnapshotInitializer, error) {
316
return &SnapshotInitializer{
317
Location: loc,
318
Snapshot: req.Snapshot,
319
Storage: rs,
320
FromVolumeSnapshot: req.FromVolumeSnapshot,
321
}, nil
322
}
323
324
func downloadOTS(ctx context.Context, url string) (user, pwd string, err error) {
325
//nolint:ineffassign
326
span, ctx := opentracing.StartSpanFromContext(ctx, "downloadOTS")
327
defer tracing.FinishSpan(span, &err)
328
span.LogKV("url", url)
329
330
dl := func() (user, pwd string, err error) {
331
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
332
if err != nil {
333
return "", "", err
334
}
335
_ = opentracing.GlobalTracer().Inject(span.Context(), opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(req.Header))
336
337
resp, err := http.DefaultClient.Do(req)
338
if err != nil {
339
return "", "", err
340
}
341
defer resp.Body.Close()
342
if resp.StatusCode != http.StatusOK {
343
return "", "", xerrors.Errorf("non-OK OTS response: %s", resp.Status)
344
}
345
346
secret, err := io.ReadAll(resp.Body)
347
if err != nil {
348
return "", "", err
349
}
350
351
pwd = string(secret)
352
if segs := strings.Split(pwd, ":"); len(segs) >= 2 {
353
user = segs[0]
354
pwd = strings.Join(segs[1:], ":")
355
}
356
return
357
}
358
for i := 0; i < otsDownloadAttempts; i++ {
359
span.LogKV("attempt", i)
360
if i > 0 {
361
time.Sleep(time.Second)
362
}
363
364
user, pwd, err = dl()
365
if err == context.Canceled || err == context.DeadlineExceeded {
366
return
367
}
368
if err == nil {
369
break
370
}
371
log.WithError(err).WithField("attempt", i).Warn("cannot download OTS")
372
}
373
if err != nil {
374
log.WithError(err).Warn("failed to download OTS")
375
return "", "", err
376
}
377
378
return user, pwd, nil
379
}
380
381
// InitializeOpt configures the initialisation procedure
382
type InitializeOpt func(*initializeOpts)
383
384
type initializeOpts struct {
385
Initializer Initializer
386
CleanSlate bool
387
UID int
388
GID int
389
mappings []archive.IDMapping
390
}
391
392
// WithMappings configures the UID mappings that're used during content initialization
393
func WithMappings(mappings []archive.IDMapping) InitializeOpt {
394
return func(o *initializeOpts) {
395
o.mappings = mappings
396
}
397
}
398
399
// WithInitializer configures the initializer that's used during content initialization
400
func WithInitializer(initializer Initializer) InitializeOpt {
401
return func(o *initializeOpts) {
402
o.Initializer = initializer
403
}
404
}
405
406
// WithCleanSlate ensures there's no prior content in the workspace location
407
func WithCleanSlate(o *initializeOpts) {
408
o.CleanSlate = true
409
}
410
411
// WithChown sets a custom UID/GID the content will have after initialisation
412
func WithChown(uid, gid int) InitializeOpt {
413
return func(o *initializeOpts) {
414
o.UID = uid
415
o.GID = gid
416
}
417
}
418
419
// InitializeWorkspace initializes a workspace from backup or an initializer
420
func InitializeWorkspace(ctx context.Context, location string, remoteStorage storage.DirectDownloader, opts ...InitializeOpt) (src csapi.WorkspaceInitSource, stats csapi.InitializerMetrics, err error) {
421
//nolint:ineffassign
422
span, ctx := opentracing.StartSpanFromContext(ctx, "InitializeWorkspace")
423
span.SetTag("location", location)
424
defer tracing.FinishSpan(span, &err)
425
426
cfg := initializeOpts{
427
Initializer: &EmptyInitializer{},
428
CleanSlate: false,
429
GID: GitpodGID,
430
UID: GitpodUID,
431
}
432
for _, o := range opts {
433
o(&cfg)
434
}
435
436
src = csapi.WorkspaceInitFromOther
437
438
// Note: it's important that CleanSlate does not remove the location itself, but merely its content.
439
// If the location were removed that might break the filesystem quota we have put in place prior.
440
if cfg.CleanSlate {
441
// 1. Clean out the workspace directory
442
if _, err := os.Stat(location); errors.Is(err, fs.ErrNotExist) {
443
// in the very unlikely event that the workspace Pod did not mount (and thus create) the workspace directory, create it
444
err = os.Mkdir(location, 0755)
445
if os.IsExist(err) {
446
log.WithError(err).WithField("location", location).Debug("ran into non-atomic workspace location existence check")
447
span.SetTag("exists", true)
448
} else if err != nil {
449
return src, nil, xerrors.Errorf("cannot create workspace: %w", err)
450
}
451
}
452
fs, err := os.ReadDir(location)
453
if err != nil {
454
return src, nil, xerrors.Errorf("cannot clean workspace folder: %w", err)
455
}
456
for _, f := range fs {
457
path := filepath.Join(location, f.Name())
458
err := os.RemoveAll(path)
459
if err != nil {
460
return src, nil, xerrors.Errorf("cannot clean workspace folder: %w", err)
461
}
462
}
463
464
// Chown the workspace directory
465
err = os.Chown(location, cfg.UID, cfg.GID)
466
if err != nil {
467
return src, nil, xerrors.Errorf("cannot create workspace: %w", err)
468
}
469
}
470
471
// Try to download a backup first
472
initialSize, fsErr := getFsUsage()
473
if fsErr != nil {
474
log.WithError(fsErr).Error("could not get disk usage")
475
}
476
downloadStart := time.Now()
477
hasBackup, err := remoteStorage.Download(ctx, location, storage.DefaultBackup, cfg.mappings)
478
if err != nil {
479
return src, nil, xerrors.Errorf("cannot restore backup: %w", err)
480
}
481
downloadDuration := time.Since(downloadStart)
482
483
span.SetTag("hasBackup", hasBackup)
484
if hasBackup {
485
src = csapi.WorkspaceInitFromBackup
486
487
currentSize, fsErr := getFsUsage()
488
if fsErr != nil {
489
log.WithError(fsErr).Error("could not get disk usage")
490
}
491
stats = []csapi.InitializerMetric{{
492
Type: "fromBackup",
493
Duration: downloadDuration,
494
Size: currentSize - initialSize,
495
}}
496
return
497
}
498
499
// If there is not backup, run the initializer
500
src, stats, err = cfg.Initializer.Run(ctx, cfg.mappings)
501
if err != nil {
502
return src, nil, xerrors.Errorf("cannot initialize workspace: %w", err)
503
}
504
505
return
506
}
507
508
// Some workspace content may have a `/dst/.gitpod` file or directory. That would break
509
// the workspace ready file placement (see https://github.com/gitpod-io/gitpod/issues/7694).
510
// This function ensures that workspaces do not have a `.gitpod` file or directory present.
511
func EnsureCleanDotGitpodDirectory(ctx context.Context, wspath string) error {
512
var mv func(src, dst string) error
513
if git.IsWorkingCopy(wspath) {
514
c := &git.Client{
515
Location: wspath,
516
}
517
mv = func(src, dst string) error {
518
return c.Git(ctx, "mv", src, dst)
519
}
520
} else {
521
mv = os.Rename
522
}
523
524
dotGitpod := filepath.Join(wspath, ".gitpod")
525
stat, err := os.Stat(dotGitpod)
526
if errors.Is(err, fs.ErrNotExist) {
527
return nil
528
}
529
if stat.IsDir() {
530
// we need this to be a directory, we're probably ok
531
return nil
532
}
533
534
candidateFN := filepath.Join(wspath, ".gitpod.yaml")
535
if _, err := os.Stat(candidateFN); err == nil {
536
// Our candidate file already exists, hence we cannot just move things.
537
// As fallback we'll delete the .gitpod entry.
538
return os.RemoveAll(dotGitpod)
539
}
540
541
err = mv(dotGitpod, candidateFN)
542
if err != nil {
543
return err
544
}
545
546
return nil
547
}
548
549
// PlaceWorkspaceReadyFile writes a file in the workspace which indicates that the workspace has been initialized
550
func PlaceWorkspaceReadyFile(ctx context.Context, wspath string, initsrc csapi.WorkspaceInitSource, metrics csapi.InitializerMetrics, uid, gid int) (err error) {
551
//nolint:ineffassign,staticcheck
552
span, ctx := opentracing.StartSpanFromContext(ctx, "placeWorkspaceReadyFile")
553
span.SetTag("source", initsrc)
554
defer tracing.FinishSpan(span, &err)
555
556
content := csapi.WorkspaceReadyMessage{
557
Source: initsrc,
558
Metrics: metrics,
559
}
560
fc, err := json.Marshal(content)
561
if err != nil {
562
return xerrors.Errorf("cannot marshal workspace ready message: %w", err)
563
}
564
565
gitpodDir := filepath.Join(wspath, filepath.Dir(WorkspaceReadyFile))
566
err = os.MkdirAll(gitpodDir, 0777)
567
if err != nil {
568
return xerrors.Errorf("cannot create directory for workspace ready file: %w", err)
569
}
570
err = os.Chown(gitpodDir, uid, gid)
571
if err != nil {
572
return xerrors.Errorf("cannot chown directory for workspace ready file: %w", err)
573
}
574
575
tempWorkspaceReadyFile := WorkspaceReadyFile + ".tmp"
576
fn := filepath.Join(wspath, tempWorkspaceReadyFile)
577
err = os.WriteFile(fn, []byte(fc), 0644)
578
if err != nil {
579
return xerrors.Errorf("cannot write workspace ready file content: %w", err)
580
}
581
err = os.Chown(fn, uid, gid)
582
if err != nil {
583
return xerrors.Errorf("cannot chown workspace ready file: %w", err)
584
}
585
586
// Theia will listen for a rename event as trigger to start the tasks. This is a rename event
587
// because we're writing to the file and this is the most convenient way we can tell Theia that we're done writing.
588
err = os.Rename(fn, filepath.Join(wspath, WorkspaceReadyFile))
589
if err != nil {
590
return xerrors.Errorf("cannot rename workspace ready file: %w", err)
591
}
592
593
log.WithField("content", string(fc)).WithField("destination", wspath).Info("ready file metrics")
594
595
return nil
596
}
597
598
func getFsUsage() (uint64, error) {
599
var stat syscall.Statfs_t
600
601
err := syscall.Statfs("/dst", &stat)
602
if os.IsNotExist(err) {
603
err = syscall.Statfs("/workspace", &stat)
604
}
605
606
if err != nil {
607
return 0, err
608
}
609
610
size := uint64(stat.Blocks) * uint64(stat.Bsize)
611
free := uint64(stat.Bfree) * uint64(stat.Bsize)
612
613
return size - free, nil
614
}
615
616