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  }