github.com/apptainer/singularity@v3.1.1+incompatible/internal/pkg/build/remotebuilder/remotebuilder.go (about) 1 // Copyright (c) 2018, Sylabs Inc. All rights reserved. 2 // This software is licensed under a 3-clause BSD license. Please consult the 3 // LICENSE.md file distributed with the sources of this project regarding your 4 // rights to use or distribute this software. 5 6 package remotebuilder 7 8 import ( 9 "bytes" 10 "context" 11 "encoding/json" 12 "fmt" 13 "net/http" 14 "net/url" 15 "strings" 16 "time" 17 18 "github.com/globalsign/mgo/bson" 19 "github.com/gorilla/websocket" 20 "github.com/pkg/errors" 21 "github.com/sylabs/json-resp" 22 "github.com/sylabs/singularity/internal/pkg/sylog" 23 "github.com/sylabs/singularity/pkg/build/types" 24 "github.com/sylabs/singularity/pkg/client/library" 25 "github.com/sylabs/singularity/pkg/util/user-agent" 26 ) 27 28 // CloudURI holds the URI of the Library web front-end. 29 const CloudURI = "https://cloud.sylabs.io" 30 31 // RemoteBuilder contains the build request and response 32 type RemoteBuilder struct { 33 Client http.Client 34 ImagePath string 35 Force bool 36 LibraryURL string 37 Definition types.Definition 38 IsDetached bool 39 BuilderURL *url.URL 40 AuthToken string 41 } 42 43 func (rb *RemoteBuilder) setAuthHeader(h http.Header) { 44 if rb.AuthToken != "" { 45 h.Set("Authorization", fmt.Sprintf("Bearer %s", rb.AuthToken)) 46 } 47 } 48 49 // New creates a RemoteBuilder with the specified details. 50 func New(imagePath, libraryURL string, d types.Definition, isDetached, force bool, builderAddr, authToken string) (rb *RemoteBuilder, err error) { 51 builderURL, err := url.Parse(builderAddr) 52 if err != nil { 53 return nil, errors.Wrap(err, "failed to parse builder address") 54 } 55 56 rb = &RemoteBuilder{ 57 Client: http.Client{ 58 Timeout: 30 * time.Second, 59 }, 60 ImagePath: imagePath, 61 Force: force, 62 LibraryURL: libraryURL, 63 Definition: d, 64 IsDetached: isDetached, 65 BuilderURL: builderURL, 66 AuthToken: authToken, 67 } 68 69 return 70 } 71 72 // Build is responsible for making the request via the REST API to the remote builder 73 func (rb *RemoteBuilder) Build(ctx context.Context) (err error) { 74 var libraryRef string 75 76 if strings.HasPrefix(rb.ImagePath, "library://") { 77 // Image destination is Library. 78 libraryRef = rb.ImagePath 79 } 80 81 // Send build request to Remote Build Service 82 rd, err := rb.doBuildRequest(ctx, rb.Definition, libraryRef) 83 if err != nil { 84 err = errors.Wrap(err, "failed to post request to remote build service") 85 sylog.Warningf("%v", err) 86 return err 87 } 88 89 // If we're doing an detached build, print help on how to download the image 90 libraryRefRaw := strings.TrimPrefix(rd.LibraryRef, "library://") 91 if rb.IsDetached { 92 fmt.Printf("Build submitted! Once it is complete, the image can be retrieved by running:\n") 93 fmt.Printf("\tsingularity pull --library %v library://%v\n\n", rd.LibraryURL, libraryRefRaw) 94 fmt.Printf("Alternatively, you can access it from a browser at:\n\t%v/library/%v\n", CloudURI, libraryRefRaw) 95 } 96 97 // If we're doing an attached build, stream output and then download the resulting file 98 if !rb.IsDetached { 99 err = rb.streamOutput(ctx, rd.WSURL) 100 if err != nil { 101 err = errors.Wrap(err, "failed to stream output from remote build service") 102 sylog.Warningf("%v", err) 103 return err 104 } 105 106 // Get build status 107 rd, err = rb.doStatusRequest(ctx, rd.ID) 108 if err != nil { 109 err = errors.Wrap(err, "failed to get status from remote build service") 110 sylog.Warningf("%v", err) 111 return err 112 } 113 114 // Do not try to download image if not complete or image size is 0 115 if !rd.IsComplete { 116 return errors.New("build has not completed") 117 } 118 if rd.ImageSize <= 0 { 119 return errors.New("build image size <= 0") 120 } 121 122 // If image destination is local file, pull image. 123 if !strings.HasPrefix(rb.ImagePath, "library://") { 124 err = client.DownloadImage(rb.ImagePath, rd.LibraryRef, rd.LibraryURL, rb.Force, rb.AuthToken) 125 if err != nil { 126 err = errors.Wrap(err, "failed to pull image file") 127 sylog.Warningf("%v", err) 128 return err 129 } 130 } 131 } 132 133 return nil 134 } 135 136 // streamOutput attaches via websocket and streams output to the console 137 func (rb *RemoteBuilder) streamOutput(ctx context.Context, url string) (err error) { 138 h := http.Header{} 139 rb.setAuthHeader(h) 140 h.Set("User-Agent", useragent.Value()) 141 142 c, resp, err := websocket.DefaultDialer.Dial(url, h) 143 if err != nil { 144 sylog.Debugf("websocket dial err - %s, partial response: %+v", err, resp) 145 return err 146 } 147 defer c.Close() 148 149 for { 150 // Check if context has expired 151 select { 152 case <-ctx.Done(): 153 return ctx.Err() 154 default: 155 } 156 157 // Read from websocket 158 mt, msg, err := c.ReadMessage() 159 if err != nil { 160 if websocket.IsCloseError(err, websocket.CloseNormalClosure) { 161 return nil 162 } 163 sylog.Debugf("websocket read message err - %s", err) 164 return err 165 } 166 167 // Print to terminal 168 switch mt { 169 case websocket.TextMessage: 170 fmt.Printf("%s", msg) 171 case websocket.BinaryMessage: 172 fmt.Print("Ignoring binary message") 173 } 174 } 175 } 176 177 // doBuildRequest creates a new build on a Remote Build Service 178 func (rb *RemoteBuilder) doBuildRequest(ctx context.Context, d types.Definition, libraryRef string) (rd types.ResponseData, err error) { 179 if libraryRef != "" && !client.IsLibraryPushRef(libraryRef) { 180 err = fmt.Errorf("invalid library reference: %v", rb.ImagePath) 181 sylog.Warningf("%v", err) 182 return types.ResponseData{}, err 183 } 184 185 b, err := json.Marshal(types.RequestData{ 186 Definition: d, 187 LibraryRef: libraryRef, 188 LibraryURL: rb.LibraryURL, 189 }) 190 if err != nil { 191 return 192 } 193 194 req, err := http.NewRequest(http.MethodPost, rb.BuilderURL.String()+"/v1/build", bytes.NewReader(b)) 195 if err != nil { 196 return 197 } 198 req = req.WithContext(ctx) 199 rb.setAuthHeader(req.Header) 200 req.Header.Set("User-Agent", useragent.Value()) 201 req.Header.Set("Content-Type", "application/json") 202 sylog.Debugf("Sending build request to %s", req.URL.String()) 203 204 res, err := rb.Client.Do(req) 205 if err != nil { 206 return 207 } 208 defer res.Body.Close() 209 210 err = jsonresp.ReadResponse(res.Body, &rd) 211 if err == nil { 212 sylog.Debugf("Build response - id: %s, wsurl: %s, libref: %s", 213 rd.ID.Hex(), rd.WSURL, rd.LibraryRef) 214 } 215 return 216 } 217 218 // doStatusRequest gets the status of a build from the Remote Build Service 219 func (rb *RemoteBuilder) doStatusRequest(ctx context.Context, id bson.ObjectId) (rd types.ResponseData, err error) { 220 req, err := http.NewRequest(http.MethodGet, rb.BuilderURL.String()+"/v1/build/"+id.Hex(), nil) 221 if err != nil { 222 return 223 } 224 req = req.WithContext(ctx) 225 rb.setAuthHeader(req.Header) 226 req.Header.Set("User-Agent", useragent.Value()) 227 228 res, err := rb.Client.Do(req) 229 if err != nil { 230 return 231 } 232 defer res.Body.Close() 233 234 err = jsonresp.ReadResponse(res.Body, &rd) 235 return 236 }