mirror of https://github.com/go-gitea/gitea
Add Alpine package registry (#23714)
This PR adds an Alpine package registry. You can follow [this tutorial](https://wiki.alpinelinux.org/wiki/Creating_an_Alpine_package) to build a *.apk package for testing. This functionality is similar to the Debian registry (#22854) and therefore shares some methods. I marked this PR as blocked because it should be merged after #22854. ![grafik](https://user-images.githubusercontent.com/1666336/227779595-b76163aa-eea1-4a79-9583-775c24ad74e8.png) --------- Co-authored-by: techknowlogick <techknowlogick@gitea.io> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Giteabot <teabot@gitea.io>pull/24689/head^2
parent
80bde0141b
commit
9173e079ae
@ -0,0 +1,133 @@ |
||||
--- |
||||
date: "2023-03-25T00:00:00+00:00" |
||||
title: "Alpine Packages Repository" |
||||
slug: "packages/alpine" |
||||
draft: false |
||||
toc: false |
||||
menu: |
||||
sidebar: |
||||
parent: "packages" |
||||
name: "Alpine" |
||||
weight: 4 |
||||
identifier: "alpine" |
||||
--- |
||||
|
||||
# Alpine Packages Repository |
||||
|
||||
Publish [Alpine](https://pkgs.alpinelinux.org/) packages for your user or organization. |
||||
|
||||
**Table of Contents** |
||||
|
||||
{{< toc >}} |
||||
|
||||
## Requirements |
||||
|
||||
To work with the Alpine registry, you need to use a HTTP client like `curl` to upload and a package manager like `apk` to consume packages. |
||||
|
||||
The following examples use `apk`. |
||||
|
||||
## Configuring the package registry |
||||
|
||||
To register the Alpine registry add the url to the list of known apk sources (`/etc/apk/repositories`): |
||||
|
||||
``` |
||||
https://gitea.example.com/api/packages/{owner}/alpine/<branch>/<repository> |
||||
``` |
||||
|
||||
| Placeholder | Description | |
||||
| ------------ | ----------- | |
||||
| `owner` | The owner of the packages. | |
||||
| `branch` | The branch to use. | |
||||
| `repository` | The repository to use. | |
||||
|
||||
If the registry is private, provide credentials in the url. You can use a password or a [personal access token]({{< relref "doc/development/api-usage.en-us.md#authentication" >}}): |
||||
|
||||
``` |
||||
https://{username}:{your_password_or_token}@gitea.example.com/api/packages/{owner}/alpine/<branch>/<repository> |
||||
``` |
||||
|
||||
The Alpine registry files are signed with a RSA key which must be known to apk. Download the public key and store it in `/etc/apk/keys/`: |
||||
|
||||
```shell |
||||
curl -JO https://gitea.example.com/api/packages/{owner}/alpine/key |
||||
``` |
||||
|
||||
Afterwards update the local package index: |
||||
|
||||
```shell |
||||
apk update |
||||
``` |
||||
|
||||
## Publish a package |
||||
|
||||
To publish an Alpine package (`*.apk`), perform a HTTP `PUT` operation with the package content in the request body. |
||||
|
||||
``` |
||||
PUT https://gitea.example.com/api/packages/{owner}/alpine/{branch}/{repository} |
||||
``` |
||||
|
||||
| Parameter | Description | |
||||
| ------------ | ----------- | |
||||
| `owner` | The owner of the package. | |
||||
| `branch` | The branch may match the release version of the OS, ex: `v3.17`. | |
||||
| `repository` | The repository can be used [to group packages](https://wiki.alpinelinux.org/wiki/Repositories) or just `main` or similar. | |
||||
|
||||
Example request using HTTP Basic authentication: |
||||
|
||||
```shell |
||||
curl --user your_username:your_password_or_token \ |
||||
--upload-file path/to/file.apk \ |
||||
https://gitea.example.com/api/packages/testuser/alpine/v3.17/main |
||||
``` |
||||
|
||||
If you are using 2FA or OAuth use a [personal access token]({{< relref "doc/development/api-usage.en-us.md#authentication" >}}) instead of the password. |
||||
You cannot publish a file with the same name twice to a package. You must delete the existing package file first. |
||||
|
||||
The server responds with the following HTTP Status codes. |
||||
|
||||
| HTTP Status Code | Meaning | |
||||
| ----------------- | ------- | |
||||
| `201 Created` | The package has been published. | |
||||
| `400 Bad Request` | The package name, version, branch, repository or architecture are invalid. | |
||||
| `409 Conflict` | A package file with the same combination of parameters exist already in the package. | |
||||
|
||||
## Delete a package |
||||
|
||||
To delete an Alpine package perform a HTTP `DELETE` operation. This will delete the package version too if there is no file left. |
||||
|
||||
``` |
||||
DELETE https://gitea.example.com/api/packages/{owner}/alpine/{branch}/{repository}/{architecture}/{filename} |
||||
``` |
||||
|
||||
| Parameter | Description | |
||||
| -------------- | ----------- | |
||||
| `owner` | The owner of the package. | |
||||
| `branch` | The branch to use. | |
||||
| `repository` | The repository to use. | |
||||
| `architecture` | The package architecture. | |
||||
| `filename` | The file to delete. |
||||
|
||||
Example request using HTTP Basic authentication: |
||||
|
||||
```shell |
||||
curl --user your_username:your_token_or_password -X DELETE \ |
||||
https://gitea.example.com/api/packages/testuser/alpine/v3.17/main/test-package-1.0.0.apk |
||||
``` |
||||
|
||||
The server responds with the following HTTP Status codes. |
||||
|
||||
| HTTP Status Code | Meaning | |
||||
| ----------------- | ------- | |
||||
| `204 No Content` | Success | |
||||
| `404 Not Found` | The package or file was not found. | |
||||
|
||||
## Install a package |
||||
|
||||
To install a package from the Alpine registry, execute the following commands: |
||||
|
||||
```shell |
||||
# use latest version |
||||
apk add {package_name} |
||||
# use specific version |
||||
apk add {package_name}={package_version} |
||||
``` |
@ -0,0 +1,53 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages" |
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine" |
||||
) |
||||
|
||||
// GetBranches gets all available branches
|
||||
func GetBranches(ctx context.Context, ownerID int64) ([]string, error) { |
||||
return packages_model.GetDistinctPropertyValues( |
||||
ctx, |
||||
packages_model.TypeAlpine, |
||||
ownerID, |
||||
packages_model.PropertyTypeFile, |
||||
alpine_module.PropertyBranch, |
||||
nil, |
||||
) |
||||
} |
||||
|
||||
// GetRepositories gets all available repositories for the given branch
|
||||
func GetRepositories(ctx context.Context, ownerID int64, branch string) ([]string, error) { |
||||
return packages_model.GetDistinctPropertyValues( |
||||
ctx, |
||||
packages_model.TypeAlpine, |
||||
ownerID, |
||||
packages_model.PropertyTypeFile, |
||||
alpine_module.PropertyRepository, |
||||
&packages_model.DistinctPropertyDependency{ |
||||
Name: alpine_module.PropertyBranch, |
||||
Value: branch, |
||||
}, |
||||
) |
||||
} |
||||
|
||||
// GetArchitectures gets all available architectures for the given repository
|
||||
func GetArchitectures(ctx context.Context, ownerID int64, repository string) ([]string, error) { |
||||
return packages_model.GetDistinctPropertyValues( |
||||
ctx, |
||||
packages_model.TypeAlpine, |
||||
ownerID, |
||||
packages_model.PropertyTypeFile, |
||||
alpine_module.PropertyArchitecture, |
||||
&packages_model.DistinctPropertyDependency{ |
||||
Name: alpine_module.PropertyRepository, |
||||
Value: repository, |
||||
}, |
||||
) |
||||
} |
@ -0,0 +1,236 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine |
||||
|
||||
import ( |
||||
"archive/tar" |
||||
"bufio" |
||||
"compress/gzip" |
||||
"crypto/sha1" |
||||
"encoding/base64" |
||||
"io" |
||||
"strconv" |
||||
"strings" |
||||
|
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/modules/validation" |
||||
) |
||||
|
||||
var ( |
||||
ErrMissingPKGINFOFile = util.NewInvalidArgumentErrorf("PKGINFO file is missing") |
||||
ErrInvalidName = util.NewInvalidArgumentErrorf("package name is invalid") |
||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid") |
||||
) |
||||
|
||||
const ( |
||||
PropertyMetadata = "alpine.metadata" |
||||
PropertyBranch = "alpine.branch" |
||||
PropertyRepository = "alpine.repository" |
||||
PropertyArchitecture = "alpine.architecture" |
||||
|
||||
SettingKeyPrivate = "alpine.key.private" |
||||
SettingKeyPublic = "alpine.key.public" |
||||
|
||||
RepositoryPackage = "_alpine" |
||||
RepositoryVersion = "_repository" |
||||
) |
||||
|
||||
// https://wiki.alpinelinux.org/wiki/Apk_spec
|
||||
|
||||
// Package represents an Alpine package
|
||||
type Package struct { |
||||
Name string |
||||
Version string |
||||
VersionMetadata VersionMetadata |
||||
FileMetadata FileMetadata |
||||
} |
||||
|
||||
// Metadata of an Alpine package
|
||||
type VersionMetadata struct { |
||||
Description string `json:"description,omitempty"` |
||||
License string `json:"license,omitempty"` |
||||
ProjectURL string `json:"project_url,omitempty"` |
||||
Maintainer string `json:"maintainer,omitempty"` |
||||
} |
||||
|
||||
type FileMetadata struct { |
||||
Checksum string `json:"checksum"` |
||||
Packager string `json:"packager,omitempty"` |
||||
BuildDate int64 `json:"build_date,omitempty"` |
||||
Size int64 `json:"size,omitempty"` |
||||
Architecture string `json:"architecture,omitempty"` |
||||
Origin string `json:"origin,omitempty"` |
||||
CommitHash string `json:"commit_hash,omitempty"` |
||||
InstallIf string `json:"install_if,omitempty"` |
||||
Provides []string `json:"provides,omitempty"` |
||||
Dependencies []string `json:"dependencies,omitempty"` |
||||
} |
||||
|
||||
// ParsePackage parses the Alpine package file
|
||||
func ParsePackage(r io.Reader) (*Package, error) { |
||||
// Alpine packages are concated .tar.gz streams. Usually the first stream contains the package metadata.
|
||||
|
||||
br := bufio.NewReader(r) // needed for gzip Multistream
|
||||
|
||||
h := sha1.New() |
||||
|
||||
gzr, err := gzip.NewReader(&teeByteReader{br, h}) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer gzr.Close() |
||||
|
||||
for { |
||||
gzr.Multistream(false) |
||||
|
||||
tr := tar.NewReader(gzr) |
||||
for { |
||||
hd, err := tr.Next() |
||||
if err == io.EOF { |
||||
break |
||||
} |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if hd.Name == ".PKGINFO" { |
||||
p, err := ParsePackageInfo(tr) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
// drain the reader
|
||||
for { |
||||
if _, err := tr.Next(); err != nil { |
||||
break |
||||
} |
||||
} |
||||
|
||||
p.FileMetadata.Checksum = "Q1" + base64.StdEncoding.EncodeToString(h.Sum(nil)) |
||||
|
||||
return p, nil |
||||
} |
||||
} |
||||
|
||||
h = sha1.New() |
||||
|
||||
err = gzr.Reset(&teeByteReader{br, h}) |
||||
if err == io.EOF { |
||||
break |
||||
} |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
|
||||
return nil, ErrMissingPKGINFOFile |
||||
} |
||||
|
||||
// ParsePackageInfo parses a PKGINFO file to retrieve the metadata of an Alpine package
|
||||
func ParsePackageInfo(r io.Reader) (*Package, error) { |
||||
p := &Package{} |
||||
|
||||
scanner := bufio.NewScanner(r) |
||||
for scanner.Scan() { |
||||
line := scanner.Text() |
||||
|
||||
if strings.HasPrefix(line, "#") { |
||||
continue |
||||
} |
||||
|
||||
i := strings.IndexRune(line, '=') |
||||
if i == -1 { |
||||
continue |
||||
} |
||||
|
||||
key := strings.TrimSpace(line[:i]) |
||||
value := strings.TrimSpace(line[i+1:]) |
||||
|
||||
switch key { |
||||
case "pkgname": |
||||
p.Name = value |
||||
case "pkgver": |
||||
p.Version = value |
||||
case "pkgdesc": |
||||
p.VersionMetadata.Description = value |
||||
case "url": |
||||
p.VersionMetadata.ProjectURL = value |
||||
case "builddate": |
||||
n, err := strconv.ParseInt(value, 10, 64) |
||||
if err == nil { |
||||
p.FileMetadata.BuildDate = n |
||||
} |
||||
case "size": |
||||
n, err := strconv.ParseInt(value, 10, 64) |
||||
if err == nil { |
||||
p.FileMetadata.Size = n |
||||
} |
||||
case "arch": |
||||
p.FileMetadata.Architecture = value |
||||
case "origin": |
||||
p.FileMetadata.Origin = value |
||||
case "commit": |
||||
p.FileMetadata.CommitHash = value |
||||
case "maintainer": |
||||
p.VersionMetadata.Maintainer = value |
||||
case "packager": |
||||
p.FileMetadata.Packager = value |
||||
case "license": |
||||
p.VersionMetadata.License = value |
||||
case "install_if": |
||||
p.FileMetadata.InstallIf = value |
||||
case "provides": |
||||
if value != "" { |
||||
p.FileMetadata.Provides = append(p.FileMetadata.Provides, value) |
||||
} |
||||
case "depend": |
||||
if value != "" { |
||||
p.FileMetadata.Dependencies = append(p.FileMetadata.Dependencies, value) |
||||
} |
||||
} |
||||
} |
||||
if err := scanner.Err(); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
if p.Name == "" { |
||||
return nil, ErrInvalidName |
||||
} |
||||
|
||||
if p.Version == "" { |
||||
return nil, ErrInvalidVersion |
||||
} |
||||
|
||||
if !validation.IsValidURL(p.VersionMetadata.ProjectURL) { |
||||
p.VersionMetadata.ProjectURL = "" |
||||
} |
||||
|
||||
return p, nil |
||||
} |
||||
|
||||
// Same as io.TeeReader but implements io.ByteReader
|
||||
type teeByteReader struct { |
||||
r *bufio.Reader |
||||
w io.Writer |
||||
} |
||||
|
||||
func (t *teeByteReader) Read(p []byte) (int, error) { |
||||
n, err := t.r.Read(p) |
||||
if n > 0 { |
||||
if n, err := t.w.Write(p[:n]); err != nil { |
||||
return n, err |
||||
} |
||||
} |
||||
return n, err |
||||
} |
||||
|
||||
func (t *teeByteReader) ReadByte() (byte, error) { |
||||
b, err := t.r.ReadByte() |
||||
if err == nil { |
||||
if _, err := t.w.Write([]byte{b}); err != nil { |
||||
return 0, err |
||||
} |
||||
} |
||||
return b, err |
||||
} |
@ -0,0 +1,143 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine |
||||
|
||||
import ( |
||||
"archive/tar" |
||||
"bytes" |
||||
"compress/gzip" |
||||
"io" |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
const ( |
||||
packageName = "gitea" |
||||
packageVersion = "1.0.1" |
||||
packageDescription = "Package Description" |
||||
packageProjectURL = "https://gitea.io" |
||||
packageMaintainer = "KN4CK3R <dummy@gitea.io>" |
||||
) |
||||
|
||||
func createPKGINFOContent(name, version string) []byte { |
||||
return []byte(`pkgname = ` + name + ` |
||||
pkgver = ` + version + ` |
||||
pkgdesc = ` + packageDescription + ` |
||||
url = ` + packageProjectURL + ` |
||||
# comment |
||||
builddate = 1678834800 |
||||
packager = Gitea <pack@ag.er> |
||||
size = 123456 |
||||
arch = aarch64 |
||||
origin = origin |
||||
commit = 1111e709613fbc979651b09ac2bc27c6591a9999 |
||||
maintainer = ` + packageMaintainer + ` |
||||
license = MIT |
||||
depend = common |
||||
install_if = value |
||||
depend = gitea |
||||
provides = common |
||||
provides = gitea`) |
||||
} |
||||
|
||||
func TestParsePackage(t *testing.T) { |
||||
createPackage := func(name string, content []byte) io.Reader { |
||||
names := []string{"first.stream", name} |
||||
contents := [][]byte{{0}, content} |
||||
|
||||
var buf bytes.Buffer |
||||
zw := gzip.NewWriter(&buf) |
||||
|
||||
for i := range names { |
||||
if i != 0 { |
||||
zw.Close() |
||||
zw.Reset(&buf) |
||||
} |
||||
|
||||
tw := tar.NewWriter(zw) |
||||
hdr := &tar.Header{ |
||||
Name: names[i], |
||||
Mode: 0o600, |
||||
Size: int64(len(contents[i])), |
||||
} |
||||
tw.WriteHeader(hdr) |
||||
tw.Write(contents[i]) |
||||
tw.Close() |
||||
} |
||||
|
||||
zw.Close() |
||||
|
||||
return &buf |
||||
} |
||||
|
||||
t.Run("MissingPKGINFOFile", func(t *testing.T) { |
||||
data := createPackage("dummy.txt", []byte{}) |
||||
|
||||
pp, err := ParsePackage(data) |
||||
assert.Nil(t, pp) |
||||
assert.ErrorIs(t, err, ErrMissingPKGINFOFile) |
||||
}) |
||||
|
||||
t.Run("InvalidPKGINFOFile", func(t *testing.T) { |
||||
data := createPackage(".PKGINFO", []byte{}) |
||||
|
||||
pp, err := ParsePackage(data) |
||||
assert.Nil(t, pp) |
||||
assert.ErrorIs(t, err, ErrInvalidName) |
||||
}) |
||||
|
||||
t.Run("Valid", func(t *testing.T) { |
||||
data := createPackage(".PKGINFO", createPKGINFOContent(packageName, packageVersion)) |
||||
|
||||
p, err := ParsePackage(data) |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, p) |
||||
|
||||
assert.Equal(t, "Q1SRYURM5+uQDqfHSwTnNIOIuuDVQ=", p.FileMetadata.Checksum) |
||||
}) |
||||
} |
||||
|
||||
func TestParsePackageInfo(t *testing.T) { |
||||
t.Run("InvalidName", func(t *testing.T) { |
||||
data := createPKGINFOContent("", packageVersion) |
||||
|
||||
p, err := ParsePackageInfo(bytes.NewReader(data)) |
||||
assert.Nil(t, p) |
||||
assert.ErrorIs(t, err, ErrInvalidName) |
||||
}) |
||||
|
||||
t.Run("InvalidVersion", func(t *testing.T) { |
||||
data := createPKGINFOContent(packageName, "") |
||||
|
||||
p, err := ParsePackageInfo(bytes.NewReader(data)) |
||||
assert.Nil(t, p) |
||||
assert.ErrorIs(t, err, ErrInvalidVersion) |
||||
}) |
||||
|
||||
t.Run("Valid", func(t *testing.T) { |
||||
data := createPKGINFOContent(packageName, packageVersion) |
||||
|
||||
p, err := ParsePackageInfo(bytes.NewReader(data)) |
||||
assert.NoError(t, err) |
||||
assert.NotNil(t, p) |
||||
|
||||
assert.Equal(t, packageName, p.Name) |
||||
assert.Equal(t, packageVersion, p.Version) |
||||
assert.Equal(t, packageDescription, p.VersionMetadata.Description) |
||||
assert.Equal(t, packageMaintainer, p.VersionMetadata.Maintainer) |
||||
assert.Equal(t, packageProjectURL, p.VersionMetadata.ProjectURL) |
||||
assert.Equal(t, "MIT", p.VersionMetadata.License) |
||||
assert.Empty(t, p.FileMetadata.Checksum) |
||||
assert.Equal(t, "Gitea <pack@ag.er>", p.FileMetadata.Packager) |
||||
assert.EqualValues(t, 1678834800, p.FileMetadata.BuildDate) |
||||
assert.EqualValues(t, 123456, p.FileMetadata.Size) |
||||
assert.Equal(t, "aarch64", p.FileMetadata.Architecture) |
||||
assert.Equal(t, "origin", p.FileMetadata.Origin) |
||||
assert.Equal(t, "1111e709613fbc979651b09ac2bc27c6591a9999", p.FileMetadata.CommitHash) |
||||
assert.Equal(t, "value", p.FileMetadata.InstallIf) |
||||
assert.ElementsMatch(t, []string{"common", "gitea"}, p.FileMetadata.Provides) |
||||
assert.ElementsMatch(t, []string{"common", "gitea"}, p.FileMetadata.Dependencies) |
||||
}) |
||||
} |
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1,253 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine |
||||
|
||||
import ( |
||||
"crypto/x509" |
||||
"encoding/hex" |
||||
"encoding/pem" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"net/http" |
||||
"strings" |
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages" |
||||
"code.gitea.io/gitea/modules/context" |
||||
"code.gitea.io/gitea/modules/json" |
||||
packages_module "code.gitea.io/gitea/modules/packages" |
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine" |
||||
"code.gitea.io/gitea/modules/util" |
||||
"code.gitea.io/gitea/routers/api/packages/helper" |
||||
packages_service "code.gitea.io/gitea/services/packages" |
||||
alpine_service "code.gitea.io/gitea/services/packages/alpine" |
||||
) |
||||
|
||||
func apiError(ctx *context.Context, status int, obj interface{}) { |
||||
helper.LogAndProcessError(ctx, status, obj, func(message string) { |
||||
ctx.PlainText(status, message) |
||||
}) |
||||
} |
||||
|
||||
func GetRepositoryKey(ctx *context.Context) { |
||||
_, pub, err := alpine_service.GetOrCreateKeyPair(ctx.Package.Owner.ID) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
pubPem, _ := pem.Decode([]byte(pub)) |
||||
if pubPem == nil { |
||||
apiError(ctx, http.StatusInternalServerError, "failed to decode private key pem") |
||||
return |
||||
} |
||||
|
||||
pubKey, err := x509.ParsePKIXPublicKey(pubPem.Bytes) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
fingerprint, err := util.CreatePublicKeyFingerprint(pubKey) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
ctx.ServeContent(strings.NewReader(pub), &context.ServeHeaderOptions{ |
||||
ContentType: "application/x-pem-file", |
||||
Filename: fmt.Sprintf("%s@%s.rsa.pub", ctx.Package.Owner.LowerName, hex.EncodeToString(fingerprint)), |
||||
}) |
||||
} |
||||
|
||||
func GetRepositoryFile(ctx *context.Context) { |
||||
pv, err := alpine_service.GetOrCreateRepositoryVersion(ctx.Package.Owner.ID) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
s, pf, err := packages_service.GetFileStreamByPackageVersion( |
||||
ctx, |
||||
pv, |
||||
&packages_service.PackageFileInfo{ |
||||
Filename: alpine_service.IndexFilename, |
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")), |
||||
}, |
||||
) |
||||
if err != nil { |
||||
if errors.Is(err, util.ErrNotExist) { |
||||
apiError(ctx, http.StatusNotFound, err) |
||||
} else { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
} |
||||
return |
||||
} |
||||
defer s.Close() |
||||
|
||||
ctx.ServeContent(s, &context.ServeHeaderOptions{ |
||||
Filename: pf.Name, |
||||
LastModified: pf.CreatedUnix.AsLocalTime(), |
||||
}) |
||||
} |
||||
|
||||
func UploadPackageFile(ctx *context.Context) { |
||||
branch := strings.TrimSpace(ctx.Params("branch")) |
||||
repository := strings.TrimSpace(ctx.Params("repository")) |
||||
if branch == "" || repository == "" { |
||||
apiError(ctx, http.StatusBadRequest, "invalid branch or repository") |
||||
return |
||||
} |
||||
|
||||
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) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
defer buf.Close() |
||||
|
||||
pck, err := alpine_module.ParsePackage(buf) |
||||
if err != nil { |
||||
if errors.Is(err, util.ErrInvalidArgument) || err == io.EOF { |
||||
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 |
||||
} |
||||
|
||||
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.TypeAlpine, |
||||
Name: pck.Name, |
||||
Version: pck.Version, |
||||
}, |
||||
Creator: ctx.Doer, |
||||
Metadata: pck.VersionMetadata, |
||||
}, |
||||
&packages_service.PackageFileCreationInfo{ |
||||
PackageFileInfo: packages_service.PackageFileInfo{ |
||||
Filename: fmt.Sprintf("%s-%s.apk", pck.Name, pck.Version), |
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, pck.FileMetadata.Architecture), |
||||
}, |
||||
Creator: ctx.Doer, |
||||
Data: buf, |
||||
IsLead: true, |
||||
Properties: map[string]string{ |
||||
alpine_module.PropertyBranch: branch, |
||||
alpine_module.PropertyRepository: repository, |
||||
alpine_module.PropertyArchitecture: pck.FileMetadata.Architecture, |
||||
alpine_module.PropertyMetadata: string(fileMetadataRaw), |
||||
}, |
||||
}, |
||||
) |
||||
if err != nil { |
||||
switch err { |
||||
case packages_model.ErrDuplicatePackageVersion, packages_model.ErrDuplicatePackageFile: |
||||
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 |
||||
} |
||||
|
||||
if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, pck.FileMetadata.Architecture); err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
ctx.Status(http.StatusCreated) |
||||
} |
||||
|
||||
func DownloadPackageFile(ctx *context.Context) { |
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ |
||||
OwnerID: ctx.Package.Owner.ID, |
||||
PackageType: packages_model.TypeAlpine, |
||||
Query: ctx.Params("filename"), |
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture")), |
||||
}) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
if len(pfs) != 1 { |
||||
apiError(ctx, http.StatusNotFound, nil) |
||||
return |
||||
} |
||||
|
||||
s, pf, err := packages_service.GetPackageFileStream(ctx, pfs[0]) |
||||
if err != nil { |
||||
if errors.Is(err, util.ErrNotExist) { |
||||
apiError(ctx, http.StatusNotFound, err) |
||||
} else { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
} |
||||
return |
||||
} |
||||
defer s.Close() |
||||
|
||||
ctx.ServeContent(s, &context.ServeHeaderOptions{ |
||||
Filename: pf.Name, |
||||
LastModified: pf.CreatedUnix.AsLocalTime(), |
||||
}) |
||||
} |
||||
|
||||
func DeletePackageFile(ctx *context.Context) { |
||||
branch, repository, architecture := ctx.Params("branch"), ctx.Params("repository"), ctx.Params("architecture") |
||||
|
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ |
||||
OwnerID: ctx.Package.Owner.ID, |
||||
PackageType: packages_model.TypeAlpine, |
||||
Query: ctx.Params("filename"), |
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture), |
||||
}) |
||||
if err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
if len(pfs) != 1 { |
||||
apiError(ctx, http.StatusNotFound, nil) |
||||
return |
||||
} |
||||
|
||||
if err := packages_service.RemovePackageFileAndVersionIfUnreferenced(ctx.Doer, pfs[0]); err != nil { |
||||
if errors.Is(err, util.ErrNotExist) { |
||||
apiError(ctx, http.StatusNotFound, err) |
||||
} else { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
} |
||||
return |
||||
} |
||||
|
||||
if err := alpine_service.BuildSpecificRepositoryFiles(ctx, ctx.Package.Owner.ID, branch, repository, architecture); err != nil { |
||||
apiError(ctx, http.StatusInternalServerError, err) |
||||
return |
||||
} |
||||
|
||||
ctx.Status(http.StatusNoContent) |
||||
} |
@ -0,0 +1,328 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package alpine |
||||
|
||||
import ( |
||||
"archive/tar" |
||||
"bytes" |
||||
"compress/gzip" |
||||
"context" |
||||
"crypto" |
||||
"crypto/rand" |
||||
"crypto/rsa" |
||||
"crypto/sha1" |
||||
"crypto/x509" |
||||
"encoding/hex" |
||||
"encoding/pem" |
||||
"errors" |
||||
"fmt" |
||||
"io" |
||||
"strings" |
||||
|
||||
packages_model "code.gitea.io/gitea/models/packages" |
||||
alpine_model "code.gitea.io/gitea/models/packages/alpine" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/json" |
||||
packages_module "code.gitea.io/gitea/modules/packages" |
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine" |
||||
"code.gitea.io/gitea/modules/util" |
||||
packages_service "code.gitea.io/gitea/services/packages" |
||||
) |
||||
|
||||
const IndexFilename = "APKINDEX.tar.gz" |
||||
|
||||
// GetOrCreateRepositoryVersion gets or creates the internal repository package
|
||||
// The Alpine registry needs multiple index files which are stored in this package.
|
||||
func GetOrCreateRepositoryVersion(ownerID int64) (*packages_model.PackageVersion, error) { |
||||
return packages_service.GetOrCreateInternalPackageVersion(ownerID, packages_model.TypeAlpine, alpine_module.RepositoryPackage, alpine_module.RepositoryVersion) |
||||
} |
||||
|
||||
// GetOrCreateKeyPair gets or creates the RSA keys used to sign repository files
|
||||
func GetOrCreateKeyPair(ownerID int64) (string, string, error) { |
||||
priv, err := user_model.GetSetting(ownerID, alpine_module.SettingKeyPrivate) |
||||
if err != nil && !errors.Is(err, util.ErrNotExist) { |
||||
return "", "", err |
||||
} |
||||
|
||||
pub, err := user_model.GetSetting(ownerID, alpine_module.SettingKeyPublic) |
||||
if err != nil && !errors.Is(err, util.ErrNotExist) { |
||||
return "", "", err |
||||
} |
||||
|
||||
if priv == "" || pub == "" { |
||||
priv, pub, err = util.GenerateKeyPair(4096) |
||||
if err != nil { |
||||
return "", "", err |
||||
} |
||||
|
||||
if err := user_model.SetUserSetting(ownerID, alpine_module.SettingKeyPrivate, priv); err != nil { |
||||
return "", "", err |
||||
} |
||||
|
||||
if err := user_model.SetUserSetting(ownerID, alpine_module.SettingKeyPublic, pub); err != nil { |
||||
return "", "", err |
||||
} |
||||
} |
||||
|
||||
return priv, pub, nil |
||||
} |
||||
|
||||
// BuildAllRepositoryFiles (re)builds all repository files for every available distributions, components and architectures
|
||||
func BuildAllRepositoryFiles(ctx context.Context, ownerID int64) error { |
||||
pv, err := GetOrCreateRepositoryVersion(ownerID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// 1. Delete all existing repository files
|
||||
pfs, err := packages_model.GetFilesByVersionID(ctx, pv.ID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, pf := range pfs { |
||||
if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { |
||||
return err |
||||
} |
||||
if err := packages_model.DeleteFileByID(ctx, pf.ID); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
// 2. (Re)Build repository files for existing packages
|
||||
branches, err := alpine_model.GetBranches(ctx, ownerID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, branch := range branches { |
||||
repositories, err := alpine_model.GetRepositories(ctx, ownerID, branch) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, repository := range repositories { |
||||
architectures, err := alpine_model.GetArchitectures(ctx, ownerID, repository) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
for _, architecture := range architectures { |
||||
if err := buildPackagesIndex(ctx, ownerID, pv, branch, repository, architecture); err != nil { |
||||
return fmt.Errorf("failed to build repository files [%s/%s/%s]: %w", branch, repository, architecture, err) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// BuildSpecificRepositoryFiles builds index files for the repository
|
||||
func BuildSpecificRepositoryFiles(ctx context.Context, ownerID int64, branch, repository, architecture string) error { |
||||
pv, err := GetOrCreateRepositoryVersion(ownerID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
return buildPackagesIndex(ctx, ownerID, pv, branch, repository, architecture) |
||||
} |
||||
|
||||
type packageData struct { |
||||
Package *packages_model.Package |
||||
Version *packages_model.PackageVersion |
||||
Blob *packages_model.PackageBlob |
||||
VersionMetadata *alpine_module.VersionMetadata |
||||
FileMetadata *alpine_module.FileMetadata |
||||
} |
||||
|
||||
type packageCache = map[*packages_model.PackageFile]*packageData |
||||
|
||||
// https://wiki.alpinelinux.org/wiki/Apk_spec#APKINDEX_Format
|
||||
func buildPackagesIndex(ctx context.Context, ownerID int64, repoVersion *packages_model.PackageVersion, branch, repository, architecture string) error { |
||||
pfs, _, err := packages_model.SearchFiles(ctx, &packages_model.PackageFileSearchOptions{ |
||||
OwnerID: ownerID, |
||||
PackageType: packages_model.TypeAlpine, |
||||
Query: "%.apk", |
||||
Properties: map[string]string{ |
||||
alpine_module.PropertyBranch: branch, |
||||
alpine_module.PropertyRepository: repository, |
||||
alpine_module.PropertyArchitecture: architecture, |
||||
}, |
||||
}) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
// Delete the package indices if there are no packages
|
||||
if len(pfs) == 0 { |
||||
pf, err := packages_model.GetFileForVersionByName(ctx, repoVersion.ID, IndexFilename, fmt.Sprintf("%s|%s|%s", branch, repository, architecture)) |
||||
if err != nil && !errors.Is(err, util.ErrNotExist) { |
||||
return err |
||||
} |
||||
|
||||
if err := packages_model.DeleteAllProperties(ctx, packages_model.PropertyTypeFile, pf.ID); err != nil { |
||||
return err |
||||
} |
||||
return packages_model.DeleteFileByID(ctx, pf.ID) |
||||
} |
||||
|
||||
// Cache data needed for all repository files
|
||||
cache := make(packageCache) |
||||
for _, pf := range pfs { |
||||
pv, err := packages_model.GetVersionByID(ctx, pf.VersionID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
p, err := packages_model.GetPackageByID(ctx, pv.PackageID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
pb, err := packages_model.GetBlobByID(ctx, pf.BlobID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
pps, err := packages_model.GetPropertiesByName(ctx, packages_model.PropertyTypeFile, pf.ID, alpine_module.PropertyMetadata) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
pd := &packageData{ |
||||
Package: p, |
||||
Version: pv, |
||||
Blob: pb, |
||||
} |
||||
|
||||
if err := json.Unmarshal([]byte(pv.MetadataJSON), &pd.VersionMetadata); err != nil { |
||||
return err |
||||
} |
||||
if len(pps) > 0 { |
||||
if err := json.Unmarshal([]byte(pps[0].Value), &pd.FileMetadata); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
cache[pf] = pd |
||||
} |
||||
|
||||
var buf bytes.Buffer |
||||
for _, pf := range pfs { |
||||
pd := cache[pf] |
||||
|
||||
fmt.Fprintf(&buf, "C:%s\n", pd.FileMetadata.Checksum) |
||||
fmt.Fprintf(&buf, "P:%s\n", pd.Package.Name) |
||||
fmt.Fprintf(&buf, "V:%s\n", pd.Version.Version) |
||||
fmt.Fprintf(&buf, "A:%s\n", pd.FileMetadata.Architecture) |
||||
if pd.VersionMetadata.Description != "" { |
||||
fmt.Fprintf(&buf, "T:%s\n", pd.VersionMetadata.Description) |
||||
} |
||||
if pd.VersionMetadata.ProjectURL != "" { |
||||
fmt.Fprintf(&buf, "U:%s\n", pd.VersionMetadata.ProjectURL) |
||||
} |
||||
if pd.VersionMetadata.License != "" { |
||||
fmt.Fprintf(&buf, "L:%s\n", pd.VersionMetadata.License) |
||||
} |
||||
fmt.Fprintf(&buf, "S:%d\n", pd.Blob.Size) |
||||
fmt.Fprintf(&buf, "I:%d\n", pd.FileMetadata.Size) |
||||
fmt.Fprintf(&buf, "o:%s\n", pd.FileMetadata.Origin) |
||||
fmt.Fprintf(&buf, "m:%s\n", pd.VersionMetadata.Maintainer) |
||||
fmt.Fprintf(&buf, "t:%d\n", pd.FileMetadata.BuildDate) |
||||
if pd.FileMetadata.CommitHash != "" { |
||||
fmt.Fprintf(&buf, "c:%s\n", pd.FileMetadata.CommitHash) |
||||
} |
||||
if len(pd.FileMetadata.Dependencies) > 0 { |
||||
fmt.Fprintf(&buf, "D:%s\n", strings.Join(pd.FileMetadata.Dependencies, " ")) |
||||
} |
||||
if len(pd.FileMetadata.Provides) > 0 { |
||||
fmt.Fprintf(&buf, "p:%s\n", strings.Join(pd.FileMetadata.Provides, " ")) |
||||
} |
||||
fmt.Fprint(&buf, "\n") |
||||
} |
||||
|
||||
unsignedIndexContent, _ := packages_module.NewHashedBuffer() |
||||
h := sha1.New() |
||||
|
||||
if err := writeGzipStream(io.MultiWriter(unsignedIndexContent, h), "APKINDEX", buf.Bytes(), true); err != nil { |
||||
return err |
||||
} |
||||
|
||||
priv, _, err := GetOrCreateKeyPair(ownerID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
privPem, _ := pem.Decode([]byte(priv)) |
||||
if privPem == nil { |
||||
return fmt.Errorf("failed to decode private key pem") |
||||
} |
||||
|
||||
privKey, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
sign, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA1, h.Sum(nil)) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
owner, err := user_model.GetUserByID(ctx, ownerID) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
fingerprint, err := util.CreatePublicKeyFingerprint(&privKey.PublicKey) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
signedIndexContent, _ := packages_module.NewHashedBuffer() |
||||
|
||||
if err := writeGzipStream( |
||||
signedIndexContent, |
||||
fmt.Sprintf(".SIGN.RSA.%s@%s.rsa.pub", owner.LowerName, hex.EncodeToString(fingerprint)), |
||||
sign, |
||||
false, |
||||
); err != nil { |
||||
return err |
||||
} |
||||
|
||||
if _, err := io.Copy(signedIndexContent, unsignedIndexContent); err != nil { |
||||
return err |
||||
} |
||||
|
||||
_, err = packages_service.AddFileToPackageVersionInternal( |
||||
repoVersion, |
||||
&packages_service.PackageFileCreationInfo{ |
||||
PackageFileInfo: packages_service.PackageFileInfo{ |
||||
Filename: IndexFilename, |
||||
CompositeKey: fmt.Sprintf("%s|%s|%s", branch, repository, architecture), |
||||
}, |
||||
Creator: user_model.NewGhostUser(), |
||||
Data: signedIndexContent, |
||||
IsLead: false, |
||||
OverwriteExisting: true, |
||||
}, |
||||
) |
||||
return err |
||||
} |
||||
|
||||
func writeGzipStream(w io.Writer, filename string, content []byte, addTarEnd bool) error { |
||||
zw := gzip.NewWriter(w) |
||||
defer zw.Close() |
||||
|
||||
tw := tar.NewWriter(zw) |
||||
if addTarEnd { |
||||
defer tw.Close() |
||||
} |
||||
hdr := &tar.Header{ |
||||
Name: filename, |
||||
Mode: 0o600, |
||||
Size: int64(len(content)), |
||||
} |
||||
if err := tw.WriteHeader(hdr); err != nil { |
||||
return err |
||||
} |
||||
if _, err := tw.Write(content); err != nil { |
||||
return err |
||||
} |
||||
return nil |
||||
} |
@ -0,0 +1,52 @@ |
||||
{{if eq .PackageDescriptor.Package.Type "alpine"}} |
||||
<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.alpine.registry" | Safe}}</label> |
||||
<div class="markup"><pre class="code-block"><code><gitea-origin-url data-url="{{AppSubUrl}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/alpine"></gitea-origin-url>/$branch/$repository</code></pre></div> |
||||
<p>{{.locale.Tr "packages.alpine.registry.info" | Safe}}</p> |
||||
</div> |
||||
<div class="field"> |
||||
<label>{{svg "octicon-terminal"}} {{.locale.Tr "packages.alpine.registry.key" | Safe}}</label> |
||||
<div class="markup"><pre class="code-block"><code>curl -JO <gitea-origin-url data-url="{{AppSubUrl}}/api/packages/{{$.PackageDescriptor.Owner.Name}}/alpine/key"></gitea-origin-url></code></pre></div> |
||||
</div> |
||||
<div class="field"> |
||||
<label>{{svg "octicon-terminal"}} {{.locale.Tr "packages.alpine.install"}}</label> |
||||
<div class="markup"> |
||||
<pre class="code-block"><code>sudo apk add {{$.PackageDescriptor.Package.Name}}={{$.PackageDescriptor.Version.Version}}</code></pre> |
||||
</div> |
||||
</div> |
||||
<div class="field"> |
||||
<label>{{.locale.Tr "packages.alpine.documentation" "https://docs.gitea.io/en-us/packages/alpine/" | Safe}}</label> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<h4 class="ui top attached header">{{.locale.Tr "packages.alpine.repository"}}</h4> |
||||
<div class="ui attached segment"> |
||||
<table class="ui single line very basic table"> |
||||
<tbody> |
||||
<tr> |
||||
<td class="collapsing"><h5>{{.locale.Tr "packages.alpine.repository.branches"}}</h5></td> |
||||
<td>{{StringUtils.Join .Branches ", "}}</td> |
||||
</tr> |
||||
<tr> |
||||
<td class="collapsing"><h5>{{.locale.Tr "packages.alpine.repository.repositories"}}</h5></td> |
||||
<td>{{StringUtils.Join .Repositories ", "}}</td> |
||||
</tr> |
||||
<tr> |
||||
<td class="collapsing"><h5>{{.locale.Tr "packages.alpine.repository.architectures"}}</h5></td> |
||||
<td>{{StringUtils.Join .Architectures ", "}}</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
|
||||
{{if .PackageDescriptor.Metadata.Description}} |
||||
<h4 class="ui top attached header">{{.locale.Tr "packages.about"}}</h4> |
||||
<div class="ui attached segment"> |
||||
{{.PackageDescriptor.Metadata.Description}} |
||||
</div> |
||||
{{end}} |
||||
{{end}} |
@ -0,0 +1,5 @@ |
||||
{{if eq .PackageDescriptor.Package.Type "alpine"}} |
||||
{{if .PackageDescriptor.Metadata.Maintainer}}<div class="item" title="{{.locale.Tr "packages.details.author"}}">{{svg "octicon-person" 16 "mr-3"}} {{.PackageDescriptor.Metadata.Maintainer}}</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.License}}<div class="item" title="{{$.locale.Tr "packages.details.license"}}">{{svg "octicon-law" 16 "gt-mr-3"}} {{.PackageDescriptor.Metadata.License}}</div>{{end}} |
||||
{{end}} |
@ -0,0 +1,229 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration |
||||
|
||||
import ( |
||||
"archive/tar" |
||||
"bufio" |
||||
"bytes" |
||||
"compress/gzip" |
||||
"encoding/base64" |
||||
"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" |
||||
alpine_module "code.gitea.io/gitea/modules/packages/alpine" |
||||
"code.gitea.io/gitea/tests" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestPackageAlpine(t *testing.T) { |
||||
defer tests.PrepareTestEnv(t)() |
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) |
||||
|
||||
packageName := "gitea-test" |
||||
packageVersion := "1.4.1-r3" |
||||
|
||||
base64AlpinePackageContent := `H4sIAAAAAAACA9ML9nT30wsKdtTLzjNJzjYuckjPLElN1DUzMUxMNTa11CsqTtQrKE1ioAAYAIGZ |
||||
iQmYBgJ02hDENjQxMTAzMzQ1MTVjMDA0MTQ1ZlAwYKADKC0uSSxSUGAYoWDm4sZZtypv75+q2fVT |
||||
POD1bKkFB22ms+g1z+H4dk7AhC3HwUSj9EbT0Rk3Dn55dHxy/K7Q+Nl/i+L7Z036ypcRvvpZuMiN |
||||
s7wbZL/klqRGGshv9Gi0qHTgTZfw3HytnJdx9c3NTRp/PHn+Z50uq2pjkilzjtpfd+uzQMw1M7cY |
||||
i9RXJasnT2M+vDXCesLK7MilJt8sGplj4xUlLMUun9SzY+phFpxWxRXa06AseV9WvzH3jtGGoL5A |
||||
vQkea+VKPj5R+Cb461tIk97qpa9nJYsJujTNl2B/J1P52H/D2rPr/j19uU8p7cMSq5tmXk51ReXl |
||||
F/Yddr9XsMpEwFKlXSPo3QSGwnCOG8y2uadjm6ui998WYXNYubjg78N3a7bnXjhrl5fB8voI++LI |
||||
1FP5W44e2xf4Ou2wrtyic1Onz7MzMV5ksuno2V/LVG4eN/15X/n2/2vJ2VV+T68aT327dOrhd6e6 |
||||
q5Y0V82Y83tdqkFa8TW2BvGCZ0ds/iibHVpzKuPcuSULO63/bNmfrnhjWqXzhMSXTb5Cv4vPaxSL |
||||
8LFMdqmxbN7+Y+Yi0ZyZhz4UxexLuHHFd1VFvk+kwvniq3P+f9rh52InWnL8Lpvedcecoh1GFSc5 |
||||
xZ9VBGex2V269HZfwxSVCvP35wQfi2xKX+lYMXtF48n1R65O2PLWpm69RdESMa79dlrTGazsZacu |
||||
MbMLeSSScPORZde76/MBV6SFJAAEAAAfiwgAAAAAAAID7VRLaxsxEN6zfoUgZ++OVq+1aUIhUDeY |
||||
pKa49FhmJdkW3ofRysXpr69220t9SCk0gZJ+IGaY56eBmbxY4/m9Q+vCUOTr1fLu4d2H7O8CEpQQ |
||||
k0y4lAClypgQoBSTQqoMGBMgMnrOXgCnIWJIVLLXCcaoib5110CSij/V7D9eCZ5p5f9o/5VkF/tf |
||||
MqUzCi+5/6Hv41Nxv/Nffu4fwRVdus4FjM7S+pFiffKNpTxnkMMsALmin5PnHgMtS8rkgvGFBPpp |
||||
c0tLKDk5HnYdto5e052PDmfRDXE0fnUh2VgucjYLU5h1g0mm5RhGNymMrtEccOfIKTTJsY/xOCyK |
||||
YqqT+74gExWbmI2VlJ6LeQUcyPFH2lh/9SBuV/wjfXPohDnw8HZKviGD/zYmCZgrgsHsk36u1Bcl |
||||
SB/8zne/0jV92/qYbKRF38X0niiemN2QxhvXDWOL+7tNGhGeYt+m22mwaR6pddGZNM8FSeRxj8PY |
||||
X7PaqdqAVlqWXHKnmQGmK43VlqNlILRilbBSMI2jV5Vbu5XGSVsDyGc7yd8B/gK2qgAIAAAfiwgA |
||||
AAAAAAID7dNNSgMxGAbg7MSCOxcu5wJOv0x+OlkU7K5QoYXqVsxMMihlKMwP1Fu48QQewCN4DfEQ |
||||
egUz4sYuFKEtFN9n870hWSSQN+7P7GrsrfNV3Y9dW5Z3bNMo0FJ+zmB9EhcJ41KS1lxJpRnxbsWi |
||||
FduBtm5sFa7C/ifOo7y5Lf2QeiHar6jTaDSbnF5Mp+fzOL/x+aJuy3g+HvGhs8JY4b3yOpMZOZEo |
||||
lRW+MEoTTw3ZwqU0INNjsAe2VPk/9b/L3/s/kIKzqOtk+IbJGTtmr+bx7WoxOUoun98frk/un14O |
||||
Djfa/2q5bH4699v++uMAAAAAAAAAAAAAAAAAAAAAAHbgA/eXQh8AKAAA` |
||||
content, err := base64.StdEncoding.DecodeString(base64AlpinePackageContent) |
||||
assert.NoError(t, err) |
||||
|
||||
branches := []string{"v3.16", "v3.17"} |
||||
repositories := []string{"main", "testing"} |
||||
|
||||
rootURL := fmt.Sprintf("/api/packages/%s/alpine", user.Name) |
||||
|
||||
t.Run("RepositoryKey", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "GET", rootURL+"/key") |
||||
resp := MakeRequest(t, req, http.StatusOK) |
||||
|
||||
assert.Equal(t, "application/x-pem-file", resp.Header().Get("Content-Type")) |
||||
assert.Contains(t, resp.Body.String(), "-----BEGIN PUBLIC KEY-----") |
||||
}) |
||||
|
||||
for _, branch := range branches { |
||||
for _, repository := range repositories { |
||||
t.Run(fmt.Sprintf("[Branch:%s,Repository:%s]", branch, repository), func(t *testing.T) { |
||||
t.Run("Upload", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
uploadURL := fmt.Sprintf("%s/%s/%s", rootURL, branch, repository) |
||||
|
||||
req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) |
||||
MakeRequest(t, req, http.StatusUnauthorized) |
||||
|
||||
req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})) |
||||
AddBasicAuthHeader(req, user.Name) |
||||
MakeRequest(t, req, http.StatusBadRequest) |
||||
|
||||
req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)) |
||||
AddBasicAuthHeader(req, user.Name) |
||||
MakeRequest(t, req, http.StatusCreated) |
||||
|
||||
pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeAlpine) |
||||
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, &alpine_module.VersionMetadata{}, 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.NotEmpty(t, pfs) |
||||
assert.Condition(t, func() bool { |
||||
seen := false |
||||
expectedFilename := fmt.Sprintf("%s-%s.apk", packageName, packageVersion) |
||||
expectedCompositeKey := fmt.Sprintf("%s|%s|x86_64", branch, repository) |
||||
for _, pf := range pfs { |
||||
if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey { |
||||
if seen { |
||||
return false |
||||
} |
||||
seen = true |
||||
|
||||
assert.True(t, pf.IsLead) |
||||
|
||||
pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID) |
||||
assert.NoError(t, err) |
||||
|
||||
for _, pfp := range pfps { |
||||
switch pfp.Name { |
||||
case alpine_module.PropertyBranch: |
||||
assert.Equal(t, branch, pfp.Value) |
||||
case alpine_module.PropertyRepository: |
||||
assert.Equal(t, repository, pfp.Value) |
||||
case alpine_module.PropertyArchitecture: |
||||
assert.Equal(t, "x86_64", pfp.Value) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
return seen |
||||
}) |
||||
}) |
||||
|
||||
t.Run("Index", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
url := fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository) |
||||
|
||||
req := NewRequest(t, "GET", url) |
||||
resp := MakeRequest(t, req, http.StatusOK) |
||||
|
||||
assert.Condition(t, func() bool { |
||||
br := bufio.NewReader(resp.Body) |
||||
|
||||
gzr, err := gzip.NewReader(br) |
||||
assert.NoError(t, err) |
||||
|
||||
for { |
||||
gzr.Multistream(false) |
||||
|
||||
tr := tar.NewReader(gzr) |
||||
for { |
||||
hd, err := tr.Next() |
||||
if err == io.EOF { |
||||
break |
||||
} |
||||
assert.NoError(t, err) |
||||
|
||||
if hd.Name == "APKINDEX" { |
||||
buf, err := io.ReadAll(tr) |
||||
assert.NoError(t, err) |
||||
|
||||
s := string(buf) |
||||
|
||||
assert.Contains(t, s, "C:Q1/se1PjO94hYXbfpNR1/61hVORIc=\n") |
||||
assert.Contains(t, s, "P:"+packageName+"\n") |
||||
assert.Contains(t, s, "V:"+packageVersion+"\n") |
||||
assert.Contains(t, s, "A:x86_64\n") |
||||
assert.Contains(t, s, "T:Gitea Test Package\n") |
||||
assert.Contains(t, s, "U:https://gitea.io/\n") |
||||
assert.Contains(t, s, "L:MIT\n") |
||||
assert.Contains(t, s, "S:1353\n") |
||||
assert.Contains(t, s, "I:4096\n") |
||||
assert.Contains(t, s, "o:gitea-test\n") |
||||
assert.Contains(t, s, "m:KN4CK3R <kn4ck3r@gitea.io>\n") |
||||
assert.Contains(t, s, "t:1679498030\n") |
||||
|
||||
return true |
||||
} |
||||
} |
||||
|
||||
err = gzr.Reset(br) |
||||
if err == io.EOF { |
||||
break |
||||
} |
||||
assert.NoError(t, err) |
||||
} |
||||
|
||||
return false |
||||
}) |
||||
}) |
||||
|
||||
t.Run("Download", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) |
||||
MakeRequest(t, req, http.StatusOK) |
||||
}) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
t.Run("Delete", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
for _, branch := range branches { |
||||
for _, repository := range repositories { |
||||
req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) |
||||
MakeRequest(t, req, http.StatusUnauthorized) |
||||
|
||||
req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)) |
||||
AddBasicAuthHeader(req, user.Name) |
||||
MakeRequest(t, req, http.StatusNoContent) |
||||
|
||||
// Deleting the last file of an architecture should remove that index
|
||||
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository)) |
||||
MakeRequest(t, req, http.StatusNotFound) |
||||
} |
||||
} |
||||
}) |
||||
} |
After Width: | Height: | Size: 1.4 KiB |
Loading…
Reference in new issue