19099: Fix typos.
[arvados.git] / lib / crunchrun / singularity.go
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 package crunchrun
6
7 import (
8         "bytes"
9         "errors"
10         "fmt"
11         "io/ioutil"
12         "net"
13         "os"
14         "os/exec"
15         "os/user"
16         "regexp"
17         "sort"
18         "strconv"
19         "syscall"
20         "time"
21
22         "git.arvados.org/arvados.git/sdk/go/arvados"
23         "golang.org/x/net/context"
24 )
25
26 type singularityExecutor struct {
27         logf          func(string, ...interface{})
28         fakeroot      bool // use --fakeroot flag, allow --network=bridge when non-root (currently only used by tests)
29         spec          containerSpec
30         tmpdir        string
31         child         *exec.Cmd
32         imageFilename string // "sif" image
33 }
34
35 func newSingularityExecutor(logf func(string, ...interface{})) (*singularityExecutor, error) {
36         tmpdir, err := ioutil.TempDir("", "crunch-run-singularity-")
37         if err != nil {
38                 return nil, err
39         }
40         return &singularityExecutor{
41                 logf:   logf,
42                 tmpdir: tmpdir,
43         }, nil
44 }
45
46 func (e *singularityExecutor) Runtime() string { return "singularity" }
47
48 func (e *singularityExecutor) getOrCreateProject(ownerUuid string, name string, containerClient *arvados.Client) (*arvados.Group, error) {
49         var gp arvados.GroupList
50         err := containerClient.RequestAndDecode(&gp,
51                 arvados.EndpointGroupList.Method,
52                 arvados.EndpointGroupList.Path,
53                 nil, arvados.ListOptions{Filters: []arvados.Filter{
54                         arvados.Filter{"owner_uuid", "=", ownerUuid},
55                         arvados.Filter{"name", "=", name},
56                         arvados.Filter{"group_class", "=", "project"},
57                 },
58                         Limit: 1})
59         if err != nil {
60                 return nil, err
61         }
62         if len(gp.Items) == 1 {
63                 return &gp.Items[0], nil
64         }
65
66         var rgroup arvados.Group
67         err = containerClient.RequestAndDecode(&rgroup,
68                 arvados.EndpointGroupCreate.Method,
69                 arvados.EndpointGroupCreate.Path,
70                 nil, map[string]interface{}{
71                         "group": map[string]string{
72                                 "owner_uuid":  ownerUuid,
73                                 "name":        name,
74                                 "group_class": "project",
75                         },
76                 })
77         if err != nil {
78                 return nil, err
79         }
80         return &rgroup, nil
81 }
82
83 func (e *singularityExecutor) checkImageCache(dockerImageID string, container arvados.Container, arvMountPoint string,
84         containerClient *arvados.Client) (collection *arvados.Collection, err error) {
85
86         // Cache the image to keep
87         cacheGroup, err := e.getOrCreateProject(container.RuntimeUserUUID, ".cache", containerClient)
88         if err != nil {
89                 return nil, fmt.Errorf("error getting '.cache' project: %v", err)
90         }
91         imageGroup, err := e.getOrCreateProject(cacheGroup.UUID, "auto-generated singularity images", containerClient)
92         if err != nil {
93                 return nil, fmt.Errorf("error getting 'auto-generated singularity images' project: %s", err)
94         }
95
96         collectionName := fmt.Sprintf("singularity image for %v", dockerImageID)
97         var cl arvados.CollectionList
98         err = containerClient.RequestAndDecode(&cl,
99                 arvados.EndpointCollectionList.Method,
100                 arvados.EndpointCollectionList.Path,
101                 nil, arvados.ListOptions{Filters: []arvados.Filter{
102                         arvados.Filter{"owner_uuid", "=", imageGroup.UUID},
103                         arvados.Filter{"name", "=", collectionName},
104                 },
105                         Limit: 1})
106         if err != nil {
107                 return nil, fmt.Errorf("error querying for collection '%v': %v", collectionName, err)
108         }
109         var imageCollection arvados.Collection
110         if len(cl.Items) == 1 {
111                 imageCollection = cl.Items[0]
112         } else {
113                 collectionName := "converting " + collectionName
114                 exp := time.Now().Add(24 * 7 * 2 * time.Hour)
115                 err = containerClient.RequestAndDecode(&imageCollection,
116                         arvados.EndpointCollectionCreate.Method,
117                         arvados.EndpointCollectionCreate.Path,
118                         nil, map[string]interface{}{
119                                 "collection": map[string]string{
120                                         "owner_uuid": imageGroup.UUID,
121                                         "name":       collectionName,
122                                         "trash_at":   exp.UTC().Format(time.RFC3339),
123                                 },
124                                 "ensure_unique_name": true,
125                         })
126                 if err != nil {
127                         return nil, fmt.Errorf("error creating '%v' collection: %s", collectionName, err)
128                 }
129
130         }
131
132         return &imageCollection, nil
133 }
134
135 // LoadImage will satisfy ContainerExecuter interface transforming
136 // containerImage into a sif file for later use.
137 func (e *singularityExecutor) LoadImage(dockerImageID string, imageTarballPath string, container arvados.Container, arvMountPoint string,
138         containerClient *arvados.Client) error {
139
140         var imageFilename string
141         var sifCollection *arvados.Collection
142         var err error
143         if containerClient != nil {
144                 sifCollection, err = e.checkImageCache(dockerImageID, container, arvMountPoint, containerClient)
145                 if err != nil {
146                         return err
147                 }
148                 imageFilename = fmt.Sprintf("%s/by_uuid/%s/image.sif", arvMountPoint, sifCollection.UUID)
149         } else {
150                 imageFilename = e.tmpdir + "/image.sif"
151         }
152
153         if _, err := os.Stat(imageFilename); os.IsNotExist(err) {
154                 // Make sure the docker image is readable, and error
155                 // out if not.
156                 if _, err := os.Stat(imageTarballPath); err != nil {
157                         return err
158                 }
159
160                 e.logf("building singularity image")
161                 // "singularity build" does not accept a
162                 // docker-archive://... filename containing a ":" character,
163                 // as in "/path/to/sha256:abcd...1234.tar". Workaround: make a
164                 // symlink that doesn't have ":" chars.
165                 err := os.Symlink(imageTarballPath, e.tmpdir+"/image.tar")
166                 if err != nil {
167                         return err
168                 }
169
170                 // Set up a cache and tmp dir for singularity build
171                 err = os.Mkdir(e.tmpdir+"/cache", 0700)
172                 if err != nil {
173                         return err
174                 }
175                 defer os.RemoveAll(e.tmpdir + "/cache")
176                 err = os.Mkdir(e.tmpdir+"/tmp", 0700)
177                 if err != nil {
178                         return err
179                 }
180                 defer os.RemoveAll(e.tmpdir + "/tmp")
181
182                 build := exec.Command("singularity", "build", imageFilename, "docker-archive://"+e.tmpdir+"/image.tar")
183                 build.Env = os.Environ()
184                 build.Env = append(build.Env, "SINGULARITY_CACHEDIR="+e.tmpdir+"/cache")
185                 build.Env = append(build.Env, "SINGULARITY_TMPDIR="+e.tmpdir+"/tmp")
186                 e.logf("%v", build.Args)
187                 out, err := build.CombinedOutput()
188                 // INFO:    Starting build...
189                 // Getting image source signatures
190                 // Copying blob ab15617702de done
191                 // Copying config 651e02b8a2 done
192                 // Writing manifest to image destination
193                 // Storing signatures
194                 // 2021/04/22 14:42:14  info unpack layer: sha256:21cbfd3a344c52b197b9fa36091e66d9cbe52232703ff78d44734f85abb7ccd3
195                 // INFO:    Creating SIF file...
196                 // INFO:    Build complete: arvados-jobs.latest.sif
197                 e.logf("%s", out)
198                 if err != nil {
199                         return err
200                 }
201         }
202
203         if containerClient == nil {
204                 e.imageFilename = imageFilename
205                 return nil
206         }
207
208         // update TTL to now + two weeks
209         exp := time.Now().Add(24 * 7 * 2 * time.Hour)
210
211         uuidPath, err := containerClient.PathForUUID("update", sifCollection.UUID)
212         if err != nil {
213                 e.logf("error PathForUUID: %v", err)
214                 return nil
215         }
216         var imageCollection arvados.Collection
217         err = containerClient.RequestAndDecode(&imageCollection,
218                 arvados.EndpointCollectionUpdate.Method,
219                 uuidPath,
220                 nil, map[string]interface{}{
221                         "collection": map[string]string{
222                                 "name":     fmt.Sprintf("singularity image for %v", dockerImageID),
223                                 "trash_at": exp.UTC().Format(time.RFC3339),
224                         },
225                 })
226         if err == nil {
227                 // If we just wrote the image to the cache, the
228                 // response also returns the updated PDH
229                 e.imageFilename = fmt.Sprintf("%s/by_id/%s/image.sif", arvMountPoint, imageCollection.PortableDataHash)
230                 return nil
231         }
232
233         e.logf("error updating/renaming collection for cached sif image: %v", err)
234         // Failed to update but maybe it lost a race and there is
235         // another cached collection in the same place, so check the cache
236         // again
237         sifCollection, err = e.checkImageCache(dockerImageID, container, arvMountPoint, containerClient)
238         if err != nil {
239                 return err
240         }
241         e.imageFilename = fmt.Sprintf("%s/by_id/%s/image.sif", arvMountPoint, sifCollection.PortableDataHash)
242
243         return nil
244 }
245
246 func (e *singularityExecutor) Create(spec containerSpec) error {
247         e.spec = spec
248         return nil
249 }
250
251 func (e *singularityExecutor) execCmd(path string) *exec.Cmd {
252         args := []string{path, "exec", "--containall", "--cleanenv", "--pwd=" + e.spec.WorkingDir}
253         if e.fakeroot {
254                 args = append(args, "--fakeroot")
255         }
256         if !e.spec.EnableNetwork {
257                 args = append(args, "--net", "--network=none")
258         } else if u, err := user.Current(); err == nil && u.Uid == "0" || e.fakeroot {
259                 // Specifying --network=bridge fails unless (a) we are
260                 // root, (b) we are using --fakeroot, or (c)
261                 // singularity has been configured to allow our
262                 // uid/gid to use it like so:
263                 //
264                 // singularity config global --set 'allow net networks' bridge
265                 // singularity config global --set 'allow net groups' mygroup
266                 args = append(args, "--net", "--network=bridge")
267         }
268         if e.spec.CUDADeviceCount != 0 {
269                 args = append(args, "--nv")
270         }
271
272         readonlyflag := map[bool]string{
273                 false: "rw",
274                 true:  "ro",
275         }
276         var binds []string
277         for path, _ := range e.spec.BindMounts {
278                 binds = append(binds, path)
279         }
280         sort.Strings(binds)
281         for _, path := range binds {
282                 mount := e.spec.BindMounts[path]
283                 if path == e.spec.Env["HOME"] {
284                         // Singularity treats $HOME as special case
285                         args = append(args, "--home", mount.HostPath+":"+path)
286                 } else {
287                         args = append(args, "--bind", mount.HostPath+":"+path+":"+readonlyflag[mount.ReadOnly])
288                 }
289         }
290
291         // This is for singularity 3.5.2. There are some behaviors
292         // that will change in singularity 3.6, please see:
293         // https://sylabs.io/guides/3.7/user-guide/environment_and_metadata.html
294         // https://sylabs.io/guides/3.5/user-guide/environment_and_metadata.html
295         env := make([]string, 0, len(e.spec.Env))
296         for k, v := range e.spec.Env {
297                 if k == "HOME" {
298                         // Singularity treats $HOME as special case,
299                         // this is handled with --home above
300                         continue
301                 }
302                 env = append(env, "SINGULARITYENV_"+k+"="+v)
303         }
304
305         // Singularity always makes all nvidia devices visible to the
306         // container.  If a resource manager such as slurm or LSF told
307         // us to select specific devices we need to propagate that.
308         if cudaVisibleDevices := os.Getenv("CUDA_VISIBLE_DEVICES"); cudaVisibleDevices != "" {
309                 // If a resource manager such as slurm or LSF told
310                 // us to select specific devices we need to propagate that.
311                 env = append(env, "SINGULARITYENV_CUDA_VISIBLE_DEVICES="+cudaVisibleDevices)
312         }
313
314         args = append(args, e.imageFilename)
315         args = append(args, e.spec.Command...)
316
317         return &exec.Cmd{
318                 Path:   path,
319                 Args:   args,
320                 Env:    env,
321                 Stdin:  e.spec.Stdin,
322                 Stdout: e.spec.Stdout,
323                 Stderr: e.spec.Stderr,
324         }
325 }
326
327 func (e *singularityExecutor) Start() error {
328         path, err := exec.LookPath("singularity")
329         if err != nil {
330                 return err
331         }
332         child := e.execCmd(path)
333         err = child.Start()
334         if err != nil {
335                 return err
336         }
337         e.child = child
338         return nil
339 }
340
341 func (e *singularityExecutor) CgroupID() string {
342         return ""
343 }
344
345 func (e *singularityExecutor) Stop() error {
346         if err := e.child.Process.Signal(syscall.Signal(0)); err != nil {
347                 // process already exited
348                 return nil
349         }
350         return e.child.Process.Signal(syscall.SIGKILL)
351 }
352
353 func (e *singularityExecutor) Wait(context.Context) (int, error) {
354         err := e.child.Wait()
355         if err, ok := err.(*exec.ExitError); ok {
356                 return err.ProcessState.ExitCode(), nil
357         }
358         if err != nil {
359                 return 0, err
360         }
361         return e.child.ProcessState.ExitCode(), nil
362 }
363
364 func (e *singularityExecutor) Close() {
365         err := os.RemoveAll(e.tmpdir)
366         if err != nil {
367                 e.logf("error removing temp dir: %s", err)
368         }
369 }
370
371 func (e *singularityExecutor) InjectCommand(ctx context.Context, detachKeys, username string, usingTTY bool, injectcmd []string) (*exec.Cmd, error) {
372         target, err := e.containedProcess()
373         if err != nil {
374                 return nil, err
375         }
376         return exec.CommandContext(ctx, "nsenter", append([]string{fmt.Sprintf("--target=%d", target), "--all"}, injectcmd...)...), nil
377 }
378
379 var (
380         errContainerHasNoIPAddress = errors.New("container has no IP address distinct from host")
381 )
382
383 func (e *singularityExecutor) IPAddress() (string, error) {
384         target, err := e.containedProcess()
385         if err != nil {
386                 return "", err
387         }
388         targetIPs, err := processIPs(target)
389         if err != nil {
390                 return "", err
391         }
392         selfIPs, err := processIPs(os.Getpid())
393         if err != nil {
394                 return "", err
395         }
396         for ip := range targetIPs {
397                 if !selfIPs[ip] {
398                         return ip, nil
399                 }
400         }
401         return "", errContainerHasNoIPAddress
402 }
403
404 func processIPs(pid int) (map[string]bool, error) {
405         fibtrie, err := os.ReadFile(fmt.Sprintf("/proc/%d/net/fib_trie", pid))
406         if err != nil {
407                 return nil, err
408         }
409
410         addrs := map[string]bool{}
411         // When we see a pair of lines like this:
412         //
413         //              |-- 10.1.2.3
414         //                 /32 host LOCAL
415         //
416         // ...we set addrs["10.1.2.3"] = true
417         lines := bytes.Split(fibtrie, []byte{'\n'})
418         for linenumber, line := range lines {
419                 if !bytes.HasSuffix(line, []byte("/32 host LOCAL")) {
420                         continue
421                 }
422                 if linenumber < 1 {
423                         continue
424                 }
425                 i := bytes.LastIndexByte(lines[linenumber-1], ' ')
426                 if i < 0 || i >= len(line)-7 {
427                         continue
428                 }
429                 addr := string(lines[linenumber-1][i+1:])
430                 if net.ParseIP(addr).To4() != nil {
431                         addrs[addr] = true
432                 }
433         }
434         return addrs, nil
435 }
436
437 var (
438         errContainerNotStarted = errors.New("container has not started yet")
439         errCannotFindChild     = errors.New("failed to find any process inside the container")
440         reProcStatusPPid       = regexp.MustCompile(`\nPPid:\t(\d+)\n`)
441 )
442
443 // Return the PID of a process that is inside the container (not
444 // necessarily the topmost/pid=1 process in the container).
445 func (e *singularityExecutor) containedProcess() (int, error) {
446         if e.child == nil || e.child.Process == nil {
447                 return 0, errContainerNotStarted
448         }
449         lsns, err := exec.Command("lsns").CombinedOutput()
450         if err != nil {
451                 return 0, fmt.Errorf("lsns: %w", err)
452         }
453         for _, line := range bytes.Split(lsns, []byte{'\n'}) {
454                 fields := bytes.Fields(line)
455                 if len(fields) < 4 {
456                         continue
457                 }
458                 if !bytes.Equal(fields[1], []byte("pid")) {
459                         continue
460                 }
461                 pid, err := strconv.ParseInt(string(fields[3]), 10, 64)
462                 if err != nil {
463                         return 0, fmt.Errorf("error parsing PID field in lsns output: %q", fields[3])
464                 }
465                 for parent := pid; ; {
466                         procstatus, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", parent))
467                         if err != nil {
468                                 break
469                         }
470                         m := reProcStatusPPid.FindSubmatch(procstatus)
471                         if m == nil {
472                                 break
473                         }
474                         parent, err = strconv.ParseInt(string(m[1]), 10, 64)
475                         if err != nil {
476                                 break
477                         }
478                         if int(parent) == e.child.Process.Pid {
479                                 return int(pid), nil
480                         }
481                 }
482         }
483         return 0, errCannotFindChild
484 }