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>
This commit is contained in:
KN4CK3R 2023-02-06 02:49:21 +01:00 committed by GitHub
parent ff18d17442
commit d987ac6bf1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1737 additions and 20 deletions

View File

@ -2460,6 +2460,8 @@ ROUTER = console
;LIMIT_TOTAL_OWNER_SIZE = -1
;; Maximum size of a Cargo upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
;LIMIT_SIZE_CARGO = -1
;; Maximum size of a Chef upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
;LIMIT_SIZE_CHEF = -1
;; Maximum size of a Composer upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
;LIMIT_SIZE_COMPOSER = -1
;; Maximum size of a Conan upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)

View File

@ -1214,6 +1214,7 @@ Task queue configuration has been moved to `queue.task`. However, the below conf
- `LIMIT_TOTAL_OWNER_COUNT`: **-1**: Maximum count of package versions a single owner can have (`-1` means no limits)
- `LIMIT_TOTAL_OWNER_SIZE`: **-1**: Maximum size of packages a single owner can use (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
- `LIMIT_SIZE_CARGO`: **-1**: Maximum size of a Cargo upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
- `LIMIT_SIZE_CHEF`: **-1**: Maximum size of a Chef upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
- `LIMIT_SIZE_COMPOSER`: **-1**: Maximum size of a Composer upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
- `LIMIT_SIZE_CONAN`: **-1**: Maximum size of a Conan upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)
- `LIMIT_SIZE_CONDA`: **-1**: Maximum size of a Conda upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`)

View File

@ -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. |

View File

@ -27,6 +27,7 @@ The following package managers are currently supported:
| Name | Language | Package client |
| ---- | -------- | -------------- |
| [Cargo]({{< relref "doc/packages/cargo.en-us.md" >}}) | Rust | `cargo` |
| [Chef]({{< relref "doc/packages/chef.en-us.md" >}}) | - | `knife` |
| [Composer]({{< relref "doc/packages/composer.en-us.md" >}}) | PHP | `composer` |
| [Conan]({{< relref "doc/packages/conan.en-us.md" >}}) | C++ | `conan` |
| [Conda]({{< relref "doc/packages/conda.en-us.md" >}}) | - | `conda` |

View File

@ -12,6 +12,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/packages/cargo"
"code.gitea.io/gitea/modules/packages/chef"
"code.gitea.io/gitea/modules/packages/composer"
"code.gitea.io/gitea/modules/packages/conan"
"code.gitea.io/gitea/modules/packages/conda"
@ -132,6 +133,8 @@ func GetPackageDescriptor(ctx context.Context, pv *PackageVersion) (*PackageDesc
switch p.Type {
case TypeCargo:
metadata = &cargo.Metadata{}
case TypeChef:
metadata = &chef.Metadata{}
case TypeComposer:
metadata = &composer.Metadata{}
case TypeConan:

View File

@ -31,6 +31,7 @@ type Type string
// List of supported packages
const (
TypeCargo Type = "cargo"
TypeChef Type = "chef"
TypeComposer Type = "composer"
TypeConan Type = "conan"
TypeConda Type = "conda"
@ -48,6 +49,7 @@ const (
var TypeList = []Type{
TypeCargo,
TypeChef,
TypeComposer,
TypeConan,
TypeConda,
@ -68,6 +70,8 @@ func (pt Type) Name() string {
switch pt {
case TypeCargo:
return "Cargo"
case TypeChef:
return "Chef"
case TypeComposer:
return "Composer"
case TypeConan:
@ -103,6 +107,8 @@ func (pt Type) SVGName() string {
switch pt {
case TypeCargo:
return "gitea-cargo"
case TypeChef:
return "gitea-chef"
case TypeComposer:
return "gitea-composer"
case TypeConan:

View File

@ -5,8 +5,11 @@ package activitypub
import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
)
const rsaBits = 2048
// GetKeyPair function returns a user's private and public keys
func GetKeyPair(user *user_model.User) (pub, priv string, err error) {
var settings map[string]*user_model.Setting
@ -14,7 +17,7 @@ func GetKeyPair(user *user_model.User) (pub, priv string, err error) {
if err != nil {
return
} else if len(settings) == 0 {
if priv, pub, err = GenerateKeyPair(); err != nil {
if priv, pub, err = util.GenerateKeyPair(rsaBits); err != nil {
return
}
if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPrivPem, priv); err != nil {

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -26,6 +26,7 @@ var (
LimitTotalOwnerCount int64
LimitTotalOwnerSize int64
LimitSizeCargo int64
LimitSizeChef int64
LimitSizeComposer int64
LimitSizeConan int64
LimitSizeConda int64
@ -67,6 +68,7 @@ func newPackages() {
Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE")
Packages.LimitSizeCargo = mustBytes(sec, "LIMIT_SIZE_CARGO")
Packages.LimitSizeChef = mustBytes(sec, "LIMIT_SIZE_CHEF")
Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER")
Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN")
Packages.LimitSizeConda = mustBytes(sec, "LIMIT_SIZE_CONDA")

View File

@ -1,7 +1,7 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
package util
import (
"crypto/rand"
@ -10,11 +10,9 @@ import (
"encoding/pem"
)
const rsaBits = 2048
// GenerateKeyPair generates a public and private keypair for signing actions by users for activitypub purposes
func GenerateKeyPair() (string, string, error) {
priv, _ := rsa.GenerateKey(rand.Reader, rsaBits)
// GenerateKeyPair generates a public and private keypair
func GenerateKeyPair(bits int) (string, string, error) {
priv, _ := rsa.GenerateKey(rand.Reader, bits)
privPem, err := pemBlockForPriv(priv)
if err != nil {
return "", "", err

View File

@ -1,7 +1,7 @@
// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
package util
import (
"crypto"
@ -17,7 +17,7 @@ import (
)
func TestKeygen(t *testing.T) {
priv, pub, err := GenerateKeyPair()
priv, pub, err := GenerateKeyPair(2048)
assert.NoError(t, err)
assert.NotEmpty(t, priv)
@ -28,7 +28,7 @@ func TestKeygen(t *testing.T) {
}
func TestSignUsingKeys(t *testing.T) {
priv, pub, err := GenerateKeyPair()
priv, pub, err := GenerateKeyPair(2048)
assert.NoError(t, err)
privPem, _ := pem.Decode([]byte(priv))

View File

@ -3145,6 +3145,8 @@ keywords = Keywords
details = Details
details.author = Author
details.project_site = Project Site
details.repository_site = Repository Site
details.documentation_site = Documentation Site
details.license = License
assets = Assets
versions = Versions
@ -3157,6 +3159,9 @@ cargo.install = To install the package using Cargo, run the following command:
cargo.documentation = For more information on the Cargo registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/cargo/">the documentation</a>.
cargo.details.repository_site = Repository Site
cargo.details.documentation_site = Documentation Site
chef.registry = Setup this registry in your <code>~/.chef/config.rb</code> file:
chef.install = To install the package, run the following command:
chef.documentation = For more information on the Chef registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/chef/">the documentation</a>.
composer.registry = Setup this registry in your <code>~/.composer/config.json</code> file:
composer.install = To install the package using Composer, run the following command:
composer.documentation = For more information on the Composer registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/composer/">the documentation</a>.
@ -3173,8 +3178,6 @@ conda.details.repository_site = Repository Site
conda.details.documentation_site = Documentation Site
container.details.type = Image Type
container.details.platform = Platform
container.details.repository_site = Repository Site
container.details.documentation_site = Documentation Site
container.pull = Pull the image from the command line:
container.digest = Digest:
container.documentation = For more information on the Container registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/container/">the documentation</a>.
@ -3208,8 +3211,6 @@ npm.dependencies.optional = Optional Dependencies
npm.details.tag = Tag
pub.install = To install the package using Dart, run the following command:
pub.documentation = For more information on the Pub registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/pub/">the documentation</a>.
pub.details.repository_site = Repository Site
pub.details.documentation_site = Documentation Site
pypi.requires = Requires Python
pypi.install = To install the package using pip, run the following command:
pypi.documentation = For more information on the PyPI registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/pypi/">the documentation</a>.
@ -3262,6 +3263,9 @@ owner.settings.cleanuprules.remove.days = Remove versions older than
owner.settings.cleanuprules.remove.pattern = Remove versions matching
owner.settings.cleanuprules.success.update = Cleanup rule has been updated.
owner.settings.cleanuprules.success.delete = Cleanup rule has been deleted.
owner.settings.chef.title = Chef Registry
owner.settings.chef.keypair = Generate key pair
owner.settings.chef.keypair.description = Generate a key pair used to authenticate against the Chef registry. The previous key can not be used afterwards.
[secrets]
secrets = Secrets

View File

@ -0,0 +1 @@
<svg viewBox="0 0 36 36" class="svg gitea-chef" width="16" height="16" aria-hidden="true"><g fill="none" fill-rule="evenodd"><path fill="#435363" d="M18 25.8c-4.3 0-7.7-3.6-7.7-8s3.4-7.9 7.7-7.9c3.5 0 6.4 2.4 7.3 5.7h3c-1-5-5.2-8.7-10.3-8.7-5.9 0-10.6 4.9-10.6 10.9 0 6.1 4.7 11 10.6 11 5.1 0 9.3-3.7 10.3-8.7h-3c-.9 3.3-3.8 5.7-7.3 5.7"/><path fill="#435363" d="M12.8 23.2c1.3 1.4 3.1 2.3 5.2 2.3v-3.2c-1.2 0-2.3-.5-3.1-1.3l-2.1 2.2"/><path fill="#F38B00" d="M10.6 17.8c0 1.1.3 2.2.6 3.1l2.9-1.3c-.3-.5-.4-1.1-.4-1.8 0-2.4 1.9-4.4 4.3-4.4v-3.2c-4.1 0-7.4 3.4-7.4 7.6"/><path fill="#435363" d="m20.6 10.7-1.1 3c.9.4 1.7 1.1 2.2 1.9H25c-.7-2.2-2.3-4-4.4-4.9"/><path fill="#F38B00" d="m19.5 22 1.1 2.9c2.1-.8 3.7-2.6 4.4-4.8h-3.3c-.5.8-1.3 1.5-2.2 1.9"/><path fill="#435363" d="M4.4 22.1c-.1-.2-.1-.3-.1-.5-.1-.2-.1-.3-.2-.5V21c0-.1 0-.3-.1-.4v-.5c-.1-.1-.1-.2-.1-.3-.1-.6-.1-1.3-.1-2H.9c0 .8 0 1.5.1 2.2 0 .2.1.4.1.6v.1c0 .2.1.4.1.5s0 .2.1.3v.3c.1.1.1.2.1.4 0 0 .1.1.1.2 0 .2 0 .3.1.4v.2c.2.7.5 1.3.7 2L5 23.8c-.2-.6-.4-1.1-.6-1.7"/><path fill="#F38B00" d="M18 32.6c-3.9 0-7.5-1.7-10.1-4.4l-2 2.2c3.1 3.2 7.3 5.2 12.1 5.2 8.7 0 15.8-6.8 16.9-15.5H32c-1.1 7-7 12.5-14 12.5M18 3.1c3.1 0 6.1 1.1 8.4 2.9l1.8-2.4C25.3 1.4 21.8.1 18 .1 10.7.1 4.5 4.8 2.1 11.4l2.7 1.1C6.8 7 12 3.1 18 3.1"/><path fill="#435363" d="M32 15.6h2.9c-.3-2.6-1.2-5-2.5-7.2L30 10c1 1.7 1.7 3.6 2 5.6"/><path fill="#F38B00" d="M28.7 15.6h2.9c-.8-5.1-4.1-9.3-8.6-11.1l-1.1 2.8c3.5 1.3 6 4.5 6.8 8.3"/><path fill="#435363" d="M18 6.5v-3c-5.9 0-10.9 3.8-12.9 9.1l2.7 1.1C9.4 9.5 13.3 6.5 18 6.5"/><path fill="#F38B00" d="M7 17.8H4.1c0 6.1 3.6 11.2 8.7 13.4l1.1-2.8C9.9 26.7 7 22.6 7 17.8"/><path fill="#435363" d="M18 29.2v3c6.9 0 12.6-5.3 13.6-12.1h-2.9c-1 5.2-5.4 9.1-10.7 9.1"/></g></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -15,6 +15,7 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/routers/api/packages/cargo"
"code.gitea.io/gitea/routers/api/packages/chef"
"code.gitea.io/gitea/routers/api/packages/composer"
"code.gitea.io/gitea/routers/api/packages/conan"
"code.gitea.io/gitea/routers/api/packages/conda"
@ -54,6 +55,7 @@ func CommonRoutes(ctx gocontext.Context) *web.Route {
&auth.Basic{},
&nuget.Auth{},
&conan.Auth{},
&chef.Auth{},
}
if setting.Service.EnableReverseProxyAuth {
authMethods = append(authMethods, &auth.ReverseProxy{})
@ -86,6 +88,25 @@ func CommonRoutes(ctx gocontext.Context) *web.Route {
})
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/chef", func() {
r.Group("/api/v1", func() {
r.Get("/universe", chef.PackagesUniverse)
r.Get("/search", chef.EnumeratePackages)
r.Group("/cookbooks", func() {
r.Get("", chef.EnumeratePackages)
r.Post("", reqPackageAccess(perm.AccessModeWrite), chef.UploadPackage)
r.Group("/{name}", func() {
r.Get("", chef.PackageMetadata)
r.Group("/versions/{version}", func() {
r.Get("", chef.PackageVersionMetadata)
r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackageVersion)
r.Get("/download", chef.DownloadPackage)
})
r.Delete("", reqPackageAccess(perm.AccessModeWrite), chef.DeletePackage)
})
})
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/composer", func() {
r.Get("/packages.json", composer.ServiceIndex)
r.Get("/search.json", composer.SearchPackages)

View File

@ -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
}

View File

@ -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)
}

View File

@ -40,7 +40,7 @@ func ListPackages(ctx *context.APIContext) {
// in: query
// description: package type filter
// type: string
// enum: [cargo, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant]
// enum: [cargo, chef, composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant]
// - name: q
// in: query
// description: name filter

View File

@ -5,10 +5,14 @@ package setting
import (
"net/http"
"strings"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/context"
chef_module "code.gitea.io/gitea/modules/packages/chef"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
shared "code.gitea.io/gitea/routers/web/shared/packages"
)
@ -95,3 +99,21 @@ func RebuildCargoIndex(ctx *context.Context) {
ctx.Redirect(setting.AppSubURL + "/user/settings/packages")
}
func RegenerateChefKeyPair(ctx *context.Context) {
priv, pub, err := util.GenerateKeyPair(chef_module.KeyBits)
if err != nil {
ctx.ServerError("GenerateKeyPair", err)
return
}
if err := user_model.SetUserSetting(ctx.Doer.ID, chef_module.SettingPublicPem, pub); err != nil {
ctx.ServerError("SetUserSetting", err)
return
}
ctx.ServeContent(strings.NewReader(priv), &context.ServeHeaderOptions{
ContentType: "application/x-pem-file",
Filename: ctx.Doer.Name + ".priv",
})
}

View File

@ -472,6 +472,7 @@ func RegisterRoutes(m *web.Route) {
m.Post("/initialize", user_setting.InitializeCargoIndex)
m.Post("/rebuild", user_setting.RebuildCargoIndex)
})
m.Post("/chef/regenerate_keypair", user_setting.RegenerateChefKeyPair)
}, packagesEnabled)
m.Group("/secrets", func() {
m.Get("", user_setting.Secrets)

View File

@ -15,7 +15,7 @@ import (
type PackageCleanupRuleForm struct {
ID int64
Enabled bool
Type string `binding:"Required;In(cargo,composer,conan,conda,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"`
Type string `binding:"Required;In(cargo,chef,composer,conan,conda,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"`
KeepCount int `binding:"In(0,1,5,10,25,50,100)"`
KeepPattern string `binding:"RegexPattern"`
RemoveDays int `binding:"In(0,7,14,30,60,90,180)"`

View File

@ -335,6 +335,8 @@ func CheckSizeQuotaExceeded(ctx context.Context, doer, owner *user_model.User, p
switch packageType {
case packages_model.TypeCargo:
typeSpecificSize = setting.Packages.LimitSizeCargo
case packages_model.TypeChef:
typeSpecificSize = setting.Packages.LimitSizeChef
case packages_model.TypeComposer:
typeSpecificSize = setting.Packages.LimitSizeComposer
case packages_model.TypeConan:

View File

@ -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}}

View File

@ -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}}

View File

@ -4,6 +4,6 @@
{{range .PackageDescriptor.Metadata.Authors}}<div class="item" title="{{$.locale.Tr "packages.details.author"}}">{{svg "octicon-person" 16 "mr-3"}} {{.}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Licenses}}<div class="item">{{svg "octicon-law" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Licenses}}</div>{{end}}
{{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.container.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.container.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}}

View File

@ -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}}

View File

@ -20,6 +20,7 @@
</div>
<div class="twelve wide column">
{{template "package/content/cargo" .}}
{{template "package/content/chef" .}}
{{template "package/content/composer" .}}
{{template "package/content/conan" .}}
{{template "package/content/conda" .}}
@ -45,6 +46,7 @@
<div class="item">{{svg "octicon-calendar" 16 "mr-3"}} {{TimeSinceUnix .PackageDescriptor.Version.CreatedUnix $.locale}}</div>
<div class="item">{{svg "octicon-download" 16 "mr-3"}} {{.PackageDescriptor.Version.DownloadCount}}</div>
{{template "package/metadata/cargo" .}}
{{template "package/metadata/chef" .}}
{{template "package/metadata/composer" .}}
{{template "package/metadata/conan" .}}
{{template "package/metadata/conda" .}}

View File

@ -2101,6 +2101,7 @@
{
"enum": [
"cargo",
"chef",
"composer",
"conan",
"conda",

View File

@ -5,6 +5,24 @@
{{template "base/alert" .}}
{{template "package/shared/cleanup_rules/list" .}}
{{template "package/shared/cargo" .}}
<h4 class="ui top attached header">
{{.locale.Tr "packages.owner.settings.chef.title"}}
</h4>
<div class="ui attached segment">
<div class="ui form">
<div class="field">
<label>{{$.locale.Tr "packages.owner.settings.chef.keypair.description"}}</label>
</div>
<form class="field" action="{{.Link}}/chef/regenerate_keypair" method="post">
{{.CsrfTokenHtml}}
<button class="ui green button">{{$.locale.Tr "packages.owner.settings.chef.keypair"}}</button>
</form>
<div class="field">
<label>{{.locale.Tr "packages.chef.documentation" | Safe}}</label>
</div>
</div>
</div>
</div>
</div>
{{template "base/footer" .}}

View File

@ -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)
})
})
}

View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<g transform="translate(-54 -32)">
<path d="m72 57.8c-4.3 0-7.7-3.6-7.7-8s3.4-7.9 7.7-7.9c3.5 0 6.4 2.4 7.3 5.7h3c-1-5-5.2-8.7-10.3-8.7-5.9 0-10.6 4.9-10.6 10.9 0 6.1 4.7 11 10.6 11 5.1 0 9.3-3.7 10.3-8.7h-3c-0.9 3.3-3.8 5.7-7.3 5.7" fill="#435363"/>
<path d="m66.8 55.2c1.3 1.4 3.1 2.3 5.2 2.3v-3.2c-1.2 0-2.3-0.5-3.1-1.3l-2.1 2.2" fill="#435363"/>
<path d="m64.6 49.8c0 1.1 0.3 2.2 0.6 3.1l2.9-1.3c-0.3-0.5-0.4-1.1-0.4-1.8 0-2.4 1.9-4.4 4.3-4.4v-3.2c-4.1 0-7.4 3.4-7.4 7.6" fill="#F38B00"/>
<path d="m74.6 42.7-1.1 3c0.9 0.4 1.7 1.1 2.2 1.9h3.3c-0.7-2.2-2.3-4-4.4-4.9" fill="#435363"/>
<path d="m73.5 54 1.1 2.9c2.1-0.8 3.7-2.6 4.4-4.8h-3.3c-0.5 0.8-1.3 1.5-2.2 1.9" fill="#F38B00"/>
<path d="m58.4 54.1c-0.1-0.2-0.1-0.3-0.1-0.5-0.1-0.2-0.1-0.3-0.2-0.5v-0.1c0-0.1 0-0.3-0.1-0.4v-0.2-0.3c-0.1-0.1-0.1-0.2-0.1-0.3-0.1-0.6-0.1-1.3-0.1-2h-2.9c0 0.8 0 1.5 0.1 2.2 0 0.2 0.1 0.4 0.1 0.6v0.1c0 0.2 0.1 0.4 0.1 0.5s0 0.2 0.1 0.3v0.3c0.1 0.1 0.1 0.2 0.1 0.4 0 0 0.1 0.1 0.1 0.2 0 0.2 0 0.3 0.1 0.4v0.2c0.2 0.7 0.5 1.3 0.7 2l2.7-1.2c-0.2-0.6-0.4-1.1-0.6-1.7" fill="#435363"/>
<path d="m72 64.6c-3.9 0-7.5-1.7-10.1-4.4l-2 2.2c3.1 3.2 7.3 5.2 12.1 5.2 8.7 0 15.8-6.8 16.9-15.5h-2.9c-1.1 7-7 12.5-14 12.5" fill="#F38B00"/>
<path d="m72 35.1c3.1 0 6.1 1.1 8.4 2.9l1.8-2.4c-2.9-2.2-6.4-3.5-10.2-3.5-7.3 0-13.5 4.7-15.9 11.3l2.7 1.1c2-5.5 7.2-9.4 13.2-9.4" fill="#F38B00"/>
<path d="m86 47.6h2.9c-0.3-2.6-1.2-5-2.5-7.2l-2.4 1.6c1 1.7 1.7 3.6 2 5.6" fill="#435363"/>
<path d="m82.7 47.6h2.9c-0.8-5.1-4.1-9.3-8.6-11.1l-1.1 2.8c3.5 1.3 6 4.5 6.8 8.3" fill="#F38B00"/>
<path d="m72 38.5v-3c-5.9 0-10.9 3.8-12.9 9.1l2.7 1.1c1.6-4.2 5.5-7.2 10.2-7.2" fill="#435363"/>
<path d="m61 49.8h-2.9c0 6.1 3.6 11.2 8.7 13.4l1.1-2.8c-4-1.7-6.9-5.8-6.9-10.6" fill="#F38B00"/>
<path d="m72 61.2v3c6.9 0 12.6-5.3 13.6-12.1h-2.9c-1 5.2-5.4 9.1-10.7 9.1" fill="#435363"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB