17813: Upload .sif file to cache project & read it from keep
[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"
10         "io/ioutil"
11         "os"
12         "os/exec"
13         "sort"
14         "strings"
15         "syscall"
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         containerClient *arvados.Client
28         container       arvados.Container
29         keepClient      IKeepClient
30         keepMount       string
31 }
32
33 func newSingularityExecutor(logf func(string, ...interface{})) (*singularityExecutor, error) {
34         tmpdir, err := ioutil.TempDir("", "crunch-run-singularity-")
35         if err != nil {
36                 return nil, err
37         }
38         return &singularityExecutor{
39                 logf:   logf,
40                 tmpdir: tmpdir,
41         }, nil
42 }
43
44 func (e *singularityExecutor) getOrCreateProject(ownerUuid string, name string, create bool) (*arvados.Group, error) {
45         var gp arvados.GroupList
46         err := e.containerClient.RequestAndDecode(&gp,
47                 arvados.EndpointGroupList.Method,
48                 arvados.EndpointGroupList.Path,
49                 nil, arvados.ListOptions{Filters: []arvados.Filter{
50                         arvados.Filter{"owner_uuid", "=", ownerUuid},
51                         arvados.Filter{"name", "=", name},
52                         arvados.Filter{"group_class", "=", "project"},
53                 },
54                         Limit: 1})
55         if err != nil {
56                 return nil, err
57         }
58         if len(gp.Items) == 1 {
59                 return &gp.Items[0], nil
60         }
61         if !create {
62                 return nil, nil
63         }
64         var rgroup arvados.Group
65         err = e.containerClient.RequestAndDecode(&rgroup,
66                 arvados.EndpointGroupCreate.Method,
67                 arvados.EndpointGroupCreate.Path,
68                 nil, map[string]interface{}{
69                         "group": map[string]string{
70                                 "owner_uuid":  ownerUuid,
71                                 "name":        name,
72                                 "group_class": "project",
73                         },
74                 })
75         if err != nil {
76                 return nil, err
77         }
78         return &rgroup, nil
79 }
80
81 func (e *singularityExecutor) ImageLoaded(imageId string) bool {
82         // Check if docker image is cached in keep & if so set imageFilename
83
84         // Cache the image to keep
85         cacheGroup, err := e.getOrCreateProject(e.container.RuntimeUserUUID, ".cache", false)
86         if err != nil {
87                 e.logf("error getting '.cache' project: %v", err)
88                 return false
89         }
90         imageGroup, err := e.getOrCreateProject(cacheGroup.UUID, "auto-generated singularity images", false)
91         if err != nil {
92                 e.logf("error getting 'auto-generated singularity images' project: %s", err)
93                 return false
94         }
95
96         collectionName := fmt.Sprintf("singularity image for %v", imageId)
97         var cl arvados.CollectionList
98         err = e.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                 e.logf("error getting collection '%v' project: %v", err)
108                 return false
109         }
110         if len(cl.Items) == 0 {
111                 e.logf("no cached image '%v' found", collectionName)
112                 return false
113         }
114
115         path := fmt.Sprintf("%s/by_id/%s/image.sif", e.keepMount, cl.Items[0].PortableDataHash)
116         e.logf("Looking for %v", path)
117         if _, err = os.Stat(path); os.IsNotExist(err) {
118                 return false
119         }
120         e.imageFilename = path
121
122         return true
123 }
124
125 // LoadImage will satisfy ContainerExecuter interface transforming
126 // containerImage into a sif file for later use.
127 func (e *singularityExecutor) LoadImage(imageTarballPath string) error {
128         if e.imageFilename != "" {
129                 e.logf("using singularity image %v", e.imageFilename)
130
131                 // was set by ImageLoaded
132                 return nil
133         }
134
135         e.logf("building singularity image")
136         // "singularity build" does not accept a
137         // docker-archive://... filename containing a ":" character,
138         // as in "/path/to/sha256:abcd...1234.tar". Workaround: make a
139         // symlink that doesn't have ":" chars.
140         err := os.Symlink(imageTarballPath, e.tmpdir+"/image.tar")
141         if err != nil {
142                 return err
143         }
144         e.imageFilename = e.tmpdir + "/image.sif"
145         build := exec.Command("singularity", "build", e.imageFilename, "docker-archive://"+e.tmpdir+"/image.tar")
146         e.logf("%v", build.Args)
147         out, err := build.CombinedOutput()
148         // INFO:    Starting build...
149         // Getting image source signatures
150         // Copying blob ab15617702de done
151         // Copying config 651e02b8a2 done
152         // Writing manifest to image destination
153         // Storing signatures
154         // 2021/04/22 14:42:14  info unpack layer: sha256:21cbfd3a344c52b197b9fa36091e66d9cbe52232703ff78d44734f85abb7ccd3
155         // INFO:    Creating SIF file...
156         // INFO:    Build complete: arvados-jobs.latest.sif
157         e.logf("%s", out)
158         if err != nil {
159                 return err
160         }
161
162         // Cache the image to keep
163         cacheGroup, err := e.getOrCreateProject(e.container.RuntimeUserUUID, ".cache", true)
164         if err != nil {
165                 e.logf("error getting '.cache' project: %v", err)
166                 return nil
167         }
168         imageGroup, err := e.getOrCreateProject(cacheGroup.UUID, "auto-generated singularity images", true)
169         if err != nil {
170                 e.logf("error getting 'auto-generated singularity images' project: %v", err)
171                 return nil
172         }
173
174         parts := strings.Split(imageTarballPath, "/")
175         imageId := parts[len(parts)-1]
176         if strings.HasSuffix(imageId, ".tar") {
177                 imageId = imageId[0 : len(imageId)-4]
178         }
179
180         fs, err := (&arvados.Collection{ManifestText: ""}).FileSystem(e.containerClient, e.keepClient)
181         if err != nil {
182                 e.logf("error creating FileSystem: %s", err)
183         }
184
185         dst, err := fs.OpenFile("image.sif", os.O_CREATE|os.O_WRONLY, 0666)
186         if err != nil {
187                 e.logf("error creating opening collection file for writing: %s", err)
188         }
189
190         src, err := os.Open(e.imageFilename)
191         if err != nil {
192                 dst.Close()
193                 return nil
194         }
195         defer src.Close()
196         _, err = io.Copy(dst, src)
197         if err != nil {
198                 dst.Close()
199                 return nil
200         }
201
202         manifestText, err := fs.MarshalManifest(".")
203         if err != nil {
204                 e.logf("error creating manifest text: %s", err)
205         }
206
207         var imageCollection arvados.Collection
208         collectionName := fmt.Sprintf("singularity image for %s", imageId)
209         err = e.containerClient.RequestAndDecode(&imageCollection,
210                 arvados.EndpointCollectionCreate.Method,
211                 arvados.EndpointCollectionCreate.Path,
212                 nil, map[string]interface{}{
213                         "collection": map[string]string{
214                                 "owner_uuid":    imageGroup.UUID,
215                                 "name":          collectionName,
216                                 "manifest_text": manifestText,
217                         },
218                 })
219         if err != nil {
220                 e.logf("error creating '%v' collection: %s", collectionName, err)
221         }
222
223         return nil
224 }
225
226 func (e *singularityExecutor) Create(spec containerSpec) error {
227         e.spec = spec
228         return nil
229 }
230
231 func (e *singularityExecutor) Start() error {
232         args := []string{"singularity", "exec", "--containall", "--no-home", "--cleanenv", "--pwd", e.spec.WorkingDir}
233         if !e.spec.EnableNetwork {
234                 args = append(args, "--net", "--network=none")
235         }
236         readonlyflag := map[bool]string{
237                 false: "rw",
238                 true:  "ro",
239         }
240         var binds []string
241         for path, _ := range e.spec.BindMounts {
242                 binds = append(binds, path)
243         }
244         sort.Strings(binds)
245         for _, path := range binds {
246                 mount := e.spec.BindMounts[path]
247                 args = append(args, "--bind", mount.HostPath+":"+path+":"+readonlyflag[mount.ReadOnly])
248         }
249         args = append(args, e.imageFilename)
250         args = append(args, e.spec.Command...)
251
252         // This is for singularity 3.5.2. There are some behaviors
253         // that will change in singularity 3.6, please see:
254         // https://sylabs.io/guides/3.7/user-guide/environment_and_metadata.html
255         // https://sylabs.io/guides/3.5/user-guide/environment_and_metadata.html
256         env := make([]string, 0, len(e.spec.Env))
257         for k, v := range e.spec.Env {
258                 env = append(env, "SINGULARITYENV_"+k+"="+v)
259         }
260
261         path, err := exec.LookPath(args[0])
262         if err != nil {
263                 return err
264         }
265         child := &exec.Cmd{
266                 Path:   path,
267                 Args:   args,
268                 Env:    env,
269                 Stdin:  e.spec.Stdin,
270                 Stdout: e.spec.Stdout,
271                 Stderr: e.spec.Stderr,
272         }
273         err = child.Start()
274         if err != nil {
275                 return err
276         }
277         e.child = child
278         return nil
279 }
280
281 func (e *singularityExecutor) CgroupID() string {
282         return ""
283 }
284
285 func (e *singularityExecutor) Stop() error {
286         if err := e.child.Process.Signal(syscall.Signal(0)); err != nil {
287                 // process already exited
288                 return nil
289         }
290         return e.child.Process.Signal(syscall.SIGKILL)
291 }
292
293 func (e *singularityExecutor) Wait(context.Context) (int, error) {
294         err := e.child.Wait()
295         if err, ok := err.(*exec.ExitError); ok {
296                 return err.ProcessState.ExitCode(), nil
297         }
298         if err != nil {
299                 return 0, err
300         }
301         return e.child.ProcessState.ExitCode(), nil
302 }
303
304 func (e *singularityExecutor) Close() {
305         err := os.RemoveAll(e.tmpdir)
306         if err != nil {
307                 e.logf("error removing temp dir: %s", err)
308         }
309 }
310
311 func (e *singularityExecutor) SetArvadoClient(containerClient *arvados.Client, keepClient IKeepClient, container arvados.Container, keepMount string) {
312         e.containerClient = containerClient
313         e.container = container
314         e.keepClient = keepClient
315         e.keepMount = keepMount
316 }