17813: Singularity image caching wip
[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
16         "git.arvados.org/arvados.git/sdk/go/arvados"
17         "golang.org/x/net/context"
18 )
19
20 type singularityExecutor struct {
21         logf            func(string, ...interface{})
22         spec            containerSpec
23         tmpdir          string
24         child           *exec.Cmd
25         imageFilename   string // "sif" image
26         containerClient *arvados.Client
27         container       arvados.Container
28 }
29
30 func newSingularityExecutor(logf func(string, ...interface{})) (*singularityExecutor, error) {
31         tmpdir, err := ioutil.TempDir("", "crunch-run-singularity-")
32         if err != nil {
33                 return nil, err
34         }
35         return &singularityExecutor{
36                 logf:   logf,
37                 tmpdir: tmpdir,
38         }, nil
39 }
40
41 func (e *singularityExecutor) getOrCreateProject(ownerUuid string, name string, create bool) (*arvados.Group, error) {
42         var gp arvados.GroupList
43         err := e.containerClient.RequestAndDecode(&gp,
44                 arvados.EndpointGroupList.Method,
45                 arvados.EndpointGroupList.Path,
46                 nil, arvados.ListOptions{Filters: []arvados.Filter{
47                         arvados.Filter{"owner_uuid", "=", ownerUuid},
48                         arvados.Filter{"name", "=", name},
49                         arvados.Filter{"group_class", "=", "project"},
50                 }})
51         if err != nil {
52                 return nil, err
53         }
54         if len(gp.Items) > 0 {
55                 return &gp.Items[0], nil
56         }
57         if !create {
58                 return nil, nil
59         }
60         var rgroup arvados.Group
61         err = e.containerClient.RequestAndDecode(&rgroup,
62                 arvados.EndpointGroupCreate.Method,
63                 arvados.EndpointGroupCreate.Path,
64                 nil, map[string]interface{}{
65                         "group": map[string]string{
66                                 "owner_uuid":  ownerUuid,
67                                 "name":        name,
68                                 "group_class": "project",
69                         },
70                 })
71         if err != nil {
72                 return nil, err
73         }
74         return &rgroup, nil
75 }
76
77 func (e *singularityExecutor) ImageLoaded(string) bool {
78         // Check if docker image is cached in keep & if so set imageFilename
79
80         return false
81 }
82
83 // LoadImage will satisfy ContainerExecuter interface transforming
84 // containerImage into a sif file for later use.
85 func (e *singularityExecutor) LoadImage(imageTarballPath string) error {
86         if e.imageFilename != "" {
87                 // was set by ImageLoaded
88                 return nil
89         }
90
91         e.logf("building singularity image")
92         // "singularity build" does not accept a
93         // docker-archive://... filename containing a ":" character,
94         // as in "/path/to/sha256:abcd...1234.tar". Workaround: make a
95         // symlink that doesn't have ":" chars.
96         err := os.Symlink(imageTarballPath, e.tmpdir+"/image.tar")
97         if err != nil {
98                 return err
99         }
100         e.imageFilename = e.tmpdir + "/image.sif"
101         build := exec.Command("singularity", "build", e.imageFilename, "docker-archive://"+e.tmpdir+"/image.tar")
102         e.logf("%v", build.Args)
103         out, err := build.CombinedOutput()
104         // INFO:    Starting build...
105         // Getting image source signatures
106         // Copying blob ab15617702de done
107         // Copying config 651e02b8a2 done
108         // Writing manifest to image destination
109         // Storing signatures
110         // 2021/04/22 14:42:14  info unpack layer: sha256:21cbfd3a344c52b197b9fa36091e66d9cbe52232703ff78d44734f85abb7ccd3
111         // INFO:    Creating SIF file...
112         // INFO:    Build complete: arvados-jobs.latest.sif
113         e.logf("%s", out)
114         if err != nil {
115                 return err
116         }
117
118         // Cache the image to keep
119         cacheGroup, err := e.getOrCreateProject(e.container.RuntimeUserUUID, ".cache", true)
120         if err != nil {
121                 e.logf("error getting '.cache' project: %s", err)
122                 return nil
123         }
124         imageGroup, err := e.getOrCreateProject(cacheGroup.UUID, "auto-generated singularity images", true)
125         if err != nil {
126                 e.logf("error getting 'auto-generated singularity images' project: %s", err)
127                 return nil
128         }
129
130         parts := strings.Split(imageTarballPath, "/")
131         imageId := parts[len(parts)-1]
132
133         var imageCollection arvados.Collection
134         err = e.containerClient.RequestAndDecode(&imageCollection,
135                 arvados.EndpointCollectionCreate.Method,
136                 arvados.EndpointCollectionCreate.Path,
137                 nil, map[string]interface{}{
138                         "collection": map[string]string{
139                                 "owner_uuid": imageGroup.UUID,
140                                 "name": fmt.Sprintf("singularity image for %s", imageId),
141                         }
142                 })
143         if err != nil {
144                 e.logf("error creating 'auto-generated singularity images' collection: %s", err)
145         }
146
147         return nil
148 }
149
150 func (e *singularityExecutor) Create(spec containerSpec) error {
151         e.spec = spec
152         return nil
153 }
154
155 func (e *singularityExecutor) Start() error {
156         args := []string{"singularity", "exec", "--containall", "--no-home", "--cleanenv", "--pwd", e.spec.WorkingDir}
157         if !e.spec.EnableNetwork {
158                 args = append(args, "--net", "--network=none")
159         }
160         readonlyflag := map[bool]string{
161                 false: "rw",
162                 true:  "ro",
163         }
164         var binds []string
165         for path, _ := range e.spec.BindMounts {
166                 binds = append(binds, path)
167         }
168         sort.Strings(binds)
169         for _, path := range binds {
170                 mount := e.spec.BindMounts[path]
171                 args = append(args, "--bind", mount.HostPath+":"+path+":"+readonlyflag[mount.ReadOnly])
172         }
173         args = append(args, e.imageFilename)
174         args = append(args, e.spec.Command...)
175
176         // This is for singularity 3.5.2. There are some behaviors
177         // that will change in singularity 3.6, please see:
178         // https://sylabs.io/guides/3.7/user-guide/environment_and_metadata.html
179         // https://sylabs.io/guides/3.5/user-guide/environment_and_metadata.html
180         env := make([]string, 0, len(e.spec.Env))
181         for k, v := range e.spec.Env {
182                 env = append(env, "SINGULARITYENV_"+k+"="+v)
183         }
184
185         path, err := exec.LookPath(args[0])
186         if err != nil {
187                 return err
188         }
189         child := &exec.Cmd{
190                 Path:   path,
191                 Args:   args,
192                 Env:    env,
193                 Stdin:  e.spec.Stdin,
194                 Stdout: e.spec.Stdout,
195                 Stderr: e.spec.Stderr,
196         }
197         err = child.Start()
198         if err != nil {
199                 return err
200         }
201         e.child = child
202         return nil
203 }
204
205 func (e *singularityExecutor) CgroupID() string {
206         return ""
207 }
208
209 func (e *singularityExecutor) Stop() error {
210         if err := e.child.Process.Signal(syscall.Signal(0)); err != nil {
211                 // process already exited
212                 return nil
213         }
214         return e.child.Process.Signal(syscall.SIGKILL)
215 }
216
217 func (e *singularityExecutor) Wait(context.Context) (int, error) {
218         err := e.child.Wait()
219         if err, ok := err.(*exec.ExitError); ok {
220                 return err.ProcessState.ExitCode(), nil
221         }
222         if err != nil {
223                 return 0, err
224         }
225         return e.child.ProcessState.ExitCode(), nil
226 }
227
228 func (e *singularityExecutor) Close() {
229         err := os.RemoveAll(e.tmpdir)
230         if err != nil {
231                 e.logf("error removing temp dir: %s", err)
232         }
233 }
234
235 func (e *singularityExecutor) SetArvadoClient(containerClient *arvados.Client, container arvados.Container) {
236         e.containerClient = containerClient
237         e.container = container
238 }