~juju-qa/ubuntu/xenial/juju/xenial-2.0-beta3

« back to all changes in this revision

Viewing changes to src/github.com/juju/juju/apiserver/gui.go

  • Committer: Martin Packman
  • Date: 2016-03-30 19:31:08 UTC
  • mfrom: (1.1.41)
  • Revision ID: martin.packman@canonical.com-20160330193108-h9iz3ak334uk0z5r
Merge new upstream source 2.0~beta3

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
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
        "compress/bzip2"
 
9
        "fmt"
 
10
        "io"
 
11
        "io/ioutil"
 
12
        "mime"
 
13
        "net/http"
 
14
        "net/url"
 
15
        "os"
 
16
        "path"
 
17
        "path/filepath"
 
18
        "strings"
 
19
        "text/template"
 
20
 
 
21
        "github.com/bmizerany/pat"
 
22
        "github.com/juju/errors"
 
23
 
 
24
        agenttools "github.com/juju/juju/agent/tools"
 
25
        "github.com/juju/juju/state/binarystorage"
 
26
        "github.com/juju/juju/version"
 
27
)
 
28
 
 
29
var (
 
30
        jsMimeType = mime.TypeByExtension(".js")
 
31
        spritePath = filepath.FromSlash("static/gui/build/app/assets/stack/svg/sprite.css.svg")
 
32
)
 
33
 
 
34
// guiRouter serves the Juju GUI routes.
 
35
// Serving the Juju GUI is done with the following assumptions:
 
36
// - the archive is compressed in tar.bz2 format;
 
37
// - the archive includes a top directory named "jujugui-{version}" where
 
38
//   version is semver (like "2.0.1"). This directory includes another
 
39
//   "jujugui" directory where the actual Juju GUI files live;
 
40
// - the "jujugui" directory includes a "static" subdirectory with the Juju
 
41
//   GUI assets to be served statically;
 
42
// - the "jujugui" directory specifically includes a
 
43
//   "static/gui/build/app/assets/stack/svg/sprite.css.svg" file, which is
 
44
//   required to render the Juju GUI index file;
 
45
// - the "jujugui" directory includes a "templates/index.html.go" file which is
 
46
//   used to render the Juju GUI index. The template receives at least the
 
47
//   following variables in its context: "comboURL", "configURL", "debug"
 
48
//   and "spriteContent". It might receive more variables but cannot assume
 
49
//   them to be always provided;
 
50
// - the "jujugui" directory includes a "templates/config.js.go" file which is
 
51
//   used to render the Juju GUI configuration file. The template receives at
 
52
//   least the following variables in its context: "base", "host", "socket",
 
53
//   "uuid" and "version". It might receive more variables but cannot assume
 
54
//   them to be always provided.
 
55
type guiRouter struct {
 
56
        dataDir string
 
57
        ctxt    httpContext
 
58
        pattern string
 
59
}
 
60
 
 
61
// handleGUI adds the Juju GUI routes to the given serve mux.
 
62
// The given pattern is used as base URL path, and is assumed to include
 
63
// ":modeluuid" and a trailing slash.
 
64
func handleGUI(mux *pat.PatternServeMux, pattern string, dataDir string, ctxt httpContext) {
 
65
        gr := &guiRouter{
 
66
                dataDir: dataDir,
 
67
                ctxt:    ctxt,
 
68
                pattern: pattern,
 
69
        }
 
70
        guiHandleAll := func(pattern string, h func(*guiHandler, http.ResponseWriter, *http.Request)) {
 
71
                handleAll(mux, pattern, gr.ensureFileHandler(h))
 
72
        }
 
73
        hashedPattern := pattern + ":hash"
 
74
        guiHandleAll(hashedPattern+"/config.js", (*guiHandler).serveConfig)
 
75
        guiHandleAll(hashedPattern+"/combo", (*guiHandler).serveCombo)
 
76
        guiHandleAll(hashedPattern+"/static/", (*guiHandler).serveStatic)
 
77
        // The index is served when all remaining URLs are requested, so that
 
78
        // the single page JavaScript application can properly handles its routes.
 
79
        guiHandleAll(pattern, (*guiHandler).serveIndex)
 
80
}
 
81
 
 
82
// ensureFileHandler decorates the given function to ensure the Juju GUI files
 
83
// are available on disk.
 
84
func (gr *guiRouter) ensureFileHandler(h func(gh *guiHandler, w http.ResponseWriter, req *http.Request)) http.Handler {
 
85
        return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 
86
                rootDir, hash, err := gr.ensureFiles(req)
 
87
                if err != nil {
 
88
                        // Note that ensureFiles also checks that the model UUID is valid.
 
89
                        sendError(w, err)
 
90
                        return
 
91
                }
 
92
                qhash := req.URL.Query().Get(":hash")
 
93
                if qhash != "" && qhash != hash {
 
94
                        sendError(w, errors.NotFoundf("resource with %q hash", qhash))
 
95
                        return
 
96
                }
 
97
                uuid := req.URL.Query().Get(":modeluuid")
 
98
                gh := &guiHandler{
 
99
                        rootDir:     rootDir,
 
100
                        baseURLPath: strings.Replace(gr.pattern, ":modeluuid", uuid, -1),
 
101
                        hash:        hash,
 
102
                        uuid:        uuid,
 
103
                }
 
104
                h(gh, w, req)
 
105
        })
 
106
}
 
107
 
 
108
// ensureFiles checks that the GUI files are available on disk.
 
109
// If they are not, it means this is the first time this Juju GUI version is
 
110
// accessed. In this case, retrieve the Juju GUI archive from the storage and
 
111
// uncompress it to disk. This function returns the current GUI root directory
 
112
// and archive hash.
 
113
func (gr *guiRouter) ensureFiles(req *http.Request) (rootDir string, hash string, err error) {
 
114
        // Retrieve the Juju GUI info from the GUI storage.
 
115
        st, err := gr.ctxt.stateForRequestUnauthenticated(req)
 
116
        if err != nil {
 
117
                return "", "", errors.Annotate(err, "cannot open state")
 
118
        }
 
119
        storage, err := st.GUIStorage()
 
120
        if err != nil {
 
121
                return "", "", errors.Annotate(err, "cannot open GUI storage")
 
122
        }
 
123
        defer storage.Close()
 
124
        vers, hash, err := guiVersionAndHash(storage)
 
125
        if err != nil {
 
126
                return "", "", errors.Trace(err)
 
127
        }
 
128
        logger.Debugf("serving Juju GUI version %s", vers)
 
129
 
 
130
        // Check if the current Juju GUI archive has been already expanded on disk.
 
131
        baseDir := agenttools.SharedGUIDir(gr.dataDir)
 
132
        // Note that we include the hash in the root directory so that when the GUI
 
133
        // archive changes we can be sure that clients will not use files from
 
134
        // mixed versions.
 
135
        rootDir = filepath.Join(baseDir, hash)
 
136
        info, err := os.Stat(rootDir)
 
137
        if err == nil {
 
138
                if info.IsDir() {
 
139
                        return rootDir, hash, nil
 
140
                }
 
141
                return "", "", errors.Errorf("cannot use Juju GUI root directory %q: not a directory", rootDir)
 
142
        }
 
143
        if !os.IsNotExist(err) {
 
144
                return "", "", errors.Annotate(err, "cannot stat Juju GUI root directory")
 
145
        }
 
146
 
 
147
        // Fetch the Juju GUI archive from the GUI storage and expand it.
 
148
        _, r, err := storage.Open(vers)
 
149
        if err != nil {
 
150
                return "", "", errors.Annotatef(err, "cannot find GUI archive version %q", vers)
 
151
        }
 
152
        defer r.Close()
 
153
        if err := os.MkdirAll(baseDir, 0755); err != nil {
 
154
                return "", "", errors.Annotate(err, "cannot create Juju GUI base directory")
 
155
        }
 
156
        guiDir := "jujugui-" + vers + "/jujugui"
 
157
        if err := uncompressGUI(r, guiDir, rootDir); err != nil {
 
158
                return "", "", errors.Annotate(err, "cannot uncompress Juju GUI archive")
 
159
        }
 
160
        return rootDir, hash, nil
 
161
}
 
162
 
 
163
// guiVersionAndHash returns the version and the SHA256 hash of the current
 
164
// Juju GUI archive.
 
165
func guiVersionAndHash(storage binarystorage.Storage) (vers, hash string, err error) {
 
166
        // TODO frankban: retrieve current GUI version from somewhere.
 
167
        // For now, just return an arbitrary version from the storage.
 
168
        allMeta, err := storage.AllMetadata()
 
169
        if err != nil {
 
170
                return "", "", errors.Annotate(err, "cannot retrieve GUI metadata")
 
171
        }
 
172
        if len(allMeta) == 0 {
 
173
                return "", "", errors.NotFoundf("Juju GUI")
 
174
        }
 
175
        return allMeta[0].Version, allMeta[0].SHA256, nil
 
176
}
 
177
 
 
178
// uncompressGUI uncompresses the tar.bz2 Juju GUI archive provided in r.
 
179
// The sourceDir directory included in the tar archive is copied to targetDir.
 
180
func uncompressGUI(r io.Reader, sourceDir, targetDir string) error {
 
181
        tempDir, err := ioutil.TempDir("", "gui")
 
182
        if err != nil {
 
183
                return errors.Annotate(err, "cannot create Juju GUI temporary directory")
 
184
        }
 
185
        defer os.Remove(tempDir)
 
186
        tr := tar.NewReader(bzip2.NewReader(r))
 
187
        for {
 
188
                hdr, err := tr.Next()
 
189
                if err == io.EOF {
 
190
                        break
 
191
                }
 
192
                if err != nil {
 
193
                        return errors.Annotate(err, "cannot parse archive")
 
194
                }
 
195
                if hdr.Name != sourceDir && !strings.HasPrefix(hdr.Name, sourceDir+"/") {
 
196
                        continue
 
197
                }
 
198
                path := filepath.Join(tempDir, hdr.Name)
 
199
                info := hdr.FileInfo()
 
200
                if info.IsDir() {
 
201
                        if err := os.MkdirAll(path, info.Mode()); err != nil {
 
202
                                return errors.Annotate(err, "cannot create directory")
 
203
                        }
 
204
                        continue
 
205
                }
 
206
                f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
 
207
                if err != nil {
 
208
                        return errors.Annotate(err, "cannot open file")
 
209
                }
 
210
                defer f.Close()
 
211
                if _, err := io.Copy(f, tr); err != nil {
 
212
                        return errors.Annotate(err, "cannot copy file content")
 
213
                }
 
214
        }
 
215
        if err := os.Rename(filepath.Join(tempDir, sourceDir), targetDir); err != nil {
 
216
                return errors.Annotate(err, "cannot rename Juju GUI root directory")
 
217
        }
 
218
        return nil
 
219
}
 
220
 
 
221
// guiHandler serves the Juju GUI.
 
222
type guiHandler struct {
 
223
        baseURLPath string
 
224
        rootDir     string
 
225
        hash        string
 
226
        uuid        string
 
227
}
 
228
 
 
229
// serveStatic serves the GUI static files.
 
230
func (h *guiHandler) serveStatic(w http.ResponseWriter, req *http.Request) {
 
231
        staticDir := filepath.Join(h.rootDir, "static")
 
232
        fs := http.FileServer(http.Dir(staticDir))
 
233
        http.StripPrefix(h.hashedPath("static/"), fs).ServeHTTP(w, req)
 
234
}
 
235
 
 
236
// serveCombo serves the GUI JavaScript and CSS files, dynamically combined.
 
237
func (h *guiHandler) serveCombo(w http.ResponseWriter, req *http.Request) {
 
238
        ctype := ""
 
239
        // The combo query is like /combo/?path/to/file1&path/to/file2 ...
 
240
        parts := strings.Split(req.URL.RawQuery, "&")
 
241
        paths := make([]string, 0, len(parts))
 
242
        for _, p := range parts {
 
243
                fpath, err := getGUIComboPath(h.rootDir, p)
 
244
                if err != nil {
 
245
                        sendError(w, errors.Annotate(err, "cannot combine files"))
 
246
                        return
 
247
                }
 
248
                if fpath == "" {
 
249
                        continue
 
250
                }
 
251
                paths = append(paths, fpath)
 
252
                // Assume the Juju GUI does not mix different content types when
 
253
                // combining contents.
 
254
                if ctype == "" {
 
255
                        ctype = mime.TypeByExtension(filepath.Ext(fpath))
 
256
                }
 
257
        }
 
258
        w.Header().Set("Content-Type", ctype)
 
259
        for _, fpath := range paths {
 
260
                sendGUIComboFile(w, fpath)
 
261
        }
 
262
}
 
263
 
 
264
func getGUIComboPath(rootDir, query string) (string, error) {
 
265
        k := strings.SplitN(query, "=", 2)[0]
 
266
        fname, err := url.QueryUnescape(k)
 
267
        if err != nil {
 
268
                return "", errors.NewBadRequest(err, fmt.Sprintf("invalid file name %q", k))
 
269
        }
 
270
        // Ignore pat injected queries.
 
271
        if strings.HasPrefix(fname, ":") {
 
272
                return "", nil
 
273
        }
 
274
        // The Juju GUI references its combined files starting from the
 
275
        // "static/gui/build" directory.
 
276
        fname = filepath.Clean(fname)
 
277
        if fname == ".." || strings.HasPrefix(fname, "../") {
 
278
                return "", errors.BadRequestf("forbidden file path %q", k)
 
279
        }
 
280
        return filepath.Join(rootDir, "static", "gui", "build", fname), nil
 
281
}
 
282
 
 
283
func sendGUIComboFile(w io.Writer, fpath string) {
 
284
        f, err := os.Open(fpath)
 
285
        if err != nil {
 
286
                logger.Infof("cannot send combo file %q: %s", fpath, err)
 
287
                return
 
288
        }
 
289
        defer f.Close()
 
290
        if _, err := io.Copy(w, f); err != nil {
 
291
                return
 
292
        }
 
293
        fmt.Fprintf(w, "\n/* %s */\n", filepath.Base(fpath))
 
294
}
 
295
 
 
296
// serveIndex serves the GUI index file.
 
297
func (h *guiHandler) serveIndex(w http.ResponseWriter, req *http.Request) {
 
298
        spriteFile := filepath.Join(h.rootDir, spritePath)
 
299
        spriteContent, err := ioutil.ReadFile(spriteFile)
 
300
        if err != nil {
 
301
                sendError(w, errors.Annotate(err, "cannot read sprite file"))
 
302
                return
 
303
        }
 
304
        tmpl := filepath.Join(h.rootDir, "templates", "index.html.go")
 
305
        renderGUITemplate(w, tmpl, map[string]interface{}{
 
306
                "comboURL":  h.hashedPath("combo"),
 
307
                "configURL": h.hashedPath("config.js"),
 
308
                // TODO frankban: make it possible to enable debug.
 
309
                "debug":         false,
 
310
                "spriteContent": string(spriteContent),
 
311
        })
 
312
}
 
313
 
 
314
// serveConfig serves the Juju GUI JavaScript configuration file.
 
315
func (h *guiHandler) serveConfig(w http.ResponseWriter, req *http.Request) {
 
316
        w.Header().Set("Content-Type", jsMimeType)
 
317
        tmpl := filepath.Join(h.rootDir, "templates", "config.js.go")
 
318
        renderGUITemplate(w, tmpl, map[string]interface{}{
 
319
                "base":    h.baseURLPath,
 
320
                "host":    req.Host,
 
321
                "socket":  "/model/$uuid/api",
 
322
                "uuid":    h.uuid,
 
323
                "version": version.Current.String(),
 
324
        })
 
325
}
 
326
 
 
327
// hashedPath returns the gull path (including the GUI archive hash) to the
 
328
// given path, that must not start with a slash.
 
329
func (h *guiHandler) hashedPath(p string) string {
 
330
        return path.Join(h.baseURLPath, h.hash, p)
 
331
}
 
332
 
 
333
func renderGUITemplate(w http.ResponseWriter, tmpl string, ctx map[string]interface{}) {
 
334
        // TODO frankban: cache parsed template.
 
335
        t, err := template.ParseFiles(tmpl)
 
336
        if err != nil {
 
337
                sendError(w, errors.Annotate(err, "cannot parse template"))
 
338
                return
 
339
        }
 
340
        if err := t.Execute(w, ctx); err != nil {
 
341
                sendError(w, errors.Annotate(err, "cannot render template"))
 
342
        }
 
343
}