Merge branch '19081-singularity-no-eval'
[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         "fmt"
9         "io/ioutil"
10         "os"
11         "os/exec"
12         "sort"
13         "strings"
14         "syscall"
15         "time"
16
17         "git.arvados.org/arvados.git/sdk/go/arvados"
18         "golang.org/x/net/context"
19 )
20
21 type singularityExecutor struct {
22         logf          func(string, ...interface{})
23         spec          containerSpec
24         tmpdir        string
25         child         *exec.Cmd
26         imageFilename string // "sif" image
27 }
28
29 func newSingularityExecutor(logf func(string, ...interface{})) (*singularityExecutor, error) {
30         tmpdir, err := ioutil.TempDir("", "crunch-run-singularity-")
31         if err != nil {
32                 return nil, err
33         }
34         return &singularityExecutor{
35                 logf:   logf,
36                 tmpdir: tmpdir,
37         }, nil
38 }
39
40 func (e *singularityExecutor) Runtime() string {
41         buf, err := exec.Command("singularity", "--version").CombinedOutput()
42         if err != nil {
43                 return "singularity (unknown version)"
44         }
45         return strings.TrimSuffix(string(buf), "\n")
46 }
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.spec.EnableNetwork {
254                 args = append(args, "--net", "--network=none")
255         }
256
257         if e.spec.CUDADeviceCount != 0 {
258                 args = append(args, "--nv")
259         }
260
261         readonlyflag := map[bool]string{
262                 false: "rw",
263                 true:  "ro",
264         }
265         var binds []string
266         for path, _ := range e.spec.BindMounts {
267                 binds = append(binds, path)
268         }
269         sort.Strings(binds)
270         for _, path := range binds {
271                 mount := e.spec.BindMounts[path]
272                 if path == e.spec.Env["HOME"] {
273                         // Singularity treates $HOME as special case
274                         args = append(args, "--home", mount.HostPath+":"+path)
275                 } else {
276                         args = append(args, "--bind", mount.HostPath+":"+path+":"+readonlyflag[mount.ReadOnly])
277                 }
278         }
279
280         // This is for singularity 3.5.2. There are some behaviors
281         // that will change in singularity 3.6, please see:
282         // https://sylabs.io/guides/3.7/user-guide/environment_and_metadata.html
283         // https://sylabs.io/guides/3.5/user-guide/environment_and_metadata.html
284         env := make([]string, 0, len(e.spec.Env))
285         for k, v := range e.spec.Env {
286                 if k == "HOME" {
287                         // Singularity treates $HOME as special case, this is handled
288                         // with --home above
289                         continue
290                 }
291                 env = append(env, "SINGULARITYENV_"+k+"="+v)
292         }
293
294         // Singularity always makes all nvidia devices visible to the
295         // container.  If a resource manager such as slurm or LSF told
296         // us to select specific devices we need to propagate that.
297         if cudaVisibleDevices := os.Getenv("CUDA_VISIBLE_DEVICES"); cudaVisibleDevices != "" {
298                 // If a resource manager such as slurm or LSF told
299                 // us to select specific devices we need to propagate that.
300                 env = append(env, "SINGULARITYENV_CUDA_VISIBLE_DEVICES="+cudaVisibleDevices)
301         }
302         // Singularity's default behavior is to evaluate each
303         // SINGULARITYENV_* env var with a shell as a double-quoted
304         // string and pass the result to the contained
305         // process. Singularity 3.10+ has an option to pass env vars
306         // through literally without evaluating, which is what we
307         // want. See https://github.com/sylabs/singularity/pull/704
308         // and https://dev.arvados.org/issues/19081
309         env = append(env, "SINGULARITY_NO_EVAL=1")
310
311         args = append(args, e.imageFilename)
312         args = append(args, e.spec.Command...)
313
314         return &exec.Cmd{
315                 Path:   path,
316                 Args:   args,
317                 Env:    env,
318                 Stdin:  e.spec.Stdin,
319                 Stdout: e.spec.Stdout,
320                 Stderr: e.spec.Stderr,
321         }
322 }
323
324 func (e *singularityExecutor) Start() error {
325         path, err := exec.LookPath("singularity")
326         if err != nil {
327                 return err
328         }
329         child := e.execCmd(path)
330         err = child.Start()
331         if err != nil {
332                 return err
333         }
334         e.child = child
335         return nil
336 }
337
338 func (e *singularityExecutor) CgroupID() string {
339         return ""
340 }
341
342 func (e *singularityExecutor) Stop() error {
343         if err := e.child.Process.Signal(syscall.Signal(0)); err != nil {
344                 // process already exited
345                 return nil
346         }
347         return e.child.Process.Signal(syscall.SIGKILL)
348 }
349
350 func (e *singularityExecutor) Wait(context.Context) (int, error) {
351         err := e.child.Wait()
352         if err, ok := err.(*exec.ExitError); ok {
353                 return err.ProcessState.ExitCode(), nil
354         }
355         if err != nil {
356                 return 0, err
357         }
358         return e.child.ProcessState.ExitCode(), nil
359 }
360
361 func (e *singularityExecutor) Close() {
362         err := os.RemoveAll(e.tmpdir)
363         if err != nil {
364                 e.logf("error removing temp dir: %s", err)
365         }
366 }