mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 05:24:18 +09:00
feat(packages): add support for uploading helm provenance files (#36695)
Adds `POST helm/api/prov` endpoint for helm repository allowing for upload of provenance files. Tested manually to a degree but I really didn't want to mess with gpg again so I'm not sure if helm will correctly verify the chart. Initial draft made by gemini 3 flash but was finetuned somewhat. Additionally there's an route that allows for upload of both files via /api/charts - as separate files in form. If there's any interest in that I guess it can be added but I think helm is moving to OCI anyway which we support. Fixes: https://github.com/go-gitea/gitea/issues/36678 Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -5,6 +5,7 @@ package helm
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
"gitea.dev/modules/util"
|
"gitea.dev/modules/util"
|
||||||
"gitea.dev/modules/validation"
|
"gitea.dev/modules/validation"
|
||||||
|
|
||||||
|
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
|
||||||
"github.com/hashicorp/go-version"
|
"github.com/hashicorp/go-version"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,8 @@ var (
|
|||||||
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
ErrInvalidVersion = util.NewInvalidArgumentErrorf("package version is invalid")
|
||||||
// ErrInvalidChart indicates an invalid chart
|
// ErrInvalidChart indicates an invalid chart
|
||||||
ErrInvalidChart = util.NewInvalidArgumentErrorf("chart is invalid")
|
ErrInvalidChart = util.NewInvalidArgumentErrorf("chart is invalid")
|
||||||
|
// ErrInvalidProvenance indicates an invalid provenance file
|
||||||
|
ErrInvalidProvenance = util.NewInvalidArgumentErrorf("provenance file is invalid")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Metadata for a Chart file. This models the structure of a Chart.yaml file.
|
// Metadata for a Chart file. This models the structure of a Chart.yaml file.
|
||||||
@@ -128,3 +132,20 @@ func ParseChartFile(r io.Reader) (*Metadata, error) {
|
|||||||
|
|
||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseProvenanceFile parses a provenance file to retrieve the metadata of a Helm chart
|
||||||
|
func ParseProvenanceFile(r io.Reader) (*Metadata, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(r, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provenance file must be a clearsigned PGP message
|
||||||
|
block, _ := clearsign.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return nil, ErrInvalidProvenance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the plaintext content from the clearsigned message
|
||||||
|
return ParseChartFile(bytes.NewReader(block.Plaintext))
|
||||||
|
}
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ func CommonRoutes() *web.Router {
|
|||||||
r.Get("/index.yaml", helm.Index)
|
r.Get("/index.yaml", helm.Index)
|
||||||
r.Get("/{filename}", helm.DownloadPackageFile)
|
r.Get("/{filename}", helm.DownloadPackageFile)
|
||||||
r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), helm.UploadPackage)
|
r.Post("/api/charts", reqPackageAccess(perm.AccessModeWrite), helm.UploadPackage)
|
||||||
|
r.Post("/api/prov", reqPackageAccess(perm.AccessModeWrite), helm.UploadProvenanceFile)
|
||||||
}, reqPackageAccess(perm.AccessModeRead))
|
}, reqPackageAccess(perm.AccessModeRead))
|
||||||
r.Group("/maven", func() {
|
r.Group("/maven", func() {
|
||||||
r.Put("/*", reqPackageAccess(perm.AccessModeWrite), maven.UploadPackageFile)
|
r.Put("/*", reqPackageAccess(perm.AccessModeWrite), maven.UploadPackageFile)
|
||||||
|
|||||||
@@ -209,6 +209,76 @@ func UploadPackage(ctx *context.Context) {
|
|||||||
ctx.Status(http.StatusCreated)
|
ctx.Status(http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadProvenanceFile uploads and attaches the provenance file to existing helm chart
|
||||||
|
func UploadProvenanceFile(ctx *context.Context) {
|
||||||
|
upload, needToClose, err := ctx.UploadStream()
|
||||||
|
if err != nil {
|
||||||
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if needToClose {
|
||||||
|
defer upload.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
buf, err := packages_module.CreateHashedBufferFromReader(upload)
|
||||||
|
if err != nil {
|
||||||
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer buf.Close()
|
||||||
|
|
||||||
|
metadata, err := helm_module.ParseProvenanceFile(buf)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, util.ErrInvalidArgument) || errors.Is(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
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = packages_service.AddFileToExistingPackage(
|
||||||
|
ctx,
|
||||||
|
&packages_service.PackageInfo{
|
||||||
|
Owner: ctx.Package.Owner,
|
||||||
|
PackageType: packages_model.TypeHelm,
|
||||||
|
Name: metadata.Name,
|
||||||
|
Version: metadata.Version,
|
||||||
|
},
|
||||||
|
&packages_service.PackageFileCreationInfo{
|
||||||
|
PackageFileInfo: packages_service.PackageFileInfo{
|
||||||
|
Filename: createProvenanceFilename(metadata),
|
||||||
|
},
|
||||||
|
Creator: ctx.Doer,
|
||||||
|
Data: buf,
|
||||||
|
IsLead: false,
|
||||||
|
OverwriteExisting: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case packages_model.ErrPackageNotExist:
|
||||||
|
apiError(ctx, http.StatusNotFound, err)
|
||||||
|
case packages_service.ErrQuotaTotalCount, packages_service.ErrQuotaTypeSize, packages_service.ErrQuotaTotalSize:
|
||||||
|
apiError(ctx, http.StatusForbidden, err)
|
||||||
|
default:
|
||||||
|
apiError(ctx, http.StatusInternalServerError, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
func createFilename(metadata *helm_module.Metadata) string {
|
func createFilename(metadata *helm_module.Metadata) string {
|
||||||
return strings.ToLower(fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
|
return strings.ToLower(fmt.Sprintf("%s-%s.tgz", metadata.Name, metadata.Version))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createProvenanceFilename(metadata *helm_module.Metadata) string {
|
||||||
|
return strings.ToLower(fmt.Sprintf("%s-%s.tgz.prov", metadata.Name, metadata.Version))
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,8 +60,48 @@ dependencies:
|
|||||||
zw.Close()
|
zw.Close()
|
||||||
content := buf.Bytes()
|
content := buf.Bytes()
|
||||||
|
|
||||||
|
// The signature is invalid, but the repository isn't verifying it.
|
||||||
|
provContent := `-----BEGIN PGP SIGNED MESSAGE-----
|
||||||
|
Hash: SHA512
|
||||||
|
|
||||||
|
apiVersion: v2
|
||||||
|
description: ` + packageDescription + `
|
||||||
|
name: ` + packageName + `
|
||||||
|
type: application
|
||||||
|
version: ` + packageVersion + `
|
||||||
|
maintainers:
|
||||||
|
- - name: ` + packageAuthor + `
|
||||||
|
dependencies:
|
||||||
|
- - name: dep1
|
||||||
|
repository: https://example.com/
|
||||||
|
|
||||||
|
...
|
||||||
|
files:
|
||||||
|
` + filename + `: sha256:d31d2f08b885ec696c37c7f7ef106709aaf5e8575b6d3dc5d52112ed29a9cb92
|
||||||
|
-----BEGIN PGP SIGNATURE-----
|
||||||
|
|
||||||
|
wsBcBAEBCgAQBQJdy0ReCRCEO7+YH8GHYgAAfhUIADx3pHHLLINv0MFkiEYpX/Kd
|
||||||
|
nvHFBNps7hXqSocsg0a9Fi1LRAc3OpVh3knjPfHNGOy8+xOdhbqpdnB+5ty8YopI
|
||||||
|
mYMWp6cP/Mwpkt7/gP1ecWFMevicbaFH5AmJCBihBaKJE4R1IX49/wTIaLKiWkv2
|
||||||
|
cR64bmZruQPSW83UTNULtdD7kuTZXeAdTMjAK0NECsCz9/eK5AFggP4CDf7r2zNi
|
||||||
|
hZsNrzloIlBZlGGns6mUOTO42J/+JojnOLIhI3Psd0HBD2bTlsm/rSfty4yZUs7D
|
||||||
|
qtgooNdohoyGSzR5oapd7fEvauRQswJxOA0m0V+u9/eyLR0+JcYB8Udi1prnWf8=
|
||||||
|
=aHfz
|
||||||
|
-----END PGP SIGNATURE-----`
|
||||||
|
|
||||||
url := fmt.Sprintf("/api/packages/%s/helm", user.Name)
|
url := fmt.Sprintf("/api/packages/%s/helm", user.Name)
|
||||||
|
|
||||||
|
t.Run("UploadProvFileWithoutChart", func(t *testing.T) {
|
||||||
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
|
provURL := url + "/api/prov"
|
||||||
|
|
||||||
|
// Attempt to upload provenance file without chart to back it.
|
||||||
|
req := NewRequestWithBody(t, "POST", provURL, bytes.NewReader([]byte(provContent))).
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
MakeRequest(t, req, http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Upload", func(t *testing.T) {
|
t.Run("Upload", func(t *testing.T) {
|
||||||
defer tests.PrintCurrentTest(t)()
|
defer tests.PrintCurrentTest(t)()
|
||||||
|
|
||||||
@@ -95,6 +135,37 @@ dependencies:
|
|||||||
req = NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)).
|
req = NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)).
|
||||||
AddBasicAuth(user.Name)
|
AddBasicAuth(user.Name)
|
||||||
MakeRequest(t, req, http.StatusCreated)
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
provURL := url + "/api/prov"
|
||||||
|
|
||||||
|
// Upload Provenance file
|
||||||
|
req = NewRequestWithBody(t, "POST", provURL, bytes.NewReader([]byte(provContent))).
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
MakeRequest(t, req, http.StatusCreated)
|
||||||
|
|
||||||
|
pvs, err = packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeHelm)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, pvs, 1)
|
||||||
|
|
||||||
|
pfs, err = packages.GetFilesByVersionID(t.Context(), pvs[0].ID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, pfs, 2)
|
||||||
|
|
||||||
|
var provFile *packages.PackageFile
|
||||||
|
for _, pf := range pfs {
|
||||||
|
if pf.Name == filename+".prov" {
|
||||||
|
provFile = pf
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.NotNil(t, provFile)
|
||||||
|
assert.False(t, provFile.IsLead)
|
||||||
|
|
||||||
|
req = NewRequest(t, "GET", fmt.Sprintf("%s/%s.prov", url, filename)).
|
||||||
|
AddBasicAuth(user.Name)
|
||||||
|
resp := MakeRequest(t, req, http.StatusOK)
|
||||||
|
|
||||||
|
assert.Equal(t, provContent, resp.Body.String())
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Download", func(t *testing.T) {
|
t.Run("Download", func(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user