This PR adds a [Conda](https://conda.io/) package registry.tags/v1.19.0-rc0
;LIMIT_SIZE_COMPOSER = -1 | ;LIMIT_SIZE_COMPOSER = -1 | ||||
;; Maximum size of a Conan upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ;; Maximum size of a Conan upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||
;LIMIT_SIZE_CONAN = -1 | ;LIMIT_SIZE_CONAN = -1 | ||||
;; Maximum size of a Conda upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | |||||
;LIMIT_SIZE_CONDA = -1 | |||||
;; Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ;; Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||
;LIMIT_SIZE_CONTAINER = -1 | ;LIMIT_SIZE_CONTAINER = -1 | ||||
;; Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ;; Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) |
- `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_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_COMPOSER`: **-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 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_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`) | |||||
- `LIMIT_SIZE_CONTAINER`: **-1**: Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | - `LIMIT_SIZE_CONTAINER`: **-1**: Maximum size of a Container upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||
- `LIMIT_SIZE_GENERIC`: **-1**: Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | - `LIMIT_SIZE_GENERIC`: **-1**: Maximum size of a Generic upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | ||||
- `LIMIT_SIZE_HELM`: **-1**: Maximum size of a Helm upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) | - `LIMIT_SIZE_HELM`: **-1**: Maximum size of a Helm upload (`-1` means no limits, format `1000`, `1 MB`, `1 GiB`) |
--- | |||||
date: "2022-12-28T00:00:00+00:00" | |||||
title: "Conda Packages Repository" | |||||
slug: "packages/conda" | |||||
draft: false | |||||
toc: false | |||||
menu: | |||||
sidebar: | |||||
parent: "packages" | |||||
name: "Conda" | |||||
weight: 25 | |||||
identifier: "conda" | |||||
--- | |||||
# Conda Packages Repository | |||||
Publish [Conda](https://docs.conda.io/en/latest/) packages for your user or organization. | |||||
**Table of Contents** | |||||
{{< toc >}} | |||||
## Requirements | |||||
To work with the Conda package registry, you need to use [conda](https://docs.conda.io/projects/conda/en/stable/user-guide/install/index.html). | |||||
## Configuring the package registry | |||||
To register the package registry and provide credentials, edit your `.condarc` file: | |||||
```yaml | |||||
channel_alias: https://gitea.example.com/api/packages/{owner}/conda | |||||
channels: | |||||
- https://gitea.example.com/api/packages/{owner}/conda | |||||
default_channels: | |||||
- https://gitea.example.com/api/packages/{owner}/conda | |||||
``` | |||||
| Placeholder | Description | | |||||
| ------------ | ----------- | | |||||
| `owner` | The owner of the package. | | |||||
See the [official documentation](https://conda.io/projects/conda/en/latest/user-guide/configuration/use-condarc.html) for explanations of the individual settings. | |||||
If you need to provide credentials, you may embed them as part of the channel url (`https://user:password@gitea.example.com/...`). | |||||
## Publish a package | |||||
To publish a package, perform a HTTP PUT operation with the package content in the request body. | |||||
``` | |||||
PUT https://gitea.example.com/api/packages/{owner}/conda/{channel}/{filename} | |||||
``` | |||||
| Placeholder | Description | | |||||
| ------------ | ----------- | | |||||
| `owner` | The owner of the package. | | |||||
| `channel` | The [channel](https://conda.io/projects/conda/en/latest/user-guide/concepts/channels.html) of the package. (optional) | | |||||
| `filename` | The name of the file. | | |||||
Example request using HTTP Basic authentication: | |||||
```shell | |||||
curl --user your_username:your_password_or_token \ | |||||
--upload-file path/to/package-1.0.conda \ | |||||
https://gitea.example.com/api/packages/testuser/conda/package-1.0.conda | |||||
``` | |||||
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 one of the following commands: | |||||
```shell | |||||
conda install {package_name} | |||||
conda install {package_name}={package_version} | |||||
conda install -c {channel} {package_name} | |||||
``` | |||||
| Parameter | Description | | |||||
| ----------------- | ----------- | | |||||
| `package_name` | The package name. | | |||||
| `package_version` | The package version. | | |||||
| `channel` | The channel of the package. (optional) | |
| ---- | -------- | -------------- | | | ---- | -------- | -------------- | | ||||
| [Composer]({{< relref "doc/packages/composer.en-us.md" >}}) | PHP | `composer` | | | [Composer]({{< relref "doc/packages/composer.en-us.md" >}}) | PHP | `composer` | | ||||
| [Conan]({{< relref "doc/packages/conan.en-us.md" >}}) | C++ | `conan` | | | [Conan]({{< relref "doc/packages/conan.en-us.md" >}}) | C++ | `conan` | | ||||
| [Conda]({{< relref "doc/packages/conda.en-us.md" >}}) | - | `conda` | | |||||
| [Container]({{< relref "doc/packages/container.en-us.md" >}}) | - | any OCI compliant client | | | [Container]({{< relref "doc/packages/container.en-us.md" >}}) | - | any OCI compliant client | | ||||
| [Generic]({{< relref "doc/packages/generic.en-us.md" >}}) | - | any HTTP client | | | [Generic]({{< relref "doc/packages/generic.en-us.md" >}}) | - | any HTTP client | | ||||
| [Helm]({{< relref "doc/packages/helm.en-us.md" >}}) | - | any HTTP client, `cm-push` | | | [Helm]({{< relref "doc/packages/helm.en-us.md" >}}) | - | any HTTP client, `cm-push` | |
github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 | github.com/dimiro1/reply v0.0.0-20200315094148-d0136a4c9e21 | ||||
github.com/djherbis/buffer v1.2.0 | github.com/djherbis/buffer v1.2.0 | ||||
github.com/djherbis/nio/v3 v3.0.1 | github.com/djherbis/nio/v3 v3.0.1 | ||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 | |||||
github.com/dustin/go-humanize v1.0.0 | github.com/dustin/go-humanize v1.0.0 | ||||
github.com/editorconfig/editorconfig-core-go/v2 v2.5.1 | github.com/editorconfig/editorconfig-core-go/v2 v2.5.1 | ||||
github.com/emersion/go-imap v1.2.1 | github.com/emersion/go-imap v1.2.1 | ||||
github.com/davecgh/go-spew v1.1.1 // indirect | github.com/davecgh/go-spew v1.1.1 // indirect | ||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect | ||||
github.com/dlclark/regexp2 v1.7.0 // indirect | github.com/dlclark/regexp2 v1.7.0 // indirect | ||||
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect | |||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect | github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect | ||||
github.com/fatih/color v1.13.0 // indirect | github.com/fatih/color v1.13.0 // indirect | ||||
github.com/felixge/httpsnoop v1.0.3 // indirect | github.com/felixge/httpsnoop v1.0.3 // indirect |
// Copyright 2022 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package conda | |||||
import ( | |||||
"context" | |||||
"strings" | |||||
"code.gitea.io/gitea/models/db" | |||||
"code.gitea.io/gitea/models/packages" | |||||
conda_module "code.gitea.io/gitea/modules/packages/conda" | |||||
"xorm.io/builder" | |||||
) | |||||
type FileSearchOptions struct { | |||||
OwnerID int64 | |||||
Channel string | |||||
Subdir string | |||||
Filename string | |||||
} | |||||
// SearchFiles gets all files matching the search options | |||||
func SearchFiles(ctx context.Context, opts *FileSearchOptions) ([]*packages.PackageFile, error) { | |||||
var cond builder.Cond = builder.Eq{ | |||||
"package.type": packages.TypeConda, | |||||
"package.owner_id": opts.OwnerID, | |||||
"package_version.is_internal": false, | |||||
} | |||||
if opts.Filename != "" { | |||||
cond = cond.And(builder.Eq{ | |||||
"package_file.lower_name": strings.ToLower(opts.Filename), | |||||
}) | |||||
} | |||||
var versionPropsCond builder.Cond = builder.Eq{ | |||||
"package_property.ref_type": packages.PropertyTypePackage, | |||||
"package_property.name": conda_module.PropertyChannel, | |||||
"package_property.value": opts.Channel, | |||||
} | |||||
cond = cond.And(builder.In("package.id", builder.Select("package_property.ref_id").Where(versionPropsCond).From("package_property"))) | |||||
var filePropsCond builder.Cond = builder.Eq{ | |||||
"package_property.ref_type": packages.PropertyTypeFile, | |||||
"package_property.name": conda_module.PropertySubdir, | |||||
"package_property.value": opts.Subdir, | |||||
} | |||||
cond = cond.And(builder.In("package_file.id", builder.Select("package_property.ref_id").Where(filePropsCond).From("package_property"))) | |||||
sess := db.GetEngine(ctx). | |||||
Select("package_file.*"). | |||||
Table("package_file"). | |||||
Join("INNER", "package_version", "package_version.id = package_file.version_id"). | |||||
Join("INNER", "package", "package.id = package_version.package_id"). | |||||
Where(cond) | |||||
pfs := make([]*packages.PackageFile, 0, 10) | |||||
return pfs, sess.Find(&pfs) | |||||
} |
"code.gitea.io/gitea/modules/json" | "code.gitea.io/gitea/modules/json" | ||||
"code.gitea.io/gitea/modules/packages/composer" | "code.gitea.io/gitea/modules/packages/composer" | ||||
"code.gitea.io/gitea/modules/packages/conan" | "code.gitea.io/gitea/modules/packages/conan" | ||||
"code.gitea.io/gitea/modules/packages/conda" | |||||
"code.gitea.io/gitea/modules/packages/container" | "code.gitea.io/gitea/modules/packages/container" | ||||
"code.gitea.io/gitea/modules/packages/helm" | "code.gitea.io/gitea/modules/packages/helm" | ||||
"code.gitea.io/gitea/modules/packages/maven" | "code.gitea.io/gitea/modules/packages/maven" | ||||
metadata = &composer.Metadata{} | metadata = &composer.Metadata{} | ||||
case TypeConan: | case TypeConan: | ||||
metadata = &conan.Metadata{} | metadata = &conan.Metadata{} | ||||
case TypeConda: | |||||
metadata = &conda.VersionMetadata{} | |||||
case TypeContainer: | case TypeContainer: | ||||
metadata = &container.Metadata{} | metadata = &container.Metadata{} | ||||
case TypeGeneric: | case TypeGeneric: |
const ( | const ( | ||||
TypeComposer Type = "composer" | TypeComposer Type = "composer" | ||||
TypeConan Type = "conan" | TypeConan Type = "conan" | ||||
TypeConda Type = "conda" | |||||
TypeContainer Type = "container" | TypeContainer Type = "container" | ||||
TypeGeneric Type = "generic" | TypeGeneric Type = "generic" | ||||
TypeHelm Type = "helm" | TypeHelm Type = "helm" | ||||
var TypeList = []Type{ | var TypeList = []Type{ | ||||
TypeComposer, | TypeComposer, | ||||
TypeConan, | TypeConan, | ||||
TypeConda, | |||||
TypeContainer, | TypeContainer, | ||||
TypeGeneric, | TypeGeneric, | ||||
TypeHelm, | TypeHelm, | ||||
return "Composer" | return "Composer" | ||||
case TypeConan: | case TypeConan: | ||||
return "Conan" | return "Conan" | ||||
case TypeConda: | |||||
return "Conda" | |||||
case TypeContainer: | case TypeContainer: | ||||
return "Container" | return "Container" | ||||
case TypeGeneric: | case TypeGeneric: | ||||
return "gitea-composer" | return "gitea-composer" | ||||
case TypeConan: | case TypeConan: | ||||
return "gitea-conan" | return "gitea-conan" | ||||
case TypeConda: | |||||
return "gitea-conda" | |||||
case TypeContainer: | case TypeContainer: | ||||
return "octicon-container" | return "octicon-container" | ||||
case TypeGeneric: | case TypeGeneric: |
// Copyright 2022 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package conda | |||||
import ( | |||||
"archive/tar" | |||||
"archive/zip" | |||||
"compress/bzip2" | |||||
"io" | |||||
"strings" | |||||
"code.gitea.io/gitea/modules/json" | |||||
"code.gitea.io/gitea/modules/util" | |||||
"code.gitea.io/gitea/modules/validation" | |||||
"github.com/klauspost/compress/zstd" | |||||
) | |||||
var ( | |||||
ErrInvalidStructure = util.SilentWrap{Message: "package structure is invalid", Err: util.ErrInvalidArgument} | |||||
ErrInvalidName = util.SilentWrap{Message: "package name is invalid", Err: util.ErrInvalidArgument} | |||||
ErrInvalidVersion = util.SilentWrap{Message: "package version is invalid", Err: util.ErrInvalidArgument} | |||||
) | |||||
const ( | |||||
PropertyName = "conda.name" | |||||
PropertyChannel = "conda.channel" | |||||
PropertySubdir = "conda.subdir" | |||||
PropertyMetadata = "conda.metdata" | |||||
) | |||||
// Package represents a Conda package | |||||
type Package struct { | |||||
Name string | |||||
Version string | |||||
Subdir string | |||||
VersionMetadata *VersionMetadata | |||||
FileMetadata *FileMetadata | |||||
} | |||||
// VersionMetadata represents the metadata of a Conda package | |||||
type VersionMetadata struct { | |||||
Description string `json:"description,omitempty"` | |||||
Summary string `json:"summary,omitempty"` | |||||
ProjectURL string `json:"project_url,omitempty"` | |||||
RepositoryURL string `json:"repository_url,omitempty"` | |||||
DocumentationURL string `json:"documentation_url,omitempty"` | |||||
License string `json:"license,omitempty"` | |||||
LicenseFamily string `json:"license_family,omitempty"` | |||||
} | |||||
// FileMetadata represents the metadata of a Conda package file | |||||
type FileMetadata struct { | |||||
IsCondaPackage bool `json:"is_conda"` | |||||
Architecture string `json:"architecture,omitempty"` | |||||
NoArch string `json:"noarch,omitempty"` | |||||
Build string `json:"build,omitempty"` | |||||
BuildNumber int64 `json:"build_number,omitempty"` | |||||
Dependencies []string `json:"dependencies,omitempty"` | |||||
Platform string `json:"platform,omitempty"` | |||||
Timestamp int64 `json:"timestamp,omitempty"` | |||||
} | |||||
type index struct { | |||||
Name string `json:"name"` | |||||
Version string `json:"version"` | |||||
Architecture string `json:"arch"` | |||||
NoArch string `json:"noarch"` | |||||
Build string `json:"build"` | |||||
BuildNumber int64 `json:"build_number"` | |||||
Dependencies []string `json:"depends"` | |||||
License string `json:"license"` | |||||
LicenseFamily string `json:"license_family"` | |||||
Platform string `json:"platform"` | |||||
Subdir string `json:"subdir"` | |||||
Timestamp int64 `json:"timestamp"` | |||||
} | |||||
type about struct { | |||||
Description string `json:"description"` | |||||
Summary string `json:"summary"` | |||||
ProjectURL string `json:"home"` | |||||
RepositoryURL string `json:"dev_url"` | |||||
DocumentationURL string `json:"doc_url"` | |||||
} | |||||
type ReaderAndReaderAt interface { | |||||
io.Reader | |||||
io.ReaderAt | |||||
} | |||||
// ParsePackageBZ2 parses the Conda package file compressed with bzip2 | |||||
func ParsePackageBZ2(r io.Reader) (*Package, error) { | |||||
gzr := bzip2.NewReader(r) | |||||
return parsePackageTar(gzr) | |||||
} | |||||
// ParsePackageConda parses the Conda package file compressed with zip and zstd | |||||
func ParsePackageConda(r io.ReaderAt, size int64) (*Package, error) { | |||||
zr, err := zip.NewReader(r, size) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
for _, file := range zr.File { | |||||
if strings.HasPrefix(file.Name, "info-") && strings.HasSuffix(file.Name, ".tar.zst") { | |||||
f, err := zr.Open(file.Name) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
defer f.Close() | |||||
dec, err := zstd.NewReader(f) | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
defer dec.Close() | |||||
p, err := parsePackageTar(dec) | |||||
if p != nil { | |||||
p.FileMetadata.IsCondaPackage = true | |||||
} | |||||
return p, err | |||||
} | |||||
} | |||||
return nil, ErrInvalidStructure | |||||
} | |||||
func parsePackageTar(r io.Reader) (*Package, error) { | |||||
var i *index | |||||
var a *about | |||||
tr := tar.NewReader(r) | |||||
for { | |||||
hdr, err := tr.Next() | |||||
if err == io.EOF { | |||||
break | |||||
} | |||||
if err != nil { | |||||
return nil, err | |||||
} | |||||
if hdr.Typeflag != tar.TypeReg { | |||||
continue | |||||
} | |||||
if hdr.Name == "info/index.json" { | |||||
if err := json.NewDecoder(tr).Decode(&i); err != nil { | |||||
return nil, err | |||||
} | |||||
if !checkName(i.Name) { | |||||
return nil, ErrInvalidName | |||||
} | |||||
if !checkVersion(i.Version) { | |||||
return nil, ErrInvalidVersion | |||||
} | |||||
if a != nil { | |||||
break // stop loop if both files were found | |||||
} | |||||
} else if hdr.Name == "info/about.json" { | |||||
if err := json.NewDecoder(tr).Decode(&a); err != nil { | |||||
return nil, err | |||||
} | |||||
if !validation.IsValidURL(a.ProjectURL) { | |||||
a.ProjectURL = "" | |||||
} | |||||
if !validation.IsValidURL(a.RepositoryURL) { | |||||
a.RepositoryURL = "" | |||||
} | |||||
if !validation.IsValidURL(a.DocumentationURL) { | |||||
a.DocumentationURL = "" | |||||
} | |||||
if i != nil { | |||||
break // stop loop if both files were found | |||||
} | |||||
} | |||||
} | |||||
if i == nil { | |||||
return nil, ErrInvalidStructure | |||||
} | |||||
if a == nil { | |||||
a = &about{} | |||||
} | |||||
return &Package{ | |||||
Name: i.Name, | |||||
Version: i.Version, | |||||
Subdir: i.Subdir, | |||||
VersionMetadata: &VersionMetadata{ | |||||
License: i.License, | |||||
LicenseFamily: i.LicenseFamily, | |||||
Description: a.Description, | |||||
Summary: a.Summary, | |||||
ProjectURL: a.ProjectURL, | |||||
RepositoryURL: a.RepositoryURL, | |||||
DocumentationURL: a.DocumentationURL, | |||||
}, | |||||
FileMetadata: &FileMetadata{ | |||||
Architecture: i.Architecture, | |||||
NoArch: i.NoArch, | |||||
Build: i.Build, | |||||
BuildNumber: i.BuildNumber, | |||||
Dependencies: i.Dependencies, | |||||
Platform: i.Platform, | |||||
Timestamp: i.Timestamp, | |||||
}, | |||||
}, nil | |||||
} | |||||
// https://github.com/conda/conda-build/blob/db9a728a9e4e6cfc895637ca3221117970fc2663/conda_build/metadata.py#L1393 | |||||
func checkName(name string) bool { | |||||
if name == "" { | |||||
return false | |||||
} | |||||
if name != strings.ToLower(name) { | |||||
return false | |||||
} | |||||
return !checkBadCharacters(name, "!") | |||||
} | |||||
// https://github.com/conda/conda-build/blob/db9a728a9e4e6cfc895637ca3221117970fc2663/conda_build/metadata.py#L1403 | |||||
func checkVersion(version string) bool { | |||||
if version == "" { | |||||
return false | |||||
} | |||||
return !checkBadCharacters(version, "-") | |||||
} | |||||
func checkBadCharacters(s, additional string) bool { | |||||
if strings.ContainsAny(s, "=@#$%^&*:;\"'\\|<>?/ ") { | |||||
return true | |||||
} | |||||
return strings.ContainsAny(s, additional) | |||||
} |
// Copyright 2022 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package conda | |||||
import ( | |||||
"archive/tar" | |||||
"archive/zip" | |||||
"bytes" | |||||
"io" | |||||
"testing" | |||||
"github.com/dsnet/compress/bzip2" | |||||
"github.com/klauspost/compress/zstd" | |||||
"github.com/stretchr/testify/assert" | |||||
) | |||||
const ( | |||||
packageName = "gitea" | |||||
packageVersion = "1.0.1" | |||||
description = "Package Description" | |||||
projectURL = "https://gitea.io" | |||||
repositoryURL = "https://gitea.io/gitea/gitea" | |||||
documentationURL = "https://docs.gitea.io" | |||||
) | |||||
func TestParsePackage(t *testing.T) { | |||||
createArchive := func(files map[string][]byte) *bytes.Buffer { | |||||
var buf bytes.Buffer | |||||
tw := tar.NewWriter(&buf) | |||||
for filename, content := range files { | |||||
hdr := &tar.Header{ | |||||
Name: filename, | |||||
Mode: 0o600, | |||||
Size: int64(len(content)), | |||||
} | |||||
tw.WriteHeader(hdr) | |||||
tw.Write(content) | |||||
} | |||||
tw.Close() | |||||
return &buf | |||||
} | |||||
t.Run("MissingIndexFile", func(t *testing.T) { | |||||
buf := createArchive(map[string][]byte{"dummy.txt": {}}) | |||||
p, err := parsePackageTar(buf) | |||||
assert.Nil(t, p) | |||||
assert.ErrorIs(t, err, ErrInvalidStructure) | |||||
}) | |||||
t.Run("MissingAboutFile", func(t *testing.T) { | |||||
buf := createArchive(map[string][]byte{"info/index.json": []byte(`{"name":"name","version":"1.0"}`)}) | |||||
p, err := parsePackageTar(buf) | |||||
assert.NotNil(t, p) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, "name", p.Name) | |||||
assert.Equal(t, "1.0", p.Version) | |||||
assert.Empty(t, p.VersionMetadata.ProjectURL) | |||||
}) | |||||
t.Run("InvalidName", func(t *testing.T) { | |||||
for _, name := range []string{"", "name!", "nAMe"} { | |||||
buf := createArchive(map[string][]byte{"info/index.json": []byte(`{"name":"` + name + `","version":"1.0"}`)}) | |||||
p, err := parsePackageTar(buf) | |||||
assert.Nil(t, p) | |||||
assert.ErrorIs(t, err, ErrInvalidName) | |||||
} | |||||
}) | |||||
t.Run("InvalidVersion", func(t *testing.T) { | |||||
for _, version := range []string{"", "1.0-2"} { | |||||
buf := createArchive(map[string][]byte{"info/index.json": []byte(`{"name":"name","version":"` + version + `"}`)}) | |||||
p, err := parsePackageTar(buf) | |||||
assert.Nil(t, p) | |||||
assert.ErrorIs(t, err, ErrInvalidVersion) | |||||
} | |||||
}) | |||||
t.Run("Valid", func(t *testing.T) { | |||||
buf := createArchive(map[string][]byte{ | |||||
"info/index.json": []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `","subdir":"linux-64"}`), | |||||
"info/about.json": []byte(`{"description":"` + description + `","dev_url":"` + repositoryURL + `","doc_url":"` + documentationURL + `","home":"` + projectURL + `"}`), | |||||
}) | |||||
p, err := parsePackageTar(buf) | |||||
assert.NotNil(t, p) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, packageName, p.Name) | |||||
assert.Equal(t, packageVersion, p.Version) | |||||
assert.Equal(t, "linux-64", p.Subdir) | |||||
assert.Equal(t, description, p.VersionMetadata.Description) | |||||
assert.Equal(t, projectURL, p.VersionMetadata.ProjectURL) | |||||
assert.Equal(t, repositoryURL, p.VersionMetadata.RepositoryURL) | |||||
assert.Equal(t, documentationURL, p.VersionMetadata.DocumentationURL) | |||||
}) | |||||
t.Run(".tar.bz2", func(t *testing.T) { | |||||
tarArchive := createArchive(map[string][]byte{ | |||||
"info/index.json": []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `"}`), | |||||
}) | |||||
var buf bytes.Buffer | |||||
bw, _ := bzip2.NewWriter(&buf, nil) | |||||
io.Copy(bw, tarArchive) | |||||
bw.Close() | |||||
br := bytes.NewReader(buf.Bytes()) | |||||
p, err := ParsePackageBZ2(br) | |||||
assert.NotNil(t, p) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, packageName, p.Name) | |||||
assert.Equal(t, packageVersion, p.Version) | |||||
assert.False(t, p.FileMetadata.IsCondaPackage) | |||||
}) | |||||
t.Run(".conda", func(t *testing.T) { | |||||
tarArchive := createArchive(map[string][]byte{ | |||||
"info/index.json": []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `"}`), | |||||
}) | |||||
var infoBuf bytes.Buffer | |||||
zsw, _ := zstd.NewWriter(&infoBuf) | |||||
io.Copy(zsw, tarArchive) | |||||
zsw.Close() | |||||
var buf bytes.Buffer | |||||
zpw := zip.NewWriter(&buf) | |||||
w, _ := zpw.Create("info-x.tar.zst") | |||||
w.Write(infoBuf.Bytes()) | |||||
zpw.Close() | |||||
br := bytes.NewReader(buf.Bytes()) | |||||
p, err := ParsePackageConda(br, int64(br.Len())) | |||||
assert.NotNil(t, p) | |||||
assert.NoError(t, err) | |||||
assert.Equal(t, packageName, p.Name) | |||||
assert.Equal(t, packageVersion, p.Version) | |||||
assert.True(t, p.FileMetadata.IsCondaPackage) | |||||
}) | |||||
} |
LimitTotalOwnerSize int64 | LimitTotalOwnerSize int64 | ||||
LimitSizeComposer int64 | LimitSizeComposer int64 | ||||
LimitSizeConan int64 | LimitSizeConan int64 | ||||
LimitSizeConda int64 | |||||
LimitSizeContainer int64 | LimitSizeContainer int64 | ||||
LimitSizeGeneric int64 | LimitSizeGeneric int64 | ||||
LimitSizeHelm int64 | LimitSizeHelm int64 | ||||
Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE") | Packages.LimitTotalOwnerSize = mustBytes(sec, "LIMIT_TOTAL_OWNER_SIZE") | ||||
Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER") | Packages.LimitSizeComposer = mustBytes(sec, "LIMIT_SIZE_COMPOSER") | ||||
Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN") | Packages.LimitSizeConan = mustBytes(sec, "LIMIT_SIZE_CONAN") | ||||
Packages.LimitSizeConda = mustBytes(sec, "LIMIT_SIZE_CONDA") | |||||
Packages.LimitSizeContainer = mustBytes(sec, "LIMIT_SIZE_CONTAINER") | Packages.LimitSizeContainer = mustBytes(sec, "LIMIT_SIZE_CONTAINER") | ||||
Packages.LimitSizeGeneric = mustBytes(sec, "LIMIT_SIZE_GENERIC") | Packages.LimitSizeGeneric = mustBytes(sec, "LIMIT_SIZE_GENERIC") | ||||
Packages.LimitSizeHelm = mustBytes(sec, "LIMIT_SIZE_HELM") | Packages.LimitSizeHelm = mustBytes(sec, "LIMIT_SIZE_HELM") |
conan.registry = Setup this registry from the command line: | conan.registry = Setup this registry from the command line: | ||||
conan.install = To install the package using Conan, run the following command: | conan.install = To install the package using Conan, run the following command: | ||||
conan.documentation = For more information on the Conan registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/conan/">the documentation</a>. | conan.documentation = For more information on the Conan registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/conan/">the documentation</a>. | ||||
conda.registry = Setup this registry as a Conda repository in your <code>.condarc</code> file: | |||||
conda.install = To install the package using Conda, run the following command: | |||||
conda.documentation = For more information on the Conda registry, see <a target="_blank" rel="noopener noreferrer" href="https://docs.gitea.io/en-us/packages/conda/">the documentation</a>. | |||||
conda.details.repository_site = Repository Site | |||||
conda.details.documentation_site = Documentation Site | |||||
container.details.type = Image Type | container.details.type = Image Type | ||||
container.details.platform = Platform | container.details.platform = Platform | ||||
container.details.repository_site = Repository Site | container.details.repository_site = Repository Site |
<svg viewBox="0 0 32 32" class="svg gitea-conda" width="16" height="16" aria-hidden="true"><path fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068" d="M16.559 8.137a7.2 7.2 0 0 0-1.234-1.708 7.586 7.586 0 0 0-.19 2.183 5.161 5.161 0 0 1 1.424-.475ZM13.617 9.466a7.992 7.992 0 0 0-1.993-1.2 8.123 8.123 0 0 0 .885 2.183c0 .063.443-.475 1.108-.981ZM17.445 7.188a9.143 9.143 0 0 1 1.3-2.246A7.585 7.585 0 0 0 17 2.854a8.35 8.35 0 0 0-1.3 2.278 8.451 8.451 0 0 1 1.74 2.056ZM11.592 11.744a10.276 10.276 0 0 0-2.692-.158 7.478 7.478 0 0 0 1.93 1.9 6.858 6.858 0 0 1 .759-1.74zM6.878 15.161a7.44 7.44 0 0 1 2.942-1.139 10.019 10.019 0 0 1-2.056-2.278 7.639 7.639 0 0 0-2.847 1.2 7.11 7.11 0 0 0 1.961 2.215zM10.516 14.876a6.16 6.16 0 0 0-2.815.886 9.936 9.936 0 0 0 2.815 1.2 7.683 7.683 0 0 1 0-2.088zM14.281 5.543A7.839 7.839 0 0 0 11.592 4.4 8.361 8.361 0 0 0 11.4 7a8.875 8.875 0 0 1 2.47 1.264 10.292 10.292 0 0 1 .411-2.721ZM24.025 3.234a20.488 20.488 0 0 1 .917 4.112 6.823 6.823 0 0 0-3.068 1.519 7.443 7.443 0 0 1 1.55 1.044 1.351 1.351 0 0 0 1.645.316 36.938 36.938 0 0 0 2.721-2.72 1.273 1.273 0 0 0-.159-1.835 20.521 20.521 0 0 0-3.606-2.436ZM4.379 12.06a8.67 8.67 0 0 1 2.847-1.26 7.763 7.763 0 0 1-.759-2.974 14.687 14.687 0 0 0-2.088 4.234ZM11.339 10.668a9.991 9.991 0 0 1-.949-2.784 7.928 7.928 0 0 0-2.911-.126 7.312 7.312 0 0 0 .791 2.879 9.664 9.664 0 0 1 3.069.031ZM6.119 15.73a8.894 8.894 0 0 1-2.025-2.373 14.208 14.208 0 0 0-.063 4.9 8.522 8.522 0 0 1 2.088-2.527Z"/><path fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068" d="M22.538 3.487A7.581 7.581 0 0 0 20.323 5.1a11.789 11.789 0 0 1 .823 2.5 9.775 9.775 0 0 1 2.309-1.329 6.593 6.593 0 0 0-.917-2.784ZM19.374 6.3a8.608 8.608 0 0 0-.822 1.676 9.645 9.645 0 0 1 1.329.19 7.568 7.568 0 0 0-.507-1.866ZM19.659 3.9a9.577 9.577 0 0 1 2.056-1.487A15.38 15.38 0 0 0 18.046 2a9.709 9.709 0 0 1 1.613 1.9Z"/><path fill="#43b02a" d="M27.378 23.892c-1.993-1.9-2.4-3.132-4.081-1.835a7.837 7.837 0 0 1-12.591-4.144A10.179 10.179 0 0 1 6.878 16.3a9.427 9.427 0 0 0-2.562 3.321h-.032C7.163 30.5 21.178 33.035 27.663 26.233c1.076-1.139.095-1.933-.285-2.341ZM6.309 20.855a7.559 7.559 0 0 1 .917-2.025 6.872 6.872 0 0 0 2.151.538c1.013 2.689 4.556 6.264 8.922 6.264a9.632 9.632 0 0 0 6.3-2.309 12.841 12.841 0 0 1 1.772 1.771c.095.127.095.159.095.159-5.766 5.03-15.538 4.302-20.157-4.398Z"/><path fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".067" d="M10.67 4.11a19.934 19.934 0 0 0-.214 2.509 10.512 10.512 0 0 0-2.689-.093A18 18 0 0 1 10.67 4.11ZM12.26 3.274a9.107 9.107 0 0 1 2.445 1.053 14.083 14.083 0 0 1 1.253-2.137 12.106 12.106 0 0 0-3.698 1.084z"/></svg> |
"code.gitea.io/gitea/modules/web" | "code.gitea.io/gitea/modules/web" | ||||
"code.gitea.io/gitea/routers/api/packages/composer" | "code.gitea.io/gitea/routers/api/packages/composer" | ||||
"code.gitea.io/gitea/routers/api/packages/conan" | "code.gitea.io/gitea/routers/api/packages/conan" | ||||
"code.gitea.io/gitea/routers/api/packages/conda" | |||||
"code.gitea.io/gitea/routers/api/packages/container" | "code.gitea.io/gitea/routers/api/packages/container" | ||||
"code.gitea.io/gitea/routers/api/packages/generic" | "code.gitea.io/gitea/routers/api/packages/generic" | ||||
"code.gitea.io/gitea/routers/api/packages/helm" | "code.gitea.io/gitea/routers/api/packages/helm" | ||||
}) | }) | ||||
}) | }) | ||||
}, reqPackageAccess(perm.AccessModeRead)) | }, reqPackageAccess(perm.AccessModeRead)) | ||||
r.Group("/conda", func() { | |||||
var ( | |||||
downloadPattern = regexp.MustCompile(`\A(.+/)?(.+)/((?:[^/]+(?:\.tar\.bz2|\.conda))|(?:current_)?repodata\.json(?:\.bz2)?)\z`) | |||||
uploadPattern = regexp.MustCompile(`\A(.+/)?([^/]+(?:\.tar\.bz2|\.conda))\z`) | |||||
) | |||||
r.Get("/*", func(ctx *context.Context) { | |||||
m := downloadPattern.FindStringSubmatch(ctx.Params("*")) | |||||
if len(m) == 0 { | |||||
ctx.Status(http.StatusNotFound) | |||||
return | |||||
} | |||||
ctx.SetParams("channel", strings.TrimSuffix(m[1], "/")) | |||||
ctx.SetParams("architecture", m[2]) | |||||
ctx.SetParams("filename", m[3]) | |||||
switch m[3] { | |||||
case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2": | |||||
conda.EnumeratePackages(ctx) | |||||
default: | |||||
conda.DownloadPackageFile(ctx) | |||||
} | |||||
}) | |||||
r.Put("/*", reqPackageAccess(perm.AccessModeWrite), func(ctx *context.Context) { | |||||
m := uploadPattern.FindStringSubmatch(ctx.Params("*")) | |||||
if len(m) == 0 { | |||||
ctx.Status(http.StatusNotFound) | |||||
return | |||||
} | |||||
ctx.SetParams("channel", strings.TrimSuffix(m[1], "/")) | |||||
ctx.SetParams("filename", m[2]) | |||||
conda.UploadPackageFile(ctx) | |||||
}) | |||||
}, reqPackageAccess(perm.AccessModeRead)) | |||||
r.Group("/generic", func() { | r.Group("/generic", func() { | ||||
r.Group("/{packagename}/{packageversion}", func() { | r.Group("/{packagename}/{packageversion}", func() { | ||||
r.Delete("", reqPackageAccess(perm.AccessModeWrite), generic.DeletePackage) | r.Delete("", reqPackageAccess(perm.AccessModeWrite), generic.DeletePackage) |
// Copyright 2022 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package conda | |||||
import ( | |||||
"errors" | |||||
"fmt" | |||||
"io" | |||||
"net/http" | |||||
"strings" | |||||
packages_model "code.gitea.io/gitea/models/packages" | |||||
conda_model "code.gitea.io/gitea/models/packages/conda" | |||||
"code.gitea.io/gitea/modules/context" | |||||
"code.gitea.io/gitea/modules/json" | |||||
"code.gitea.io/gitea/modules/log" | |||||
packages_module "code.gitea.io/gitea/modules/packages" | |||||
conda_module "code.gitea.io/gitea/modules/packages/conda" | |||||
"code.gitea.io/gitea/modules/util" | |||||
"code.gitea.io/gitea/routers/api/packages/helper" | |||||
packages_service "code.gitea.io/gitea/services/packages" | |||||
"github.com/dsnet/compress/bzip2" | |||||
) | |||||
func apiError(ctx *context.Context, status int, obj interface{}) { | |||||
helper.LogAndProcessError(ctx, status, obj, func(message string) { | |||||
ctx.JSON(status, struct { | |||||
Reason string `json:"reason"` | |||||
Message string `json:"message"` | |||||
}{ | |||||
Reason: http.StatusText(status), | |||||
Message: message, | |||||
}) | |||||
}) | |||||
} | |||||
func EnumeratePackages(ctx *context.Context) { | |||||
type Info struct { | |||||
Subdir string `json:"subdir"` | |||||
} | |||||
type PackageInfo struct { | |||||
Name string `json:"name"` | |||||
Version string `json:"version"` | |||||
NoArch string `json:"noarch"` | |||||
Subdir string `json:"subdir"` | |||||
Timestamp int64 `json:"timestamp"` | |||||
Build string `json:"build"` | |||||
BuildNumber int64 `json:"build_number"` | |||||
Dependencies []string `json:"depends"` | |||||
License string `json:"license"` | |||||
LicenseFamily string `json:"license_family"` | |||||
HashMD5 string `json:"md5"` | |||||
HashSHA256 string `json:"sha256"` | |||||
Size int64 `json:"size"` | |||||
} | |||||
type RepoData struct { | |||||
Info Info `json:"info"` | |||||
Packages map[string]*PackageInfo `json:"packages"` | |||||
PackagesConda map[string]*PackageInfo `json:"packages.conda"` | |||||
Removed map[string]*PackageInfo `json:"removed"` | |||||
} | |||||
repoData := &RepoData{ | |||||
Info: Info{ | |||||
Subdir: ctx.Params("architecture"), | |||||
}, | |||||
Packages: make(map[string]*PackageInfo), | |||||
PackagesConda: make(map[string]*PackageInfo), | |||||
Removed: make(map[string]*PackageInfo), | |||||
} | |||||
pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{ | |||||
OwnerID: ctx.Package.Owner.ID, | |||||
Channel: ctx.Params("channel"), | |||||
Subdir: repoData.Info.Subdir, | |||||
}) | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
if len(pfs) == 0 { | |||||
apiError(ctx, http.StatusNotFound, nil) | |||||
return | |||||
} | |||||
pds := make(map[int64]*packages_model.PackageDescriptor) | |||||
for _, pf := range pfs { | |||||
pd, exists := pds[pf.VersionID] | |||||
if !exists { | |||||
pv, err := packages_model.GetVersionByID(ctx, pf.VersionID) | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
pd, err = packages_model.GetPackageDescriptor(ctx, pv) | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
pds[pf.VersionID] = pd | |||||
} | |||||
var pfd *packages_model.PackageFileDescriptor | |||||
for _, d := range pd.Files { | |||||
if d.File.ID == pf.ID { | |||||
pfd = d | |||||
break | |||||
} | |||||
} | |||||
var fileMetadata *conda_module.FileMetadata | |||||
if err := json.Unmarshal([]byte(pfd.Properties.GetByName(conda_module.PropertyMetadata)), &fileMetadata); err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
versionMetadata := pd.Metadata.(*conda_module.VersionMetadata) | |||||
pi := &PackageInfo{ | |||||
Name: pd.PackageProperties.GetByName(conda_module.PropertyName), | |||||
Version: pd.Version.Version, | |||||
NoArch: fileMetadata.NoArch, | |||||
Subdir: repoData.Info.Subdir, | |||||
Timestamp: fileMetadata.Timestamp, | |||||
Build: fileMetadata.Build, | |||||
BuildNumber: fileMetadata.BuildNumber, | |||||
Dependencies: fileMetadata.Dependencies, | |||||
License: versionMetadata.License, | |||||
LicenseFamily: versionMetadata.LicenseFamily, | |||||
HashMD5: pfd.Blob.HashMD5, | |||||
HashSHA256: pfd.Blob.HashSHA256, | |||||
Size: pfd.Blob.Size, | |||||
} | |||||
if fileMetadata.IsCondaPackage { | |||||
repoData.PackagesConda[pfd.File.Name] = pi | |||||
} else { | |||||
repoData.Packages[pfd.File.Name] = pi | |||||
} | |||||
} | |||||
resp := ctx.Resp | |||||
var w io.Writer = resp | |||||
if strings.HasSuffix(ctx.Params("filename"), ".json") { | |||||
resp.Header().Set("Content-Type", "application/json") | |||||
} else { | |||||
resp.Header().Set("Content-Type", "application/x-bzip2") | |||||
zw, err := bzip2.NewWriter(w, nil) | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
defer zw.Close() | |||||
w = zw | |||||
} | |||||
resp.WriteHeader(http.StatusOK) | |||||
if err := json.NewEncoder(w).Encode(repoData); err != nil { | |||||
log.Error("JSON encode: %v", err) | |||||
} | |||||
} | |||||
func UploadPackageFile(ctx *context.Context) { | |||||
upload, close, err := ctx.UploadStream() | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
if close { | |||||
defer upload.Close() | |||||
} | |||||
buf, err := packages_module.CreateHashedBufferFromReader(upload, 32*1024*1024) | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
defer buf.Close() | |||||
var pck *conda_module.Package | |||||
if strings.HasSuffix(strings.ToLower(ctx.Params("filename")), ".tar.bz2") { | |||||
pck, err = conda_module.ParsePackageBZ2(buf) | |||||
} else { | |||||
pck, err = conda_module.ParsePackageConda(buf, buf.Size()) | |||||
} | |||||
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 | |||||
} | |||||
fullName := pck.Name | |||||
channel := ctx.Params("channel") | |||||
if channel != "" { | |||||
fullName = channel + "/" + pck.Name | |||||
} | |||||
extension := ".tar.bz2" | |||||
if pck.FileMetadata.IsCondaPackage { | |||||
extension = ".conda" | |||||
} | |||||
fileMetadataRaw, err := json.Marshal(pck.FileMetadata) | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
_, _, err = packages_service.CreatePackageOrAddFileToExisting( | |||||
&packages_service.PackageCreationInfo{ | |||||
PackageInfo: packages_service.PackageInfo{ | |||||
Owner: ctx.Package.Owner, | |||||
PackageType: packages_model.TypeConda, | |||||
Name: fullName, | |||||
Version: pck.Version, | |||||
}, | |||||
SemverCompatible: false, | |||||
Creator: ctx.Doer, | |||||
Metadata: pck.VersionMetadata, | |||||
PackageProperties: map[string]string{ | |||||
conda_module.PropertyName: pck.Name, | |||||
conda_module.PropertyChannel: channel, | |||||
}, | |||||
}, | |||||
&packages_service.PackageFileCreationInfo{ | |||||
PackageFileInfo: packages_service.PackageFileInfo{ | |||||
Filename: fmt.Sprintf("%s-%s-%s%s", pck.Name, pck.Version, pck.FileMetadata.Build, extension), | |||||
CompositeKey: pck.Subdir, | |||||
}, | |||||
Creator: ctx.Doer, | |||||
Data: buf, | |||||
IsLead: true, | |||||
Properties: map[string]string{ | |||||
conda_module.PropertySubdir: pck.Subdir, | |||||
conda_module.PropertyMetadata: string(fileMetadataRaw), | |||||
}, | |||||
}, | |||||
) | |||||
if err != nil { | |||||
switch err { | |||||
case packages_model.ErrDuplicatePackageFile: | |||||
apiError(ctx, http.StatusConflict, err) | |||||
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize: | |||||
apiError(ctx, http.StatusForbidden, err) | |||||
default: | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
} | |||||
return | |||||
} | |||||
ctx.Status(http.StatusCreated) | |||||
} | |||||
func DownloadPackageFile(ctx *context.Context) { | |||||
pfs, err := conda_model.SearchFiles(ctx, &conda_model.FileSearchOptions{ | |||||
OwnerID: ctx.Package.Owner.ID, | |||||
Channel: ctx.Params("channel"), | |||||
Subdir: ctx.Params("architecture"), | |||||
Filename: ctx.Params("filename"), | |||||
}) | |||||
if err != nil { | |||||
apiError(ctx, http.StatusInternalServerError, err) | |||||
return | |||||
} | |||||
if len(pfs) != 1 { | |||||
apiError(ctx, http.StatusNotFound, nil) | |||||
return | |||||
} | |||||
pf := pfs[0] | |||||
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(), | |||||
}) | |||||
} |
// in: query | // in: query | ||||
// description: package type filter | // description: package type filter | ||||
// type: string | // type: string | ||||
// enum: [composer, conan, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant] | |||||
// enum: [composer, conan, conda, container, generic, helm, maven, npm, nuget, pub, pypi, rubygems, vagrant] | |||||
// - name: q | // - name: q | ||||
// in: query | // in: query | ||||
// description: name filter | // description: name filter |
type PackageCleanupRuleForm struct { | type PackageCleanupRuleForm struct { | ||||
ID int64 | ID int64 | ||||
Enabled bool | Enabled bool | ||||
Type string `binding:"Required;In(composer,conan,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"` | |||||
Type string `binding:"Required;In(composer,conan,conda,container,generic,helm,maven,npm,nuget,pub,pypi,rubygems,vagrant)"` | |||||
KeepCount int `binding:"In(0,1,5,10,25,50,100)"` | KeepCount int `binding:"In(0,1,5,10,25,50,100)"` | ||||
KeepPattern string `binding:"RegexPattern"` | KeepPattern string `binding:"RegexPattern"` | ||||
RemoveDays int `binding:"In(0,7,14,30,60,90,180)"` | RemoveDays int `binding:"In(0,7,14,30,60,90,180)"` |
typeSpecificSize = setting.Packages.LimitSizeComposer | typeSpecificSize = setting.Packages.LimitSizeComposer | ||||
case packages_model.TypeConan: | case packages_model.TypeConan: | ||||
typeSpecificSize = setting.Packages.LimitSizeConan | typeSpecificSize = setting.Packages.LimitSizeConan | ||||
case packages_model.TypeConda: | |||||
typeSpecificSize = setting.Packages.LimitSizeConda | |||||
case packages_model.TypeContainer: | case packages_model.TypeContainer: | ||||
typeSpecificSize = setting.Packages.LimitSizeContainer | typeSpecificSize = setting.Packages.LimitSizeContainer | ||||
case packages_model.TypeGeneric: | case packages_model.TypeGeneric: |
{{if eq .PackageDescriptor.Package.Type "conda"}} | |||||
<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.conda.registry" | Safe}}</label> | |||||
<div class="markup"><pre class="code-block"><code>channel_alias: {{AppUrl}}api/packages/{{.PackageDescriptor.Owner.Name}}/conda | |||||
channels: | |||||
  - {{AppUrl}}api/packages/{{.PackageDescriptor.Owner.Name}}/conda | |||||
default_channels: | |||||
  - {{AppUrl}}api/packages/{{.PackageDescriptor.Owner.Name}}/conda</code></pre></div> | |||||
</div> | |||||
<div class="field"> | |||||
<label>{{svg "octicon-terminal"}} {{.locale.Tr "packages.conda.install"}}</label> | |||||
{{$channel := .PackageDescriptor.PackageProperties.GetByName "conda.channel"}} | |||||
<div class="markup"><pre class="code-block"><code>conda install{{if $channel}} -c {{$channel}}{{end}} {{.PackageDescriptor.PackageProperties.GetByName "conda.name"}}={{.PackageDescriptor.Version.Version}}</code></pre></div> | |||||
</div> | |||||
<div class="field"> | |||||
<label>{{.locale.Tr "packages.conda.documentation" | Safe}}</label> | |||||
</div> | |||||
</div> | |||||
</div> | |||||
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Summary}} | |||||
<h4 class="ui top attached header">{{.locale.Tr "packages.about"}}</h4> | |||||
<div class="ui attached segment"> | |||||
{{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}{{else}}{{.PackageDescriptor.Metadata.Summary}}{{end}} | |||||
</div> | |||||
{{end}} | |||||
{{end}} |
{{if eq .PackageDescriptor.Package.Type "conda"}} | |||||
{{if .PackageDescriptor.Metadata.License}}<div class="item">{{svg "octicon-law" 16 "mr-3"}} {{.PackageDescriptor.Metadata.License}}</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.conda.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.conda.details.documentation_site"}}</a></div>{{end}} | |||||
{{end}} |
<div class="twelve wide column"> | <div class="twelve wide column"> | ||||
{{template "package/content/composer" .}} | {{template "package/content/composer" .}} | ||||
{{template "package/content/conan" .}} | {{template "package/content/conan" .}} | ||||
{{template "package/content/conda" .}} | |||||
{{template "package/content/container" .}} | {{template "package/content/container" .}} | ||||
{{template "package/content/generic" .}} | {{template "package/content/generic" .}} | ||||
{{template "package/content/helm" .}} | {{template "package/content/helm" .}} | ||||
<div class="item">{{svg "octicon-download" 16 "mr-3"}} {{.PackageDescriptor.Version.DownloadCount}}</div> | <div class="item">{{svg "octicon-download" 16 "mr-3"}} {{.PackageDescriptor.Version.DownloadCount}}</div> | ||||
{{template "package/metadata/composer" .}} | {{template "package/metadata/composer" .}} | ||||
{{template "package/metadata/conan" .}} | {{template "package/metadata/conan" .}} | ||||
{{template "package/metadata/conda" .}} | |||||
{{template "package/metadata/container" .}} | {{template "package/metadata/container" .}} | ||||
{{template "package/metadata/generic" .}} | {{template "package/metadata/generic" .}} | ||||
{{template "package/metadata/helm" .}} | {{template "package/metadata/helm" .}} |
"enum": [ | "enum": [ | ||||
"composer", | "composer", | ||||
"conan", | "conan", | ||||
"conda", | |||||
"container", | "container", | ||||
"generic", | "generic", | ||||
"helm", | "helm", |
// Copyright 2022 The Gitea Authors. All rights reserved. | |||||
// SPDX-License-Identifier: MIT | |||||
package integration | |||||
import ( | |||||
"archive/tar" | |||||
"archive/zip" | |||||
"bytes" | |||||
"fmt" | |||||
"io" | |||||
"net/http" | |||||
"testing" | |||||
"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" | |||||
conda_module "code.gitea.io/gitea/modules/packages/conda" | |||||
"code.gitea.io/gitea/tests" | |||||
"github.com/dsnet/compress/bzip2" | |||||
"github.com/klauspost/compress/zstd" | |||||
"github.com/stretchr/testify/assert" | |||||
) | |||||
func TestPackageConda(t *testing.T) { | |||||
defer tests.PrepareTestEnv(t)() | |||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) | |||||
packageName := "test_package" | |||||
packageVersion := "1.0.1" | |||||
channel := "test-channel" | |||||
root := fmt.Sprintf("/api/packages/%s/conda", user.Name) | |||||
t.Run("Upload", func(t *testing.T) { | |||||
tarContent := func() []byte { | |||||
var buf bytes.Buffer | |||||
tw := tar.NewWriter(&buf) | |||||
content := []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `","subdir":"noarch","build":"xxx"}`) | |||||
hdr := &tar.Header{ | |||||
Name: "info/index.json", | |||||
Mode: 0o600, | |||||
Size: int64(len(content)), | |||||
} | |||||
tw.WriteHeader(hdr) | |||||
tw.Write(content) | |||||
tw.Close() | |||||
return buf.Bytes() | |||||
}() | |||||
t.Run(".tar.bz2", func(t *testing.T) { | |||||
defer tests.PrintCurrentTest(t)() | |||||
var buf bytes.Buffer | |||||
bw, _ := bzip2.NewWriter(&buf, nil) | |||||
io.Copy(bw, bytes.NewReader(tarContent)) | |||||
bw.Close() | |||||
filename := fmt.Sprintf("%s-%s.tar.bz2", packageName, packageVersion) | |||||
req := NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())) | |||||
MakeRequest(t, req, http.StatusUnauthorized) | |||||
req = NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())) | |||||
AddBasicAuthHeader(req, user.Name) | |||||
MakeRequest(t, req, http.StatusCreated) | |||||
req = NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())) | |||||
AddBasicAuthHeader(req, user.Name) | |||||
MakeRequest(t, req, http.StatusConflict) | |||||
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConda) | |||||
assert.NoError(t, err) | |||||
assert.Len(t, pvs, 1) | |||||
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0]) | |||||
assert.NoError(t, err) | |||||
assert.Nil(t, pd.SemVer) | |||||
assert.IsType(t, &conda_module.VersionMetadata{}, pd.Metadata) | |||||
assert.Equal(t, packageName, pd.Package.Name) | |||||
assert.Equal(t, packageVersion, pd.Version.Version) | |||||
assert.Empty(t, pd.PackageProperties.GetByName(conda_module.PropertyChannel)) | |||||
}) | |||||
t.Run(".conda", func(t *testing.T) { | |||||
defer tests.PrintCurrentTest(t)() | |||||
var infoBuf bytes.Buffer | |||||
zsw, _ := zstd.NewWriter(&infoBuf) | |||||
io.Copy(zsw, bytes.NewReader(tarContent)) | |||||
zsw.Close() | |||||
var buf bytes.Buffer | |||||
zpw := zip.NewWriter(&buf) | |||||
w, _ := zpw.Create("info-x.tar.zst") | |||||
w.Write(infoBuf.Bytes()) | |||||
zpw.Close() | |||||
fullName := channel + "/" + packageName | |||||
filename := fmt.Sprintf("%s-%s.conda", packageName, packageVersion) | |||||
req := NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())) | |||||
MakeRequest(t, req, http.StatusUnauthorized) | |||||
req = NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())) | |||||
AddBasicAuthHeader(req, user.Name) | |||||
MakeRequest(t, req, http.StatusCreated) | |||||
req = NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())) | |||||
AddBasicAuthHeader(req, user.Name) | |||||
MakeRequest(t, req, http.StatusConflict) | |||||
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConda) | |||||
assert.NoError(t, err) | |||||
assert.Len(t, pvs, 2) | |||||
pds, err := packages.GetPackageDescriptors(db.DefaultContext, pvs) | |||||
assert.NoError(t, err) | |||||
assert.Condition(t, func() bool { | |||||
for _, pd := range pds { | |||||
if pd.Package.Name == fullName { | |||||
return true | |||||
} | |||||
} | |||||
return false | |||||
}) | |||||
for _, pd := range pds { | |||||
if pd.Package.Name == fullName { | |||||
assert.Nil(t, pd.SemVer) | |||||
assert.IsType(t, &conda_module.VersionMetadata{}, pd.Metadata) | |||||
assert.Equal(t, fullName, pd.Package.Name) | |||||
assert.Equal(t, packageVersion, pd.Version.Version) | |||||
assert.Equal(t, channel, pd.PackageProperties.GetByName(conda_module.PropertyChannel)) | |||||
} | |||||
} | |||||
}) | |||||
}) | |||||
t.Run("Download", func(t *testing.T) { | |||||
t.Run(".tar.bz2", func(t *testing.T) { | |||||
defer tests.PrintCurrentTest(t)() | |||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/%s-%s-xxx.tar.bz2", root, packageName, packageVersion)) | |||||
MakeRequest(t, req, http.StatusOK) | |||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/%s-%s-xxx.tar.bz2", root, channel, packageName, packageVersion)) | |||||
MakeRequest(t, req, http.StatusNotFound) | |||||
}) | |||||
t.Run(".conda", func(t *testing.T) { | |||||
defer tests.PrintCurrentTest(t)() | |||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/%s-%s-xxx.conda", root, packageName, packageVersion)) | |||||
MakeRequest(t, req, http.StatusNotFound) | |||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/%s-%s-xxx.conda", root, channel, packageName, packageVersion)) | |||||
MakeRequest(t, req, http.StatusOK) | |||||
}) | |||||
}) | |||||
t.Run("EnumeratePackages", func(t *testing.T) { | |||||
type Info struct { | |||||
Subdir string `json:"subdir"` | |||||
} | |||||
type PackageInfo struct { | |||||
Name string `json:"name"` | |||||
Version string `json:"version"` | |||||
NoArch string `json:"noarch"` | |||||
Subdir string `json:"subdir"` | |||||
Timestamp int64 `json:"timestamp"` | |||||
Build string `json:"build"` | |||||
BuildNumber int64 `json:"build_number"` | |||||
Dependencies []string `json:"depends"` | |||||
License string `json:"license"` | |||||
LicenseFamily string `json:"license_family"` | |||||
HashMD5 string `json:"md5"` | |||||
HashSHA256 string `json:"sha256"` | |||||
Size int64 `json:"size"` | |||||
} | |||||
type RepoData struct { | |||||
Info Info `json:"info"` | |||||
Packages map[string]*PackageInfo `json:"packages"` | |||||
PackagesConda map[string]*PackageInfo `json:"packages.conda"` | |||||
Removed map[string]*PackageInfo `json:"removed"` | |||||
} | |||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json", root)) | |||||
resp := MakeRequest(t, req, http.StatusOK) | |||||
assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) | |||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json.bz2", root)) | |||||
resp = MakeRequest(t, req, http.StatusOK) | |||||
assert.Equal(t, "application/x-bzip2", resp.Header().Get("Content-Type")) | |||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/current_repodata.json", root)) | |||||
resp = MakeRequest(t, req, http.StatusOK) | |||||
assert.Equal(t, "application/json", resp.Header().Get("Content-Type")) | |||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/current_repodata.json.bz2", root)) | |||||
resp = MakeRequest(t, req, http.StatusOK) | |||||
assert.Equal(t, "application/x-bzip2", resp.Header().Get("Content-Type")) | |||||
t.Run(".tar.bz2", func(t *testing.T) { | |||||
defer tests.PrintCurrentTest(t)() | |||||
pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeConda, packageName, packageVersion) | |||||
assert.NoError(t, err) | |||||
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) | |||||
assert.NoError(t, err) | |||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json", root)) | |||||
resp := MakeRequest(t, req, http.StatusOK) | |||||
var result RepoData | |||||
DecodeJSON(t, resp, &result) | |||||
assert.Equal(t, "noarch", result.Info.Subdir) | |||||
assert.Empty(t, result.PackagesConda) | |||||
assert.Empty(t, result.Removed) | |||||
filename := fmt.Sprintf("%s-%s-xxx.tar.bz2", packageName, packageVersion) | |||||
assert.Contains(t, result.Packages, filename) | |||||
packageInfo := result.Packages[filename] | |||||
assert.Equal(t, packageName, packageInfo.Name) | |||||
assert.Equal(t, packageVersion, packageInfo.Version) | |||||
assert.Equal(t, "noarch", packageInfo.Subdir) | |||||
assert.Equal(t, "xxx", packageInfo.Build) | |||||
assert.Equal(t, pd.Files[0].Blob.HashMD5, packageInfo.HashMD5) | |||||
assert.Equal(t, pd.Files[0].Blob.HashSHA256, packageInfo.HashSHA256) | |||||
assert.Equal(t, pd.Files[0].Blob.Size, packageInfo.Size) | |||||
}) | |||||
t.Run(".conda", func(t *testing.T) { | |||||
defer tests.PrintCurrentTest(t)() | |||||
pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeConda, channel+"/"+packageName, packageVersion) | |||||
assert.NoError(t, err) | |||||
pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv) | |||||
assert.NoError(t, err) | |||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/repodata.json", root, channel)) | |||||
resp := MakeRequest(t, req, http.StatusOK) | |||||
var result RepoData | |||||
DecodeJSON(t, resp, &result) | |||||
assert.Equal(t, "noarch", result.Info.Subdir) | |||||
assert.Empty(t, result.Packages) | |||||
assert.Empty(t, result.Removed) | |||||
filename := fmt.Sprintf("%s-%s-xxx.conda", packageName, packageVersion) | |||||
assert.Contains(t, result.PackagesConda, filename) | |||||
packageInfo := result.PackagesConda[filename] | |||||
assert.Equal(t, packageName, packageInfo.Name) | |||||
assert.Equal(t, packageVersion, packageInfo.Version) | |||||
assert.Equal(t, "noarch", packageInfo.Subdir) | |||||
assert.Equal(t, "xxx", packageInfo.Build) | |||||
assert.Equal(t, pd.Files[0].Blob.HashMD5, packageInfo.HashMD5) | |||||
assert.Equal(t, pd.Files[0].Blob.HashSHA256, packageInfo.HashSHA256) | |||||
assert.Equal(t, pd.Files[0].Blob.Size, packageInfo.Size) | |||||
}) | |||||
}) | |||||
} |
<?xml version="1.0" encoding="UTF-8"?> | |||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> | |||||
<path d="M16.559,8.137a7.2,7.2,0,0,0-1.234-1.708,7.586,7.586,0,0,0-.19,2.183,5.161,5.161,0,0,1,1.424-.475Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M13.617,9.466a7.992,7.992,0,0,0-1.993-1.2,8.123,8.123,0,0,0,.885,2.183c0,.063.443-.475,1.108-.981Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M17.445,7.188a9.143,9.143,0,0,1,1.3-2.246A7.585,7.585,0,0,0,17,2.854a8.35,8.35,0,0,0-1.3,2.278,8.451,8.451,0,0,1,1.74,2.056Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="m11.592 11.744a10.276 10.276 0 0 0-2.692-0.158 7.478 7.478 0 0 0 1.93 1.9 6.858 6.858 0 0 1 0.759-1.74z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="m6.878 15.161a7.44 7.44 0 0 1 2.942-1.139 10.019 10.019 0 0 1-2.056-2.278 7.639 7.639 0 0 0-2.847 1.2 7.11 7.11 0 0 0 1.961 2.215z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="m10.516 14.876a6.16 6.16 0 0 0-2.815 0.886 9.936 9.936 0 0 0 2.815 1.2 7.683 7.683 0 0 1 0-2.088z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M14.281,5.543A7.839,7.839,0,0,0,11.592,4.4,8.361,8.361,0,0,0,11.4,7,8.875,8.875,0,0,1,13.87,8.264a10.292,10.292,0,0,1,.411-2.721Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M24.025,3.234a20.488,20.488,0,0,1,.917,4.112,6.823,6.823,0,0,0-3.068,1.519,7.443,7.443,0,0,1,1.55,1.044,1.351,1.351,0,0,0,1.645.316,36.938,36.938,0,0,0,2.721-2.72,1.273,1.273,0,0,0-.159-1.835,20.521,20.521,0,0,0-3.606-2.436Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M4.379,12.06A8.67,8.67,0,0,1,7.226,10.8a7.763,7.763,0,0,1-.759-2.974A14.687,14.687,0,0,0,4.379,12.06Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M11.339,10.668a9.991,9.991,0,0,1-.949-2.784,7.928,7.928,0,0,0-2.911-.126,7.312,7.312,0,0,0,.791,2.879,9.664,9.664,0,0,1,3.069.031Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M6.119,15.73a8.894,8.894,0,0,1-2.025-2.373,14.208,14.208,0,0,0-.063,4.9A8.522,8.522,0,0,1,6.119,15.73Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M22.538,3.487A7.581,7.581,0,0,0,20.323,5.1a11.789,11.789,0,0,1,.823,2.5,9.775,9.775,0,0,1,2.309-1.329,6.593,6.593,0,0,0-.917-2.784Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M19.374,6.3a8.608,8.608,0,0,0-.822,1.676h0a9.645,9.645,0,0,1,1.329.19A7.568,7.568,0,0,0,19.374,6.3Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M19.659,3.9a9.577,9.577,0,0,1,2.056-1.487A15.38,15.38,0,0,0,18.046,2a9.709,9.709,0,0,1,1.613,1.9Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".068489px"/> | |||||
<path d="M27.378,23.892c-1.993-1.9-2.4-3.132-4.081-1.835a7.837,7.837,0,0,1-12.591-4.144A10.179,10.179,0,0,1,6.878,16.3a9.427,9.427,0,0,0-2.562,3.321H4.284C7.163,30.5,21.178,33.035,27.663,26.233,28.739,25.094,27.758,24.3,27.378,23.892ZM6.309,20.855a7.559,7.559,0,0,1,.917-2.025,6.872,6.872,0,0,0,2.151.538c1.013,2.689,4.556,6.264,8.922,6.264a9.632,9.632,0,0,0,6.3-2.309,12.841,12.841,0,0,1,1.772,1.771c.095.127.095.159.095.159C20.7,30.283,10.928,29.555,6.309,20.855Z" fill="#43b02a"/> | |||||
<path d="M10.67,4.11a19.934,19.934,0,0,0-.214,2.509,10.512,10.512,0,0,0-2.689-.093A18,18,0,0,1,10.67,4.11Z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".066605px"/> | |||||
<path d="m12.26 3.274a9.107 9.107 0 0 1 2.445 1.053 14.083 14.083 0 0 1 1.253-2.137 12.106 12.106 0 0 0-3.698 1.084z" fill="#43b02a" fill-rule="evenodd" stroke="#43b02a" stroke-width=".066605px"/> | |||||
</svg> |