github.com/wallyworld/juju@v0.0.0-20161013125918-6cf1bc9d917a/apiserver/gui.go (about)

     1  // Copyright 2016 Canonical Ltd.
     2  // Licensed under the AGPLv3, see LICENCE file for details.
     3  
     4  package apiserver
     5  
     6  import (
     7  	"archive/tar"
     8  	"bytes"
     9  	"compress/bzip2"
    10  	"encoding/json"
    11  	"fmt"
    12  	"io"
    13  	"io/ioutil"
    14  	"mime"
    15  	"net/http"
    16  	"net/url"
    17  	"os"
    18  	"path"
    19  	"path/filepath"
    20  	"strings"
    21  	"text/template"
    22  
    23  	"github.com/juju/errors"
    24  	"github.com/juju/version"
    25  
    26  	agenttools "github.com/juju/juju/agent/tools"
    27  	"github.com/juju/juju/apiserver/common"
    28  	"github.com/juju/juju/apiserver/common/apihttp"
    29  	"github.com/juju/juju/apiserver/params"
    30  	"github.com/juju/juju/state"
    31  	"github.com/juju/juju/state/binarystorage"
    32  	jujuversion "github.com/juju/juju/version"
    33  )
    34  
    35  const (
    36  	bzMimeType = "application/x-tar-bzip2"
    37  )
    38  
    39  var (
    40  	jsMimeType = mime.TypeByExtension(".js")
    41  	spritePath = filepath.FromSlash("static/gui/build/app/assets/stack/svg/sprite.css.svg")
    42  )
    43  
    44  // guiRouter serves the Juju GUI routes.
    45  // Serving the Juju GUI is done with the following assumptions:
    46  // - the archive is compressed in tar.bz2 format;
    47  // - the archive includes a top directory named "jujugui-{version}" where
    48  //   version is semver (like "2.0.1"). This directory includes another
    49  //   "jujugui" directory where the actual Juju GUI files live;
    50  // - the "jujugui" directory includes a "static" subdirectory with the Juju
    51  //   GUI assets to be served statically;
    52  // - the "jujugui" directory specifically includes a
    53  //   "static/gui/build/app/assets/stack/svg/sprite.css.svg" file, which is
    54  //   required to render the Juju GUI index file;
    55  // - the "jujugui" directory includes a "templates/index.html.go" file which is
    56  //   used to render the Juju GUI index. The template receives at least the
    57  //   following variables in its context: "staticURL", comboURL", "configURL",
    58  //   "debug" and "spriteContent". It might receive more variables but cannot
    59  //   assume them to be always provided;
    60  // - the "jujugui" directory includes a "templates/config.js.go" file which is
    61  //   used to render the Juju GUI configuration file. The template receives at
    62  //   least the following variables in its context: "base", "host", "socket",
    63  //   "controllerSocket", "staticURL", "uuid" and "version". It might receive
    64  //   more variables but cannot assume them to be always provided.
    65  type guiRouter struct {
    66  	dataDir string
    67  	ctxt    httpContext
    68  	pattern string
    69  }
    70  
    71  func guiEndpoints(pattern, dataDir string, ctxt httpContext) []apihttp.Endpoint {
    72  	gr := &guiRouter{
    73  		dataDir: dataDir,
    74  		ctxt:    ctxt,
    75  		pattern: pattern,
    76  	}
    77  	var endpoints []apihttp.Endpoint
    78  	add := func(pattern string, h func(*guiHandler, http.ResponseWriter, *http.Request)) {
    79  		handler := gr.ensureFileHandler(h)
    80  		// TODO: We can switch from all methods to specific ones for entries
    81  		// where we only want to support specific request methods. However, our
    82  		// tests currently assert that errors come back as application/json and
    83  		// pat only does "text/plain" responses.
    84  		for _, method := range common.DefaultHTTPMethods {
    85  			endpoints = append(endpoints, apihttp.Endpoint{
    86  				Pattern: pattern,
    87  				Method:  method,
    88  				Handler: handler,
    89  			})
    90  		}
    91  	}
    92  	hashedPattern := pattern + ":hash"
    93  	add(hashedPattern+"/config.js", (*guiHandler).serveConfig)
    94  	add(hashedPattern+"/combo", (*guiHandler).serveCombo)
    95  	add(hashedPattern+"/static/", (*guiHandler).serveStatic)
    96  	// The index is served when all remaining URLs are requested, so that
    97  	// the single page JavaScript application can properly handles its routes.
    98  	add(pattern, (*guiHandler).serveIndex)
    99  	return endpoints
   100  }
   101  
   102  // ensureFileHandler decorates the given function to ensure the Juju GUI files
   103  // are available on disk.
   104  func (gr *guiRouter) ensureFileHandler(h func(gh *guiHandler, w http.ResponseWriter, req *http.Request)) http.Handler {
   105  	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
   106  		rootDir, hash, err := gr.ensureFiles(req)
   107  		if err != nil {
   108  			// Note that ensureFiles also checks that the model UUID is valid.
   109  			if err := sendError(w, err); err != nil {
   110  				logger.Errorf("%v", err)
   111  			}
   112  			return
   113  		}
   114  		qhash := req.URL.Query().Get(":hash")
   115  		if qhash != "" && qhash != hash {
   116  			if err := sendError(w, errors.NotFoundf("resource with %q hash", qhash)); err != nil {
   117  				logger.Errorf("%v", err)
   118  			}
   119  			return
   120  		}
   121  		uuid := req.URL.Query().Get(":modeluuid")
   122  		gh := &guiHandler{
   123  			rootDir:     rootDir,
   124  			baseURLPath: strings.Replace(gr.pattern, ":modeluuid", uuid, -1),
   125  			hash:        hash,
   126  			uuid:        uuid,
   127  		}
   128  		h(gh, w, req)
   129  	})
   130  }
   131  
   132  // ensureFiles checks that the GUI files are available on disk.
   133  // If they are not, it means this is the first time this Juju GUI version is
   134  // accessed. In this case, retrieve the Juju GUI archive from the storage and
   135  // uncompress it to disk. This function returns the current GUI root directory
   136  // and archive hash.
   137  func (gr *guiRouter) ensureFiles(req *http.Request) (rootDir string, hash string, err error) {
   138  	// Retrieve the Juju GUI info from the GUI storage.
   139  	st, err := gr.ctxt.stateForRequestUnauthenticated(req)
   140  	if err != nil {
   141  		return "", "", errors.Annotate(err, "cannot open state")
   142  	}
   143  	storage, err := st.GUIStorage()
   144  	if err != nil {
   145  		return "", "", errors.Annotate(err, "cannot open GUI storage")
   146  	}
   147  	defer storage.Close()
   148  	vers, hash, err := guiVersionAndHash(st, storage)
   149  	if err != nil {
   150  		return "", "", errors.Trace(err)
   151  	}
   152  	logger.Debugf("serving Juju GUI version %s", vers)
   153  
   154  	// Check if the current Juju GUI archive has been already expanded on disk.
   155  	baseDir := agenttools.SharedGUIDir(gr.dataDir)
   156  	// Note that we include the hash in the root directory so that when the GUI
   157  	// archive changes we can be sure that clients will not use files from
   158  	// mixed versions.
   159  	rootDir = filepath.Join(baseDir, hash)
   160  	info, err := os.Stat(rootDir)
   161  	if err == nil {
   162  		if info.IsDir() {
   163  			return rootDir, hash, nil
   164  		}
   165  		return "", "", errors.Errorf("cannot use Juju GUI root directory %q: not a directory", rootDir)
   166  	}
   167  	if !os.IsNotExist(err) {
   168  		return "", "", errors.Annotate(err, "cannot stat Juju GUI root directory")
   169  	}
   170  
   171  	// Fetch the Juju GUI archive from the GUI storage and expand it.
   172  	_, r, err := storage.Open(vers)
   173  	if err != nil {
   174  		return "", "", errors.Annotatef(err, "cannot find GUI archive version %q", vers)
   175  	}
   176  	defer r.Close()
   177  	if err := os.MkdirAll(baseDir, 0755); err != nil {
   178  		return "", "", errors.Annotate(err, "cannot create Juju GUI base directory")
   179  	}
   180  	guiDir := "jujugui-" + vers + "/jujugui"
   181  	if err := uncompressGUI(r, guiDir, rootDir); err != nil {
   182  		return "", "", errors.Annotate(err, "cannot uncompress Juju GUI archive")
   183  	}
   184  	return rootDir, hash, nil
   185  }
   186  
   187  // guiVersionAndHash returns the version and the SHA256 hash of the current
   188  // Juju GUI archive.
   189  func guiVersionAndHash(st *state.State, storage binarystorage.Storage) (vers, hash string, err error) {
   190  	currentVers, err := st.GUIVersion()
   191  	if errors.IsNotFound(err) {
   192  		return "", "", errors.NotFoundf("Juju GUI")
   193  	}
   194  	if err != nil {
   195  		return "", "", errors.Annotate(err, "cannot retrieve current GUI version")
   196  	}
   197  	metadata, err := storage.Metadata(currentVers.String())
   198  	if err != nil {
   199  		return "", "", errors.Annotate(err, "cannot retrieve GUI metadata")
   200  	}
   201  	return metadata.Version, metadata.SHA256, nil
   202  }
   203  
   204  // uncompressGUI uncompresses the tar.bz2 Juju GUI archive provided in r.
   205  // The sourceDir directory included in the tar archive is copied to targetDir.
   206  func uncompressGUI(r io.Reader, sourceDir, targetDir string) error {
   207  	tempDir, err := ioutil.TempDir("", "gui")
   208  	if err != nil {
   209  		return errors.Annotate(err, "cannot create Juju GUI temporary directory")
   210  	}
   211  	defer os.Remove(tempDir)
   212  	tr := tar.NewReader(bzip2.NewReader(r))
   213  	for {
   214  		hdr, err := tr.Next()
   215  		if err == io.EOF {
   216  			break
   217  		}
   218  		if err != nil {
   219  			return errors.Annotate(err, "cannot parse archive")
   220  		}
   221  		if hdr.Name != sourceDir && !strings.HasPrefix(hdr.Name, sourceDir+"/") {
   222  			continue
   223  		}
   224  		path := filepath.Join(tempDir, hdr.Name)
   225  		info := hdr.FileInfo()
   226  		if info.IsDir() {
   227  			if err := os.MkdirAll(path, info.Mode()); err != nil {
   228  				return errors.Annotate(err, "cannot create directory")
   229  			}
   230  			continue
   231  		}
   232  		f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
   233  		if err != nil {
   234  			return errors.Annotate(err, "cannot open file")
   235  		}
   236  		defer f.Close()
   237  		if _, err := io.Copy(f, tr); err != nil {
   238  			return errors.Annotate(err, "cannot copy file content")
   239  		}
   240  	}
   241  	if err := os.Rename(filepath.Join(tempDir, sourceDir), targetDir); err != nil {
   242  		return errors.Annotate(err, "cannot rename Juju GUI root directory")
   243  	}
   244  	return nil
   245  }
   246  
   247  // guiHandler serves the Juju GUI.
   248  type guiHandler struct {
   249  	baseURLPath string
   250  	rootDir     string
   251  	hash        string
   252  	uuid        string
   253  }
   254  
   255  // serveStatic serves the GUI static files.
   256  func (h *guiHandler) serveStatic(w http.ResponseWriter, req *http.Request) {
   257  	staticDir := filepath.Join(h.rootDir, "static")
   258  	fs := http.FileServer(http.Dir(staticDir))
   259  	http.StripPrefix(h.hashedPath("static/"), fs).ServeHTTP(w, req)
   260  }
   261  
   262  // serveCombo serves the GUI JavaScript and CSS files, dynamically combined.
   263  func (h *guiHandler) serveCombo(w http.ResponseWriter, req *http.Request) {
   264  	ctype := ""
   265  	// The combo query is like /combo/?path/to/file1&path/to/file2 ...
   266  	parts := strings.Split(req.URL.RawQuery, "&")
   267  	paths := make([]string, 0, len(parts))
   268  	for _, p := range parts {
   269  		fpath, err := getGUIComboPath(h.rootDir, p)
   270  		if err != nil {
   271  			if err := sendError(w, errors.Annotate(err, "cannot combine files")); err != nil {
   272  				logger.Errorf("%v", err)
   273  			}
   274  			return
   275  		}
   276  		if fpath == "" {
   277  			continue
   278  		}
   279  		paths = append(paths, fpath)
   280  		// Assume the Juju GUI does not mix different content types when
   281  		// combining contents.
   282  		if ctype == "" {
   283  			ctype = mime.TypeByExtension(filepath.Ext(fpath))
   284  		}
   285  	}
   286  	w.Header().Set("Content-Type", ctype)
   287  	for _, fpath := range paths {
   288  		sendGUIComboFile(w, fpath)
   289  	}
   290  }
   291  
   292  func getGUIComboPath(rootDir, query string) (string, error) {
   293  	k := strings.SplitN(query, "=", 2)[0]
   294  	fname, err := url.QueryUnescape(k)
   295  	if err != nil {
   296  		return "", errors.NewBadRequest(err, fmt.Sprintf("invalid file name %q", k))
   297  	}
   298  	// Ignore pat injected queries.
   299  	if strings.HasPrefix(fname, ":") {
   300  		return "", nil
   301  	}
   302  	// The Juju GUI references its combined files starting from the
   303  	// "static/gui/build" directory.
   304  	fname = filepath.Clean(fname)
   305  	if fname == ".." || strings.HasPrefix(fname, "../") {
   306  		return "", errors.BadRequestf("forbidden file path %q", k)
   307  	}
   308  	return filepath.Join(rootDir, "static", "gui", "build", fname), nil
   309  }
   310  
   311  func sendGUIComboFile(w io.Writer, fpath string) {
   312  	f, err := os.Open(fpath)
   313  	if err != nil {
   314  		logger.Infof("cannot send combo file %q: %s", fpath, err)
   315  		return
   316  	}
   317  	defer f.Close()
   318  	if _, err := io.Copy(w, f); err != nil {
   319  		return
   320  	}
   321  	fmt.Fprintf(w, "\n/* %s */\n", filepath.Base(fpath))
   322  }
   323  
   324  // serveIndex serves the GUI index file.
   325  func (h *guiHandler) serveIndex(w http.ResponseWriter, req *http.Request) {
   326  	spriteFile := filepath.Join(h.rootDir, spritePath)
   327  	spriteContent, err := ioutil.ReadFile(spriteFile)
   328  	if err != nil {
   329  		if err := sendError(w, errors.Annotate(err, "cannot read sprite file")); err != nil {
   330  			logger.Errorf("%v", err)
   331  		}
   332  		return
   333  	}
   334  	tmpl := filepath.Join(h.rootDir, "templates", "index.html.go")
   335  	if err := renderGUITemplate(w, tmpl, map[string]interface{}{
   336  		// staticURL holds the root of the static hierarchy, hence why the
   337  		// empty string is used here.
   338  		"staticURL": h.hashedPath(""),
   339  		"comboURL":  h.hashedPath("combo"),
   340  		"configURL": h.hashedPath("config.js"),
   341  		// TODO frankban: make it possible to enable debug.
   342  		"debug":         false,
   343  		"spriteContent": string(spriteContent),
   344  	}); err != nil {
   345  		if err := sendError(w, err); err != nil {
   346  			logger.Errorf("%v", errors.Annotate(err, "cannot send error to client from rendering GUI template"))
   347  		}
   348  	}
   349  }
   350  
   351  // serveConfig serves the Juju GUI JavaScript configuration file.
   352  func (h *guiHandler) serveConfig(w http.ResponseWriter, req *http.Request) {
   353  	w.Header().Set("Content-Type", jsMimeType)
   354  	tmpl := filepath.Join(h.rootDir, "templates", "config.js.go")
   355  	if err := renderGUITemplate(w, tmpl, map[string]interface{}{
   356  		"base":             h.baseURLPath,
   357  		"host":             req.Host,
   358  		"controllerSocket": "/api",
   359  		"socket":           "/model/$uuid/api",
   360  		// staticURL holds the root of the static hierarchy, hence why the
   361  		// empty string is used here.
   362  		"staticURL": h.hashedPath(""),
   363  		"uuid":      h.uuid,
   364  		"version":   jujuversion.Current.String(),
   365  	}); err != nil {
   366  		if err := sendError(w, err); err != nil {
   367  			logger.Errorf("%v", errors.Annotate(err, "cannot send error to client from rendering GUI template"))
   368  		}
   369  	}
   370  }
   371  
   372  // hashedPath returns the gull path (including the GUI archive hash) to the
   373  // given path, that must not start with a slash.
   374  func (h *guiHandler) hashedPath(p string) string {
   375  	return path.Join(h.baseURLPath, h.hash, p)
   376  }
   377  
   378  func renderGUITemplate(w http.ResponseWriter, tmpl string, ctx map[string]interface{}) error {
   379  	// TODO frankban: cache parsed template.
   380  	t, err := template.ParseFiles(tmpl)
   381  	if err != nil {
   382  		return errors.Annotate(err, "cannot parse template")
   383  	}
   384  	return errors.Annotate(t.Execute(w, ctx), "cannot render template")
   385  }
   386  
   387  // guiArchiveHandler serves the Juju GUI archive endpoints, used for uploading
   388  // and retrieving information about GUI archives.
   389  type guiArchiveHandler struct {
   390  	ctxt httpContext
   391  }
   392  
   393  // ServeHTTP implements http.Handler.
   394  func (h *guiArchiveHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
   395  	var handler func(http.ResponseWriter, *http.Request) error
   396  	switch req.Method {
   397  	case "GET":
   398  		handler = h.handleGet
   399  	case "POST":
   400  		handler = h.handlePost
   401  	default:
   402  		if err := sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method)); err != nil {
   403  			logger.Errorf("%v", err)
   404  		}
   405  		return
   406  	}
   407  	if err := handler(w, req); err != nil {
   408  		if err := sendError(w, errors.Trace(err)); err != nil {
   409  			logger.Errorf("%v", err)
   410  		}
   411  	}
   412  }
   413  
   414  // handleGet returns information on Juju GUI archives in the controller.
   415  func (h *guiArchiveHandler) handleGet(w http.ResponseWriter, req *http.Request) error {
   416  	// Open the GUI archive storage.
   417  	st, err := h.ctxt.stateForRequestUnauthenticated(req)
   418  	if err != nil {
   419  		return errors.Annotate(err, "cannot open state")
   420  	}
   421  	storage, err := st.GUIStorage()
   422  	if err != nil {
   423  		return errors.Annotate(err, "cannot open GUI storage")
   424  	}
   425  	defer storage.Close()
   426  
   427  	// Retrieve metadata information.
   428  	allMeta, err := storage.AllMetadata()
   429  	if err != nil {
   430  		return errors.Annotate(err, "cannot retrieve GUI metadata")
   431  	}
   432  
   433  	// Prepare and send the response.
   434  	var currentVersion string
   435  	vers, err := st.GUIVersion()
   436  	if err == nil {
   437  		currentVersion = vers.String()
   438  	} else if !errors.IsNotFound(err) {
   439  		return errors.Annotate(err, "cannot retrieve current GUI version")
   440  	}
   441  	versions := make([]params.GUIArchiveVersion, len(allMeta))
   442  	for i, m := range allMeta {
   443  		vers, err := version.Parse(m.Version)
   444  		if err != nil {
   445  			return errors.Annotate(err, "cannot parse GUI version")
   446  		}
   447  		versions[i] = params.GUIArchiveVersion{
   448  			Version: vers,
   449  			SHA256:  m.SHA256,
   450  			Current: m.Version == currentVersion,
   451  		}
   452  	}
   453  	return errors.Trace(sendStatusAndJSON(w, http.StatusOK, params.GUIArchiveResponse{
   454  		Versions: versions,
   455  	}))
   456  }
   457  
   458  // handlePost is used to upload new Juju GUI archives to the controller.
   459  func (h *guiArchiveHandler) handlePost(w http.ResponseWriter, req *http.Request) error {
   460  	// Validate the request.
   461  	if ctype := req.Header.Get("Content-Type"); ctype != bzMimeType {
   462  		return errors.BadRequestf("invalid content type %q: expected %q", ctype, bzMimeType)
   463  	}
   464  	if err := req.ParseForm(); err != nil {
   465  		return errors.Annotate(err, "cannot parse form")
   466  	}
   467  	versParam := req.Form.Get("version")
   468  	if versParam == "" {
   469  		return errors.BadRequestf("version parameter not provided")
   470  	}
   471  	vers, err := version.Parse(versParam)
   472  	if err != nil {
   473  		return errors.BadRequestf("invalid version parameter %q", versParam)
   474  	}
   475  	hashParam := req.Form.Get("hash")
   476  	if hashParam == "" {
   477  		return errors.BadRequestf("hash parameter not provided")
   478  	}
   479  	if req.ContentLength == -1 {
   480  		return errors.BadRequestf("content length not provided")
   481  	}
   482  
   483  	// Open the GUI archive storage.
   484  	st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req)
   485  	if err != nil {
   486  		return errors.Annotate(err, "cannot open state")
   487  	}
   488  	storage, err := st.GUIStorage()
   489  	if err != nil {
   490  		return errors.Annotate(err, "cannot open GUI storage")
   491  	}
   492  	defer storage.Close()
   493  
   494  	// Read and validate the archive data.
   495  	data, hash, err := readAndHash(req.Body)
   496  	size := int64(len(data))
   497  	if size != req.ContentLength {
   498  		return errors.BadRequestf("archive does not match provided content length")
   499  	}
   500  	if hash != hashParam {
   501  		return errors.BadRequestf("archive does not match provided hash")
   502  	}
   503  
   504  	// Add the archive to the GUI storage.
   505  	metadata := binarystorage.Metadata{
   506  		Version: vers.String(),
   507  		Size:    size,
   508  		SHA256:  hash,
   509  	}
   510  	if err := storage.Add(bytes.NewReader(data), metadata); err != nil {
   511  		return errors.Annotate(err, "cannot add GUI archive to storage")
   512  	}
   513  
   514  	// Prepare and return the response.
   515  	resp := params.GUIArchiveVersion{
   516  		Version: vers,
   517  		SHA256:  hash,
   518  	}
   519  	if currentVers, err := st.GUIVersion(); err == nil {
   520  		if currentVers == vers {
   521  			resp.Current = true
   522  		}
   523  	} else if !errors.IsNotFound(err) {
   524  		return errors.Annotate(err, "cannot retrieve current GUI version")
   525  
   526  	}
   527  	return errors.Trace(sendStatusAndJSON(w, http.StatusOK, resp))
   528  }
   529  
   530  // guiVersionHandler is used to select the Juju GUI version served by the
   531  // controller. The specified version must be available in the controller.
   532  type guiVersionHandler struct {
   533  	ctxt httpContext
   534  }
   535  
   536  // ServeHTTP implements http.Handler.
   537  func (h *guiVersionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
   538  	if req.Method != "PUT" {
   539  		if err := sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method)); err != nil {
   540  			logger.Errorf("%v", err)
   541  		}
   542  		return
   543  	}
   544  	if err := h.handlePut(w, req); err != nil {
   545  		if err := sendError(w, errors.Trace(err)); err != nil {
   546  			logger.Errorf("%v", err)
   547  		}
   548  	}
   549  }
   550  
   551  // handlePut is used to switch to a specific Juju GUI version.
   552  func (h *guiVersionHandler) handlePut(w http.ResponseWriter, req *http.Request) error {
   553  	// Validate the request.
   554  	if ctype := req.Header.Get("Content-Type"); ctype != params.ContentTypeJSON {
   555  		return errors.BadRequestf("invalid content type %q: expected %q", ctype, params.ContentTypeJSON)
   556  	}
   557  
   558  	// Authenticate the request and retrieve the Juju state.
   559  	st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req)
   560  	if err != nil {
   561  		return errors.Annotate(err, "cannot open state")
   562  	}
   563  
   564  	var selected params.GUIVersionRequest
   565  	decoder := json.NewDecoder(req.Body)
   566  	if err := decoder.Decode(&selected); err != nil {
   567  		return errors.NewBadRequest(err, "invalid request body")
   568  	}
   569  
   570  	// Switch to the provided GUI version.
   571  	if err = st.GUISetVersion(selected.Version); err != nil {
   572  		return errors.Trace(err)
   573  	}
   574  	return nil
   575  }