Merge lp:~frankban/juju-core/get-charm-api into lp:~go-bot/juju-core/trunk

Proposed by Francesco Banconi
Status: Merged
Approved by: Francesco Banconi
Approved revision: no longer in the source branch.
Merged at revision: 2355
Proposed branch: lp:~frankban/juju-core/get-charm-api
Merge into: lp:~go-bot/juju-core/trunk
Diff against target: 555 lines (+356/-30)
4 files modified
state/api/params/internal.go (+4/-3)
state/apiserver/apiserver.go (+1/-1)
state/apiserver/charms.go (+168/-3)
state/apiserver/charms_test.go (+183/-23)
To merge this branch: bzr merge lp:~frankban/juju-core/get-charm-api
Reviewer Review Type Date Requested Status
Juju Engineering Pending
Review via email: mp+207994@code.launchpad.net

Commit message

Implement the get charm file API.

Add to the HTTPS API server the ability
to serve local charm files.

As discussed with Rick and Dimiter if the query includes the file,
then the file contents are served. If file points to a directory
an error is returned. If the query does not include a file, then
a JSON response is sent including a list of charm files.

Description of the change

Implement the get charm file API.

Add to the HTTPS API server the ability
to serve local charm files.

As discussed with Rick and Dimiter if the query includes the file,
then the file contents are served. If file points to a directory
an error is returned. If the query does not include a file, then
a JSON response is sent including a list of charm files.

QA:
- Download the Ghost charm from https://github.com/hatched/ghost-charm
  (the zip archive can be downloaded from the sidebar on the right).
- Bootstrap a local environment using this branch.
- Upload the ghost local charm, e.g.:
  `curl -ikL --data-binary @/path/to/ghost-charm-master.zip -H "Content-Type: application/zip" https://user-admin:MYPASSWD@10.0.3.1:17070/charms?series=precise`.
  The response should be something like: {"CharmURL":"local:precise/ghost-4"}.
- Download ghost charm files, e.g.:
  `curl -ikLG -d "url=local:precise/ghost-4&file=icon.svg" https://user-admin:MYPASSWD@10.0.3.1:17070/charms`
  or
  `curl -ikLG -d "url=local:precise/ghost-4&file=hooks/install" https://user-admin:MYPASSWD@10.0.3.1:17070/charms`:
  you should see the file contents.
- Ensure directories are not allowed, e.g.:
  `curl -ikLG -d "url=local:precise/ghost-4&file=hooks" https://user-admin:MYPASSWD@10.0.3.1:17070/charms`: you should see a 403 forbidden.
- Retrieve a list of the charm files:
  `curl -ikLG -d "url=local:precise/ghost-4" https://user-admin:MYPASSWD@10.0.3.1:17070/charms`.
- Done, destroy the environment, thank you!

https://codereview.appspot.com/67750045/

To post a comment you must log in.
Revision history for this message
Francesco Banconi (frankban) wrote :

Reviewers: mp+207994_code.launchpad.net,

Message:
Please take a look.

Description:
Implement the get charm file API.

Add to the HTTPS API server the ability
to serve local charm files.

As discussed with Rick and Dimiter if the query includes the file,
then the file contents are served. If file points to a directory
an error is returned. If the query does not include a file, then
a JSON response is sent including a list of charm files.

QA:
- Download the Ghost charm from https://github.com/hatched/ghost-charm
   (the zip archive can be downloaded from the sidebar on the right).
- Bootstrap a local environment using this branch.
- Upload the ghost local charm, e.g.:
   `curl -ikL --data-binary @/path/to/ghost-charm-master.zip -H
"Content-Type: application/zip"
https://user-admin:MYPASSWD@10.0.3.1:17070/charms?series=precise`.
   The response should be something like:
{"CharmURL":"local:precise/ghost-4"}.
- Download ghost charm files, e.g.:
   `curl -ikLG -d "url=local:precise/ghost-4&file=icon.svg"
https://user-admin:MYPASSWD@10.0.3.1:17070/charms`
   or
   `curl -ikLG -d "url=local:precise/ghost-4&file=hooks/install"
https://user-admin:MYPASSWD@10.0.3.1:17070/charms`:
   you should see the file contents.
- Ensure directories are not allowed, e.g.:
   `curl -ikLG -d "url=local:precise/ghost-4&file=hooks"
https://user-admin:MYPASSWD@10.0.3.1:17070/charms`: you should see a 403
forbidden.
- Retrieve a list of the charm files:
   `curl -ikLG -d "url=local:precise/ghost-4"
https://user-admin:MYPASSWD@10.0.3.1:17070/charms`.
- Done, destroy the environment, thank you!

https://code.launchpad.net/~frankban/juju-core/get-charm-api/+merge/207994

(do not edit description out of merge proposal)

Please review this at https://codereview.appspot.com/67750045/

Affected files (+352, -30 lines):
   A [revision details]
   M state/api/params/internal.go
   M state/apiserver/apiserver.go
   M state/apiserver/charms.go
   M state/apiserver/charms_test.go

Revision history for this message
Dimiter Naydenov (dimitern) wrote :

LGTM, thanks for doing this!

https://codereview.appspot.com/67750045/diff/1/state/api/params/internal.go
File state/api/params/internal.go (right):

https://codereview.appspot.com/67750045/diff/1/state/api/params/internal.go#newcode509
state/api/params/internal.go:509: // CharmsResponse is the server
response to charm upload or get requests.
s/get/GET/

https://codereview.appspot.com/67750045/diff/1/state/apiserver/charms.go
File state/apiserver/charms.go (right):

https://codereview.appspot.com/67750045/diff/1/state/apiserver/charms.go#newcode540
state/apiserver/charms.go:540: if !strings.HasPrefix(filePath,
bundlePath+"/") {
Please, use filepath.Dir(filePath) != bundlePath here instead.

https://codereview.appspot.com/67750045/diff/1/state/apiserver/charms_test.go
File state/apiserver/charms_test.go (right):

https://codereview.appspot.com/67750045/diff/1/state/apiserver/charms_test.go#newcode249
state/apiserver/charms_test.go:249: "expected url=CharmURL query
argument")
I'd prefer if you formatted this and other places where
assertErrorResponse is too long, like this:

s.assertErrorResponse(
     c, resp, http.StatusBadRequest,
     expected url=CharmURL query argument",
)

https://codereview.appspot.com/67750045/

Revision history for this message
Dimiter Naydenov (dimitern) wrote :

On second though and after discussion with the team on today's standup,
some changes are needed before this can land. Please ping me to take a
look again when ready.

Instead of downloading AND extracting the charm, we got a general
agreement you should cache the charm archive, and then use something
like charm.zipOpen to read a single file from it, giving a path. You can
also simplify file path related operations, because the zip reader
allows you to range over all files, including their paths and do simple
matching urlFileArg == zipFile.Name(). Look into findArchiveRootDir and
fixPath inside the charms handler in apiserver.

https://codereview.appspot.com/67750045/

Revision history for this message
Francesco Banconi (frankban) wrote :

Please take a look.

https://codereview.appspot.com/67750045/diff/1/state/api/params/internal.go
File state/api/params/internal.go (right):

https://codereview.appspot.com/67750045/diff/1/state/api/params/internal.go#newcode509
state/api/params/internal.go:509: // CharmsResponse is the server
response to charm upload or get requests.
On 2014/02/25 10:07:09, dimitern wrote:
> s/get/GET/

Done.

https://codereview.appspot.com/67750045/diff/1/state/apiserver/charms_test.go
File state/apiserver/charms_test.go (right):

https://codereview.appspot.com/67750045/diff/1/state/apiserver/charms_test.go#newcode249
state/apiserver/charms_test.go:249: "expected url=CharmURL query
argument")
On 2014/02/25 10:07:09, dimitern wrote:
> I'd prefer if you formatted this and other places where
assertErrorResponse is
> too long, like this:

> s.assertErrorResponse(
> c, resp, http.StatusBadRequest,
> expected url=CharmURL query argument",
> )

Done.

https://codereview.appspot.com/67750045/

Revision history for this message
Dimiter Naydenov (dimitern) wrote :
Download full text (3.6 KiB)

Much better, thanks! LGTM with just a few formatting suggestions below.
Make sure you've run gofmt on the file, because indentation looks
inconsistent in places.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go
File state/apiserver/charms.go (right):

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode125
state/apiserver/charms.go:125: for _, file := range reader.File {
gofmt again?

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode138
state/apiserver/charms.go:138: } else {
Instead of else, you could just return in the if block above.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode511
state/apiserver/charms.go:511: // processGet handles a charm file
download GET request after authentication.
s/download//

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode546
state/apiserver/charms.go:546: // save the corresponding zip archive to
the given charmArchivePath.
s/save/saves/

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode580
state/apiserver/charms.go:580: if err =
os.Rename(tempCharmArchive.Name(), charmArchivePath); err != nil {
In this case I think you should defer os.Remove(tempCharmArchive.Name())
here.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go
File state/apiserver/charms_test.go (right):

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode265
state/apiserver/charms_test.go:265: func (s *charmsSuite)
TestGetReturnsFileNotFound(c *gc.C) {
s/TestGetReturnsFileNotFound/TestGetReturnsNotFoundWhenMissing/ ?

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode271
state/apiserver/charms_test.go:271: // Ensure a 404 is returned fo files
not included in the charm.
s/fo/for/

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode271
state/apiserver/charms_test.go:271: // Ensure a 404 is returned fo files
not included in the charm.
Please, put a blank line before this one, for readability (I'll use "b"
in all similar cases below).

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode283
state/apiserver/charms_test.go:283: func (s *charmsSuite)
TestGetReturnsDirectoryForbidden(c *gc.C) {
Similarly,
s/TestGetReturnsDirectoryForbidden/TestGetReturnsForbiddenWithDirectory/
?

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode289
state/apiserver/charms_test.go:289: // Ensure a 403 is returned if the
requested file is a directory.
b

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode302
state/apiserver/charms_test.go:302: // Ensure the file contents are
properly returned.
b

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode335
state/apiserver/charms_test.go:335: // Ensure charm files are properly
listed.
b

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode353
state/apiserver/charms_test.go:353: // Create and save t...

Read more...

Revision history for this message
Francesco Banconi (frankban) wrote :

Please take a look.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go
File state/apiserver/charms.go (right):

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode125
state/apiserver/charms.go:125: for _, file := range reader.File {
On 2014/02/25 17:40:03, dimitern wrote:
> gofmt again?

Done, and it seems already ok.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode138
state/apiserver/charms.go:138: } else {
On 2014/02/25 17:40:03, dimitern wrote:
> Instead of else, you could just return in the if block above.

We need contents in the scope, that's why I used else.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode511
state/apiserver/charms.go:511: // processGet handles a charm file
download GET request after authentication.
On 2014/02/25 17:40:03, dimitern wrote:
> s/download//

Done.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode546
state/apiserver/charms.go:546: // save the corresponding zip archive to
the given charmArchivePath.
On 2014/02/25 17:40:03, dimitern wrote:
> s/save/saves/

Done.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode580
state/apiserver/charms.go:580: if err =
os.Rename(tempCharmArchive.Name(), charmArchivePath); err != nil {
On 2014/02/25 17:40:03, dimitern wrote:
> In this case I think you should defer
os.Remove(tempCharmArchive.Name()) here.

Good catch, fixed.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go
File state/apiserver/charms_test.go (right):

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode265
state/apiserver/charms_test.go:265: func (s *charmsSuite)
TestGetReturnsFileNotFound(c *gc.C) {
On 2014/02/25 17:40:03, dimitern wrote:
> s/TestGetReturnsFileNotFound/TestGetReturnsNotFoundWhenMissing/ ?

Done.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode271
state/apiserver/charms_test.go:271: // Ensure a 404 is returned fo files
not included in the charm.
On 2014/02/25 17:40:03, dimitern wrote:
> s/fo/for/

Done.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode283
state/apiserver/charms_test.go:283: func (s *charmsSuite)
TestGetReturnsDirectoryForbidden(c *gc.C) {
On 2014/02/25 17:40:03, dimitern wrote:
> Similarly,

s/TestGetReturnsDirectoryForbidden/TestGetReturnsForbiddenWithDirectory/
?

Done.

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms_test.go#newcode441
state/apiserver/charms_test.go:441: ctype :=
resp.Header.Get("content-type")
On 2014/02/25 17:40:03, dimitern wrote:
> s/content-type/Content-Type/

Done.

https://codereview.appspot.com/67750045/

Revision history for this message
Francesco Banconi (frankban) wrote :
Revision history for this message
Dimiter Naydenov (dimitern) wrote :

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go
File state/apiserver/charms.go (right):

https://codereview.appspot.com/67750045/diff/20001/state/apiserver/charms.go#newcode138
state/apiserver/charms.go:138: } else {
On 2014/02/26 09:34:28, frankban wrote:
> On 2014/02/25 17:40:03, dimitern wrote:
> > Instead of else, you could just return in the if block above.

> We need contents in the scope, that's why I used else.

Then you can just move the assignment out of the if.

https://codereview.appspot.com/67750045/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'state/api/params/internal.go'
--- state/api/params/internal.go 2014-02-03 11:45:15 +0000
+++ state/api/params/internal.go 2014-02-26 09:13:18 +0000
@@ -506,10 +506,11 @@
506 Results []RelationUnitsWatchResult506 Results []RelationUnitsWatchResult
507}507}
508508
509// CharmsResponse is the server response to a charm upload request.509// CharmsResponse is the server response to charm upload or GET requests.
510type CharmsResponse struct {510type CharmsResponse struct {
511 Error string `json:",omitempty"`511 Error string `json:",omitempty"`
512 CharmURL string `json:",omitempty"`512 CharmURL string `json:",omitempty"`
513 Files []string `json:",omitempty"`
513}514}
514515
515// RunParams is used to provide the parameters to the Run method.516// RunParams is used to provide the parameters to the Run method.
516517
=== modified file 'state/apiserver/apiserver.go'
--- state/apiserver/apiserver.go 2014-02-04 10:18:04 +0000
+++ state/apiserver/apiserver.go 2014-02-26 09:13:18 +0000
@@ -152,7 +152,7 @@
152 }()152 }()
153 mux := http.NewServeMux()153 mux := http.NewServeMux()
154 mux.HandleFunc("/", srv.apiHandler)154 mux.HandleFunc("/", srv.apiHandler)
155 mux.Handle("/charms", &charmsHandler{state: srv.state})155 mux.Handle("/charms", &charmsHandler{state: srv.state, dataDir: srv.dataDir})
156 // The error from http.Serve is not interesting.156 // The error from http.Serve is not interesting.
157 http.Serve(lis, mux)157 http.Serve(lis, mux)
158}158}
159159
=== modified file 'state/apiserver/charms.go'
--- state/apiserver/charms.go 2014-02-03 14:31:54 +0000
+++ state/apiserver/charms.go 2014-02-26 09:13:18 +0000
@@ -12,10 +12,12 @@
12 "fmt"12 "fmt"
13 "io"13 "io"
14 "io/ioutil"14 "io/ioutil"
15 "mime"
15 "net/http"16 "net/http"
16 "net/url"17 "net/url"
17 "os"18 "os"
18 "path/filepath"19 "path/filepath"
20 "strconv"
19 "strings"21 "strings"
2022
21 "github.com/errgo/errgo"23 "github.com/errgo/errgo"
@@ -30,9 +32,14 @@
3032
31// charmsHandler handles charm upload through HTTPS in the API server.33// charmsHandler handles charm upload through HTTPS in the API server.
32type charmsHandler struct {34type charmsHandler struct {
33 state *state.State35 state *state.State
36 dataDir string
34}37}
3538
39// zipContentsSenderFunc functions are responsible of sending a zip archive
40// related response. The zip archive can be accessed through the given reader.
41type zipContentsSenderFunc func(w http.ResponseWriter, r *http.Request, reader *zip.ReadCloser)
42
36func (h *charmsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {43func (h *charmsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
37 if err := h.authenticate(r); err != nil {44 if err := h.authenticate(r); err != nil {
38 h.authError(w)45 h.authError(w)
@@ -41,13 +48,28 @@
4148
42 switch r.Method {49 switch r.Method {
43 case "POST":50 case "POST":
51 // Add a local charm to the store provider.
52 // Requires a "series" query specifying the series to use for the charm.
44 charmURL, err := h.processPost(r)53 charmURL, err := h.processPost(r)
45 if err != nil {54 if err != nil {
46 h.sendError(w, http.StatusBadRequest, err.Error())55 h.sendError(w, http.StatusBadRequest, err.Error())
47 return56 return
48 }57 }
49 h.sendJSON(w, http.StatusOK, &params.CharmsResponse{CharmURL: charmURL.String()})58 h.sendJSON(w, http.StatusOK, &params.CharmsResponse{CharmURL: charmURL.String()})
50 // Possible future extensions, like GET.59 case "GET":
60 // Retrieve or list charm files.
61 // Requires "url" (charm URL) and an optional "file" (the path to the
62 // charm file) to be included in the query.
63 if charmArchivePath, filePath, err := h.processGet(r); err != nil {
64 // An error occurred retrieving the charm bundle.
65 h.sendError(w, http.StatusBadRequest, err.Error())
66 } else if filePath == "" {
67 // The client requested the list of charm files.
68 sendZipContents(w, r, charmArchivePath, h.fileListSender)
69 } else {
70 // The client requested a specific file.
71 sendZipContents(w, r, charmArchivePath, h.fileSender(filePath))
72 }
51 default:73 default:
52 h.sendError(w, http.StatusMethodNotAllowed, fmt.Sprintf("unsupported method: %q", r.Method))74 h.sendError(w, http.StatusMethodNotAllowed, fmt.Sprintf("unsupported method: %q", r.Method))
53 }75 }
@@ -55,6 +77,7 @@
5577
56// sendJSON sends a JSON-encoded response to the client.78// sendJSON sends a JSON-encoded response to the client.
57func (h *charmsHandler) sendJSON(w http.ResponseWriter, statusCode int, response *params.CharmsResponse) error {79func (h *charmsHandler) sendJSON(w http.ResponseWriter, statusCode int, response *params.CharmsResponse) error {
80 w.Header().Set("Content-Type", "application/json")
58 w.WriteHeader(statusCode)81 w.WriteHeader(statusCode)
59 body, err := json.Marshal(response)82 body, err := json.Marshal(response)
60 if err != nil {83 if err != nil {
@@ -64,6 +87,72 @@
64 return nil87 return nil
65}88}
6689
90// sendZipContents uses the given zipContentsSenderFunc to send a response
91// related to the zip archive located in the given archivePath.
92func sendZipContents(w http.ResponseWriter, r *http.Request, archivePath string, zipContentsSender zipContentsSenderFunc) {
93 reader, err := zip.OpenReader(archivePath)
94 if err != nil {
95 http.Error(
96 w, fmt.Sprintf("unable to read archive in %q: %v", archivePath, err),
97 http.StatusInternalServerError)
98 return
99 }
100 defer reader.Close()
101 // The zipContentsSenderFunc will handle the zip contents, set up and send
102 // an appropriate response.
103 zipContentsSender(w, r, reader)
104}
105
106// fileListSender sends a JSON-encoded response to the client including the
107// list of files contained in the zip archive.
108func (h *charmsHandler) fileListSender(w http.ResponseWriter, r *http.Request, reader *zip.ReadCloser) {
109 var files []string
110 for _, file := range reader.File {
111 fileInfo := file.FileInfo()
112 if !fileInfo.IsDir() {
113 files = append(files, file.Name)
114 }
115 }
116 h.sendJSON(w, http.StatusOK, &params.CharmsResponse{Files: files})
117}
118
119// fileSender returns a zipContentsSenderFunc which is responsible of sending
120// the contents of filePath included in the given zip.
121// A 404 page not found is returned if path does not exist in the zip.
122// A 403 forbidden error is returned if path points to a directory.
123func (h *charmsHandler) fileSender(filePath string) zipContentsSenderFunc {
124 return func(w http.ResponseWriter, r *http.Request, reader *zip.ReadCloser) {
125 for _, file := range reader.File {
126 if h.fixPath(file.Name) != filePath {
127 continue
128 }
129 fileInfo := file.FileInfo()
130 if fileInfo.IsDir() {
131 http.Error(w, "directory listing not allowed", http.StatusForbidden)
132 return
133 }
134 if contents, err := file.Open(); err != nil {
135 http.Error(
136 w, fmt.Sprintf("unable to read file %q: %v", filePath, err),
137 http.StatusInternalServerError)
138 return
139 } else {
140 defer contents.Close()
141 ctype := mime.TypeByExtension(filepath.Ext(filePath))
142 if ctype != "" {
143 w.Header().Set("Content-Type", ctype)
144 }
145 w.Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10))
146 w.WriteHeader(http.StatusOK)
147 io.Copy(w, contents)
148 }
149 return
150 }
151 http.NotFound(w, r)
152 return
153 }
154}
155
67// sendError sends a JSON-encoded error response.156// sendError sends a JSON-encoded error response.
68func (h *charmsHandler) sendError(w http.ResponseWriter, statusCode int, message string) error {157func (h *charmsHandler) sendError(w http.ResponseWriter, statusCode int, message string) error {
69 return h.sendJSON(w, statusCode, &params.CharmsResponse{Error: message})158 return h.sendJSON(w, statusCode, &params.CharmsResponse{Error: message})
@@ -113,7 +202,7 @@
113 query := r.URL.Query()202 query := r.URL.Query()
114 series := query.Get("series")203 series := query.Get("series")
115 if series == "" {204 if series == "" {
116 return nil, fmt.Errorf("expected series= URL argument")205 return nil, fmt.Errorf("expected series=URL argument")
117 }206 }
118 // Make sure the content type is zip.207 // Make sure the content type is zip.
119 contentType := r.Header.Get("Content-Type")208 contentType := r.Header.Get("Content-Type")
@@ -419,3 +508,79 @@
419 }508 }
420 return nil509 return nil
421}510}
511
512// processGet handles a charm file GET request after authentication.
513// It returns the bundle path, the requested file path (if any) and an error.
514func (h *charmsHandler) processGet(r *http.Request) (string, string, error) {
515 query := r.URL.Query()
516
517 // Retrieve and validate query parameters.
518 curl := query.Get("url")
519 if curl == "" {
520 return "", "", fmt.Errorf("expected url=CharmURL query argument")
521 }
522 var filePath string
523 file := query.Get("file")
524 if file == "" {
525 filePath = ""
526 } else {
527 filePath = h.fixPath(file)
528 }
529
530 // Prepare the bundle directories.
531 name := charm.Quote(curl)
532 charmArchivePath := filepath.Join(h.dataDir, "charm-get-cache", name+".zip")
533
534 // Check if the charm archive is already in the cache.
535 if _, err := os.Stat(charmArchivePath); os.IsNotExist(err) {
536 // Download the charm archive and save it to the cache.
537 if err = h.downloadCharm(name, charmArchivePath); err != nil {
538 return "", "", fmt.Errorf("unable to retrieve and save the charm: %v", err)
539 }
540 } else if err != nil {
541 return "", "", fmt.Errorf("cannot access the charms cache: %v", err)
542 }
543 return charmArchivePath, filePath, nil
544}
545
546// downloadCharm downloads the given charm name from the provider storage and
547// saves the corresponding zip archive to the given charmArchivePath.
548func (h *charmsHandler) downloadCharm(name, charmArchivePath string) error {
549 // Get the provider storage.
550 storage, err := envtesting.GetEnvironStorage(h.state)
551 if err != nil {
552 return errgo.Annotate(err, "cannot access provider storage")
553 }
554
555 // Use the storage to retrieve and save the charm archive.
556 reader, err := storage.Get(name)
557 if err != nil {
558 return errgo.Annotate(err, "charm not found in the provider storage")
559 }
560 defer reader.Close()
561 data, err := ioutil.ReadAll(reader)
562 if err != nil {
563 return errgo.Annotate(err, "cannot read charm data")
564 }
565 // In order to avoid races, the archive is saved in a temporary file which
566 // is then atomically renamed. The temporary file is created in the
567 // charm cache directory so that we can safely assume the rename source and
568 // target live in the same file system.
569 cacheDir := filepath.Dir(charmArchivePath)
570 if err = os.MkdirAll(cacheDir, 0755); err != nil {
571 return errgo.Annotate(err, "cannot create the charms cache")
572 }
573 tempCharmArchive, err := ioutil.TempFile(cacheDir, "charm")
574 if err != nil {
575 return errgo.Annotate(err, "cannot create charm archive temp file")
576 }
577 defer tempCharmArchive.Close()
578 if err = ioutil.WriteFile(tempCharmArchive.Name(), data, 0644); err != nil {
579 return errgo.Annotate(err, "error processing charm archive download")
580 }
581 if err = os.Rename(tempCharmArchive.Name(), charmArchivePath); err != nil {
582 defer os.Remove(tempCharmArchive.Name())
583 return errgo.Annotate(err, "error renaming the charm archive")
584 }
585 return nil
586}
422587
=== modified file 'state/apiserver/charms_test.go'
--- state/apiserver/charms_test.go 2014-01-30 16:55:00 +0000
+++ state/apiserver/charms_test.go 2014-02-26 09:13:18 +0000
@@ -4,6 +4,8 @@
4package apiserver_test4package apiserver_test
55
6import (6import (
7 "archive/zip"
8 "bytes"
7 "encoding/json"9 "encoding/json"
8 "fmt"10 "fmt"
9 "io"11 "io"
@@ -55,13 +57,13 @@
55func (s *charmsSuite) TestRequiresAuth(c *gc.C) {57func (s *charmsSuite) TestRequiresAuth(c *gc.C) {
56 resp, err := s.sendRequest(c, "", "", "GET", s.charmsURI(c, ""), "", nil)58 resp, err := s.sendRequest(c, "", "", "GET", s.charmsURI(c, ""), "", nil)
57 c.Assert(err, gc.IsNil)59 c.Assert(err, gc.IsNil)
58 s.assertResponse(c, resp, http.StatusUnauthorized, "unauthorized", "")60 s.assertErrorResponse(c, resp, http.StatusUnauthorized, "unauthorized")
59}61}
6062
61func (s *charmsSuite) TestUploadRequiresPOST(c *gc.C) {63func (s *charmsSuite) TestRequiresPOSTorGET(c *gc.C) {
62 resp, err := s.authRequest(c, "GET", s.charmsURI(c, ""), "", nil)64 resp, err := s.authRequest(c, "PUT", s.charmsURI(c, ""), "", nil)
63 c.Assert(err, gc.IsNil)65 c.Assert(err, gc.IsNil)
64 s.assertResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "GET"`, "")66 s.assertErrorResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "PUT"`)
65}67}
6668
67func (s *charmsSuite) TestAuthRequiresUser(c *gc.C) {69func (s *charmsSuite) TestAuthRequiresUser(c *gc.C) {
@@ -77,18 +79,18 @@
7779
78 resp, err := s.sendRequest(c, machine.Tag(), password, "GET", s.charmsURI(c, ""), "", nil)80 resp, err := s.sendRequest(c, machine.Tag(), password, "GET", s.charmsURI(c, ""), "", nil)
79 c.Assert(err, gc.IsNil)81 c.Assert(err, gc.IsNil)
80 s.assertResponse(c, resp, http.StatusUnauthorized, "unauthorized", "")82 s.assertErrorResponse(c, resp, http.StatusUnauthorized, "unauthorized")
8183
82 // Now try a user login.84 // Now try a user login.
83 resp, err = s.authRequest(c, "GET", s.charmsURI(c, ""), "", nil)85 resp, err = s.authRequest(c, "GET", s.charmsURI(c, ""), "", nil)
84 c.Assert(err, gc.IsNil)86 c.Assert(err, gc.IsNil)
85 s.assertResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "GET"`, "")87 s.assertErrorResponse(c, resp, http.StatusBadRequest, "expected url=CharmURL query argument")
86}88}
8789
88func (s *charmsSuite) TestUploadRequiresSeries(c *gc.C) {90func (s *charmsSuite) TestUploadRequiresSeries(c *gc.C) {
89 resp, err := s.authRequest(c, "POST", s.charmsURI(c, ""), "", nil)91 resp, err := s.authRequest(c, "POST", s.charmsURI(c, ""), "", nil)
90 c.Assert(err, gc.IsNil)92 c.Assert(err, gc.IsNil)
91 s.assertResponse(c, resp, http.StatusBadRequest, "expected series= URL argument", "")93 s.assertErrorResponse(c, resp, http.StatusBadRequest, "expected series=URL argument")
92}94}
9395
94func (s *charmsSuite) TestUploadFailsWithInvalidZip(c *gc.C) {96func (s *charmsSuite) TestUploadFailsWithInvalidZip(c *gc.C) {
@@ -100,12 +102,12 @@
100 // check the error at extraction time later.102 // check the error at extraction time later.
101 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, tempFile.Name())103 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, tempFile.Name())
102 c.Assert(err, gc.IsNil)104 c.Assert(err, gc.IsNil)
103 s.assertResponse(c, resp, http.StatusBadRequest, "cannot open charm archive: zip: not a valid zip file", "")105 s.assertErrorResponse(c, resp, http.StatusBadRequest, "cannot open charm archive: zip: not a valid zip file")
104106
105 // Now try with the default Content-Type.107 // Now try with the default Content-Type.
106 resp, err = s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), false, tempFile.Name())108 resp, err = s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), false, tempFile.Name())
107 c.Assert(err, gc.IsNil)109 c.Assert(err, gc.IsNil)
108 s.assertResponse(c, resp, http.StatusBadRequest, "expected Content-Type: application/zip, got: application/octet-stream", "")110 s.assertErrorResponse(c, resp, http.StatusBadRequest, "expected Content-Type: application/zip, got: application/octet-stream")
109}111}
110112
111func (s *charmsSuite) TestUploadBumpsRevision(c *gc.C) {113func (s *charmsSuite) TestUploadBumpsRevision(c *gc.C) {
@@ -124,7 +126,7 @@
124 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, ch.Path)126 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, ch.Path)
125 c.Assert(err, gc.IsNil)127 c.Assert(err, gc.IsNil)
126 expectedURL := charm.MustParseURL("local:quantal/dummy-2")128 expectedURL := charm.MustParseURL("local:quantal/dummy-2")
127 s.assertResponse(c, resp, http.StatusOK, "", expectedURL.String())129 s.assertUploadResponse(c, resp, expectedURL.String())
128 sch, err := s.State.Charm(expectedURL)130 sch, err := s.State.Charm(expectedURL)
129 c.Assert(err, gc.IsNil)131 c.Assert(err, gc.IsNil)
130 c.Assert(sch.URL(), gc.DeepEquals, expectedURL)132 c.Assert(sch.URL(), gc.DeepEquals, expectedURL)
@@ -152,7 +154,7 @@
152 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, tempFile.Name())154 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, tempFile.Name())
153 c.Assert(err, gc.IsNil)155 c.Assert(err, gc.IsNil)
154 expectedURL := charm.MustParseURL("local:quantal/dummy-123")156 expectedURL := charm.MustParseURL("local:quantal/dummy-123")
155 s.assertResponse(c, resp, http.StatusOK, "", expectedURL.String())157 s.assertUploadResponse(c, resp, expectedURL.String())
156 sch, err := s.State.Charm(expectedURL)158 sch, err := s.State.Charm(expectedURL)
157 c.Assert(err, gc.IsNil)159 c.Assert(err, gc.IsNil)
158 c.Assert(sch.URL(), gc.DeepEquals, expectedURL)160 c.Assert(sch.URL(), gc.DeepEquals, expectedURL)
@@ -207,7 +209,7 @@
207 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, tempFile.Name())209 resp, err := s.uploadRequest(c, s.charmsURI(c, "?series=quantal"), true, tempFile.Name())
208 c.Assert(err, gc.IsNil)210 c.Assert(err, gc.IsNil)
209 expectedURL := charm.MustParseURL("local:quantal/dummy-1")211 expectedURL := charm.MustParseURL("local:quantal/dummy-1")
210 s.assertResponse(c, resp, http.StatusOK, "", expectedURL.String())212 s.assertUploadResponse(c, resp, expectedURL.String())
211 sch, err := s.State.Charm(expectedURL)213 sch, err := s.State.Charm(expectedURL)
212 c.Assert(err, gc.IsNil)214 c.Assert(err, gc.IsNil)
213 c.Assert(sch.URL(), gc.DeepEquals, expectedURL)215 c.Assert(sch.URL(), gc.DeepEquals, expectedURL)
@@ -240,6 +242,141 @@
240 c.Assert(bundle.Config(), jc.DeepEquals, sch.Config())242 c.Assert(bundle.Config(), jc.DeepEquals, sch.Config())
241}243}
242244
245func (s *charmsSuite) TestGetRequiresCharmURL(c *gc.C) {
246 uri := s.charmsURI(c, "?file=hooks/install")
247 resp, err := s.authRequest(c, "GET", uri, "", nil)
248 c.Assert(err, gc.IsNil)
249 s.assertErrorResponse(
250 c, resp, http.StatusBadRequest,
251 "expected url=CharmURL query argument",
252 )
253}
254
255func (s *charmsSuite) TestGetFailsWithInvalidCharmURL(c *gc.C) {
256 uri := s.charmsURI(c, "?url=local:precise/no-such")
257 resp, err := s.authRequest(c, "GET", uri, "", nil)
258 c.Assert(err, gc.IsNil)
259 s.assertErrorResponse(
260 c, resp, http.StatusBadRequest,
261 "unable to retrieve and save the charm: charm not found in the provider storage: .*",
262 )
263}
264
265func (s *charmsSuite) TestGetReturnsNotFoundWhenMissing(c *gc.C) {
266 // Add the dummy charm.
267 ch := coretesting.Charms.Bundle(c.MkDir(), "dummy")
268 _, err := s.uploadRequest(
269 c, s.charmsURI(c, "?series=quantal"), true, ch.Path)
270 c.Assert(err, gc.IsNil)
271
272 // Ensure a 404 is returned for files not included in the charm.
273 for i, file := range []string{
274 "no-such-file", "..", "../../../etc/passwd", "hooks/delete",
275 } {
276 c.Logf("test %d: %s", i, file)
277 uri := s.charmsURI(c, "?url=local:quantal/dummy-1&file="+file)
278 resp, err := s.authRequest(c, "GET", uri, "", nil)
279 c.Assert(err, gc.IsNil)
280 c.Assert(resp.StatusCode, gc.Equals, http.StatusNotFound)
281 }
282}
283
284func (s *charmsSuite) TestGetReturnsForbiddenWithDirectory(c *gc.C) {
285 // Add the dummy charm.
286 ch := coretesting.Charms.Bundle(c.MkDir(), "dummy")
287 _, err := s.uploadRequest(
288 c, s.charmsURI(c, "?series=quantal"), true, ch.Path)
289 c.Assert(err, gc.IsNil)
290
291 // Ensure a 403 is returned if the requested file is a directory.
292 uri := s.charmsURI(c, "?url=local:quantal/dummy-1&file=hooks")
293 resp, err := s.authRequest(c, "GET", uri, "", nil)
294 c.Assert(err, gc.IsNil)
295 c.Assert(resp.StatusCode, gc.Equals, http.StatusForbidden)
296}
297
298func (s *charmsSuite) TestGetReturnsFileContents(c *gc.C) {
299 // Add the dummy charm.
300 ch := coretesting.Charms.Bundle(c.MkDir(), "dummy")
301 _, err := s.uploadRequest(
302 c, s.charmsURI(c, "?series=quantal"), true, ch.Path)
303 c.Assert(err, gc.IsNil)
304
305 // Ensure the file contents are properly returned.
306 for i, t := range []struct {
307 summary string
308 file string
309 response string
310 }{{
311 summary: "relative path",
312 file: "revision",
313 response: "1",
314 }, {
315 summary: "exotic path",
316 file: "./hooks/../revision",
317 response: "1",
318 }, {
319 summary: "sub-directory path",
320 file: "hooks/install",
321 response: "#!/bin/bash\necho \"Done!\"\n",
322 },
323 } {
324 c.Logf("test %d: %s", i, t.summary)
325 uri := s.charmsURI(c, "?url=local:quantal/dummy-1&file="+t.file)
326 resp, err := s.authRequest(c, "GET", uri, "", nil)
327 c.Assert(err, gc.IsNil)
328 s.assertGetFileResponse(c, resp, t.response, "text/plain; charset=utf-8")
329 }
330}
331
332func (s *charmsSuite) TestGetListsFiles(c *gc.C) {
333 // Add the dummy charm.
334 ch := coretesting.Charms.Bundle(c.MkDir(), "dummy")
335 _, err := s.uploadRequest(
336 c, s.charmsURI(c, "?series=quantal"), true, ch.Path)
337 c.Assert(err, gc.IsNil)
338
339 // Ensure charm files are properly listed.
340 uri := s.charmsURI(c, "?url=local:quantal/dummy-1")
341 resp, err := s.authRequest(c, "GET", uri, "", nil)
342 c.Assert(err, gc.IsNil)
343 expectedFiles := []string{
344 "revision", "config.yaml", "hooks/install", "metadata.yaml",
345 "src/hello.c",
346 }
347 s.assertGetFileListResponse(c, resp, expectedFiles)
348 ctype := resp.Header.Get("content-type")
349 c.Assert(ctype, gc.Equals, "application/json")
350}
351
352func (s *charmsSuite) TestGetUsesCache(c *gc.C) {
353 // Add a fake charm archive in the cache directory.
354 cacheDir := filepath.Join(s.DataDir(), "charm-get-cache")
355 err := os.MkdirAll(cacheDir, 0755)
356 c.Assert(err, gc.IsNil)
357
358 // Create and save the zip archive.
359 buffer := new(bytes.Buffer)
360 writer := zip.NewWriter(buffer)
361 file, err := writer.Create("utils.js")
362 c.Assert(err, gc.IsNil)
363 contents := "// these are the voyages"
364 _, err = file.Write([]byte(contents))
365 c.Assert(err, gc.IsNil)
366 err = writer.Close()
367 c.Assert(err, gc.IsNil)
368 charmArchivePath := filepath.Join(
369 cacheDir, charm.Quote("local:trusty/django-42")+".zip")
370 err = ioutil.WriteFile(charmArchivePath, buffer.Bytes(), 0644)
371 c.Assert(err, gc.IsNil)
372
373 // Ensure the cached contents are properly retrieved.
374 uri := s.charmsURI(c, "?url=local:trusty/django-42&file=utils.js")
375 resp, err := s.authRequest(c, "GET", uri, "", nil)
376 c.Assert(err, gc.IsNil)
377 s.assertGetFileResponse(c, resp, contents, "application/javascript")
378}
379
243func (s *charmsSuite) charmsURI(c *gc.C, query string) string {380func (s *charmsSuite) charmsURI(c *gc.C, query string) string {
244 _, info, err := s.APIConn.Environ.StateInfo()381 _, info, err := s.APIConn.Environ.StateInfo()
245 c.Assert(err, gc.IsNil)382 c.Assert(err, gc.IsNil)
@@ -278,19 +415,42 @@
278 return s.authRequest(c, "POST", uri, contentType, file)415 return s.authRequest(c, "POST", uri, contentType, file)
279}416}
280417
281func (s *charmsSuite) assertResponse(c *gc.C, resp *http.Response, expCode int, expError, expCharmURL string) {418func (s *charmsSuite) assertUploadResponse(c *gc.C, resp *http.Response, expCharmURL string) {
419 body := assertResponse(c, resp, http.StatusOK, "application/json")
420 charmResponse := jsonResponse(c, body)
421 c.Check(charmResponse.Error, gc.Equals, "")
422 c.Check(charmResponse.CharmURL, gc.Equals, expCharmURL)
423}
424
425func (s *charmsSuite) assertGetFileResponse(c *gc.C, resp *http.Response, expBody, expContentType string) {
426 body := assertResponse(c, resp, http.StatusOK, expContentType)
427 c.Check(string(body), gc.Equals, expBody)
428}
429
430func (s *charmsSuite) assertGetFileListResponse(c *gc.C, resp *http.Response, expFiles []string) {
431 body := assertResponse(c, resp, http.StatusOK, "application/json")
432 charmResponse := jsonResponse(c, body)
433 c.Check(charmResponse.Error, gc.Equals, "")
434 c.Check(charmResponse.Files, gc.DeepEquals, expFiles)
435}
436
437func (s *charmsSuite) assertErrorResponse(c *gc.C, resp *http.Response, expCode int, expError string) {
438 body := assertResponse(c, resp, expCode, "application/json")
439 c.Check(jsonResponse(c, body).Error, gc.Matches, expError)
440}
441
442func assertResponse(c *gc.C, resp *http.Response, expCode int, expContentType string) []byte {
443 c.Check(resp.StatusCode, gc.Equals, expCode)
282 body, err := ioutil.ReadAll(resp.Body)444 body, err := ioutil.ReadAll(resp.Body)
283 defer resp.Body.Close()445 defer resp.Body.Close()
284 c.Assert(err, gc.IsNil)446 c.Assert(err, gc.IsNil)
285 var jsonResponse params.CharmsResponse447 ctype := resp.Header.Get("Content-Type")
286 err = json.Unmarshal(body, &jsonResponse)448 c.Assert(ctype, gc.Equals, expContentType)
449 return body
450}
451
452func jsonResponse(c *gc.C, body []byte) (jsonResponse params.CharmsResponse) {
453 err := json.Unmarshal(body, &jsonResponse)
287 c.Assert(err, gc.IsNil)454 c.Assert(err, gc.IsNil)
288 if expError != "" {455 return
289 c.Check(jsonResponse.Error, gc.Matches, expError)
290 c.Check(jsonResponse.CharmURL, gc.Equals, "")
291 } else {
292 c.Check(jsonResponse.Error, gc.Equals, "")
293 c.Check(jsonResponse.CharmURL, gc.Equals, expCharmURL)
294 }
295 c.Check(resp.StatusCode, gc.Equals, expCode)
296}456}

Subscribers

People subscribed via source and target branches

to status/vote changes: