1
1
package storage
2
2
3
3
import (
4
+ "bytes"
4
5
"context"
5
- "errors"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
6
9
"net/http"
7
10
"os"
11
+ "path"
12
+ "sort"
13
+ "strings"
8
14
15
+ "golang.org/x/mod/semver"
9
16
"golang.org/x/xerrors"
10
17
11
18
"cdr.dev/slog"
19
+
20
+ "github.com/coder/code-marketplace/util"
12
21
)
13
22
14
23
const ArtifactoryTokenEnvKey = "ARTIFACTORY_TOKEN"
15
24
25
+ type ArtifactoryError struct {
26
+ Status int `json:"status"`
27
+ Message string `json:"message"`
28
+ }
29
+
30
+ type ArtifactoryResponse struct {
31
+ Errors []ArtifactoryError `json:"errors"`
32
+ }
33
+
34
+ type ArtifactoryFile struct {
35
+ URI string `json:"uri"`
36
+ Folder bool `json:"folder"`
37
+ }
38
+
39
+ type ArtifactoryFolder struct {
40
+ Children []ArtifactoryFile `json:"children"`
41
+ }
42
+
16
43
// Artifactory implements Storage. It stores extensions remotely through
17
44
// Artifactory by both copying the VSIX and extracting said VSIX to a tree
18
45
// structure in the form of publisher/extension/version to easily serve
@@ -36,34 +63,250 @@ func NewArtifactoryStorage(uri, repo string, logger slog.Logger) (*Artifactory,
36
63
37
64
return & Artifactory {
38
65
logger : logger ,
39
- repo : repo ,
66
+ repo : path . Clean ( repo ) ,
40
67
token : token ,
41
68
uri : uri ,
42
69
}, nil
43
70
}
44
71
72
+ // request makes a request against Artifactory and returns the response. If
73
+ // there is an error it reads the response first to get any error messages. The
74
+ // code is returned so it can be relayed when proxying file requests. 404s are
75
+ // turned into os.ErrNotExist errors.
76
+ func (s * Artifactory ) request (ctx context.Context , method , endpoint string , r io.Reader ) (* http.Response , int , error ) {
77
+ req , err := http .NewRequestWithContext (ctx , method , s .uri + endpoint , r )
78
+ if err != nil {
79
+ return nil , http .StatusInternalServerError , err
80
+ }
81
+ req .Header .Add ("X-JFrog-Art-Api" , s .token )
82
+ resp , err := http .DefaultClient .Do (req )
83
+ if err != nil {
84
+ return nil , http .StatusInternalServerError , err
85
+ }
86
+ if resp .StatusCode < http .StatusOK || resp .StatusCode >= http .StatusBadRequest {
87
+ defer resp .Body .Close ()
88
+ if resp .StatusCode == http .StatusNotFound {
89
+ return nil , resp .StatusCode , os .ErrNotExist
90
+ }
91
+ var ar ArtifactoryResponse
92
+ err = json .NewDecoder (resp .Body ).Decode (& ar )
93
+ if err != nil {
94
+ s .logger .Warn (ctx , "failed to unmarshal response" , slog .F ("error" , err ))
95
+ }
96
+ messages := []string {}
97
+ for _ , e := range ar .Errors {
98
+ if e .Message != "" {
99
+ messages = append (messages , e .Message )
100
+ }
101
+ }
102
+ if len (messages ) == 0 {
103
+ messages = append (messages , "the server did not provide any additional details" )
104
+ }
105
+ return nil , resp .StatusCode , xerrors .Errorf ("request failed with code %d: %s" , resp .StatusCode , strings .Join (messages , ", " ))
106
+ }
107
+ return resp , resp .StatusCode , nil
108
+ }
109
+
110
+ func (s * Artifactory ) list (ctx context.Context , endpoint string ) ([]ArtifactoryFile , int , error ) {
111
+ ctx = slog .With (ctx , slog .F ("path" , endpoint ), slog .F ("repo" , s .repo ))
112
+ s .logger .Debug (ctx , "listing" )
113
+ resp , code , err := s .request (ctx , http .MethodGet , path .Join ("api/storage" , s .repo , endpoint ), nil )
114
+ if err != nil {
115
+ return nil , code , err
116
+ }
117
+ defer resp .Body .Close ()
118
+ var ar ArtifactoryFolder
119
+ err = json .NewDecoder (resp .Body ).Decode (& ar )
120
+ if err != nil {
121
+ return nil , code , err
122
+ }
123
+ return ar .Children , code , nil
124
+ }
125
+
126
+ func (s * Artifactory ) read (ctx context.Context , endpoint string ) (io.ReadCloser , int , error ) {
127
+ resp , code , err := s .request (ctx , http .MethodGet , path .Join (s .repo , endpoint ), nil )
128
+ if err != nil {
129
+ return nil , code , err
130
+ }
131
+ return resp .Body , code , err
132
+ }
133
+
134
+ func (s * Artifactory ) delete (ctx context.Context , endpoint string ) (int , error ) {
135
+ ctx = slog .With (ctx , slog .F ("path" , endpoint ), slog .F ("repo" , s .repo ))
136
+ s .logger .Debug (ctx , "deleting" )
137
+ resp , code , err := s .request (ctx , http .MethodDelete , path .Join (s .repo , endpoint ), nil )
138
+ if err != nil {
139
+ return code , err
140
+ }
141
+ defer resp .Body .Close ()
142
+ return code , nil
143
+ }
144
+
145
+ func (s * Artifactory ) upload (ctx context.Context , endpoint string , r io.Reader ) (int , error ) {
146
+ ctx = slog .With (ctx , slog .F ("path" , endpoint ), slog .F ("repo" , s .repo ))
147
+ s .logger .Debug (ctx , "uploading" )
148
+ resp , code , err := s .request (ctx , http .MethodPut , path .Join (s .repo , endpoint ), r )
149
+ if err != nil {
150
+ return code , err
151
+ }
152
+ defer resp .Body .Close ()
153
+ return code , nil
154
+ }
155
+
45
156
func (s * Artifactory ) AddExtension (ctx context.Context , manifest * VSIXManifest , vsix []byte ) (string , error ) {
46
- return "" , errors .New ("not implemented" )
157
+ // Extract the zip to the correct path.
158
+ identity := manifest .Metadata .Identity
159
+ dir := path .Join (identity .Publisher , identity .ID , identity .Version )
160
+
161
+ // Uploading every file in an extension such as ms-python.python can take
162
+ // quite a while (16 minutes!!). As a compromise only extract a file if it
163
+ // might be directly requested by VS Code. This includes the manifest, any
164
+ // assets listed as addressable in that manifest, and the browser entry point.
165
+ var browser string
166
+ assets := []string {"extension.vsixmanifest" }
167
+ for _ , a := range manifest .Assets .Asset {
168
+ if a .Addressable == "true" {
169
+ assets = append (assets , a .Path )
170
+ }
171
+ // The browser entry point is listed in the package.json (which they also
172
+ // confusingly call the manifest) rather than the top-level VSIX manifest.
173
+ if a .Type == ManifestAssetType {
174
+ packageJSON , err := ReadVSIXPackageJSON (vsix , a .Path )
175
+ if err != nil {
176
+ return "" , err
177
+ }
178
+ if packageJSON .Browser != "" {
179
+ browser = path .Join (path .Dir (a .Path ), path .Clean (packageJSON .Browser ))
180
+ }
181
+ }
182
+ }
183
+
184
+ err := ExtractZip (vsix , func (name string , r io.Reader ) error {
185
+ if util .Contains (assets , name ) || (browser != "" && strings .HasPrefix (name , browser )) {
186
+ _ , err := s .upload (ctx , path .Join (dir , name ), r )
187
+ return err
188
+ }
189
+ return nil
190
+ })
191
+ if err != nil {
192
+ return "" , err
193
+ }
194
+
195
+ // Copy the VSIX itself as well.
196
+ vsixName := fmt .Sprintf ("%s.vsix" , ExtensionID (manifest ))
197
+ _ , err = s .upload (ctx , path .Join (dir , vsixName ), bytes .NewReader (vsix ))
198
+ if err != nil {
199
+ return "" , err
200
+ }
201
+
202
+ return s .uri + dir , nil
47
203
}
48
204
49
205
func (s * Artifactory ) FileServer () http.Handler {
206
+ // TODO: Since we only extract a subset of files perhaps if the file does not
207
+ // exist we should download the vsix and extract the requested file as a
208
+ // fallback. Obviously this seems like quite a bit of overhead so we would
209
+ // then emit a warning so we can notice that VS Code has added new asset types
210
+ // that we should be extracting to avoid that overhead. Other solutions could
211
+ // be implemented though like extracting the VSIX to disk locally and only
212
+ // going to Artifactory for the VSIX when it is missing on disk (basically
213
+ // using the disk as a cache).
50
214
return http .HandlerFunc (func (rw http.ResponseWriter , r * http.Request ) {
51
- http .Error (rw , "not found" , http .StatusNotFound )
215
+ reader , code , err := s .read (r .Context (), r .URL .Path )
216
+ if err != nil {
217
+ http .Error (rw , err .Error (), code )
218
+ return
219
+ }
220
+ defer reader .Close ()
221
+ rw .WriteHeader (http .StatusOK )
222
+ _ , _ = io .Copy (rw , reader )
52
223
})
53
224
}
54
225
55
226
func (s * Artifactory ) Manifest (ctx context.Context , publisher , name , version string ) (* VSIXManifest , error ) {
56
- return nil , errors .New ("not implemented" )
227
+ reader , _ , err := s .read (ctx , path .Join (publisher , name , version , "extension.vsixmanifest" ))
228
+ if err != nil {
229
+ return nil , err
230
+ }
231
+ defer reader .Close ()
232
+
233
+ // If the manifest is returned with an error that means it exists but is
234
+ // invalid. We will still return it as a best-effort.
235
+ manifest , err := parseVSIXManifest (reader )
236
+ if manifest == nil && err != nil {
237
+ return nil , err
238
+ } else if err != nil {
239
+ s .logger .Error (ctx , "Extension has invalid manifest" , slog .Error (err ))
240
+ }
241
+
242
+ manifest .Assets .Asset = append (manifest .Assets .Asset , VSIXAsset {
243
+ Type : VSIXAssetType ,
244
+ Path : fmt .Sprintf ("%s.vsix" , ExtensionID (manifest )),
245
+ Addressable : "true" ,
246
+ })
247
+
248
+ return manifest , nil
57
249
}
58
250
59
251
func (s * Artifactory ) RemoveExtension (ctx context.Context , publisher , name , version string ) error {
60
- return errors .New ("not implemented" )
252
+ _ , err := s .delete (ctx , path .Join (publisher , name , version ))
253
+ return err
61
254
}
62
255
63
256
func (s * Artifactory ) WalkExtensions (ctx context.Context , fn func (manifest * VSIXManifest , versions []string ) error ) error {
64
- return errors .New ("not implemented" )
257
+ publishers , err := s .getDirNames (ctx , "/" )
258
+ if err != nil {
259
+ s .logger .Error (ctx , "Error reading publisher" , slog .Error (err ))
260
+ }
261
+ for _ , publisher := range publishers {
262
+ ctx := slog .With (ctx , slog .F ("publisher" , publisher ))
263
+ extensions , err := s .getDirNames (ctx , publisher )
264
+ if err != nil {
265
+ s .logger .Error (ctx , "Error reading extensions" , slog .Error (err ))
266
+ }
267
+ for _ , extension := range extensions {
268
+ ctx := slog .With (ctx , slog .F ("extension" , extension ))
269
+ versions , err := s .Versions (ctx , publisher , extension )
270
+ if err != nil {
271
+ s .logger .Error (ctx , "Error reading versions" , slog .Error (err ))
272
+ }
273
+ if len (versions ) == 0 {
274
+ continue
275
+ }
276
+
277
+ // The manifest from the latest version is used for filtering.
278
+ manifest , err := s .Manifest (ctx , publisher , extension , versions [0 ])
279
+ if err != nil {
280
+ s .logger .Error (ctx , "Unable to read extension manifest" , slog .Error (err ))
281
+ continue
282
+ }
283
+
284
+ if err = fn (manifest , versions ); err != nil {
285
+ return err
286
+ }
287
+ }
288
+ }
289
+ return nil
65
290
}
66
291
67
292
func (s * Artifactory ) Versions (ctx context.Context , publisher , name string ) ([]string , error ) {
68
- return nil , errors .New ("not implemented" )
293
+ versions , err := s .getDirNames (ctx , path .Join (publisher , name ))
294
+ // Return anything we did get even if there was an error.
295
+ sort .Sort (sort .Reverse (semver .ByVersion (versions )))
296
+ return versions , err
297
+ }
298
+
299
+ // getDirNames get the names of directories in the provided directory. If an
300
+ // error is occured it will be returned along with any directories that were
301
+ // able to be read.
302
+ func (s * Artifactory ) getDirNames (ctx context.Context , dir string ) ([]string , error ) {
303
+ files , _ , err := s .list (ctx , dir )
304
+ names := []string {}
305
+ for _ , file := range files {
306
+ if file .Folder {
307
+ // The files come with leading slashes so clean them up.
308
+ names = append (names , strings .TrimLeft (path .Clean (file .URI ), "/" ))
309
+ }
310
+ }
311
+ return names , err
69
312
}
0 commit comments