Платформа ЦРНП "Мирокод" для разработки проектов
https://git.mirocod.ru
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
613 lines
18 KiB
613 lines
18 KiB
// Copyright 2021 The Gitea Authors. All rights reserved. |
|
// Use of this source code is governed by a MIT-style |
|
// license that can be found in the LICENSE file. |
|
|
|
package container |
|
|
|
import ( |
|
"errors" |
|
"fmt" |
|
"io" |
|
"net/http" |
|
"net/url" |
|
"regexp" |
|
"strconv" |
|
"strings" |
|
|
|
packages_model "code.gitea.io/gitea/models/packages" |
|
container_model "code.gitea.io/gitea/models/packages/container" |
|
user_model "code.gitea.io/gitea/models/user" |
|
"code.gitea.io/gitea/modules/context" |
|
"code.gitea.io/gitea/modules/json" |
|
"code.gitea.io/gitea/modules/log" |
|
packages_module "code.gitea.io/gitea/modules/packages" |
|
container_module "code.gitea.io/gitea/modules/packages/container" |
|
"code.gitea.io/gitea/modules/packages/container/oci" |
|
"code.gitea.io/gitea/modules/setting" |
|
"code.gitea.io/gitea/routers/api/packages/helper" |
|
packages_service "code.gitea.io/gitea/services/packages" |
|
container_service "code.gitea.io/gitea/services/packages/container" |
|
) |
|
|
|
// maximum size of a container manifest |
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests |
|
const maxManifestSize = 10 * 1024 * 1024 |
|
|
|
var imageNamePattern = regexp.MustCompile(`\A[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*\z`) |
|
|
|
type containerHeaders struct { |
|
Status int |
|
ContentDigest string |
|
UploadUUID string |
|
Range string |
|
Location string |
|
ContentType string |
|
ContentLength int64 |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#legacy-docker-support-http-headers |
|
func setResponseHeaders(resp http.ResponseWriter, h *containerHeaders) { |
|
if h.Location != "" { |
|
resp.Header().Set("Location", h.Location) |
|
} |
|
if h.Range != "" { |
|
resp.Header().Set("Range", h.Range) |
|
} |
|
if h.ContentType != "" { |
|
resp.Header().Set("Content-Type", h.ContentType) |
|
} |
|
if h.ContentLength != 0 { |
|
resp.Header().Set("Content-Length", strconv.FormatInt(h.ContentLength, 10)) |
|
} |
|
if h.UploadUUID != "" { |
|
resp.Header().Set("Docker-Upload-Uuid", h.UploadUUID) |
|
} |
|
if h.ContentDigest != "" { |
|
resp.Header().Set("Docker-Content-Digest", h.ContentDigest) |
|
resp.Header().Set("ETag", fmt.Sprintf(`"%s"`, h.ContentDigest)) |
|
} |
|
resp.Header().Set("Docker-Distribution-Api-Version", "registry/2.0") |
|
resp.WriteHeader(h.Status) |
|
} |
|
|
|
func jsonResponse(ctx *context.Context, status int, obj interface{}) { |
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Status: status, |
|
ContentType: "application/json", |
|
}) |
|
if err := json.NewEncoder(ctx.Resp).Encode(obj); err != nil { |
|
log.Error("JSON encode: %v", err) |
|
} |
|
} |
|
|
|
func apiError(ctx *context.Context, status int, err error) { |
|
helper.LogAndProcessError(ctx, status, err, func(message string) { |
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Status: status, |
|
}) |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#error-codes |
|
func apiErrorDefined(ctx *context.Context, err *namedError) { |
|
type ContainerError struct { |
|
Code string `json:"code"` |
|
Message string `json:"message"` |
|
} |
|
|
|
type ContainerErrors struct { |
|
Errors []ContainerError `json:"errors"` |
|
} |
|
|
|
jsonResponse(ctx, err.StatusCode, ContainerErrors{ |
|
Errors: []ContainerError{ |
|
{ |
|
Code: err.Code, |
|
Message: err.Message, |
|
}, |
|
}, |
|
}) |
|
} |
|
|
|
// ReqContainerAccess is a middleware which checks the current user valid (real user or ghost for anonymous access) |
|
func ReqContainerAccess(ctx *context.Context) { |
|
if ctx.Doer == nil { |
|
ctx.Resp.Header().Add("WWW-Authenticate", `Bearer realm="`+setting.AppURL+`v2/token"`) |
|
ctx.Resp.Header().Add("WWW-Authenticate", `Basic`) |
|
apiErrorDefined(ctx, errUnauthorized) |
|
} |
|
} |
|
|
|
// VerifyImageName is a middleware which checks if the image name is allowed |
|
func VerifyImageName(ctx *context.Context) { |
|
if !imageNamePattern.MatchString(ctx.Params("image")) { |
|
apiErrorDefined(ctx, errNameInvalid) |
|
} |
|
} |
|
|
|
// DetermineSupport is used to test if the registry supports OCI |
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support |
|
func DetermineSupport(ctx *context.Context) { |
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Status: http.StatusOK, |
|
}) |
|
} |
|
|
|
// Authenticate creates a token for the current user |
|
// If the current user is anonymous, the ghost user is used |
|
func Authenticate(ctx *context.Context) { |
|
u := ctx.Doer |
|
if u == nil { |
|
u = user_model.NewGhostUser() |
|
} |
|
|
|
token, err := packages_service.CreateAuthorizationToken(u) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
ctx.JSON(http.StatusOK, map[string]string{ |
|
"token": token, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#mounting-a-blob-from-another-repository |
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#single-post |
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks |
|
func InitiateUploadBlob(ctx *context.Context) { |
|
image := ctx.Params("image") |
|
|
|
mount := ctx.FormTrim("mount") |
|
from := ctx.FormTrim("from") |
|
if mount != "" { |
|
blob, _ := container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{ |
|
Image: from, |
|
Digest: mount, |
|
}) |
|
if blob != nil { |
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, mount), |
|
ContentDigest: mount, |
|
Status: http.StatusCreated, |
|
}) |
|
return |
|
} |
|
} |
|
|
|
digest := ctx.FormTrim("digest") |
|
if digest != "" { |
|
buf, err := packages_module.CreateHashedBufferFromReader(ctx.Req.Body, 32*1024*1024) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
defer buf.Close() |
|
|
|
if digest != digestFromHashSummer(buf) { |
|
apiErrorDefined(ctx, errDigestInvalid) |
|
return |
|
} |
|
|
|
if _, err := saveAsPackageBlob(buf, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest), |
|
ContentDigest: digest, |
|
Status: http.StatusCreated, |
|
}) |
|
return |
|
} |
|
|
|
upload, err := packages_model.CreateBlobUpload(ctx) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, upload.ID), |
|
Range: "0-0", |
|
UploadUUID: upload.ID, |
|
Status: http.StatusAccepted, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks |
|
func UploadBlob(ctx *context.Context) { |
|
image := ctx.Params("image") |
|
|
|
uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid")) |
|
if err != nil { |
|
if err == packages_model.ErrPackageBlobUploadNotExist { |
|
apiErrorDefined(ctx, errBlobUploadUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
defer uploader.Close() |
|
|
|
contentRange := ctx.Req.Header.Get("Content-Range") |
|
if contentRange != "" { |
|
start, end := 0, 0 |
|
if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil { |
|
apiErrorDefined(ctx, errBlobUploadInvalid) |
|
return |
|
} |
|
|
|
if int64(start) != uploader.Size() { |
|
apiErrorDefined(ctx, errBlobUploadInvalid.WithStatusCode(http.StatusRequestedRangeNotSatisfiable)) |
|
return |
|
} |
|
} else if uploader.Size() != 0 { |
|
apiErrorDefined(ctx, errBlobUploadInvalid.WithMessage("Stream uploads after first write are not allowed")) |
|
return |
|
} |
|
|
|
if err := uploader.Append(ctx, ctx.Req.Body); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", ctx.Package.Owner.LowerName, image, uploader.ID), |
|
Range: fmt.Sprintf("0-%d", uploader.Size()-1), |
|
UploadUUID: uploader.ID, |
|
Status: http.StatusAccepted, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-a-blob-in-chunks |
|
func EndUploadBlob(ctx *context.Context) { |
|
image := ctx.Params("image") |
|
|
|
digest := ctx.FormTrim("digest") |
|
if digest == "" { |
|
apiErrorDefined(ctx, errDigestInvalid) |
|
return |
|
} |
|
|
|
uploader, err := container_service.NewBlobUploader(ctx, ctx.Params("uuid")) |
|
if err != nil { |
|
if err == packages_model.ErrPackageBlobUploadNotExist { |
|
apiErrorDefined(ctx, errBlobUploadUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
close := true |
|
defer func() { |
|
if close { |
|
uploader.Close() |
|
} |
|
}() |
|
|
|
if ctx.Req.Body != nil { |
|
if err := uploader.Append(ctx, ctx.Req.Body); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
} |
|
|
|
if digest != digestFromHashSummer(uploader) { |
|
apiErrorDefined(ctx, errDigestInvalid) |
|
return |
|
} |
|
|
|
if _, err := saveAsPackageBlob(uploader, &packages_service.PackageInfo{Owner: ctx.Package.Owner, Name: image}); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
if err := uploader.Close(); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
close = false |
|
|
|
if err := container_service.RemoveBlobUploadByID(ctx, uploader.ID); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Location: fmt.Sprintf("/v2/%s/%s/blobs/%s", ctx.Package.Owner.LowerName, image, digest), |
|
ContentDigest: digest, |
|
Status: http.StatusCreated, |
|
}) |
|
} |
|
|
|
func getBlobFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) { |
|
digest := ctx.Params("digest") |
|
|
|
if !oci.Digest(digest).Validate() { |
|
return nil, container_model.ErrContainerBlobNotExist |
|
} |
|
|
|
return container_model.GetContainerBlob(ctx, &container_model.BlobSearchOptions{ |
|
OwnerID: ctx.Package.Owner.ID, |
|
Image: ctx.Params("image"), |
|
Digest: digest, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry |
|
func HeadBlob(ctx *context.Context) { |
|
blob, err := getBlobFromContext(ctx) |
|
if err != nil { |
|
if err == container_model.ErrContainerBlobNotExist { |
|
apiErrorDefined(ctx, errBlobUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest), |
|
ContentLength: blob.Blob.Size, |
|
Status: http.StatusOK, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-blobs |
|
func GetBlob(ctx *context.Context) { |
|
blob, err := getBlobFromContext(ctx) |
|
if err != nil { |
|
if err == container_model.ErrContainerBlobNotExist { |
|
apiErrorDefined(ctx, errBlobUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
|
|
s, _, err := packages_service.GetPackageFileStream(ctx, blob.File) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
defer s.Close() |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
ContentDigest: blob.Properties.GetByName(container_module.PropertyDigest), |
|
ContentType: blob.Properties.GetByName(container_module.PropertyMediaType), |
|
ContentLength: blob.Blob.Size, |
|
Status: http.StatusOK, |
|
}) |
|
if _, err := io.Copy(ctx.Resp, s); err != nil { |
|
log.Error("Error whilst copying content to response: %v", err) |
|
} |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-blobs |
|
func DeleteBlob(ctx *context.Context) { |
|
digest := ctx.Params("digest") |
|
|
|
if !oci.Digest(digest).Validate() { |
|
apiErrorDefined(ctx, errBlobUnknown) |
|
return |
|
} |
|
|
|
if err := deleteBlob(ctx.Package.Owner.ID, ctx.Params("image"), digest); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Status: http.StatusAccepted, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests |
|
func UploadManifest(ctx *context.Context) { |
|
reference := ctx.Params("reference") |
|
|
|
mci := &manifestCreationInfo{ |
|
MediaType: oci.MediaType(ctx.Req.Header.Get("Content-Type")), |
|
Owner: ctx.Package.Owner, |
|
Creator: ctx.Doer, |
|
Image: ctx.Params("image"), |
|
Reference: reference, |
|
IsTagged: !oci.Digest(reference).Validate(), |
|
} |
|
|
|
if mci.IsTagged && !oci.Reference(reference).Validate() { |
|
apiErrorDefined(ctx, errManifestInvalid.WithMessage("Tag is invalid")) |
|
return |
|
} |
|
|
|
maxSize := maxManifestSize + 1 |
|
buf, err := packages_module.CreateHashedBufferFromReader(&io.LimitedReader{R: ctx.Req.Body, N: int64(maxSize)}, maxSize) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
defer buf.Close() |
|
|
|
if buf.Size() > maxManifestSize { |
|
apiErrorDefined(ctx, errManifestInvalid.WithMessage("Manifest exceeds maximum size").WithStatusCode(http.StatusRequestEntityTooLarge)) |
|
return |
|
} |
|
|
|
digest, err := processManifest(mci, buf) |
|
if err != nil { |
|
var namedError *namedError |
|
if errors.As(err, &namedError) { |
|
apiErrorDefined(ctx, namedError) |
|
} else if errors.Is(err, container_model.ErrContainerBlobNotExist) { |
|
apiErrorDefined(ctx, errBlobUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Location: fmt.Sprintf("/v2/%s/%s/manifests/%s", ctx.Package.Owner.LowerName, mci.Image, reference), |
|
ContentDigest: digest, |
|
Status: http.StatusCreated, |
|
}) |
|
} |
|
|
|
func getManifestFromContext(ctx *context.Context) (*packages_model.PackageFileDescriptor, error) { |
|
reference := ctx.Params("reference") |
|
|
|
opts := &container_model.BlobSearchOptions{ |
|
OwnerID: ctx.Package.Owner.ID, |
|
Image: ctx.Params("image"), |
|
IsManifest: true, |
|
} |
|
if oci.Digest(reference).Validate() { |
|
opts.Digest = reference |
|
} else if oci.Reference(reference).Validate() { |
|
opts.Tag = reference |
|
} else { |
|
return nil, container_model.ErrContainerBlobNotExist |
|
} |
|
|
|
return container_model.GetContainerBlob(ctx, opts) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#checking-if-content-exists-in-the-registry |
|
func HeadManifest(ctx *context.Context) { |
|
manifest, err := getManifestFromContext(ctx) |
|
if err != nil { |
|
if err == container_model.ErrContainerBlobNotExist { |
|
apiErrorDefined(ctx, errManifestUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest), |
|
ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType), |
|
ContentLength: manifest.Blob.Size, |
|
Status: http.StatusOK, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests |
|
func GetManifest(ctx *context.Context) { |
|
manifest, err := getManifestFromContext(ctx) |
|
if err != nil { |
|
if err == container_model.ErrContainerBlobNotExist { |
|
apiErrorDefined(ctx, errManifestUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
|
|
s, _, err := packages_service.GetPackageFileStream(ctx, manifest.File) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
defer s.Close() |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
ContentDigest: manifest.Properties.GetByName(container_module.PropertyDigest), |
|
ContentType: manifest.Properties.GetByName(container_module.PropertyMediaType), |
|
ContentLength: manifest.Blob.Size, |
|
Status: http.StatusOK, |
|
}) |
|
if _, err := io.Copy(ctx.Resp, s); err != nil { |
|
log.Error("Error whilst copying content to response: %v", err) |
|
} |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-tags |
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#deleting-manifests |
|
func DeleteManifest(ctx *context.Context) { |
|
reference := ctx.Params("reference") |
|
|
|
opts := &container_model.BlobSearchOptions{ |
|
OwnerID: ctx.Package.Owner.ID, |
|
Image: ctx.Params("image"), |
|
IsManifest: true, |
|
} |
|
if oci.Digest(reference).Validate() { |
|
opts.Digest = reference |
|
} else if oci.Reference(reference).Validate() { |
|
opts.Tag = reference |
|
} else { |
|
apiErrorDefined(ctx, errManifestUnknown) |
|
return |
|
} |
|
|
|
pvs, err := container_model.GetManifestVersions(ctx, opts) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
if len(pvs) == 0 { |
|
apiErrorDefined(ctx, errManifestUnknown) |
|
return |
|
} |
|
|
|
for _, pv := range pvs { |
|
if err := packages_service.RemovePackageVersion(ctx.Doer, pv); err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
} |
|
|
|
setResponseHeaders(ctx.Resp, &containerHeaders{ |
|
Status: http.StatusAccepted, |
|
}) |
|
} |
|
|
|
// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery |
|
func GetTagList(ctx *context.Context) { |
|
image := ctx.Params("image") |
|
|
|
if _, err := packages_model.GetPackageByName(ctx, ctx.Package.Owner.ID, packages_model.TypeContainer, image); err != nil { |
|
if err == packages_model.ErrPackageNotExist { |
|
apiErrorDefined(ctx, errNameUnknown) |
|
} else { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
} |
|
return |
|
} |
|
|
|
n := -1 |
|
if ctx.FormTrim("n") != "" { |
|
n = ctx.FormInt("n") |
|
} |
|
last := ctx.FormTrim("last") |
|
|
|
tags, err := container_model.GetImageTags(ctx, ctx.Package.Owner.ID, image, n, last) |
|
if err != nil { |
|
apiError(ctx, http.StatusInternalServerError, err) |
|
return |
|
} |
|
|
|
type TagList struct { |
|
Name string `json:"name"` |
|
Tags []string `json:"tags"` |
|
} |
|
|
|
if len(tags) > 0 { |
|
v := url.Values{} |
|
if n > 0 { |
|
v.Add("n", strconv.Itoa(n)) |
|
} |
|
v.Add("last", tags[len(tags)-1]) |
|
|
|
ctx.Resp.Header().Set("Link", fmt.Sprintf(`</v2/%s/%s/tags/list?%s>; rel="next"`, ctx.Package.Owner.LowerName, image, v.Encode())) |
|
} |
|
|
|
jsonResponse(ctx, http.StatusOK, TagList{ |
|
Name: strings.ToLower(ctx.Package.Owner.LowerName + "/" + image), |
|
Tags: tags, |
|
}) |
|
}
|
|
|