Skip to content

Commit 366c021

Browse files
committed
Implement Artifactory storage
1 parent 55af42e commit 366c021

File tree

1 file changed

+251
-8
lines changed

1 file changed

+251
-8
lines changed

storage/artifactory.go

+251-8
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,45 @@
11
package storage
22

33
import (
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

1423
const 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+
45156
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
47203
}
48204

49205
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).
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

55226
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
57249
}
58250

59251
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
61254
}
62255

63256
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
65290
}
66291

67292
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
69312
}

0 commit comments

Comments
 (0)