mirror of https://github.com/go-gitea/gitea
Add Chef package registry (#22554)
This PR implements a [Chef registry](https://chef.io/) to manage cookbooks. This package type was a bit complicated because Chef uses RSA signed requests as authentication with the registry. ![grafik](https://user-images.githubusercontent.com/1666336/213747995-46819fd8-c3d6-45a2-afd4-a4c3c8505a4a.png) ![grafik](https://user-images.githubusercontent.com/1666336/213748145-d01c9e81-d4dd-41e3-a3cc-8241862c3166.png) Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>pull/22756/head^2
parent
ff18d17442
commit
d987ac6bf1
@ -0,0 +1,96 @@ |
||||
--- |
||||
date: "2023-01-20T00:00:00+00:00" |
||||
title: "Chef Packages Repository" |
||||
slug: "packages/chef" |
||||
draft: false |
||||
toc: false |
||||
menu: |
||||
sidebar: |
||||
parent: "packages" |
||||
name: "Chef" |
||||
weight: 5 |
||||
identifier: "chef" |
||||
--- |
||||
|
||||
# Chef Packages Repository |
||||
|
||||
Publish [Chef](https://chef.io/) cookbooks for your user or organization. |
||||
|
||||
**Table of Contents** |
||||
|
||||
{{< toc >}} |
||||
|
||||
## Requirements |
||||
|
||||
To work with the Chef package registry, you have to use [`knife`](https://docs.chef.io/workstation/knife/). |
||||
|
||||
## Authentication |
||||
|
||||
The Chef package registry does not use an username:password authentication but signed requests with a private:public key pair. |
||||
Visit the package owner settings page to create the necessary key pair. |
||||
Only the public key is stored inside Gitea. if you loose access to the private key you must re-generate the key pair. |
||||
[Configure `knife`](https://docs.chef.io/workstation/knife_setup/) to use the downloaded private key with your Gitea username as `client_name`. |
||||
|
||||
## Configure the package registry |
||||
|
||||
To [configure `knife`](https://docs.chef.io/workstation/knife_setup/) to use the Gitea package registry add the url to the `~/.chef/config.rb` file. |
||||
|
||||
``` |
||||
knife[:supermarket_site] = 'https://gitea.example.com/api/packages/{owner}/chef' |
||||
``` |
||||
|
||||
| Parameter | Description | |
||||
| --------- | ----------- | |
||||
| `owner` | The owner of the package. | |
||||
|
||||
## Publish a package |
||||
|
||||
To publish a Chef package execute the following command: |
||||
|
||||
```shell |
||||
knife supermarket share {package_name} |
||||
``` |
||||
|
||||
| Parameter | Description | |
||||
| -------------- | ----------- | |
||||
| `package_name` | The package name. | |
||||
|
||||
You cannot publish a package if a package of the same name and version already exists. You must delete the existing package first. |
||||
|
||||
## Install a package |
||||
|
||||
To install a package from the package registry, execute the following command: |
||||
|
||||
```shell |
||||
knife supermarket install {package_name} |
||||
``` |
||||
|
||||
Optional you can specify the package version: |
||||
|
||||
```shell |
||||
knife supermarket install {package_name} {package_version} |
||||
``` |
||||
|
||||
| Parameter | Description | |
||||
| ----------------- | ----------- | |
||||
| `package_name` | The package name. | |
||||
| `package_version` | The package version. | |
||||
|
||||
## Delete a package |
||||
|
||||
If you want to remove a package from the registry, execute the following command: |
||||
|
||||
```shell |
||||
knife supermarket unshare {package_name} |
||||
``` |
||||
|
||||
Optional you can specify the package version: |
||||
|
||||
```shell |
||||
knife supermarket unshare {package_name}/versions/{package_version} |
||||
``` |
||||
|
||||
| Parameter | Description | |
||||
| ----------------- | ----------- | |
||||
| `package_name` | The package name. | |
||||
| `package_version` | The package version. | |
@ -0,0 +1,134 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package chef |
||||
|
||||
import ( |
||||
"archive/tar" |
||||
"compress/gzip" |
||||
"io" |
||||
"regexp" |
||||
"strings" |
||||
|
||||
"code.gitea.io/gitea/modules/json" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/modules/validation" |
||||
) |
||||
|
||||
const ( |
||||
KeyBits = 4096 |
||||
SettingPublicPem = "chef.public_pem" |
||||
) |
||||
|
||||
var ( |
||||
ErrMissingMetadataFile = util.NewInvalidArgumentErrorf("metadata.json file is missing") |
||||
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid") |
||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") |
||||
|
||||
namePattern = regexp.MustCompile(`\A\S+\z`) |
||||
versionPattern = regexp.MustCompile(`\A\d+\.\d+(?:\.\d+)?\z`) |
||||
) |
||||
|
||||
// Package represents a Chef package
|
||||
type Package struct { |
||||
Name string |
||||
Version string |
||||
Metadata *Metadata |
||||
} |
||||
|
||||
// Metadata represents the metadata of a Chef package
|
||||
type Metadata struct { |
||||
Description string `json:"description,omitempty"` |
||||
LongDescription string `json:"long_description,omitempty"` |
||||
Author string `json:"author,omitempty"` |
||||
License string `json:"license,omitempty"` |
||||
RepositoryURL string `json:"repository_url,omitempty"` |
||||
Dependencies map[string]string `json:"dependencies,omitempty"` |
||||
} |
||||
|
||||
type chefMetadata struct { |
||||
Name string `json:"name"` |
||||
Description string `json:"description"` |
||||
LongDescription string `json:"long_description"` |
||||
Maintainer string `json:"maintainer"` |
||||
MaintainerEmail string `json:"maintainer_email"` |
||||
License string `json:"license"` |
||||
Platforms map[string]string `json:"platforms"` |
||||
Dependencies map[string]string `json:"dependencies"` |
||||
Providing map[string]string `json:"providing"` |
||||
Recipes map[string]string `json:"recipes"` |
||||
Version string `json:"version"` |
||||
SourceURL string `json:"source_url"` |
||||
IssuesURL string `json:"issues_url"` |
||||
Privacy bool `json:"privacy"` |
||||
ChefVersions [][]string `json:"chef_versions"` |
||||
Gems [][]string `json:"gems"` |
||||
EagerLoadLibraries bool `json:"eager_load_libraries"` |
||||
} |
||||
|
||||
// ParsePackage parses the Chef package file
|
||||
func ParsePackage(r io.Reader) (*Package, error) { |
||||
gzr, err := gzip.NewReader(r) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer gzr.Close() |
||||
|
||||
tr := tar.NewReader(gzr) |
||||
for { |
||||
hd, err := tr.Next() |
||||
if err == io.EOF { |
||||
break |
||||
} |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if hd.Typeflag != tar.TypeReg { |
||||
continue |
||||
} |
||||
|
||||
if strings.Count(hd.Name, "/") != 1 { |
||||
continue |
||||
} |
||||
|
||||
if hd.FileInfo().Name() == "metadata.json" { |
||||
return ParseChefMetadata(tr) |
||||
} |
||||
} |
||||
|
||||
return nil, ErrMissingMetadataFile |
||||
} |
||||
|
||||
// ParseChefMetadata parses a metadata.json file to retrieve the metadata of a Chef package
|
||||
func ParseChefMetadata(r io.Reader) (*Package, error) { |
||||
var cm chefMetadata |
||||
if err := json.NewDecoder(r).Decode(&cm); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if !namePattern.MatchString(cm.Name) { |
||||
return nil, ErrInvalidName |
||||
} |
||||
|
||||
if !versionPattern.MatchString(cm.Version) { |
||||
return nil, ErrInvalidVersion |
||||
} |
||||
|
||||
if !validation.IsValidURL(cm.SourceURL) { |
||||
cm.SourceURL = "" |
||||
} |
||||
|
||||
return &Package{ |
||||
Name: cm.Name, |
||||
Version: cm.Version, |
||||
Metadata: &Metadata{ |
||||
Description: cm.Description, |
||||
LongDescription: cm.LongDescription, |
||||
Author: cm.Maintainer, |
||||
License: cm.License, |
||||
RepositoryURL: cm.SourceURL, |
||||
Dependencies: cm.Dependencies, |
||||
}, |
||||
}, nil |
||||
} |
@ -0,0 +1,92 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package chef |
||||
|
||||
import ( |
||||
"archive/tar" |
||||
"bytes" |
||||
"compress/gzip" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
const ( |
||||
packageName = "gitea" |
||||
packageVersion = "1.0.1" |
||||
packageAuthor = "KN4CK3R" |
||||
packageDescription = "Package Description" |
||||
packageRepositoryURL = "https://gitea.io/gitea/gitea" |
||||
) |
||||
|
||||
func TestParsePackage(t *testing.T) { |
||||
t.Run("MissingMetadataFile", func(t *testing.T) { |
||||
var buf bytes.Buffer |
||||
zw := gzip.NewWriter(&buf) |
||||
tw := tar.NewWriter(zw) |
||||
tw.Close() |
||||
zw.Close() |
||||
|
||||
p, err := ParsePackage(&buf) |
||||
assert.Nil(t, p) |
||||
assert.ErrorIs(t, err, ErrMissingMetadataFile) |
||||
}) |
||||
|
||||
t.Run("Valid", func(t *testing.T) { |
||||
var buf bytes.Buffer |
||||
zw := gzip.NewWriter(&buf) |
||||
tw := tar.NewWriter(zw) |
||||
|
||||
content := `{"name":"` + packageName + `","version":"` + packageVersion + `"}` |
||||
|
||||
hdr := &tar.Header{ |
||||
Name: packageName + "/metadata.json", |
||||
Mode: 0o600, |
||||
Size: int64(len(content)), |
||||
} |
||||
tw.WriteHeader(hdr) |
||||
tw.Write([]byte(content)) |
||||
|
||||
tw.Close() |
||||
zw.Close() |
||||
|
||||
p, err := ParsePackage(&buf) |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, p) |
||||
assert.Equal(t, packageName, p.Name) |
||||
assert.Equal(t, packageVersion, p.Version) |
||||
assert.NotNil(t, p.Metadata) |
||||
}) |
||||
} |
||||
|
||||
func TestParseChefMetadata(t *testing.T) { |
||||
t.Run("InvalidName", func(t *testing.T) { |
||||
for _, name := range []string{" test", "test "} { |
||||
p, err := ParseChefMetadata(strings.NewReader(`{"name":"` + name + `","version":"1.0.0"}`)) |
||||
assert.Nil(t, p) |
||||
assert.ErrorIs(t, err, ErrInvalidName) |
||||
} |
||||
}) |
||||
|
||||
t.Run("InvalidVersion", func(t *testing.T) { |
||||
for _, version := range []string{"1", "1.2.3.4", "1.0.0 "} { |
||||
p, err := ParseChefMetadata(strings.NewReader(`{"name":"test","version":"` + version + `"}`)) |
||||
assert.Nil(t, p) |
||||
assert.ErrorIs(t, err, ErrInvalidVersion) |
||||
} |
||||
}) |
||||
|
||||
t.Run("Valid", func(t *testing.T) { |
||||
p, err := ParseChefMetadata(strings.NewReader(`{"name":"` + packageName + `","version":"` + packageVersion + `","description":"` + packageDescription + `","maintainer":"` + packageAuthor + `","source_url":"` + packageRepositoryURL + `"}`)) |
||||
assert.NotNil(t, p) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.Equal(t, packageName, p.Name) |
||||
assert.Equal(t, packageVersion, p.Version) |
||||
assert.Equal(t, packageDescription, p.Metadata.Description) |
||||
assert.Equal(t, packageAuthor, p.Metadata.Author) |
||||
assert.Equal(t, packageRepositoryURL, p.Metadata.RepositoryURL) |
||||
}) |
||||
} |
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,270 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package chef |
||||
|
||||
import ( |
||||
"crypto" |
||||
"crypto/rsa" |
||||
"crypto/sha1" |
||||
"crypto/sha256" |
||||
"crypto/x509" |
||||
"encoding/base64" |
||||
"encoding/pem" |
||||
"fmt" |
||||
"hash" |
||||
"math/big" |
||||
"net/http" |
||||
"path" |
||||
"regexp" |
||||
"strconv" |
||||
"strings" |
||||
"time" |
||||
|
||||
user_model "code.gitea.io/gitea/models/user" |
||||
chef_module "code.gitea.io/gitea/modules/packages/chef" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/services/auth" |
||||
) |
||||
|
||||
const ( |
||||
maxTimeDifference = 10 * time.Minute |
||||
) |
||||
|
||||
var ( |
||||
algorithmPattern = regexp.MustCompile(`algorithm=(\w+)`) |
||||
versionPattern = regexp.MustCompile(`version=(\d+\.\d+)`) |
||||
authorizationPattern = regexp.MustCompile(`\AX-Ops-Authorization-(\d+)`) |
||||
) |
||||
|
||||
// Documentation:
|
||||
// https://docs.chef.io/server/api_chef_server/#required-headers
|
||||
// https://github.com/chef-boneyard/chef-rfc/blob/master/rfc065-sign-v1.3.md
|
||||
// https://github.com/chef/mixlib-authentication/blob/bc8adbef833d4be23dc78cb23e6fe44b51ebc34f/lib/mixlib/authentication/signedheaderauth.rb
|
||||
|
||||
type Auth struct{} |
||||
|
||||
func (a *Auth) Name() string { |
||||
return "chef" |
||||
} |
||||
|
||||
// Verify extracts the user from the signed request
|
||||
// If the request is signed with the user private key the user is verified.
|
||||
func (a *Auth) Verify(req *http.Request, w http.ResponseWriter, store auth.DataStore, sess auth.SessionStore) (*user_model.User, error) { |
||||
u, err := getUserFromRequest(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
if u == nil { |
||||
return nil, nil |
||||
} |
||||
|
||||
pub, err := getUserPublicKey(u) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := verifyTimestamp(req); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
version, err := getSignVersion(req) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if err := verifySignedHeaders(req, version, pub.(*rsa.PublicKey)); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return u, nil |
||||
} |
||||
|
||||
func getUserFromRequest(req *http.Request) (*user_model.User, error) { |
||||
username := req.Header.Get("X-Ops-Userid") |
||||
if username == "" { |
||||
return nil, nil |
||||
} |
||||
|
||||
return user_model.GetUserByName(req.Context(), username) |
||||
} |
||||
|
||||
func getUserPublicKey(u *user_model.User) (crypto.PublicKey, error) { |
||||
pubKey, err := user_model.GetSetting(u.ID, chef_module.SettingPublicPem) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
pubPem, _ := pem.Decode([]byte(pubKey)) |
||||
|
||||
return x509.ParsePKIXPublicKey(pubPem.Bytes) |
||||
} |
||||
|
||||
func verifyTimestamp(req *http.Request) error { |
||||
hdr := req.Header.Get("X-Ops-Timestamp") |
||||
if hdr == "" { |
||||
return util.NewInvalidArgumentErrorf("X-Ops-Timestamp header missing") |
||||
} |
||||
|
||||
ts, err := time.Parse(time.RFC3339, hdr) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
diff := time.Now().UTC().Sub(ts) |
||||
if diff < 0 { |
||||
diff = -diff |
||||
} |
||||
|
||||
if diff > maxTimeDifference { |
||||
return fmt.Errorf("time difference") |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func getSignVersion(req *http.Request) (string, error) { |
||||
hdr := req.Header.Get("X-Ops-Sign") |
||||
if hdr == "" { |
||||
return "", util.NewInvalidArgumentErrorf("X-Ops-Sign header missing") |
||||
} |
||||
|
||||
m := versionPattern.FindStringSubmatch(hdr) |
||||
if len(m) != 2 { |
||||
return "", util.NewInvalidArgumentErrorf("invalid X-Ops-Sign header") |
||||
} |
||||
|
||||
switch m[1] { |
||||
case "1.0", "1.1", "1.2", "1.3": |
||||
default: |
||||
return "", util.NewInvalidArgumentErrorf("unsupported version") |
||||
} |
||||
|
||||
version := m[1] |
||||
|
||||
m = algorithmPattern.FindStringSubmatch(hdr) |
||||
if len(m) == 2 && m[1] != "sha1" && !(m[1] == "sha256" && version == "1.3") { |
||||
return "", util.NewInvalidArgumentErrorf("unsupported algorithm") |
||||
} |
||||
|
||||
return version, nil |
||||
} |
||||
|
||||
func verifySignedHeaders(req *http.Request, version string, pub *rsa.PublicKey) error { |
||||
authorizationData, err := getAuthorizationData(req) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
checkData := buildCheckData(req, version) |
||||
|
||||
switch version { |
||||
case "1.3": |
||||
return verifyDataNew(authorizationData, checkData, pub, crypto.SHA256) |
||||
case "1.2": |
||||
return verifyDataNew(authorizationData, checkData, pub, crypto.SHA1) |
||||
default: |
||||
return verifyDataOld(authorizationData, checkData, pub) |
||||
} |
||||
} |
||||
|
||||
func getAuthorizationData(req *http.Request) ([]byte, error) { |
||||
valueList := make(map[int]string) |
||||
for k, vs := range req.Header { |
||||
if m := authorizationPattern.FindStringSubmatch(k); m != nil { |
||||
index, _ := strconv.Atoi(m[1]) |
||||
var v string |
||||
if len(vs) == 0 { |
||||
v = "" |
||||
} else { |
||||
v = vs[0] |
||||
} |
||||
valueList[index] = v |
||||
} |
||||
} |
||||
|
||||
tmp := make([]string, len(valueList)) |
||||
for k, v := range valueList { |
||||
if k > len(tmp) { |
||||
return nil, fmt.Errorf("invalid X-Ops-Authorization headers") |
||||
} |
||||
tmp[k-1] = v |
||||
} |
||||
|
||||
return base64.StdEncoding.DecodeString(strings.Join(tmp, "")) |
||||
} |
||||
|
||||
func buildCheckData(req *http.Request, version string) []byte { |
||||
username := req.Header.Get("X-Ops-Userid") |
||||
if version != "1.0" && version != "1.3" { |
||||
sum := sha1.Sum([]byte(username)) |
||||
username = base64.StdEncoding.EncodeToString(sum[:]) |
||||
} |
||||
|
||||
var data string |
||||
if version == "1.3" { |
||||
data = fmt.Sprintf( |
||||
"Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s", |
||||
req.Method, |
||||
path.Clean(req.URL.Path), |
||||
req.Header.Get("X-Ops-Content-Hash"), |
||||
version, |
||||
req.Header.Get("X-Ops-Timestamp"), |
||||
username, |
||||
req.Header.Get("X-Ops-Server-Api-Version"), |
||||
) |
||||
} else { |
||||
sum := sha1.Sum([]byte(path.Clean(req.URL.Path))) |
||||
data = fmt.Sprintf( |
||||
"Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s", |
||||
req.Method, |
||||
base64.StdEncoding.EncodeToString(sum[:]), |
||||
req.Header.Get("X-Ops-Content-Hash"), |
||||
req.Header.Get("X-Ops-Timestamp"), |
||||
username, |
||||
) |
||||
} |
||||
|
||||
return []byte(data) |
||||
} |
||||
|
||||
func verifyDataNew(signature, data []byte, pub *rsa.PublicKey, algo crypto.Hash) error { |
||||
var h hash.Hash |
||||
if algo == crypto.SHA256 { |
||||
h = sha256.New() |
||||
} else { |
||||
h = sha1.New() |
||||
} |
||||
if _, err := h.Write(data); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return rsa.VerifyPKCS1v15(pub, algo, h.Sum(nil), signature) |
||||
} |
||||
|
||||
func verifyDataOld(signature, data []byte, pub *rsa.PublicKey) error { |
||||
c := new(big.Int) |
||||
m := new(big.Int) |
||||
m.SetBytes(signature) |
||||
e := big.NewInt(int64(pub.E)) |
||||
c.Exp(m, e, pub.N) |
||||
|
||||
out := c.Bytes() |
||||
|
||||
skip := 0 |
||||
for i := 2; i < len(out); i++ { |
||||
if i+1 >= len(out) { |
||||
break |
||||
} |
||||
if out[i] == 0xFF && out[i+1] == 0 { |
||||
skip = i + 2 |
||||
break |
||||
} |
||||
} |
||||
|
||||
if !util.SliceEqual(out[skip:], data) { |
||||
return fmt.Errorf("could not verify signature") |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,404 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package chef |
||||
|
||||
import ( |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"net/url" |
||||
"sort" |
||||
"strings" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
packages_model "code.gitea.io/gitea/models/packages" |
||||
"code.gitea.io/gitea/modules/context" |
||||
packages_module "code.gitea.io/gitea/modules/packages" |
||||
chef_module "code.gitea.io/gitea/modules/packages/chef" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/routers/api/packages/helper" |
||||
packages_service "code.gitea.io/gitea/services/packages" |
||||
) |
||||
|
||||
func apiError(ctx *context.Context, status int, obj interface{}) { |
||||
type Error struct { |
||||
ErrorMessages []string `json:"error_messages"` |
||||
} |
||||
|
||||
helper.LogAndProcessError(ctx, status, obj, func(message string) { |
||||
ctx.JSON(status, Error{ |
||||
ErrorMessages: []string{message}, |
||||
}) |
||||
}) |
||||
} |
||||
|
||||
func PackagesUniverse(ctx *context.Context) { |
||||
pvs, _, err := packages_model.SearchVersions(ctx, &packages_model.PackageSearchOptions{ |
||||
OwnerID: ctx.Package.Owner.ID, |
||||
Type: packages_model.TypeChef, |
||||
IsInternal: util.OptionalBoolFalse, |
||||
}) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
type VersionInfo struct { |
||||
LocationType string `json:"location_type"` |
||||
LocationPath string `json:"location_path"` |
||||
DownloadURL string `json:"download_url"` |
||||
Dependencies map[string]string `json:"dependencies"` |
||||
} |
||||
|
||||
baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1" |
||||
|
||||
universe := make(map[string]map[string]*VersionInfo) |
||||
for _, pd := range pds { |
||||
if _, ok := universe[pd.Package.Name]; !ok { |
||||
universe[pd.Package.Name] = make(map[string]*VersionInfo) |
||||
} |
||||
universe[pd.Package.Name][pd.Version.Version] = &VersionInfo{ |
||||
LocationType: "opscode", |
||||
LocationPath: baseURL, |
||||
DownloadURL: fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", baseURL, url.PathEscape(pd.Package.Name), pd.Version.Version), |
||||
Dependencies: pd.Metadata.(*chef_module.Metadata).Dependencies, |
||||
} |
||||
} |
||||
|
||||
ctx.JSON(http.StatusOK, universe) |
||||
} |
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_list.rb
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_search.rb
|
||||
func EnumeratePackages(ctx *context.Context) { |
||||
opts := &packages_model.PackageSearchOptions{ |
||||
OwnerID: ctx.Package.Owner.ID, |
||||
Type: packages_model.TypeChef, |
||||
Name: packages_model.SearchValue{Value: ctx.FormTrim("q")}, |
||||
IsInternal: util.OptionalBoolFalse, |
||||
Paginator: db.NewAbsoluteListOptions( |
||||
ctx.FormInt("start"), |
||||
ctx.FormInt("items"), |
||||
), |
||||
} |
||||
|
||||
switch strings.ToLower(ctx.FormTrim("order")) { |
||||
case "recently_updated", "recently_added": |
||||
opts.Sort = packages_model.SortCreatedDesc |
||||
default: |
||||
opts.Sort = packages_model.SortNameAsc |
||||
} |
||||
|
||||
pvs, total, err := packages_model.SearchLatestVersions(ctx, opts) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
type Item struct { |
||||
CookbookName string `json:"cookbook_name"` |
||||
CookbookMaintainer string `json:"cookbook_maintainer"` |
||||
CookbookDescription string `json:"cookbook_description"` |
||||
Cookbook string `json:"cookbook"` |
||||
} |
||||
|
||||
type Result struct { |
||||
Start int `json:"start"` |
||||
Total int `json:"total"` |
||||
Items []*Item `json:"items"` |
||||
} |
||||
|
||||
baseURL := setting.AppURL + "api/packages/" + ctx.Package.Owner.Name + "/chef/api/v1/cookbooks/" |
||||
|
||||
items := make([]*Item, 0, len(pds)) |
||||
for _, pd := range pds { |
||||
metadata := pd.Metadata.(*chef_module.Metadata) |
||||
|
||||
items = append(items, &Item{ |
||||
CookbookName: pd.Package.Name, |
||||
CookbookMaintainer: metadata.Author, |
||||
CookbookDescription: metadata.Description, |
||||
Cookbook: baseURL + url.PathEscape(pd.Package.Name), |
||||
}) |
||||
} |
||||
|
||||
skip, _ := opts.Paginator.GetSkipTake() |
||||
|
||||
ctx.JSON(http.StatusOK, &Result{ |
||||
Start: skip, |
||||
Total: int(total), |
||||
Items: items, |
||||
}) |
||||
} |
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
|
||||
func PackageMetadata(ctx *context.Context) { |
||||
packageName := ctx.Params("name") |
||||
|
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
if len(pvs) == 0 { |
||||
apiError(ctx, http.StatusNotFound, nil) |
||||
return |
||||
} |
||||
|
||||
pds, err := packages_model.GetPackageDescriptors(ctx, pvs) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
sort.Slice(pds, func(i, j int) bool { |
||||
return pds[i].SemVer.LessThan(pds[j].SemVer) |
||||
}) |
||||
|
||||
type Result struct { |
||||
Name string `json:"name"` |
||||
Maintainer string `json:"maintainer"` |
||||
Description string `json:"description"` |
||||
Category string `json:"category"` |
||||
LatestVersion string `json:"latest_version"` |
||||
SourceURL string `json:"source_url"` |
||||
CreatedAt time.Time `json:"created_at"` |
||||
UpdatedAt time.Time `json:"updated_at"` |
||||
Deprecated bool `json:"deprecated"` |
||||
Versions []string `json:"versions"` |
||||
} |
||||
|
||||
baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s/versions/", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(packageName)) |
||||
|
||||
versions := make([]string, 0, len(pds)) |
||||
for _, pd := range pds { |
||||
versions = append(versions, baseURL+pd.Version.Version) |
||||
} |
||||
|
||||
latest := pds[len(pds)-1] |
||||
|
||||
metadata := latest.Metadata.(*chef_module.Metadata) |
||||
|
||||
ctx.JSON(http.StatusOK, &Result{ |
||||
Name: latest.Package.Name, |
||||
Maintainer: metadata.Author, |
||||
Description: metadata.Description, |
||||
LatestVersion: baseURL + latest.Version.Version, |
||||
SourceURL: metadata.RepositoryURL, |
||||
CreatedAt: latest.Version.CreatedUnix.AsLocalTime(), |
||||
UpdatedAt: latest.Version.CreatedUnix.AsLocalTime(), |
||||
Deprecated: false, |
||||
Versions: versions, |
||||
}) |
||||
} |
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_show.rb
|
||||
func PackageVersionMetadata(ctx *context.Context) { |
||||
packageName := ctx.Params("name") |
||||
packageVersion := strings.ReplaceAll(ctx.Params("version"), "_", ".") // Chef calls this endpoint with "_" instead of "."?!
|
||||
|
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, packageName, packageVersion) |
||||
if err != nil { |
||||
if err == packages_model.ErrPackageNotExist { |
||||
apiError(ctx, http.StatusNotFound, err) |
||||
return |
||||
} |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
type Result struct { |
||||
Version string `json:"version"` |
||||
TarballFileSize int64 `json:"tarball_file_size"` |
||||
PublishedAt time.Time `json:"published_at"` |
||||
Cookbook string `json:"cookbook"` |
||||
File string `json:"file"` |
||||
License string `json:"license"` |
||||
Dependencies map[string]string `json:"dependencies"` |
||||
} |
||||
|
||||
baseURL := fmt.Sprintf("%sapi/packages/%s/chef/api/v1/cookbooks/%s", setting.AppURL, ctx.Package.Owner.Name, url.PathEscape(pd.Package.Name)) |
||||
|
||||
metadata := pd.Metadata.(*chef_module.Metadata) |
||||
|
||||
ctx.JSON(http.StatusOK, &Result{ |
||||
Version: pd.Version.Version, |
||||
TarballFileSize: pd.Files[0].Blob.Size, |
||||
PublishedAt: pd.Version.CreatedUnix.AsLocalTime(), |
||||
Cookbook: baseURL, |
||||
File: fmt.Sprintf("%s/versions/%s/download", baseURL, pd.Version.Version), |
||||
License: metadata.License, |
||||
Dependencies: metadata.Dependencies, |
||||
}) |
||||
} |
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_share.rb
|
||||
func UploadPackage(ctx *context.Context) { |
||||
file, _, err := ctx.Req.FormFile("tarball") |
||||
if err != nil { |
||||
apiError(ctx, http.StatusBadRequest, err) |
||||
return |
||||
} |
||||
defer file.Close() |
||||
|
||||
buf, err := packages_module.CreateHashedBufferFromReader(file, 32*1024*1024) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
defer buf.Close() |
||||
|
||||
pck, err := chef_module.ParsePackage(buf) |
||||
if err != nil { |
||||
if errors.Is(err, util.ErrInvalidArgument) { |
||||
apiError(ctx, http.StatusBadRequest, err) |
||||
} else { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
if _, err := buf.Seek(0, io.SeekStart); err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
_, _, err = packages_service.CreatePackageAndAddFile( |
||||
&packages_service.PackageCreationInfo{ |
||||
PackageInfo: packages_service.PackageInfo{ |
||||
Owner: ctx.Package.Owner, |
||||
PackageType: packages_model.TypeChef, |
||||
Name: pck.Name, |
||||
Version: pck.Version, |
||||
}, |
||||
Creator: ctx.Doer, |
||||
SemverCompatible: true, |
||||
Metadata: pck.Metadata, |
||||
}, |
||||
&packages_service.PackageFileCreationInfo{ |
||||
PackageFileInfo: packages_service.PackageFileInfo{ |
||||
Filename: strings.ToLower(pck.Version + ".tar.gz"), |
||||
}, |
||||
Creator: ctx.Doer, |
||||
Data: buf, |
||||
IsLead: true, |
||||
}, |
||||
) |
||||
if err != nil { |
||||
switch err { |
||||
case packages_model.ErrDuplicatePackageVersion: |
||||
apiError(ctx, http.StatusBadRequest, err) |
||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: |
||||
apiError(ctx, http.StatusForbidden, err) |
||||
default: |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
ctx.JSON(http.StatusCreated, make(map[any]any)) |
||||
} |
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_download.rb
|
||||
func DownloadPackage(ctx *context.Context) { |
||||
pv, err := packages_model.GetVersionByNameAndVersion(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name"), ctx.Params("version")) |
||||
if err != nil { |
||||
if err == packages_model.ErrPackageNotExist { |
||||
apiError(ctx, http.StatusNotFound, err) |
||||
return |
||||
} |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
pd, err := packages_model.GetPackageDescriptor(ctx, pv) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
pf := pd.Files[0].File |
||||
|
||||
s, _, err := packages_service.GetPackageFileStream(ctx, pf) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
defer s.Close() |
||||
|
||||
ctx.ServeContent(s, &context.ServeHeaderOptions{ |
||||
Filename: pf.Name, |
||||
LastModified: pf.CreatedUnix.AsLocalTime(), |
||||
}) |
||||
} |
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
|
||||
func DeletePackageVersion(ctx *context.Context) { |
||||
packageName := ctx.Params("name") |
||||
packageVersion := ctx.Params("version") |
||||
|
||||
err := packages_service.RemovePackageVersionByNameAndVersion( |
||||
ctx.Doer, |
||||
&packages_service.PackageInfo{ |
||||
Owner: ctx.Package.Owner, |
||||
PackageType: packages_model.TypeChef, |
||||
Name: packageName, |
||||
Version: packageVersion, |
||||
}, |
||||
) |
||||
if err != nil { |
||||
if err == packages_model.ErrPackageNotExist { |
||||
apiError(ctx, http.StatusNotFound, err) |
||||
} else { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
ctx.Status(http.StatusOK) |
||||
} |
||||
|
||||
// https://github.com/chef/chef/blob/main/knife/lib/chef/knife/supermarket_unshare.rb
|
||||
func DeletePackage(ctx *context.Context) { |
||||
pvs, err := packages_model.GetVersionsByPackageName(ctx, ctx.Package.Owner.ID, packages_model.TypeChef, ctx.Params("name")) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
if len(pvs) == 0 { |
||||
apiError(ctx, http.StatusNotFound, err) |
||||
return |
||||
} |
||||
|
||||
for _, pv := range pvs { |
||||
if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
} |
||||
|
||||
ctx.Status(http.StatusOK) |
||||
} |
@ -0,0 +1,48 @@ |
||||
{{if eq .PackageDescriptor.Package.Type "chef"}} |
||||
<h4 class="ui top attached header">{{.locale.Tr "packages.installation"}}</h4> |
||||
<div class="ui attached segment"> |
||||
<div class="ui form"> |
||||
<div class="field"> |
||||
<label>{{svg "octicon-code"}} {{.locale.Tr "packages.chef.registry" | Safe}}</label> |
||||
<div class="markup"><pre class="code-block"><code>knife[:supermarket_site] = '{{AppUrl}}api/packages/{{.PackageDescriptor.Owner.Name}}/chef'</code></pre></div> |
||||
</div> |
||||
<div class="field"> |
||||
<label>{{svg "octicon-terminal"}} {{.locale.Tr "packages.chef.install"}}</label> |
||||
<div class="markup"><pre class="code-block"><code>knife supermarket install {{.PackageDescriptor.Package.Name}} {{.PackageDescriptor.Version.Version}}</code></pre></div> |
||||
</div> |
||||
<div class="field"> |
||||
<label>{{.locale.Tr "packages.chef.documentation" | Safe}}</label> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.LongDescription}} |
||||
<h4 class="ui top attached header">{{.locale.Tr "packages.about"}}</h4> |
||||
<div class="ui attached segment"> |
||||
{{if .PackageDescriptor.Metadata.Description}}<p>{{.PackageDescriptor.Metadata.Description}}</p>{{end}} |
||||
{{if .PackageDescriptor.Metadata.LongDescription}}{{RenderMarkdownToHtml .PackageDescriptor.Metadata.LongDescription}}{{end}} |
||||
</div> |
||||
{{end}} |
||||
|
||||
{{if .PackageDescriptor.Metadata.Dependencies}} |
||||
<h4 class="ui top attached header">{{.locale.Tr "packages.dependencies"}}</h4> |
||||
<div class="ui attached segment"> |
||||
<table class="ui single line very basic table"> |
||||
<thead> |
||||
<tr> |
||||
<th class="eleven wide">{{.locale.Tr "packages.dependency.id"}}</th> |
||||
<th class="five wide">{{.locale.Tr "packages.dependency.version"}}</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{{range $dependency, $version := .PackageDescriptor.Metadata.Dependencies}} |
||||
<tr> |
||||
<td>{{$dependency}}</td> |
||||
<td>{{$version}}</td> |
||||
</tr> |
||||
{{end}} |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
{{end}} |
||||
{{end}} |
@ -0,0 +1,5 @@ |
||||
{{if eq .PackageDescriptor.Package.Type "chef"}} |
||||
{{if .PackageDescriptor.Metadata.Author}}<div class="item" title="{{$.locale.Tr "packages.details.author"}}">{{svg "octicon-person" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Author}}</div>{{end}} |
||||
{{if .PackageDescriptor.Metadata.RepositoryURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.RepositoryURL}}" target="_blank" rel="noopener noreferrer me">{{.locale.Tr "packages.details.repository_site"}}</a></div>{{end}} |
||||
{{if .PackageDescriptor.Metadata.License}}<div class="item" title="{{$.locale.Tr "packages.details.license"}}">{{svg "octicon-law" 16 "mr-3"}} {{.PackageDescriptor.Metadata.License}}</div>{{end}} |
||||
{{end}} |
@ -1,5 +1,5 @@ |
||||
{{if eq .PackageDescriptor.Package.Type "pub"}} |
||||
{{if .PackageDescriptor.Metadata.ProjectURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.ProjectURL}}" target="_blank" rel="noopener noreferrer me">{{.locale.Tr "packages.details.project_site"}}</a></div>{{end}} |
||||
{{if .PackageDescriptor.Metadata.RepositoryURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.RepositoryURL}}" target="_blank" rel="noopener noreferrer me">{{.locale.Tr "packages.pub.details.repository_site"}}</a></div>{{end}} |
||||
{{if .PackageDescriptor.Metadata.DocumentationURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.DocumentationURL}}" target="_blank" rel="noopener noreferrer me">{{.locale.Tr "packages.pub.details.documentation_site"}}</a></div>{{end}} |
||||
{{if .PackageDescriptor.Metadata.RepositoryURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.RepositoryURL}}" target="_blank" rel="noopener noreferrer me">{{.locale.Tr "packages.details.repository_site"}}</a></div>{{end}} |
||||
{{if .PackageDescriptor.Metadata.DocumentationURL}}<div class="item">{{svg "octicon-link-external" 16 "mr-3"}} <a href="{{.PackageDescriptor.Metadata.DocumentationURL}}" target="_blank" rel="noopener noreferrer me">{{.locale.Tr "packages.details.documentation_site"}}</a></div>{{end}} |
||||
{{end}} |
||||
|
@ -0,0 +1,560 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration |
||||
|
||||
import ( |
||||
"archive/tar" |
||||
"bytes" |
||||
"compress/gzip" |
||||
"crypto" |
||||
"crypto/rand" |
||||
"crypto/rsa" |
||||
"crypto/sha1" |
||||
"crypto/sha256" |
||||
"crypto/x509" |
||||
"encoding/base64" |
||||
"encoding/pem" |
||||
"fmt" |
||||
"hash" |
||||
"math/big" |
||||
"mime/multipart" |
||||
"net/http" |
||||
"path" |
||||
"strings" |
||||
"testing" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
"code.gitea.io/gitea/models/packages" |
||||
"code.gitea.io/gitea/models/unittest" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
chef_module "code.gitea.io/gitea/modules/packages/chef" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
chef_router "code.gitea.io/gitea/routers/api/packages/chef" |
||||
"code.gitea.io/gitea/tests" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestPackageChef(t *testing.T) { |
||||
defer tests.PrepareTestEnv(t)() |
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) |
||||
|
||||
privPem := `-----BEGIN RSA PRIVATE KEY----- |
||||
MIIEpQIBAAKCAQEAtWp2PZz4TSU5A6ixw41HdbfBuGJwPuTtrsdoUf0DQ0/DJBNP |
||||
qOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6XrXjyzlUxghMuXjE5SeLGpgfQvkq |
||||
bTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1GrqXznuPIc7bNss0w5iX9RiBM9dWPuX |
||||
onx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26BzBFnMKp9YRTua0DO1WqLNhcaRnda |
||||
lIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJkPuCu6vH9brvOuYo0q8hLVNkBeXc |
||||
imRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJnwIDAQABAoIBAQCotF1KxLt/ejr/ |
||||
9ROCh9JJXV3v6tL5GgkSPOv9Oq2bHgSZer/cixJNW+5VWd5nbiSe3K1WuJBw5pbW |
||||
Wj4sWORPiRRR+3mjQzqeS/nGJDTOwWJo9K8IrUzOVhLEEYLX/ksxaXJyT8PehFyb |
||||
vbNwdhCIB6ZNcXDItTWE+95twWJ5lxAIj2dNwZZni3UkwwjYnCnqFtvHCKOg0NH2 |
||||
RjQcFYmu3fncNeqLezUSdVyRyXxSCHsUdlYeX/e44StCnXdrmLUHlb2P27ZVdPGh |
||||
SW7qTUPpmJKekYiRPOpTLj+ZKXIsANkyWO+7dVtZLBm5bIyAsmp0W/DmK+wRsejj |
||||
alFbIsh5AoGBANJr7HSG695wkfn+kvu/V8qHbt+KDv4WjWHjGRsUqvxoHOUNkQmW |
||||
vZWdk4gjHYn1l+QHWmoOE3AgyqtCZ4bFILkZPLN/F8Mh3+r4B0Ac4biJJt7XGMNQ |
||||
Nv4wsk7TR7CCARsjO7GP1PT60hpjMvYmc1E36gNM7QIZE9jBE+L8eWYtAoGBANy2 |
||||
JOAWf+QeBlur6o9feH76cEmpQzUUq4Lj9mmnXgIirSsFoBnDb8VA6Ws+ltL9U9H2 |
||||
vaCoaTyi9twW9zWj+Ywg2mVR5nlSAPfdlTWS1GLUbDotlj5apc/lvnGuNlWzN+I4 |
||||
Tu64hhgBXqGvRZ0o7HzFodqRAkpVXp6CQCqBM7p7AoGAIgO0K3oL8t87ma/fTra1 |
||||
mFWgRJ5qogQ/Qo2VZ11F7ptd4GD7CxPE/cSFLsKOadi7fu75XJ994OhMGrcXSR/g |
||||
lEtSFqn6y15UdgU2FtUUX+I72FXo+Nmkqh5xFHDu68d4Kkzdv2xCvn81K3LRsByz |
||||
E3P4biQnQ+mN3cIIVu79KNkCgYEAm6uctrEn4y2KLn5DInyj8GuTZ2ELFhVOIzPG |
||||
SR7TH451tTJyiblezDHMcOfkWUx0IlN1zCr8jtgiZXmNQzg0erFxWKU7ebZtGGYh |
||||
J3g4dLx+2Unt/mzRJqFUgbnueOO/Nr+gbJ+ZdLUCmeeVohOLOTXrws0kYGl2Izab |
||||
K1+VrKECgYEAxQohoOegA0f4mofisXItbwwqTIX3bLpxBc4woa1sB4kjNrLo4slc |
||||
qtWZGVlRxwBvQUg0cYj+xtr5nyBdHLy0qwX/kMq4GqQnvW6NqsbrP3MjCZ8NX/Sj |
||||
A2W0jx50Hs/XNw6IZFLYgWVoOzCaD+jYFpHhzUZyQD6/rYhwhHrNQmU= |
||||
-----END RSA PRIVATE KEY-----` |
||||
|
||||
tmp, _ := pem.Decode([]byte(privPem)) |
||||
privKey, _ := x509.ParsePKCS1PrivateKey(tmp.Bytes) |
||||
|
||||
pubPem := `-----BEGIN PUBLIC KEY----- |
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtWp2PZz4TSU5A6ixw41H |
||||
dbfBuGJwPuTtrsdoUf0DQ0/DJBNPqOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6 |
||||
XrXjyzlUxghMuXjE5SeLGpgfQvkqbTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1Grq |
||||
XznuPIc7bNss0w5iX9RiBM9dWPuXonx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26B |
||||
zBFnMKp9YRTua0DO1WqLNhcaRndalIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJ |
||||
kPuCu6vH9brvOuYo0q8hLVNkBeXcimRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJ |
||||
nwIDAQAB |
||||
-----END PUBLIC KEY-----` |
||||
|
||||
err := user_model.SetUserSetting(user.ID, chef_module.SettingPublicPem, pubPem) |
||||
assert.NoError(t, err) |
||||
|
||||
t.Run("Authenticate", func(t *testing.T) { |
||||
auth := &chef_router.Auth{} |
||||
|
||||
t.Run("MissingUser", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "POST", "/dummy") |
||||
u, err := auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.NoError(t, err) |
||||
}) |
||||
|
||||
t.Run("NotExistingUser", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "POST", "/dummy") |
||||
req.Header.Set("X-Ops-Userid", "not-existing-user") |
||||
u, err := auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
}) |
||||
|
||||
t.Run("Timestamp", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "POST", "/dummy") |
||||
req.Header.Set("X-Ops-Userid", user.Name) |
||||
u, err := auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
|
||||
req.Header.Set("X-Ops-Timestamp", "2023-01-01T00:00:00Z") |
||||
u, err = auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
}) |
||||
|
||||
t.Run("SigningVersion", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "POST", "/dummy") |
||||
req.Header.Set("X-Ops-Userid", user.Name) |
||||
req.Header.Set("X-Ops-Timestamp", time.Now().UTC().Format(time.RFC3339)) |
||||
u, err := auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
|
||||
req.Header.Set("X-Ops-Sign", "version=none") |
||||
u, err = auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
|
||||
req.Header.Set("X-Ops-Sign", "version=1.4") |
||||
u, err = auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
|
||||
req.Header.Set("X-Ops-Sign", "version=1.0;algorithm=sha2") |
||||
u, err = auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
|
||||
req.Header.Set("X-Ops-Sign", "version=1.0;algorithm=sha256") |
||||
u, err = auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
}) |
||||
|
||||
t.Run("SignedHeaders", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
ts := time.Now().UTC().Format(time.RFC3339) |
||||
|
||||
req := NewRequest(t, "POST", "/dummy") |
||||
req.Header.Set("X-Ops-Userid", user.Name) |
||||
req.Header.Set("X-Ops-Timestamp", ts) |
||||
req.Header.Set("X-Ops-Sign", "version=1.0;algorithm=sha1") |
||||
req.Header.Set("X-Ops-Content-Hash", "unused") |
||||
req.Header.Set("X-Ops-Authorization-4", "dummy") |
||||
u, err := auth.Verify(req, nil, nil, nil) |
||||
assert.Nil(t, u) |
||||
assert.Error(t, err) |
||||
|
||||
signRequest := func(t *testing.T, req *http.Request, version string) { |
||||
username := req.Header.Get("X-Ops-Userid") |
||||
if version != "1.0" && version != "1.3" { |
||||
sum := sha1.Sum([]byte(username)) |
||||
username = base64.StdEncoding.EncodeToString(sum[:]) |
||||
} |
||||
|
||||
req.Header.Set("X-Ops-Sign", "version="+version) |
||||
|
||||
var data []byte |
||||
if version == "1.3" { |
||||
data = []byte(fmt.Sprintf( |
||||
"Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s", |
||||
req.Method, |
||||
path.Clean(req.URL.Path), |
||||
req.Header.Get("X-Ops-Content-Hash"), |
||||
version, |
||||
req.Header.Get("X-Ops-Timestamp"), |
||||
username, |
||||
req.Header.Get("X-Ops-Server-Api-Version"), |
||||
)) |
||||
} else { |
||||
sum := sha1.Sum([]byte(path.Clean(req.URL.Path))) |
||||
data = []byte(fmt.Sprintf( |
||||
"Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s", |
||||
req.Method, |
||||
base64.StdEncoding.EncodeToString(sum[:]), |
||||
req.Header.Get("X-Ops-Content-Hash"), |
||||
req.Header.Get("X-Ops-Timestamp"), |
||||
username, |
||||
)) |
||||
} |
||||
|
||||
for k := range req.Header { |
||||
if strings.HasPrefix(k, "X-Ops-Authorization-") { |
||||
req.Header.Del(k) |
||||
} |
||||
} |
||||
|
||||
var signature []byte |
||||
if version == "1.3" || version == "1.2" { |
||||
var h hash.Hash |
||||
var ch crypto.Hash |
||||
if version == "1.3" { |
||||
h = sha256.New() |
||||
ch = crypto.SHA256 |
||||
} else { |
||||
h = sha1.New() |
||||
ch = crypto.SHA1 |
||||
} |
||||
h.Write(data) |
||||
|
||||
signature, _ = rsa.SignPKCS1v15(rand.Reader, privKey, ch, h.Sum(nil)) |
||||
} else { |
||||
c := new(big.Int).SetBytes(data) |
||||
m := new(big.Int).Exp(c, privKey.D, privKey.N) |
||||
|
||||
signature = m.Bytes() |
||||
} |
||||
|
||||
enc := base64.StdEncoding.EncodeToString(signature) |
||||
|
||||
const chunkSize = 60 |
||||
chunks := make([]string, 0, (len(enc)-1)/chunkSize+1) |
||||
currentLen := 0 |
||||
currentStart := 0 |
||||
for i := range enc { |
||||
if currentLen == chunkSize { |
||||
chunks = append(chunks, enc[currentStart:i]) |
||||
currentLen = 0 |
||||
currentStart = i |
||||
} |
||||
currentLen++ |
||||
} |
||||
chunks = append(chunks, enc[currentStart:]) |
||||
|
||||
for i, chunk := range chunks { |
||||
req.Header.Set(fmt.Sprintf("X-Ops-Authorization-%d", i+1), chunk) |
||||
} |
||||
} |
||||
|
||||
for _, v := range []string{"1.0", "1.1", "1.2", "1.3"} { |
||||
t.Run(v, func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
signRequest(t, req, v) |
||||
u, err = auth.Verify(req, nil, nil, nil) |
||||
assert.NotNil(t, u) |
||||
assert.NoError(t, err) |
||||
}) |
||||
} |
||||
}) |
||||
}) |
||||
|
||||
packageName := "test" |
||||
packageVersion := "1.0.1" |
||||
packageDescription := "Test Description" |
||||
packageAuthor := "KN4CK3R" |
||||
|
||||
root := fmt.Sprintf("/api/packages/%s/chef/api/v1", user.Name) |
||||
|
||||
uploadPackage := func(t *testing.T, version string, expectedStatus int) { |
||||
var body bytes.Buffer |
||||
mpw := multipart.NewWriter(&body) |
||||
part, _ := mpw.CreateFormFile("tarball", fmt.Sprintf("%s.tar.gz", version)) |
||||
zw := gzip.NewWriter(part) |
||||
tw := tar.NewWriter(zw) |
||||
|
||||
content := `{"name":"` + packageName + `","version":"` + version + `","description":"` + packageDescription + `","maintainer":"` + packageAuthor + `"}` |
||||
|
||||
hdr := &tar.Header{ |
||||
Name: packageName + "/metadata.json", |
||||
Mode: 0o600, |
||||
Size: int64(len(content)), |
||||
} |
||||
tw.WriteHeader(hdr) |
||||
tw.Write([]byte(content)) |
||||
|
||||
tw.Close() |
||||
zw.Close() |
||||
mpw.Close() |
||||
|
||||
req := NewRequestWithBody(t, "POST", root+"/cookbooks", &body) |
||||
req.Header.Add("Content-Type", mpw.FormDataContentType()) |
||||
AddBasicAuthHeader(req, user.Name) |
||||
MakeRequest(t, req, expectedStatus) |
||||
} |
||||
|
||||
t.Run("Upload", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequestWithBody(t, "POST", root+"/cookbooks", bytes.NewReader([]byte{})) |
||||
MakeRequest(t, req, http.StatusUnauthorized) |
||||
|
||||
uploadPackage(t, packageVersion, http.StatusCreated) |
||||
|
||||
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef) |
||||
assert.NoError(t, err) |
||||
assert.Len(t, pvs, 1) |
||||
|
||||
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, pd.SemVer) |
||||
assert.IsType(t, &chef_module.Metadata{}, pd.Metadata) |
||||
assert.Equal(t, packageName, pd.Package.Name) |
||||
assert.Equal(t, packageVersion, pd.Version.Version) |
||||
|
||||
pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID) |
||||
assert.NoError(t, err) |
||||
assert.Len(t, pfs, 1) |
||||
assert.Equal(t, fmt.Sprintf("%s.tar.gz", packageVersion), pfs[0].Name) |
||||
assert.True(t, pfs[0].IsLead) |
||||
|
||||
uploadPackage(t, packageVersion, http.StatusBadRequest) |
||||
}) |
||||
|
||||
t.Run("Download", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", root, packageName, packageVersion)) |
||||
MakeRequest(t, req, http.StatusOK) |
||||
}) |
||||
|
||||
t.Run("Universe", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "GET", root+"/universe") |
||||
resp := MakeRequest(t, req, http.StatusOK) |
||||
|
||||
type VersionInfo struct { |
||||
LocationType string `json:"location_type"` |
||||
LocationPath string `json:"location_path"` |
||||
DownloadURL string `json:"download_url"` |
||||
Dependencies map[string]string `json:"dependencies"` |
||||
} |
||||
|
||||
var result map[string]map[string]*VersionInfo |
||||
DecodeJSON(t, resp, &result) |
||||
|
||||
assert.Len(t, result, 1) |
||||
assert.Contains(t, result, packageName) |
||||
|
||||
versions := result[packageName] |
||||
|
||||
assert.Len(t, versions, 1) |
||||
assert.Contains(t, versions, packageVersion) |
||||
|
||||
info := versions[packageVersion] |
||||
|
||||
assert.Equal(t, "opscode", info.LocationType) |
||||
assert.Equal(t, setting.AppURL+root[1:], info.LocationPath) |
||||
assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s/versions/%s/download", setting.AppURL, root[1:], packageName, packageVersion), info.DownloadURL) |
||||
}) |
||||
|
||||
t.Run("Search", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
cases := []struct { |
||||
Query string |
||||
Start int |
||||
Items int |
||||
ExpectedTotal int |
||||
ExpectedResults int |
||||
}{ |
||||
{"", 0, 0, 1, 1}, |
||||
{"", 0, 10, 1, 1}, |
||||
{"gitea", 0, 10, 0, 0}, |
||||
{"test", 0, 10, 1, 1}, |
||||
{"test", 1, 10, 1, 0}, |
||||
} |
||||
|
||||
type Item struct { |
||||
CookbookName string `json:"cookbook_name"` |
||||
CookbookMaintainer string `json:"cookbook_maintainer"` |
||||
CookbookDescription string `json:"cookbook_description"` |
||||
Cookbook string `json:"cookbook"` |
||||
} |
||||
|
||||
type Result struct { |
||||
Start int `json:"start"` |
||||
Total int `json:"total"` |
||||
Items []*Item `json:"items"` |
||||
} |
||||
|
||||
for i, c := range cases { |
||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/search?q=%s&start=%d&items=%d", root, c.Query, c.Start, c.Items)) |
||||
req = AddBasicAuthHeader(req, user.Name) |
||||
resp := MakeRequest(t, req, http.StatusOK) |
||||
|
||||
var result Result |
||||
DecodeJSON(t, resp, &result) |
||||
|
||||
assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) |
||||
assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i) |
||||
|
||||
if len(result.Items) == 1 { |
||||
item := result.Items[0] |
||||
assert.Equal(t, packageName, item.CookbookName) |
||||
assert.Equal(t, packageAuthor, item.CookbookMaintainer) |
||||
assert.Equal(t, packageDescription, item.CookbookDescription) |
||||
assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
t.Run("EnumeratePackages", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
cases := []struct { |
||||
Sort string |
||||
Start int |
||||
Items int |
||||
ExpectedTotal int |
||||
ExpectedResults int |
||||
}{ |
||||
{"", 0, 0, 1, 1}, |
||||
{"", 0, 10, 1, 1}, |
||||
{"RECENTLY_ADDED", 0, 10, 1, 1}, |
||||
{"RECENTLY_UPDATED", 0, 10, 1, 1}, |
||||
{"", 1, 10, 1, 0}, |
||||
} |
||||
|
||||
type Item struct { |
||||
CookbookName string `json:"cookbook_name"` |
||||
CookbookMaintainer string `json:"cookbook_maintainer"` |
||||
CookbookDescription string `json:"cookbook_description"` |
||||
Cookbook string `json:"cookbook"` |
||||
} |
||||
|
||||
type Result struct { |
||||
Start int `json:"start"` |
||||
Total int `json:"total"` |
||||
Items []*Item `json:"items"` |
||||
} |
||||
|
||||
for i, c := range cases { |
||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks?start=%d&items=%d&sort=%s", root, c.Start, c.Items, c.Sort)) |
||||
req = AddBasicAuthHeader(req, user.Name) |
||||
resp := MakeRequest(t, req, http.StatusOK) |
||||
|
||||
var result Result |
||||
DecodeJSON(t, resp, &result) |
||||
|
||||
assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i) |
||||
assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i) |
||||
|
||||
if len(result.Items) == 1 { |
||||
item := result.Items[0] |
||||
assert.Equal(t, packageName, item.CookbookName) |
||||
assert.Equal(t, packageAuthor, item.CookbookMaintainer) |
||||
assert.Equal(t, packageDescription, item.CookbookDescription) |
||||
assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
t.Run("PackageMetadata", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) |
||||
resp := MakeRequest(t, req, http.StatusOK) |
||||
|
||||
type Result struct { |
||||
Name string `json:"name"` |
||||
Maintainer string `json:"maintainer"` |
||||
Description string `json:"description"` |
||||
Category string `json:"category"` |
||||
LatestVersion string `json:"latest_version"` |
||||
SourceURL string `json:"source_url"` |
||||
CreatedAt time.Time `json:"created_at"` |
||||
UpdatedAt time.Time `json:"updated_at"` |
||||
Deprecated bool `json:"deprecated"` |
||||
Versions []string `json:"versions"` |
||||
} |
||||
|
||||
var result Result |
||||
DecodeJSON(t, resp, &result) |
||||
|
||||
versionURL := fmt.Sprintf("%s%s/cookbooks/%s/versions/%s", setting.AppURL, root[1:], packageName, packageVersion) |
||||
|
||||
assert.Equal(t, packageName, result.Name) |
||||
assert.Equal(t, packageAuthor, result.Maintainer) |
||||
assert.Equal(t, packageDescription, result.Description) |
||||
assert.Equal(t, versionURL, result.LatestVersion) |
||||
assert.False(t, result.Deprecated) |
||||
assert.ElementsMatch(t, []string{versionURL}, result.Versions) |
||||
}) |
||||
|
||||
t.Run("PackageVersionMetadata", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, packageVersion)) |
||||
resp := MakeRequest(t, req, http.StatusOK) |
||||
|
||||
type Result struct { |
||||
Version string `json:"version"` |
||||
TarballFileSize int64 `json:"tarball_file_size"` |
||||
PublishedAt time.Time `json:"published_at"` |
||||
Cookbook string `json:"cookbook"` |
||||
File string `json:"file"` |
||||
License string `json:"license"` |
||||
Dependencies map[string]string `json:"dependencies"` |
||||
} |
||||
|
||||
var result Result |
||||
DecodeJSON(t, resp, &result) |
||||
|
||||
packageURL := fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName) |
||||
|
||||
assert.Equal(t, packageVersion, result.Version) |
||||
assert.Equal(t, packageURL, result.Cookbook) |
||||
assert.Equal(t, fmt.Sprintf("%s/versions/%s/download", packageURL, packageVersion), result.File) |
||||
}) |
||||
|
||||
t.Run("Delete", func(t *testing.T) { |
||||
uploadPackage(t, "1.0.2", http.StatusCreated) |
||||
uploadPackage(t, "1.0.3", http.StatusCreated) |
||||
|
||||
t.Run("Version", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2")) |
||||
MakeRequest(t, req, http.StatusUnauthorized) |
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2")) |
||||
AddBasicAuthHeader(req, user.Name) |
||||
MakeRequest(t, req, http.StatusOK) |
||||
|
||||
pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeChef, packageName, "1.0.2") |
||||
assert.Nil(t, pv) |
||||
assert.Error(t, err) |
||||
}) |
||||
|
||||
t.Run("Package", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) |
||||
MakeRequest(t, req, http.StatusUnauthorized) |
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName)) |
||||
AddBasicAuthHeader(req, user.Name) |
||||
MakeRequest(t, req, http.StatusOK) |
||||
|
||||
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef) |
||||
assert.NoError(t, err) |
||||
assert.Empty(t, pvs) |
||||
}) |
||||
}) |
||||
} |
After Width: | Height: | Size: 2.0 KiB |
Loading…
Reference in new issue