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