11package storage
22
33import (
4+ "bytes"
45 "context"
5- "errors"
6+ "encoding/json"
7+ "fmt"
8+ "io"
69 "net/http"
710 "os"
11+ "path"
12+ "sort"
13+ "strings"
814
15+ "golang.org/x/mod/semver"
916 "golang.org/x/xerrors"
1017
1118 "cdr.dev/slog"
19+
20+ "github.com/coder/code-marketplace/util"
1221)
1322
1423const ArtifactoryTokenEnvKey = "ARTIFACTORY_TOKEN"
1524
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+
1643// Artifactory implements Storage. It stores extensions remotely through
1744// Artifactory by both copying the VSIX and extracting said VSIX to a tree
1845// structure in the form of publisher/extension/version to easily serve
@@ -36,34 +63,250 @@ func NewArtifactoryStorage(uri, repo string, logger slog.Logger) (*Artifactory,
3663
3764 return & Artifactory {
3865 logger : logger ,
39- repo : repo ,
66+ repo : path . Clean ( repo ) ,
4067 token : token ,
4168 uri : uri ,
4269 }, nil
4370}
4471
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+
45156func (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
47203}
48204
49205func (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).
50214 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 )
52223 })
53224}
54225
55226func (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
57249}
58250
59251func (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
61254}
62255
63256func (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
65290}
66291
67292func (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
69312}
0 commit comments