Merge lp:~frankban/juju-core/get-charm-api into lp:~go-bot/juju-core/trunk
- get-charm-api
- Merge into trunk
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 |
Related bugs: |
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:/
(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/
The response should be something like: {"CharmURL"
- Download ghost charm files, e.g.:
`curl -ikLG -d "url=local:
or
`curl -ikLG -d "url=local:
you should see the file contents.
- Ensure directories are not allowed, e.g.:
`curl -ikLG -d "url=local:
- Retrieve a list of the charm files:
`curl -ikLG -d "url=local:
- Done, destroy the environment, thank you!
Francesco Banconi (frankban) wrote : | # |
Dimiter Naydenov (dimitern) wrote : | # |
LGTM, thanks for doing this!
https:/
File state/api/
https:/
state/api/
response to charm upload or get requests.
s/get/GET/
https:/
File state/apiserver
https:/
state/apiserver
bundlePath+"/") {
Please, use filepath.
https:/
File state/apiserver
https:/
state/apiserver
argument")
I'd prefer if you formatted this and other places where
assertErrorResponse is too long, like this:
s.assertErrorRe
c, resp, http.StatusBadR
expected url=CharmURL query argument",
)
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.
Francesco Banconi (frankban) wrote : | # |
Please take a look.
https:/
File state/api/
https:/
state/api/
response to charm upload or get requests.
On 2014/02/25 10:07:09, dimitern wrote:
> s/get/GET/
Done.
https:/
File state/apiserver
https:/
state/apiserver
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.assertErrorRe
> c, resp, http.StatusBadR
> expected url=CharmURL query argument",
> )
Done.
Dimiter Naydenov (dimitern) wrote : | # |
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:/
File state/apiserver
https:/
state/apiserver
gofmt again?
https:/
state/apiserver
Instead of else, you could just return in the if block above.
https:/
state/apiserver
download GET request after authentication.
s/download//
https:/
state/apiserver
the given charmArchivePath.
s/save/saves/
https:/
state/apiserver
os.Rename(
In this case I think you should defer os.Remove(
here.
https:/
File state/apiserver
https:/
state/apiserver
TestGetReturnsF
s/TestGetReturn
https:/
state/apiserver
not included in the charm.
s/fo/for/
https:/
state/apiserver
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:/
state/apiserver
TestGetReturnsD
Similarly,
s/TestGetReturn
?
https:/
state/apiserver
requested file is a directory.
b
https:/
state/apiserver
properly returned.
b
https:/
state/apiserver
listed.
b
https:/
state/apiserver
Francesco Banconi (frankban) wrote : | # |
Please take a look.
https:/
File state/apiserver
https:/
state/apiserver
On 2014/02/25 17:40:03, dimitern wrote:
> gofmt again?
Done, and it seems already ok.
https:/
state/apiserver
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:/
state/apiserver
download GET request after authentication.
On 2014/02/25 17:40:03, dimitern wrote:
> s/download//
Done.
https:/
state/apiserver
the given charmArchivePath.
On 2014/02/25 17:40:03, dimitern wrote:
> s/save/saves/
Done.
https:/
state/apiserver
os.Rename(
On 2014/02/25 17:40:03, dimitern wrote:
> In this case I think you should defer
os.Remove(
Good catch, fixed.
https:/
File state/apiserver
https:/
state/apiserver
TestGetReturnsF
On 2014/02/25 17:40:03, dimitern wrote:
> s/TestGetReturn
Done.
https:/
state/apiserver
not included in the charm.
On 2014/02/25 17:40:03, dimitern wrote:
> s/fo/for/
Done.
https:/
state/apiserver
TestGetReturnsD
On 2014/02/25 17:40:03, dimitern wrote:
> Similarly,
s/TestGetReturn
?
Done.
https:/
state/apiserver
resp.Header.
On 2014/02/25 17:40:03, dimitern wrote:
> s/content-
Done.
Francesco Banconi (frankban) wrote : | # |
Thank you!
Dimiter Naydenov (dimitern) wrote : | # |
https:/
File state/apiserver
https:/
state/apiserver
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.
Preview Diff
1 | === modified file 'state/api/params/internal.go' | |||
2 | --- state/api/params/internal.go 2014-02-03 11:45:15 +0000 | |||
3 | +++ state/api/params/internal.go 2014-02-26 09:13:18 +0000 | |||
4 | @@ -506,10 +506,11 @@ | |||
5 | 506 | Results []RelationUnitsWatchResult | 506 | Results []RelationUnitsWatchResult |
6 | 507 | } | 507 | } |
7 | 508 | 508 | ||
9 | 509 | // CharmsResponse is the server response to a charm upload request. | 509 | // CharmsResponse is the server response to charm upload or GET requests. |
10 | 510 | type CharmsResponse struct { | 510 | type CharmsResponse struct { |
13 | 511 | Error string `json:",omitempty"` | 511 | Error string `json:",omitempty"` |
14 | 512 | CharmURL string `json:",omitempty"` | 512 | CharmURL string `json:",omitempty"` |
15 | 513 | Files []string `json:",omitempty"` | ||
16 | 513 | } | 514 | } |
17 | 514 | 515 | ||
18 | 515 | // RunParams is used to provide the parameters to the Run method. | 516 | // RunParams is used to provide the parameters to the Run method. |
19 | 516 | 517 | ||
20 | === modified file 'state/apiserver/apiserver.go' | |||
21 | --- state/apiserver/apiserver.go 2014-02-04 10:18:04 +0000 | |||
22 | +++ state/apiserver/apiserver.go 2014-02-26 09:13:18 +0000 | |||
23 | @@ -152,7 +152,7 @@ | |||
24 | 152 | }() | 152 | }() |
25 | 153 | mux := http.NewServeMux() | 153 | mux := http.NewServeMux() |
26 | 154 | mux.HandleFunc("/", srv.apiHandler) | 154 | mux.HandleFunc("/", srv.apiHandler) |
28 | 155 | mux.Handle("/charms", &charmsHandler{state: srv.state}) | 155 | mux.Handle("/charms", &charmsHandler{state: srv.state, dataDir: srv.dataDir}) |
29 | 156 | // The error from http.Serve is not interesting. | 156 | // The error from http.Serve is not interesting. |
30 | 157 | http.Serve(lis, mux) | 157 | http.Serve(lis, mux) |
31 | 158 | } | 158 | } |
32 | 159 | 159 | ||
33 | === modified file 'state/apiserver/charms.go' | |||
34 | --- state/apiserver/charms.go 2014-02-03 14:31:54 +0000 | |||
35 | +++ state/apiserver/charms.go 2014-02-26 09:13:18 +0000 | |||
36 | @@ -12,10 +12,12 @@ | |||
37 | 12 | "fmt" | 12 | "fmt" |
38 | 13 | "io" | 13 | "io" |
39 | 14 | "io/ioutil" | 14 | "io/ioutil" |
40 | 15 | "mime" | ||
41 | 15 | "net/http" | 16 | "net/http" |
42 | 16 | "net/url" | 17 | "net/url" |
43 | 17 | "os" | 18 | "os" |
44 | 18 | "path/filepath" | 19 | "path/filepath" |
45 | 20 | "strconv" | ||
46 | 19 | "strings" | 21 | "strings" |
47 | 20 | 22 | ||
48 | 21 | "github.com/errgo/errgo" | 23 | "github.com/errgo/errgo" |
49 | @@ -30,9 +32,14 @@ | |||
50 | 30 | 32 | ||
51 | 31 | // charmsHandler handles charm upload through HTTPS in the API server. | 33 | // charmsHandler handles charm upload through HTTPS in the API server. |
52 | 32 | type charmsHandler struct { | 34 | type charmsHandler struct { |
54 | 33 | state *state.State | 35 | state *state.State |
55 | 36 | dataDir string | ||
56 | 34 | } | 37 | } |
57 | 35 | 38 | ||
58 | 39 | // zipContentsSenderFunc functions are responsible of sending a zip archive | ||
59 | 40 | // related response. The zip archive can be accessed through the given reader. | ||
60 | 41 | type zipContentsSenderFunc func(w http.ResponseWriter, r *http.Request, reader *zip.ReadCloser) | ||
61 | 42 | |||
62 | 36 | func (h *charmsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 43 | func (h *charmsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
63 | 37 | if err := h.authenticate(r); err != nil { | 44 | if err := h.authenticate(r); err != nil { |
64 | 38 | h.authError(w) | 45 | h.authError(w) |
65 | @@ -41,13 +48,28 @@ | |||
66 | 41 | 48 | ||
67 | 42 | switch r.Method { | 49 | switch r.Method { |
68 | 43 | case "POST": | 50 | case "POST": |
69 | 51 | // Add a local charm to the store provider. | ||
70 | 52 | // Requires a "series" query specifying the series to use for the charm. | ||
71 | 44 | charmURL, err := h.processPost(r) | 53 | charmURL, err := h.processPost(r) |
72 | 45 | if err != nil { | 54 | if err != nil { |
73 | 46 | h.sendError(w, http.StatusBadRequest, err.Error()) | 55 | h.sendError(w, http.StatusBadRequest, err.Error()) |
74 | 47 | return | 56 | return |
75 | 48 | } | 57 | } |
76 | 49 | h.sendJSON(w, http.StatusOK, ¶ms.CharmsResponse{CharmURL: charmURL.String()}) | 58 | h.sendJSON(w, http.StatusOK, ¶ms.CharmsResponse{CharmURL: charmURL.String()}) |
78 | 50 | // Possible future extensions, like GET. | 59 | case "GET": |
79 | 60 | // Retrieve or list charm files. | ||
80 | 61 | // Requires "url" (charm URL) and an optional "file" (the path to the | ||
81 | 62 | // charm file) to be included in the query. | ||
82 | 63 | if charmArchivePath, filePath, err := h.processGet(r); err != nil { | ||
83 | 64 | // An error occurred retrieving the charm bundle. | ||
84 | 65 | h.sendError(w, http.StatusBadRequest, err.Error()) | ||
85 | 66 | } else if filePath == "" { | ||
86 | 67 | // The client requested the list of charm files. | ||
87 | 68 | sendZipContents(w, r, charmArchivePath, h.fileListSender) | ||
88 | 69 | } else { | ||
89 | 70 | // The client requested a specific file. | ||
90 | 71 | sendZipContents(w, r, charmArchivePath, h.fileSender(filePath)) | ||
91 | 72 | } | ||
92 | 51 | default: | 73 | default: |
93 | 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)) |
94 | 53 | } | 75 | } |
95 | @@ -55,6 +77,7 @@ | |||
96 | 55 | 77 | ||
97 | 56 | // sendJSON sends a JSON-encoded response to the client. | 78 | // sendJSON sends a JSON-encoded response to the client. |
98 | 57 | func (h *charmsHandler) sendJSON(w http.ResponseWriter, statusCode int, response *params.CharmsResponse) error { | 79 | func (h *charmsHandler) sendJSON(w http.ResponseWriter, statusCode int, response *params.CharmsResponse) error { |
99 | 80 | w.Header().Set("Content-Type", "application/json") | ||
100 | 58 | w.WriteHeader(statusCode) | 81 | w.WriteHeader(statusCode) |
101 | 59 | body, err := json.Marshal(response) | 82 | body, err := json.Marshal(response) |
102 | 60 | if err != nil { | 83 | if err != nil { |
103 | @@ -64,6 +87,72 @@ | |||
104 | 64 | return nil | 87 | return nil |
105 | 65 | } | 88 | } |
106 | 66 | 89 | ||
107 | 90 | // sendZipContents uses the given zipContentsSenderFunc to send a response | ||
108 | 91 | // related to the zip archive located in the given archivePath. | ||
109 | 92 | func sendZipContents(w http.ResponseWriter, r *http.Request, archivePath string, zipContentsSender zipContentsSenderFunc) { | ||
110 | 93 | reader, err := zip.OpenReader(archivePath) | ||
111 | 94 | if err != nil { | ||
112 | 95 | http.Error( | ||
113 | 96 | w, fmt.Sprintf("unable to read archive in %q: %v", archivePath, err), | ||
114 | 97 | http.StatusInternalServerError) | ||
115 | 98 | return | ||
116 | 99 | } | ||
117 | 100 | defer reader.Close() | ||
118 | 101 | // The zipContentsSenderFunc will handle the zip contents, set up and send | ||
119 | 102 | // an appropriate response. | ||
120 | 103 | zipContentsSender(w, r, reader) | ||
121 | 104 | } | ||
122 | 105 | |||
123 | 106 | // fileListSender sends a JSON-encoded response to the client including the | ||
124 | 107 | // list of files contained in the zip archive. | ||
125 | 108 | func (h *charmsHandler) fileListSender(w http.ResponseWriter, r *http.Request, reader *zip.ReadCloser) { | ||
126 | 109 | var files []string | ||
127 | 110 | for _, file := range reader.File { | ||
128 | 111 | fileInfo := file.FileInfo() | ||
129 | 112 | if !fileInfo.IsDir() { | ||
130 | 113 | files = append(files, file.Name) | ||
131 | 114 | } | ||
132 | 115 | } | ||
133 | 116 | h.sendJSON(w, http.StatusOK, ¶ms.CharmsResponse{Files: files}) | ||
134 | 117 | } | ||
135 | 118 | |||
136 | 119 | // fileSender returns a zipContentsSenderFunc which is responsible of sending | ||
137 | 120 | // the contents of filePath included in the given zip. | ||
138 | 121 | // A 404 page not found is returned if path does not exist in the zip. | ||
139 | 122 | // A 403 forbidden error is returned if path points to a directory. | ||
140 | 123 | func (h *charmsHandler) fileSender(filePath string) zipContentsSenderFunc { | ||
141 | 124 | return func(w http.ResponseWriter, r *http.Request, reader *zip.ReadCloser) { | ||
142 | 125 | for _, file := range reader.File { | ||
143 | 126 | if h.fixPath(file.Name) != filePath { | ||
144 | 127 | continue | ||
145 | 128 | } | ||
146 | 129 | fileInfo := file.FileInfo() | ||
147 | 130 | if fileInfo.IsDir() { | ||
148 | 131 | http.Error(w, "directory listing not allowed", http.StatusForbidden) | ||
149 | 132 | return | ||
150 | 133 | } | ||
151 | 134 | if contents, err := file.Open(); err != nil { | ||
152 | 135 | http.Error( | ||
153 | 136 | w, fmt.Sprintf("unable to read file %q: %v", filePath, err), | ||
154 | 137 | http.StatusInternalServerError) | ||
155 | 138 | return | ||
156 | 139 | } else { | ||
157 | 140 | defer contents.Close() | ||
158 | 141 | ctype := mime.TypeByExtension(filepath.Ext(filePath)) | ||
159 | 142 | if ctype != "" { | ||
160 | 143 | w.Header().Set("Content-Type", ctype) | ||
161 | 144 | } | ||
162 | 145 | w.Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size(), 10)) | ||
163 | 146 | w.WriteHeader(http.StatusOK) | ||
164 | 147 | io.Copy(w, contents) | ||
165 | 148 | } | ||
166 | 149 | return | ||
167 | 150 | } | ||
168 | 151 | http.NotFound(w, r) | ||
169 | 152 | return | ||
170 | 153 | } | ||
171 | 154 | } | ||
172 | 155 | |||
173 | 67 | // sendError sends a JSON-encoded error response. | 156 | // sendError sends a JSON-encoded error response. |
174 | 68 | func (h *charmsHandler) sendError(w http.ResponseWriter, statusCode int, message string) error { | 157 | func (h *charmsHandler) sendError(w http.ResponseWriter, statusCode int, message string) error { |
175 | 69 | return h.sendJSON(w, statusCode, ¶ms.CharmsResponse{Error: message}) | 158 | return h.sendJSON(w, statusCode, ¶ms.CharmsResponse{Error: message}) |
176 | @@ -113,7 +202,7 @@ | |||
177 | 113 | query := r.URL.Query() | 202 | query := r.URL.Query() |
178 | 114 | series := query.Get("series") | 203 | series := query.Get("series") |
179 | 115 | if series == "" { | 204 | if series == "" { |
181 | 116 | return nil, fmt.Errorf("expected series= URL argument") | 205 | return nil, fmt.Errorf("expected series=URL argument") |
182 | 117 | } | 206 | } |
183 | 118 | // Make sure the content type is zip. | 207 | // Make sure the content type is zip. |
184 | 119 | contentType := r.Header.Get("Content-Type") | 208 | contentType := r.Header.Get("Content-Type") |
185 | @@ -419,3 +508,79 @@ | |||
186 | 419 | } | 508 | } |
187 | 420 | return nil | 509 | return nil |
188 | 421 | } | 510 | } |
189 | 511 | |||
190 | 512 | // processGet handles a charm file GET request after authentication. | ||
191 | 513 | // It returns the bundle path, the requested file path (if any) and an error. | ||
192 | 514 | func (h *charmsHandler) processGet(r *http.Request) (string, string, error) { | ||
193 | 515 | query := r.URL.Query() | ||
194 | 516 | |||
195 | 517 | // Retrieve and validate query parameters. | ||
196 | 518 | curl := query.Get("url") | ||
197 | 519 | if curl == "" { | ||
198 | 520 | return "", "", fmt.Errorf("expected url=CharmURL query argument") | ||
199 | 521 | } | ||
200 | 522 | var filePath string | ||
201 | 523 | file := query.Get("file") | ||
202 | 524 | if file == "" { | ||
203 | 525 | filePath = "" | ||
204 | 526 | } else { | ||
205 | 527 | filePath = h.fixPath(file) | ||
206 | 528 | } | ||
207 | 529 | |||
208 | 530 | // Prepare the bundle directories. | ||
209 | 531 | name := charm.Quote(curl) | ||
210 | 532 | charmArchivePath := filepath.Join(h.dataDir, "charm-get-cache", name+".zip") | ||
211 | 533 | |||
212 | 534 | // Check if the charm archive is already in the cache. | ||
213 | 535 | if _, err := os.Stat(charmArchivePath); os.IsNotExist(err) { | ||
214 | 536 | // Download the charm archive and save it to the cache. | ||
215 | 537 | if err = h.downloadCharm(name, charmArchivePath); err != nil { | ||
216 | 538 | return "", "", fmt.Errorf("unable to retrieve and save the charm: %v", err) | ||
217 | 539 | } | ||
218 | 540 | } else if err != nil { | ||
219 | 541 | return "", "", fmt.Errorf("cannot access the charms cache: %v", err) | ||
220 | 542 | } | ||
221 | 543 | return charmArchivePath, filePath, nil | ||
222 | 544 | } | ||
223 | 545 | |||
224 | 546 | // downloadCharm downloads the given charm name from the provider storage and | ||
225 | 547 | // saves the corresponding zip archive to the given charmArchivePath. | ||
226 | 548 | func (h *charmsHandler) downloadCharm(name, charmArchivePath string) error { | ||
227 | 549 | // Get the provider storage. | ||
228 | 550 | storage, err := envtesting.GetEnvironStorage(h.state) | ||
229 | 551 | if err != nil { | ||
230 | 552 | return errgo.Annotate(err, "cannot access provider storage") | ||
231 | 553 | } | ||
232 | 554 | |||
233 | 555 | // Use the storage to retrieve and save the charm archive. | ||
234 | 556 | reader, err := storage.Get(name) | ||
235 | 557 | if err != nil { | ||
236 | 558 | return errgo.Annotate(err, "charm not found in the provider storage") | ||
237 | 559 | } | ||
238 | 560 | defer reader.Close() | ||
239 | 561 | data, err := ioutil.ReadAll(reader) | ||
240 | 562 | if err != nil { | ||
241 | 563 | return errgo.Annotate(err, "cannot read charm data") | ||
242 | 564 | } | ||
243 | 565 | // In order to avoid races, the archive is saved in a temporary file which | ||
244 | 566 | // is then atomically renamed. The temporary file is created in the | ||
245 | 567 | // charm cache directory so that we can safely assume the rename source and | ||
246 | 568 | // target live in the same file system. | ||
247 | 569 | cacheDir := filepath.Dir(charmArchivePath) | ||
248 | 570 | if err = os.MkdirAll(cacheDir, 0755); err != nil { | ||
249 | 571 | return errgo.Annotate(err, "cannot create the charms cache") | ||
250 | 572 | } | ||
251 | 573 | tempCharmArchive, err := ioutil.TempFile(cacheDir, "charm") | ||
252 | 574 | if err != nil { | ||
253 | 575 | return errgo.Annotate(err, "cannot create charm archive temp file") | ||
254 | 576 | } | ||
255 | 577 | defer tempCharmArchive.Close() | ||
256 | 578 | if err = ioutil.WriteFile(tempCharmArchive.Name(), data, 0644); err != nil { | ||
257 | 579 | return errgo.Annotate(err, "error processing charm archive download") | ||
258 | 580 | } | ||
259 | 581 | if err = os.Rename(tempCharmArchive.Name(), charmArchivePath); err != nil { | ||
260 | 582 | defer os.Remove(tempCharmArchive.Name()) | ||
261 | 583 | return errgo.Annotate(err, "error renaming the charm archive") | ||
262 | 584 | } | ||
263 | 585 | return nil | ||
264 | 586 | } | ||
265 | 422 | 587 | ||
266 | === modified file 'state/apiserver/charms_test.go' | |||
267 | --- state/apiserver/charms_test.go 2014-01-30 16:55:00 +0000 | |||
268 | +++ state/apiserver/charms_test.go 2014-02-26 09:13:18 +0000 | |||
269 | @@ -4,6 +4,8 @@ | |||
270 | 4 | package apiserver_test | 4 | package apiserver_test |
271 | 5 | 5 | ||
272 | 6 | import ( | 6 | import ( |
273 | 7 | "archive/zip" | ||
274 | 8 | "bytes" | ||
275 | 7 | "encoding/json" | 9 | "encoding/json" |
276 | 8 | "fmt" | 10 | "fmt" |
277 | 9 | "io" | 11 | "io" |
278 | @@ -55,13 +57,13 @@ | |||
279 | 55 | func (s *charmsSuite) TestRequiresAuth(c *gc.C) { | 57 | func (s *charmsSuite) TestRequiresAuth(c *gc.C) { |
280 | 56 | resp, err := s.sendRequest(c, "", "", "GET", s.charmsURI(c, ""), "", nil) | 58 | resp, err := s.sendRequest(c, "", "", "GET", s.charmsURI(c, ""), "", nil) |
281 | 57 | c.Assert(err, gc.IsNil) | 59 | c.Assert(err, gc.IsNil) |
283 | 58 | s.assertResponse(c, resp, http.StatusUnauthorized, "unauthorized", "") | 60 | s.assertErrorResponse(c, resp, http.StatusUnauthorized, "unauthorized") |
284 | 59 | } | 61 | } |
285 | 60 | 62 | ||
288 | 61 | func (s *charmsSuite) TestUploadRequiresPOST(c *gc.C) { | 63 | func (s *charmsSuite) TestRequiresPOSTorGET(c *gc.C) { |
289 | 62 | resp, err := s.authRequest(c, "GET", s.charmsURI(c, ""), "", nil) | 64 | resp, err := s.authRequest(c, "PUT", s.charmsURI(c, ""), "", nil) |
290 | 63 | c.Assert(err, gc.IsNil) | 65 | c.Assert(err, gc.IsNil) |
292 | 64 | s.assertResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "GET"`, "") | 66 | s.assertErrorResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "PUT"`) |
293 | 65 | } | 67 | } |
294 | 66 | 68 | ||
295 | 67 | func (s *charmsSuite) TestAuthRequiresUser(c *gc.C) { | 69 | func (s *charmsSuite) TestAuthRequiresUser(c *gc.C) { |
296 | @@ -77,18 +79,18 @@ | |||
297 | 77 | 79 | ||
298 | 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) |
299 | 79 | c.Assert(err, gc.IsNil) | 81 | c.Assert(err, gc.IsNil) |
301 | 80 | s.assertResponse(c, resp, http.StatusUnauthorized, "unauthorized", "") | 82 | s.assertErrorResponse(c, resp, http.StatusUnauthorized, "unauthorized") |
302 | 81 | 83 | ||
303 | 82 | // Now try a user login. | 84 | // Now try a user login. |
304 | 83 | resp, err = s.authRequest(c, "GET", s.charmsURI(c, ""), "", nil) | 85 | resp, err = s.authRequest(c, "GET", s.charmsURI(c, ""), "", nil) |
305 | 84 | c.Assert(err, gc.IsNil) | 86 | c.Assert(err, gc.IsNil) |
307 | 85 | s.assertResponse(c, resp, http.StatusMethodNotAllowed, `unsupported method: "GET"`, "") | 87 | s.assertErrorResponse(c, resp, http.StatusBadRequest, "expected url=CharmURL query argument") |
308 | 86 | } | 88 | } |
309 | 87 | 89 | ||
310 | 88 | func (s *charmsSuite) TestUploadRequiresSeries(c *gc.C) { | 90 | func (s *charmsSuite) TestUploadRequiresSeries(c *gc.C) { |
311 | 89 | resp, err := s.authRequest(c, "POST", s.charmsURI(c, ""), "", nil) | 91 | resp, err := s.authRequest(c, "POST", s.charmsURI(c, ""), "", nil) |
312 | 90 | c.Assert(err, gc.IsNil) | 92 | c.Assert(err, gc.IsNil) |
314 | 91 | s.assertResponse(c, resp, http.StatusBadRequest, "expected series= URL argument", "") | 93 | s.assertErrorResponse(c, resp, http.StatusBadRequest, "expected series=URL argument") |
315 | 92 | } | 94 | } |
316 | 93 | 95 | ||
317 | 94 | func (s *charmsSuite) TestUploadFailsWithInvalidZip(c *gc.C) { | 96 | func (s *charmsSuite) TestUploadFailsWithInvalidZip(c *gc.C) { |
318 | @@ -100,12 +102,12 @@ | |||
319 | 100 | // check the error at extraction time later. | 102 | // check the error at extraction time later. |
320 | 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()) |
321 | 102 | c.Assert(err, gc.IsNil) | 104 | c.Assert(err, gc.IsNil) |
323 | 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") |
324 | 104 | 106 | ||
325 | 105 | // Now try with the default Content-Type. | 107 | // Now try with the default Content-Type. |
326 | 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()) |
327 | 107 | c.Assert(err, gc.IsNil) | 109 | c.Assert(err, gc.IsNil) |
329 | 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") |
330 | 109 | } | 111 | } |
331 | 110 | 112 | ||
332 | 111 | func (s *charmsSuite) TestUploadBumpsRevision(c *gc.C) { | 113 | func (s *charmsSuite) TestUploadBumpsRevision(c *gc.C) { |
333 | @@ -124,7 +126,7 @@ | |||
334 | 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) |
335 | 125 | c.Assert(err, gc.IsNil) | 127 | c.Assert(err, gc.IsNil) |
336 | 126 | expectedURL := charm.MustParseURL("local:quantal/dummy-2") | 128 | expectedURL := charm.MustParseURL("local:quantal/dummy-2") |
338 | 127 | s.assertResponse(c, resp, http.StatusOK, "", expectedURL.String()) | 129 | s.assertUploadResponse(c, resp, expectedURL.String()) |
339 | 128 | sch, err := s.State.Charm(expectedURL) | 130 | sch, err := s.State.Charm(expectedURL) |
340 | 129 | c.Assert(err, gc.IsNil) | 131 | c.Assert(err, gc.IsNil) |
341 | 130 | c.Assert(sch.URL(), gc.DeepEquals, expectedURL) | 132 | c.Assert(sch.URL(), gc.DeepEquals, expectedURL) |
342 | @@ -152,7 +154,7 @@ | |||
343 | 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()) |
344 | 153 | c.Assert(err, gc.IsNil) | 155 | c.Assert(err, gc.IsNil) |
345 | 154 | expectedURL := charm.MustParseURL("local:quantal/dummy-123") | 156 | expectedURL := charm.MustParseURL("local:quantal/dummy-123") |
347 | 155 | s.assertResponse(c, resp, http.StatusOK, "", expectedURL.String()) | 157 | s.assertUploadResponse(c, resp, expectedURL.String()) |
348 | 156 | sch, err := s.State.Charm(expectedURL) | 158 | sch, err := s.State.Charm(expectedURL) |
349 | 157 | c.Assert(err, gc.IsNil) | 159 | c.Assert(err, gc.IsNil) |
350 | 158 | c.Assert(sch.URL(), gc.DeepEquals, expectedURL) | 160 | c.Assert(sch.URL(), gc.DeepEquals, expectedURL) |
351 | @@ -207,7 +209,7 @@ | |||
352 | 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()) |
353 | 208 | c.Assert(err, gc.IsNil) | 210 | c.Assert(err, gc.IsNil) |
354 | 209 | expectedURL := charm.MustParseURL("local:quantal/dummy-1") | 211 | expectedURL := charm.MustParseURL("local:quantal/dummy-1") |
356 | 210 | s.assertResponse(c, resp, http.StatusOK, "", expectedURL.String()) | 212 | s.assertUploadResponse(c, resp, expectedURL.String()) |
357 | 211 | sch, err := s.State.Charm(expectedURL) | 213 | sch, err := s.State.Charm(expectedURL) |
358 | 212 | c.Assert(err, gc.IsNil) | 214 | c.Assert(err, gc.IsNil) |
359 | 213 | c.Assert(sch.URL(), gc.DeepEquals, expectedURL) | 215 | c.Assert(sch.URL(), gc.DeepEquals, expectedURL) |
360 | @@ -240,6 +242,141 @@ | |||
361 | 240 | c.Assert(bundle.Config(), jc.DeepEquals, sch.Config()) | 242 | c.Assert(bundle.Config(), jc.DeepEquals, sch.Config()) |
362 | 241 | } | 243 | } |
363 | 242 | 244 | ||
364 | 245 | func (s *charmsSuite) TestGetRequiresCharmURL(c *gc.C) { | ||
365 | 246 | uri := s.charmsURI(c, "?file=hooks/install") | ||
366 | 247 | resp, err := s.authRequest(c, "GET", uri, "", nil) | ||
367 | 248 | c.Assert(err, gc.IsNil) | ||
368 | 249 | s.assertErrorResponse( | ||
369 | 250 | c, resp, http.StatusBadRequest, | ||
370 | 251 | "expected url=CharmURL query argument", | ||
371 | 252 | ) | ||
372 | 253 | } | ||
373 | 254 | |||
374 | 255 | func (s *charmsSuite) TestGetFailsWithInvalidCharmURL(c *gc.C) { | ||
375 | 256 | uri := s.charmsURI(c, "?url=local:precise/no-such") | ||
376 | 257 | resp, err := s.authRequest(c, "GET", uri, "", nil) | ||
377 | 258 | c.Assert(err, gc.IsNil) | ||
378 | 259 | s.assertErrorResponse( | ||
379 | 260 | c, resp, http.StatusBadRequest, | ||
380 | 261 | "unable to retrieve and save the charm: charm not found in the provider storage: .*", | ||
381 | 262 | ) | ||
382 | 263 | } | ||
383 | 264 | |||
384 | 265 | func (s *charmsSuite) TestGetReturnsNotFoundWhenMissing(c *gc.C) { | ||
385 | 266 | // Add the dummy charm. | ||
386 | 267 | ch := coretesting.Charms.Bundle(c.MkDir(), "dummy") | ||
387 | 268 | _, err := s.uploadRequest( | ||
388 | 269 | c, s.charmsURI(c, "?series=quantal"), true, ch.Path) | ||
389 | 270 | c.Assert(err, gc.IsNil) | ||
390 | 271 | |||
391 | 272 | // Ensure a 404 is returned for files not included in the charm. | ||
392 | 273 | for i, file := range []string{ | ||
393 | 274 | "no-such-file", "..", "../../../etc/passwd", "hooks/delete", | ||
394 | 275 | } { | ||
395 | 276 | c.Logf("test %d: %s", i, file) | ||
396 | 277 | uri := s.charmsURI(c, "?url=local:quantal/dummy-1&file="+file) | ||
397 | 278 | resp, err := s.authRequest(c, "GET", uri, "", nil) | ||
398 | 279 | c.Assert(err, gc.IsNil) | ||
399 | 280 | c.Assert(resp.StatusCode, gc.Equals, http.StatusNotFound) | ||
400 | 281 | } | ||
401 | 282 | } | ||
402 | 283 | |||
403 | 284 | func (s *charmsSuite) TestGetReturnsForbiddenWithDirectory(c *gc.C) { | ||
404 | 285 | // Add the dummy charm. | ||
405 | 286 | ch := coretesting.Charms.Bundle(c.MkDir(), "dummy") | ||
406 | 287 | _, err := s.uploadRequest( | ||
407 | 288 | c, s.charmsURI(c, "?series=quantal"), true, ch.Path) | ||
408 | 289 | c.Assert(err, gc.IsNil) | ||
409 | 290 | |||
410 | 291 | // Ensure a 403 is returned if the requested file is a directory. | ||
411 | 292 | uri := s.charmsURI(c, "?url=local:quantal/dummy-1&file=hooks") | ||
412 | 293 | resp, err := s.authRequest(c, "GET", uri, "", nil) | ||
413 | 294 | c.Assert(err, gc.IsNil) | ||
414 | 295 | c.Assert(resp.StatusCode, gc.Equals, http.StatusForbidden) | ||
415 | 296 | } | ||
416 | 297 | |||
417 | 298 | func (s *charmsSuite) TestGetReturnsFileContents(c *gc.C) { | ||
418 | 299 | // Add the dummy charm. | ||
419 | 300 | ch := coretesting.Charms.Bundle(c.MkDir(), "dummy") | ||
420 | 301 | _, err := s.uploadRequest( | ||
421 | 302 | c, s.charmsURI(c, "?series=quantal"), true, ch.Path) | ||
422 | 303 | c.Assert(err, gc.IsNil) | ||
423 | 304 | |||
424 | 305 | // Ensure the file contents are properly returned. | ||
425 | 306 | for i, t := range []struct { | ||
426 | 307 | summary string | ||
427 | 308 | file string | ||
428 | 309 | response string | ||
429 | 310 | }{{ | ||
430 | 311 | summary: "relative path", | ||
431 | 312 | file: "revision", | ||
432 | 313 | response: "1", | ||
433 | 314 | }, { | ||
434 | 315 | summary: "exotic path", | ||
435 | 316 | file: "./hooks/../revision", | ||
436 | 317 | response: "1", | ||
437 | 318 | }, { | ||
438 | 319 | summary: "sub-directory path", | ||
439 | 320 | file: "hooks/install", | ||
440 | 321 | response: "#!/bin/bash\necho \"Done!\"\n", | ||
441 | 322 | }, | ||
442 | 323 | } { | ||
443 | 324 | c.Logf("test %d: %s", i, t.summary) | ||
444 | 325 | uri := s.charmsURI(c, "?url=local:quantal/dummy-1&file="+t.file) | ||
445 | 326 | resp, err := s.authRequest(c, "GET", uri, "", nil) | ||
446 | 327 | c.Assert(err, gc.IsNil) | ||
447 | 328 | s.assertGetFileResponse(c, resp, t.response, "text/plain; charset=utf-8") | ||
448 | 329 | } | ||
449 | 330 | } | ||
450 | 331 | |||
451 | 332 | func (s *charmsSuite) TestGetListsFiles(c *gc.C) { | ||
452 | 333 | // Add the dummy charm. | ||
453 | 334 | ch := coretesting.Charms.Bundle(c.MkDir(), "dummy") | ||
454 | 335 | _, err := s.uploadRequest( | ||
455 | 336 | c, s.charmsURI(c, "?series=quantal"), true, ch.Path) | ||
456 | 337 | c.Assert(err, gc.IsNil) | ||
457 | 338 | |||
458 | 339 | // Ensure charm files are properly listed. | ||
459 | 340 | uri := s.charmsURI(c, "?url=local:quantal/dummy-1") | ||
460 | 341 | resp, err := s.authRequest(c, "GET", uri, "", nil) | ||
461 | 342 | c.Assert(err, gc.IsNil) | ||
462 | 343 | expectedFiles := []string{ | ||
463 | 344 | "revision", "config.yaml", "hooks/install", "metadata.yaml", | ||
464 | 345 | "src/hello.c", | ||
465 | 346 | } | ||
466 | 347 | s.assertGetFileListResponse(c, resp, expectedFiles) | ||
467 | 348 | ctype := resp.Header.Get("content-type") | ||
468 | 349 | c.Assert(ctype, gc.Equals, "application/json") | ||
469 | 350 | } | ||
470 | 351 | |||
471 | 352 | func (s *charmsSuite) TestGetUsesCache(c *gc.C) { | ||
472 | 353 | // Add a fake charm archive in the cache directory. | ||
473 | 354 | cacheDir := filepath.Join(s.DataDir(), "charm-get-cache") | ||
474 | 355 | err := os.MkdirAll(cacheDir, 0755) | ||
475 | 356 | c.Assert(err, gc.IsNil) | ||
476 | 357 | |||
477 | 358 | // Create and save the zip archive. | ||
478 | 359 | buffer := new(bytes.Buffer) | ||
479 | 360 | writer := zip.NewWriter(buffer) | ||
480 | 361 | file, err := writer.Create("utils.js") | ||
481 | 362 | c.Assert(err, gc.IsNil) | ||
482 | 363 | contents := "// these are the voyages" | ||
483 | 364 | _, err = file.Write([]byte(contents)) | ||
484 | 365 | c.Assert(err, gc.IsNil) | ||
485 | 366 | err = writer.Close() | ||
486 | 367 | c.Assert(err, gc.IsNil) | ||
487 | 368 | charmArchivePath := filepath.Join( | ||
488 | 369 | cacheDir, charm.Quote("local:trusty/django-42")+".zip") | ||
489 | 370 | err = ioutil.WriteFile(charmArchivePath, buffer.Bytes(), 0644) | ||
490 | 371 | c.Assert(err, gc.IsNil) | ||
491 | 372 | |||
492 | 373 | // Ensure the cached contents are properly retrieved. | ||
493 | 374 | uri := s.charmsURI(c, "?url=local:trusty/django-42&file=utils.js") | ||
494 | 375 | resp, err := s.authRequest(c, "GET", uri, "", nil) | ||
495 | 376 | c.Assert(err, gc.IsNil) | ||
496 | 377 | s.assertGetFileResponse(c, resp, contents, "application/javascript") | ||
497 | 378 | } | ||
498 | 379 | |||
499 | 243 | func (s *charmsSuite) charmsURI(c *gc.C, query string) string { | 380 | func (s *charmsSuite) charmsURI(c *gc.C, query string) string { |
500 | 244 | _, info, err := s.APIConn.Environ.StateInfo() | 381 | _, info, err := s.APIConn.Environ.StateInfo() |
501 | 245 | c.Assert(err, gc.IsNil) | 382 | c.Assert(err, gc.IsNil) |
502 | @@ -278,19 +415,42 @@ | |||
503 | 278 | return s.authRequest(c, "POST", uri, contentType, file) | 415 | return s.authRequest(c, "POST", uri, contentType, file) |
504 | 279 | } | 416 | } |
505 | 280 | 417 | ||
507 | 281 | func (s *charmsSuite) assertResponse(c *gc.C, resp *http.Response, expCode int, expError, expCharmURL string) { | 418 | func (s *charmsSuite) assertUploadResponse(c *gc.C, resp *http.Response, expCharmURL string) { |
508 | 419 | body := assertResponse(c, resp, http.StatusOK, "application/json") | ||
509 | 420 | charmResponse := jsonResponse(c, body) | ||
510 | 421 | c.Check(charmResponse.Error, gc.Equals, "") | ||
511 | 422 | c.Check(charmResponse.CharmURL, gc.Equals, expCharmURL) | ||
512 | 423 | } | ||
513 | 424 | |||
514 | 425 | func (s *charmsSuite) assertGetFileResponse(c *gc.C, resp *http.Response, expBody, expContentType string) { | ||
515 | 426 | body := assertResponse(c, resp, http.StatusOK, expContentType) | ||
516 | 427 | c.Check(string(body), gc.Equals, expBody) | ||
517 | 428 | } | ||
518 | 429 | |||
519 | 430 | func (s *charmsSuite) assertGetFileListResponse(c *gc.C, resp *http.Response, expFiles []string) { | ||
520 | 431 | body := assertResponse(c, resp, http.StatusOK, "application/json") | ||
521 | 432 | charmResponse := jsonResponse(c, body) | ||
522 | 433 | c.Check(charmResponse.Error, gc.Equals, "") | ||
523 | 434 | c.Check(charmResponse.Files, gc.DeepEquals, expFiles) | ||
524 | 435 | } | ||
525 | 436 | |||
526 | 437 | func (s *charmsSuite) assertErrorResponse(c *gc.C, resp *http.Response, expCode int, expError string) { | ||
527 | 438 | body := assertResponse(c, resp, expCode, "application/json") | ||
528 | 439 | c.Check(jsonResponse(c, body).Error, gc.Matches, expError) | ||
529 | 440 | } | ||
530 | 441 | |||
531 | 442 | func assertResponse(c *gc.C, resp *http.Response, expCode int, expContentType string) []byte { | ||
532 | 443 | c.Check(resp.StatusCode, gc.Equals, expCode) | ||
533 | 282 | body, err := ioutil.ReadAll(resp.Body) | 444 | body, err := ioutil.ReadAll(resp.Body) |
534 | 283 | defer resp.Body.Close() | 445 | defer resp.Body.Close() |
535 | 284 | c.Assert(err, gc.IsNil) | 446 | c.Assert(err, gc.IsNil) |
538 | 285 | var jsonResponse params.CharmsResponse | 447 | ctype := resp.Header.Get("Content-Type") |
539 | 286 | err = json.Unmarshal(body, &jsonResponse) | 448 | c.Assert(ctype, gc.Equals, expContentType) |
540 | 449 | return body | ||
541 | 450 | } | ||
542 | 451 | |||
543 | 452 | func jsonResponse(c *gc.C, body []byte) (jsonResponse params.CharmsResponse) { | ||
544 | 453 | err := json.Unmarshal(body, &jsonResponse) | ||
545 | 287 | c.Assert(err, gc.IsNil) | 454 | c.Assert(err, gc.IsNil) |
554 | 288 | if expError != "" { | 455 | return |
547 | 289 | c.Check(jsonResponse.Error, gc.Matches, expError) | ||
548 | 290 | c.Check(jsonResponse.CharmURL, gc.Equals, "") | ||
549 | 291 | } else { | ||
550 | 292 | c.Check(jsonResponse.Error, gc.Equals, "") | ||
551 | 293 | c.Check(jsonResponse.CharmURL, gc.Equals, expCharmURL) | ||
552 | 294 | } | ||
553 | 295 | c.Check(resp.StatusCode, gc.Equals, expCode) | ||
555 | 296 | } | 456 | } |
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: /github. com/hatched/ ghost-charm to/ghost- charm-master. zip -H /user-admin: MYPASSWD@ 10.0.3. 1:17070/ charms? series= precise`. :"local: precise/ ghost-4" }. precise/ ghost-4& file=icon. svg" /user-admin: MYPASSWD@ 10.0.3. 1:17070/ charms` precise/ ghost-4& file=hooks/ install" /user-admin: MYPASSWD@ 10.0.3. 1:17070/ charms`: precise/ ghost-4& file=hooks" /user-admin: MYPASSWD@ 10.0.3. 1:17070/ charms`: you should see a 403 precise/ ghost-4" /user-admin: MYPASSWD@ 10.0.3. 1:17070/ charms`.
- Download the Ghost charm from https:/
(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/
"Content-Type: application/zip"
https:/
The response should be something like:
{"CharmURL"
- Download ghost charm files, e.g.:
`curl -ikLG -d "url=local:
https:/
or
`curl -ikLG -d "url=local:
https:/
you should see the file contents.
- Ensure directories are not allowed, e.g.:
`curl -ikLG -d "url=local:
https:/
forbidden.
- Retrieve a list of the charm files:
`curl -ikLG -d "url=local:
https:/
- 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): params/ internal. go /apiserver. go /charms. go /charms_ test.go
A [revision details]
M state/api/
M state/apiserver
M state/apiserver
M state/apiserver