github.com/makyo/juju@v0.0.0-20160425123129-2608902037e9/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  //   "staticURL", "uuid" and "version". It might receive more variables but
    64  //   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  			sendError(w, err)
   110  			return
   111  		}
   112  		qhash := req.URL.Query().Get(":hash")
   113  		if qhash != "" && qhash != hash {
   114  			sendError(w, errors.NotFoundf("resource with %q hash", qhash))
   115  			return
   116  		}
   117  		uuid := req.URL.Query().Get(":modeluuid")
   118  		gh := &guiHandler{
   119  			rootDir:     rootDir,
   120  			baseURLPath: strings.Replace(gr.pattern, ":modeluuid", uuid, -1),
   121  			hash:        hash,
   122  			uuid:        uuid,
   123  		}
   124  		h(gh, w, req)
   125  	})
   126  }
   127  
   128  // ensureFiles checks that the GUI files are available on disk.
   129  // If they are not, it means this is the first time this Juju GUI version is
   130  // accessed. In this case, retrieve the Juju GUI archive from the storage and
   131  // uncompress it to disk. This function returns the current GUI root directory
   132  // and archive hash.
   133  func (gr *guiRouter) ensureFiles(req *http.Request) (rootDir string, hash string, err error) {
   134  	// Retrieve the Juju GUI info from the GUI storage.
   135  	st, err := gr.ctxt.stateForRequestUnauthenticated(req)
   136  	if err != nil {
   137  		return "", "", errors.Annotate(err, "cannot open state")
   138  	}
   139  	storage, err := st.GUIStorage()
   140  	if err != nil {
   141  		return "", "", errors.Annotate(err, "cannot open GUI storage")
   142  	}
   143  	defer storage.Close()
   144  	vers, hash, err := guiVersionAndHash(st, storage)
   145  	if err != nil {
   146  		return "", "", errors.Trace(err)
   147  	}
   148  	logger.Debugf("serving Juju GUI version %s", vers)
   149  
   150  	// Check if the current Juju GUI archive has been already expanded on disk.
   151  	baseDir := agenttools.SharedGUIDir(gr.dataDir)
   152  	// Note that we include the hash in the root directory so that when the GUI
   153  	// archive changes we can be sure that clients will not use files from
   154  	// mixed versions.
   155  	rootDir = filepath.Join(baseDir, hash)
   156  	info, err := os.Stat(rootDir)
   157  	if err == nil {
   158  		if info.IsDir() {
   159  			return rootDir, hash, nil
   160  		}
   161  		return "", "", errors.Errorf("cannot use Juju GUI root directory %q: not a directory", rootDir)
   162  	}
   163  	if !os.IsNotExist(err) {
   164  		return "", "", errors.Annotate(err, "cannot stat Juju GUI root directory")
   165  	}
   166  
   167  	// Fetch the Juju GUI archive from the GUI storage and expand it.
   168  	_, r, err := storage.Open(vers)
   169  	if err != nil {
   170  		return "", "", errors.Annotatef(err, "cannot find GUI archive version %q", vers)
   171  	}
   172  	defer r.Close()
   173  	if err := os.MkdirAll(baseDir, 0755); err != nil {
   174  		return "", "", errors.Annotate(err, "cannot create Juju GUI base directory")
   175  	}
   176  	guiDir := "jujugui-" + vers + "/jujugui"
   177  	if err := uncompressGUI(r, guiDir, rootDir); err != nil {
   178  		return "", "", errors.Annotate(err, "cannot uncompress Juju GUI archive")
   179  	}
   180  	return rootDir, hash, nil
   181  }
   182  
   183  // guiVersionAndHash returns the version and the SHA256 hash of the current
   184  // Juju GUI archive.
   185  func guiVersionAndHash(st *state.State, storage binarystorage.Storage) (vers, hash string, err error) {
   186  	currentVers, err := st.GUIVersion()
   187  	if errors.IsNotFound(err) {
   188  		return "", "", errors.NotFoundf("Juju GUI")
   189  	}
   190  	if err != nil {
   191  		return "", "", errors.Annotate(err, "cannot retrieve current GUI version")
   192  	}
   193  	metadata, err := storage.Metadata(currentVers.String())
   194  	if err != nil {
   195  		return "", "", errors.Annotate(err, "cannot retrieve GUI metadata")
   196  	}
   197  	return metadata.Version, metadata.SHA256, nil
   198  }
   199  
   200  // uncompressGUI uncompresses the tar.bz2 Juju GUI archive provided in r.
   201  // The sourceDir directory included in the tar archive is copied to targetDir.
   202  func uncompressGUI(r io.Reader, sourceDir, targetDir string) error {
   203  	tempDir, err := ioutil.TempDir("", "gui")
   204  	if err != nil {
   205  		return errors.Annotate(err, "cannot create Juju GUI temporary directory")
   206  	}
   207  	defer os.Remove(tempDir)
   208  	tr := tar.NewReader(bzip2.NewReader(r))
   209  	for {
   210  		hdr, err := tr.Next()
   211  		if err == io.EOF {
   212  			break
   213  		}
   214  		if err != nil {
   215  			return errors.Annotate(err, "cannot parse archive")
   216  		}
   217  		if hdr.Name != sourceDir && !strings.HasPrefix(hdr.Name, sourceDir+"/") {
   218  			continue
   219  		}
   220  		path := filepath.Join(tempDir, hdr.Name)
   221  		info := hdr.FileInfo()
   222  		if info.IsDir() {
   223  			if err := os.MkdirAll(path, info.Mode()); err != nil {
   224  				return errors.Annotate(err, "cannot create directory")
   225  			}
   226  			continue
   227  		}
   228  		f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
   229  		if err != nil {
   230  			return errors.Annotate(err, "cannot open file")
   231  		}
   232  		defer f.Close()
   233  		if _, err := io.Copy(f, tr); err != nil {
   234  			return errors.Annotate(err, "cannot copy file content")
   235  		}
   236  	}
   237  	if err := os.Rename(filepath.Join(tempDir, sourceDir), targetDir); err != nil {
   238  		return errors.Annotate(err, "cannot rename Juju GUI root directory")
   239  	}
   240  	return nil
   241  }
   242  
   243  // guiHandler serves the Juju GUI.
   244  type guiHandler struct {
   245  	baseURLPath string
   246  	rootDir     string
   247  	hash        string
   248  	uuid        string
   249  }
   250  
   251  // serveStatic serves the GUI static files.
   252  func (h *guiHandler) serveStatic(w http.ResponseWriter, req *http.Request) {
   253  	staticDir := filepath.Join(h.rootDir, "static")
   254  	fs := http.FileServer(http.Dir(staticDir))
   255  	http.StripPrefix(h.hashedPath("static/"), fs).ServeHTTP(w, req)
   256  }
   257  
   258  // serveCombo serves the GUI JavaScript and CSS files, dynamically combined.
   259  func (h *guiHandler) serveCombo(w http.ResponseWriter, req *http.Request) {
   260  	ctype := ""
   261  	// The combo query is like /combo/?path/to/file1&path/to/file2 ...
   262  	parts := strings.Split(req.URL.RawQuery, "&")
   263  	paths := make([]string, 0, len(parts))
   264  	for _, p := range parts {
   265  		fpath, err := getGUIComboPath(h.rootDir, p)
   266  		if err != nil {
   267  			sendError(w, errors.Annotate(err, "cannot combine files"))
   268  			return
   269  		}
   270  		if fpath == "" {
   271  			continue
   272  		}
   273  		paths = append(paths, fpath)
   274  		// Assume the Juju GUI does not mix different content types when
   275  		// combining contents.
   276  		if ctype == "" {
   277  			ctype = mime.TypeByExtension(filepath.Ext(fpath))
   278  		}
   279  	}
   280  	w.Header().Set("Content-Type", ctype)
   281  	for _, fpath := range paths {
   282  		sendGUIComboFile(w, fpath)
   283  	}
   284  }
   285  
   286  func getGUIComboPath(rootDir, query string) (string, error) {
   287  	k := strings.SplitN(query, "=", 2)[0]
   288  	fname, err := url.QueryUnescape(k)
   289  	if err != nil {
   290  		return "", errors.NewBadRequest(err, fmt.Sprintf("invalid file name %q", k))
   291  	}
   292  	// Ignore pat injected queries.
   293  	if strings.HasPrefix(fname, ":") {
   294  		return "", nil
   295  	}
   296  	// The Juju GUI references its combined files starting from the
   297  	// "static/gui/build" directory.
   298  	fname = filepath.Clean(fname)
   299  	if fname == ".." || strings.HasPrefix(fname, "../") {
   300  		return "", errors.BadRequestf("forbidden file path %q", k)
   301  	}
   302  	return filepath.Join(rootDir, "static", "gui", "build", fname), nil
   303  }
   304  
   305  func sendGUIComboFile(w io.Writer, fpath string) {
   306  	f, err := os.Open(fpath)
   307  	if err != nil {
   308  		logger.Infof("cannot send combo file %q: %s", fpath, err)
   309  		return
   310  	}
   311  	defer f.Close()
   312  	if _, err := io.Copy(w, f); err != nil {
   313  		return
   314  	}
   315  	fmt.Fprintf(w, "\n/* %s */\n", filepath.Base(fpath))
   316  }
   317  
   318  // serveIndex serves the GUI index file.
   319  func (h *guiHandler) serveIndex(w http.ResponseWriter, req *http.Request) {
   320  	spriteFile := filepath.Join(h.rootDir, spritePath)
   321  	spriteContent, err := ioutil.ReadFile(spriteFile)
   322  	if err != nil {
   323  		sendError(w, errors.Annotate(err, "cannot read sprite file"))
   324  		return
   325  	}
   326  	tmpl := filepath.Join(h.rootDir, "templates", "index.html.go")
   327  	renderGUITemplate(w, tmpl, map[string]interface{}{
   328  		// staticURL holds the root of the static hierarchy, hence why the
   329  		// empty string is used here.
   330  		"staticURL": h.hashedPath(""),
   331  		"comboURL":  h.hashedPath("combo"),
   332  		"configURL": h.hashedPath("config.js"),
   333  		// TODO frankban: make it possible to enable debug.
   334  		"debug":         false,
   335  		"spriteContent": string(spriteContent),
   336  	})
   337  }
   338  
   339  // serveConfig serves the Juju GUI JavaScript configuration file.
   340  func (h *guiHandler) serveConfig(w http.ResponseWriter, req *http.Request) {
   341  	w.Header().Set("Content-Type", jsMimeType)
   342  	tmpl := filepath.Join(h.rootDir, "templates", "config.js.go")
   343  	renderGUITemplate(w, tmpl, map[string]interface{}{
   344  		"base":   h.baseURLPath,
   345  		"host":   req.Host,
   346  		"socket": "/model/$uuid/api",
   347  		// staticURL holds the root of the static hierarchy, hence why the
   348  		// empty string is used here.
   349  		"staticURL": h.hashedPath(""),
   350  		"uuid":      h.uuid,
   351  		"version":   jujuversion.Current.String(),
   352  	})
   353  }
   354  
   355  // hashedPath returns the gull path (including the GUI archive hash) to the
   356  // given path, that must not start with a slash.
   357  func (h *guiHandler) hashedPath(p string) string {
   358  	return path.Join(h.baseURLPath, h.hash, p)
   359  }
   360  
   361  func renderGUITemplate(w http.ResponseWriter, tmpl string, ctx map[string]interface{}) {
   362  	// TODO frankban: cache parsed template.
   363  	t, err := template.ParseFiles(tmpl)
   364  	if err != nil {
   365  		sendError(w, errors.Annotate(err, "cannot parse template"))
   366  		return
   367  	}
   368  	if err := t.Execute(w, ctx); err != nil {
   369  		sendError(w, errors.Annotate(err, "cannot render template"))
   370  	}
   371  }
   372  
   373  // guiArchiveHandler serves the Juju GUI archive endpoints, used for uploading
   374  // and retrieving information about GUI archives.
   375  type guiArchiveHandler struct {
   376  	ctxt httpContext
   377  }
   378  
   379  // ServeHTTP implements http.Handler.
   380  func (h *guiArchiveHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
   381  	var handler func(http.ResponseWriter, *http.Request) error
   382  	switch req.Method {
   383  	case "GET":
   384  		handler = h.handleGet
   385  	case "POST":
   386  		handler = h.handlePost
   387  	default:
   388  		sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method))
   389  		return
   390  	}
   391  	if err := handler(w, req); err != nil {
   392  		sendError(w, errors.Trace(err))
   393  	}
   394  }
   395  
   396  // handleGet returns information on Juju GUI archives in the controller.
   397  func (h *guiArchiveHandler) handleGet(w http.ResponseWriter, req *http.Request) error {
   398  	// Open the GUI archive storage.
   399  	st, err := h.ctxt.stateForRequestUnauthenticated(req)
   400  	if err != nil {
   401  		return errors.Annotate(err, "cannot open state")
   402  	}
   403  	storage, err := st.GUIStorage()
   404  	if err != nil {
   405  		return errors.Annotate(err, "cannot open GUI storage")
   406  	}
   407  	defer storage.Close()
   408  
   409  	// Retrieve metadata information.
   410  	allMeta, err := storage.AllMetadata()
   411  	if err != nil {
   412  		return errors.Annotate(err, "cannot retrieve GUI metadata")
   413  	}
   414  
   415  	// Prepare and send the response.
   416  	var currentVersion string
   417  	vers, err := st.GUIVersion()
   418  	if err == nil {
   419  		currentVersion = vers.String()
   420  	} else if !errors.IsNotFound(err) {
   421  		return errors.Annotate(err, "cannot retrieve current GUI version")
   422  	}
   423  	versions := make([]params.GUIArchiveVersion, len(allMeta))
   424  	for i, m := range allMeta {
   425  		vers, err := version.Parse(m.Version)
   426  		if err != nil {
   427  			return errors.Annotate(err, "cannot parse GUI version")
   428  		}
   429  		versions[i] = params.GUIArchiveVersion{
   430  			Version: vers,
   431  			SHA256:  m.SHA256,
   432  			Current: m.Version == currentVersion,
   433  		}
   434  	}
   435  	sendStatusAndJSON(w, http.StatusOK, params.GUIArchiveResponse{
   436  		Versions: versions,
   437  	})
   438  	return nil
   439  }
   440  
   441  // handlePost is used to upload new Juju GUI archives to the controller.
   442  func (h *guiArchiveHandler) handlePost(w http.ResponseWriter, req *http.Request) error {
   443  	// Validate the request.
   444  	if ctype := req.Header.Get("Content-Type"); ctype != bzMimeType {
   445  		return errors.BadRequestf("invalid content type %q: expected %q", ctype, bzMimeType)
   446  	}
   447  	if err := req.ParseForm(); err != nil {
   448  		return errors.Annotate(err, "cannot parse form")
   449  	}
   450  	versParam := req.Form.Get("version")
   451  	if versParam == "" {
   452  		return errors.BadRequestf("version parameter not provided")
   453  	}
   454  	vers, err := version.Parse(versParam)
   455  	if err != nil {
   456  		return errors.BadRequestf("invalid version parameter %q", versParam)
   457  	}
   458  	hashParam := req.Form.Get("hash")
   459  	if hashParam == "" {
   460  		return errors.BadRequestf("hash parameter not provided")
   461  	}
   462  	if req.ContentLength == -1 {
   463  		return errors.BadRequestf("content length not provided")
   464  	}
   465  
   466  	// Open the GUI archive storage.
   467  	st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req)
   468  	if err != nil {
   469  		return errors.Annotate(err, "cannot open state")
   470  	}
   471  	storage, err := st.GUIStorage()
   472  	if err != nil {
   473  		return errors.Annotate(err, "cannot open GUI storage")
   474  	}
   475  	defer storage.Close()
   476  
   477  	// Read and validate the archive data.
   478  	data, hash, err := readAndHash(req.Body)
   479  	size := int64(len(data))
   480  	if size != req.ContentLength {
   481  		return errors.BadRequestf("archive does not match provided content length")
   482  	}
   483  	if hash != hashParam {
   484  		return errors.BadRequestf("archive does not match provided hash")
   485  	}
   486  
   487  	// Add the archive to the GUI storage.
   488  	metadata := binarystorage.Metadata{
   489  		Version: vers.String(),
   490  		Size:    size,
   491  		SHA256:  hash,
   492  	}
   493  	if err := storage.Add(bytes.NewReader(data), metadata); err != nil {
   494  		return errors.Annotate(err, "cannot add GUI archive to storage")
   495  	}
   496  
   497  	// Prepare and return the response.
   498  	resp := params.GUIArchiveVersion{
   499  		Version: vers,
   500  		SHA256:  hash,
   501  	}
   502  	if currentVers, err := st.GUIVersion(); err == nil {
   503  		if currentVers == vers {
   504  			resp.Current = true
   505  		}
   506  	} else if !errors.IsNotFound(err) {
   507  		return errors.Annotate(err, "cannot retrieve current GUI version")
   508  	}
   509  	sendStatusAndJSON(w, http.StatusOK, resp)
   510  	return nil
   511  }
   512  
   513  // guiVersionHandler is used to select the Juju GUI version served by the
   514  // controller. The specified version must be available in the controller.
   515  type guiVersionHandler struct {
   516  	ctxt httpContext
   517  }
   518  
   519  // ServeHTTP implements http.Handler.
   520  func (h *guiVersionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
   521  	if req.Method != "PUT" {
   522  		sendError(w, errors.MethodNotAllowedf("unsupported method: %q", req.Method))
   523  		return
   524  	}
   525  	if err := h.handlePut(w, req); err != nil {
   526  		sendError(w, errors.Trace(err))
   527  	}
   528  }
   529  
   530  // handlePut is used to switch to a specific Juju GUI version.
   531  func (h *guiVersionHandler) handlePut(w http.ResponseWriter, req *http.Request) error {
   532  	// Validate the request.
   533  	if ctype := req.Header.Get("Content-Type"); ctype != params.ContentTypeJSON {
   534  		return errors.BadRequestf("invalid content type %q: expected %q", ctype, params.ContentTypeJSON)
   535  	}
   536  
   537  	// Authenticate the request and retrieve the Juju state.
   538  	st, _, err := h.ctxt.stateForRequestAuthenticatedUser(req)
   539  	if err != nil {
   540  		return errors.Annotate(err, "cannot open state")
   541  	}
   542  
   543  	var selected params.GUIVersionRequest
   544  	decoder := json.NewDecoder(req.Body)
   545  	if err := decoder.Decode(&selected); err != nil {
   546  		return errors.NewBadRequest(err, "invalid request body")
   547  	}
   548  
   549  	// Switch to the provided GUI version.
   550  	if err = st.GUISetVersion(selected.Version); err != nil {
   551  		return errors.Trace(err)
   552  	}
   553  	return nil
   554  }