mirror of
https://github.com/goharbor/harbor.git
synced 2024-11-22 18:25:56 +01:00
feat(quota,notification): notification for quota exceeded and warning (#11123)
Signed-off-by: He Weiwei <hweiwei@vmware.com>
This commit is contained in:
parent
1d435bc246
commit
fe39bb6a2a
@ -1,11 +1,12 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
event2 "github.com/goharbor/harbor/src/api/event"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/pkg/notifier/event"
|
||||
"github.com/pkg/errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QuotaMetaData defines quota related event data
|
||||
@ -24,18 +25,6 @@ type QuotaMetaData struct {
|
||||
// Resolve quota exceed into common image event
|
||||
func (q *QuotaMetaData) Resolve(evt *event.Event) error {
|
||||
var topic string
|
||||
data := &event2.QuotaEvent{
|
||||
EventType: event2.TopicQuotaExceed,
|
||||
Project: q.Project,
|
||||
Resource: &event2.ImgResource{
|
||||
Tag: q.Tag,
|
||||
Digest: q.Digest,
|
||||
},
|
||||
OccurAt: q.OccurAt,
|
||||
RepoName: q.RepoName,
|
||||
Msg: q.Msg,
|
||||
}
|
||||
|
||||
switch q.Level {
|
||||
case 1:
|
||||
topic = event2.TopicQuotaExceed
|
||||
@ -46,6 +35,16 @@ func (q *QuotaMetaData) Resolve(evt *event.Event) error {
|
||||
}
|
||||
|
||||
evt.Topic = topic
|
||||
evt.Data = data
|
||||
evt.Data = &event2.QuotaEvent{
|
||||
EventType: topic,
|
||||
Project: q.Project,
|
||||
Resource: &event2.ImgResource{
|
||||
Tag: q.Tag,
|
||||
Digest: q.Digest,
|
||||
},
|
||||
OccurAt: q.OccurAt,
|
||||
RepoName: q.RepoName,
|
||||
Msg: q.Msg,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -16,11 +16,12 @@ package event
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/pkg/artifact"
|
||||
"github.com/goharbor/harbor/src/pkg/audit/model"
|
||||
v1 "github.com/goharbor/harbor/src/pkg/scan/rest/v1"
|
||||
"time"
|
||||
)
|
||||
|
||||
// the event consumers can refer to this file to find all topics and the corresponding event structures
|
||||
@ -38,7 +39,7 @@ const (
|
||||
TopicScanningFailed = "SCANNING_FAILED"
|
||||
TopicScanningCompleted = "SCANNING_COMPLETED"
|
||||
// QuotaExceedTopic is topic for quota warning event, the usage reaches the warning bar of limitation, like 85%
|
||||
TopicQuotaWarning = "QUOTA_WARNNING"
|
||||
TopicQuotaWarning = "QUOTA_WARNING"
|
||||
TopicQuotaExceed = "QUOTA_EXCEED"
|
||||
TopicUploadChart = "UPLOAD_CHART"
|
||||
TopicDownloadChart = "DOWNLOAD_CHART"
|
||||
|
@ -167,9 +167,8 @@ func (c *controller) reserveResources(ctx context.Context, reference, referenceI
|
||||
|
||||
newReserved := types.Add(reserved, resources)
|
||||
|
||||
newUsed := types.Add(used, newReserved)
|
||||
if err := quota.IsSafe(hardLimits, used, newUsed, false); err != nil {
|
||||
return ierror.DeniedError(nil).WithMessage("Quota exceeded when processing the request of %v", err)
|
||||
if err := quota.IsSafe(hardLimits, types.Add(used, reserved), types.Add(used, newReserved), false); err != nil {
|
||||
return ierror.DeniedError(err).WithMessage("Quota exceeded when processing the request of %v", err)
|
||||
}
|
||||
|
||||
if err := c.setReservedResources(ctx, reference, referenceID, newReserved); err != nil {
|
||||
|
@ -3,16 +3,15 @@ package api
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/goharbor/harbor/src/api/event"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
|
||||
"github.com/goharbor/harbor/src/api/event"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/common/rbac"
|
||||
"github.com/goharbor/harbor/src/common/utils"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
)
|
||||
|
||||
@ -373,15 +372,24 @@ func getLastTriggerTimeGroupByEventType(eventType string, policyID int64) (time.
|
||||
}
|
||||
|
||||
func initSupportedEvents() map[string]struct{} {
|
||||
var supportedEventTypes = make(map[string]struct{})
|
||||
eventTypes := []string{event.TopicPushArtifact, event.TopicPullArtifact,
|
||||
event.TopicDeleteArtifact, event.TopicUploadChart, event.TopicDeleteChart,
|
||||
event.TopicDownloadChart, event.TopicQuotaExceed, event.TopicScanningFailed,
|
||||
event.TopicScanningCompleted}
|
||||
eventTypes := []string{
|
||||
event.TopicPushArtifact,
|
||||
event.TopicPullArtifact,
|
||||
event.TopicDeleteArtifact,
|
||||
event.TopicUploadChart,
|
||||
event.TopicDeleteChart,
|
||||
event.TopicDownloadChart,
|
||||
event.TopicQuotaExceed,
|
||||
event.TopicQuotaWarning,
|
||||
event.TopicScanningFailed,
|
||||
event.TopicScanningCompleted,
|
||||
}
|
||||
|
||||
var supportedEventTypes = make(map[string]struct{})
|
||||
for _, eventType := range eventTypes {
|
||||
supportedEventTypes[eventType] = struct{}{}
|
||||
}
|
||||
|
||||
return supportedEventTypes
|
||||
}
|
||||
|
||||
|
@ -46,7 +46,7 @@ var (
|
||||
|
||||
var (
|
||||
name = fmt.Sprintf("(?P<name>%s)", ref.NameRegexp)
|
||||
reference = fmt.Sprintf("(?P<reference>(%s|%s))", ref.TagRegexp, ref.DigestRegexp)
|
||||
reference = fmt.Sprintf("(?P<reference>((%s)|(%s)))", ref.DigestRegexp, ref.TagRegexp)
|
||||
sessionID = "(?P<session_id>[a-zA-Z0-9-_.=]+)"
|
||||
|
||||
// BlobUploadURLRegexp regexp which match blob upload url
|
||||
@ -74,6 +74,16 @@ func ParseName(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseReference returns digest or tag from distribution API URL path
|
||||
func ParseReference(path string) string {
|
||||
m := utils.FindNamedMatches(ManifestURLRegexp, path)
|
||||
if len(m) > 0 {
|
||||
return m["reference"]
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParseProjectName returns project name from distribution API URL path
|
||||
func ParseProjectName(path string) string {
|
||||
projectName, _ := utils.ParseRepository(ParseName(path))
|
||||
@ -109,3 +119,8 @@ func ParseRef(s string) (string, string, error) {
|
||||
|
||||
return repository, reference, nil
|
||||
}
|
||||
|
||||
// IsDigest returns true when reference is digest
|
||||
func IsDigest(reference string) bool {
|
||||
return ref.DigestRegexp.MatchString(reference)
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/docker/distribution/manifest/manifestlist"
|
||||
_ "github.com/docker/distribution/manifest/ocischema"
|
||||
_ "github.com/docker/distribution/manifest/schema1"
|
||||
_ "github.com/docker/distribution/manifest/schema2"
|
||||
)
|
||||
@ -96,3 +97,24 @@ func TestParseProjectName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReference(t *testing.T) {
|
||||
type args struct {
|
||||
path string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
}{
|
||||
{"tag", args{"/v2/library/photon/manifests/2.0"}, "2.0"},
|
||||
{"digest", args{"/v2/library/photon/manifests/sha256:c52fca2e807cb7807cfd831d6df45a332d5826a97f886f7da0e9c61842f9ce1e"}, "sha256:c52fca2e807cb7807cfd831d6df45a332d5826a97f886f7da0e9c61842f9ce1e"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ParseReference(tt.args.path); got != tt.want {
|
||||
t.Errorf("ParseReference() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package notification
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/pkg/notification/hook"
|
||||
"github.com/goharbor/harbor/src/pkg/notification/job"
|
||||
@ -59,6 +60,14 @@ type EventCtx struct {
|
||||
MustNotify bool
|
||||
}
|
||||
|
||||
// NewEventCtx returns instance of EventCtx
|
||||
func NewEventCtx() *EventCtx {
|
||||
return &EventCtx{
|
||||
Events: list.New(),
|
||||
MustNotify: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewContext returns new context with event
|
||||
func NewContext(ctx context.Context, ec *EventCtx) context.Context {
|
||||
if ctx == nil {
|
||||
@ -69,6 +78,10 @@ func NewContext(ctx context.Context, ec *EventCtx) context.Context {
|
||||
|
||||
// AddEvent add events into request context, the event will be sent by the notification middleware eventually.
|
||||
func AddEvent(ctx context.Context, m n_event.Metadata, notify ...bool) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e, ok := ctx.Value(eventKey{}).(*EventCtx)
|
||||
if !ok {
|
||||
log.Debug("request has not event list, cannot add event into context")
|
||||
|
@ -63,6 +63,22 @@ func (errs Errors) Error() string {
|
||||
return strings.Join(errors, "; ")
|
||||
}
|
||||
|
||||
// Exceeded returns exceeded errors from errs
|
||||
func (errs Errors) Exceeded() error {
|
||||
var exceeded Errors
|
||||
for _, err := range errs.GetErrors() {
|
||||
if _, ok := err.(*ResourceOverflow); ok {
|
||||
exceeded = exceeded.Add(err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(exceeded) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return exceeded
|
||||
}
|
||||
|
||||
// ResourceOverflow ...
|
||||
type ResourceOverflow struct {
|
||||
Resource types.ResourceName
|
||||
|
@ -16,6 +16,7 @@ package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/goharbor/harbor/src/pkg/quota/driver"
|
||||
@ -86,3 +87,39 @@ func (q *Quota) SetUsed(used types.ResourceList) *Quota {
|
||||
|
||||
return q
|
||||
}
|
||||
|
||||
// GetWarningResources returns resource names which exceeded the warning percent
|
||||
func (q *Quota) GetWarningResources(warningPercent int) ([]types.ResourceName, error) {
|
||||
if warningPercent < 0 || warningPercent > 100 {
|
||||
return nil, fmt.Errorf("bad warningPercent")
|
||||
}
|
||||
|
||||
hardLimits, err := q.GetHard()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usage, err := q.GetUsed()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resources []types.ResourceName
|
||||
for resource, used := range usage {
|
||||
limited, ok := hardLimits[resource]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("resource %s not found in hard limits", resource)
|
||||
}
|
||||
|
||||
if limited == types.UNLIMITED {
|
||||
continue
|
||||
}
|
||||
|
||||
// used / limited >= warningPercent / 100
|
||||
if used*100 >= limited*int64(warningPercent) {
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
35
src/pkg/quota/models/quota_test.go
Normal file
35
src/pkg/quota/models/quota_test.go
Normal file
@ -0,0 +1,35 @@
|
||||
// Copyright Project Harbor Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/pkg/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetWarningResources(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
q := Quota{}
|
||||
|
||||
q.SetHard(types.ResourceList{types.ResourceCount: 3})
|
||||
q.SetUsed(types.ResourceList{types.ResourceCount: 3})
|
||||
|
||||
resources, err := q.GetWarningResources(85)
|
||||
assert.Nil(err)
|
||||
assert.Len(resources, 1)
|
||||
}
|
@ -15,38 +15,24 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
"github.com/goharbor/harbor/src/server/middleware"
|
||||
"net/http"
|
||||
|
||||
"github.com/goharbor/harbor/src/internal"
|
||||
evt "github.com/goharbor/harbor/src/pkg/notifier/event"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
"github.com/goharbor/harbor/src/pkg/notifier/event"
|
||||
"github.com/goharbor/harbor/src/server/middleware"
|
||||
)
|
||||
|
||||
// publishEvent publishes the events in the context, it ensures publish happens after transaction success.
|
||||
func publishEvent(es *list.List) {
|
||||
if es == nil {
|
||||
return
|
||||
}
|
||||
for e := es.Front(); e != nil; e = e.Next() {
|
||||
evt.BuildAndPublish(e.Value.(evt.Metadata))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Middleware sends the notification after transaction success
|
||||
func Middleware(skippers ...middleware.Skipper) func(http.Handler) http.Handler {
|
||||
return middleware.New(func(w http.ResponseWriter, r *http.Request, next http.Handler) {
|
||||
res := internal.NewResponseRecorder(w)
|
||||
eveCtx := ¬ification.EventCtx{
|
||||
Events: list.New(),
|
||||
MustNotify: false,
|
||||
}
|
||||
ctx := notification.NewContext(r.Context(), eveCtx)
|
||||
next.ServeHTTP(res, r.WithContext(ctx))
|
||||
if res.Success() || eveCtx.MustNotify {
|
||||
publishEvent(eveCtx.Events)
|
||||
evc := notification.NewEventCtx()
|
||||
next.ServeHTTP(res, r.WithContext(notification.NewContext(r.Context(), evc)))
|
||||
if res.Success() || evc.MustNotify {
|
||||
for e := evc.Events.Front(); e != nil; e = e.Next() {
|
||||
event.BuildAndPublish(e.Value.(event.Metadata))
|
||||
}
|
||||
}
|
||||
}, skippers...)
|
||||
}
|
||||
|
@ -1,28 +1,43 @@
|
||||
// Copyright Project Harbor Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package notification
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/goharbor/harbor/src/api/event/metadata"
|
||||
pkg_art "github.com/goharbor/harbor/src/pkg/artifact"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/api/event/metadata"
|
||||
"github.com/goharbor/harbor/src/pkg/artifact"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type NotificatoinMiddlewareTestSuite struct {
|
||||
type NotificationMiddlewareTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (suite *NotificatoinMiddlewareTestSuite) TestMiddleware() {
|
||||
func (suite *NotificationMiddlewareTestSuite) TestMiddleware() {
|
||||
next := func() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
notification.AddEvent(r.Context(), &metadata.DeleteArtifactEventMetadata{
|
||||
Ctx: context.Background(),
|
||||
Artifact: &pkg_art.Artifact{
|
||||
Artifact: &artifact.Artifact{
|
||||
ProjectID: 1,
|
||||
RepositoryID: 2,
|
||||
RepositoryName: "library/hello-world",
|
||||
@ -38,13 +53,13 @@ func (suite *NotificatoinMiddlewareTestSuite) TestMiddleware() {
|
||||
suite.Equal(http.StatusAccepted, res.Code)
|
||||
}
|
||||
|
||||
func (suite *NotificatoinMiddlewareTestSuite) TestMiddlewareMustNotify() {
|
||||
func (suite *NotificationMiddlewareTestSuite) TestMiddlewareMustNotify() {
|
||||
next := func() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
notification.AddEvent(r.Context(), &metadata.DeleteArtifactEventMetadata{
|
||||
Ctx: context.Background(),
|
||||
Artifact: &pkg_art.Artifact{
|
||||
Artifact: &artifact.Artifact{
|
||||
ProjectID: 1,
|
||||
RepositoryID: 2,
|
||||
RepositoryName: "library/hello-world",
|
||||
@ -60,6 +75,6 @@ func (suite *NotificatoinMiddlewareTestSuite) TestMiddlewareMustNotify() {
|
||||
suite.Equal(http.StatusInternalServerError, res.Code)
|
||||
}
|
||||
|
||||
func TestNotificatoinMiddlewareTestSuite(t *testing.T) {
|
||||
suite.Run(t, &NotificatoinMiddlewareTestSuite{})
|
||||
func TestNotificationMiddlewareTestSuite(t *testing.T) {
|
||||
suite.Run(t, &NotificationMiddlewareTestSuite{})
|
||||
}
|
||||
|
@ -33,12 +33,15 @@ import (
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goharbor/harbor/src/api/artifact"
|
||||
"github.com/goharbor/harbor/src/api/event/metadata"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
ierror "github.com/goharbor/harbor/src/internal/error"
|
||||
"github.com/goharbor/harbor/src/pkg/blob"
|
||||
"github.com/goharbor/harbor/src/pkg/distribution"
|
||||
"github.com/goharbor/harbor/src/pkg/notifier/event"
|
||||
"github.com/goharbor/harbor/src/pkg/q"
|
||||
"github.com/goharbor/harbor/src/pkg/types"
|
||||
)
|
||||
@ -46,8 +49,10 @@ import (
|
||||
// CopyArtifactMiddleware middleware to request count and storage resources for copy artifact API
|
||||
func CopyArtifactMiddleware() func(http.Handler) http.Handler {
|
||||
return RequestMiddleware(RequestConfig{
|
||||
ReferenceObject: projectReferenceObject,
|
||||
Resources: copyArtifactResources,
|
||||
ReferenceObject: projectReferenceObject,
|
||||
Resources: copyArtifactResources,
|
||||
ResourcesExceeded: copyArtifactResourcesEvent(1),
|
||||
ResourcesWarning: copyArtifactResourcesEvent(2),
|
||||
})
|
||||
}
|
||||
|
||||
@ -140,3 +145,51 @@ func copyArtifactResources(r *http.Request, reference, referenceID string) (type
|
||||
|
||||
return types.ResourceList{types.ResourceCount: copyCount, types.ResourceStorage: size}, nil
|
||||
}
|
||||
|
||||
func copyArtifactResourcesEvent(level int) func(*http.Request, string, string, string) event.Metadata {
|
||||
return func(r *http.Request, reference, referenceID string, message string) event.Metadata {
|
||||
ctx := r.Context()
|
||||
|
||||
logger := log.G(ctx).WithFields(log.Fields{"middleware": "quota", "action": "request", "url": r.URL.Path})
|
||||
|
||||
query := r.URL.Query()
|
||||
from := query.Get("from")
|
||||
if from == "" {
|
||||
// this will never be happened
|
||||
return nil
|
||||
}
|
||||
|
||||
repository, reference, err := distribution.ParseRef(from)
|
||||
if err != nil {
|
||||
// this will never be happened
|
||||
return nil
|
||||
}
|
||||
|
||||
art, err := artifactController.GetByReference(ctx, repository, reference, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("get artifact %s failed, error: %v", from, err)
|
||||
}
|
||||
|
||||
projectID, _ := strconv.ParseInt(referenceID, 10, 64)
|
||||
project, err := projectController.Get(ctx, projectID)
|
||||
if err != nil {
|
||||
logger.Errorf("get artifact %s failed, error: %v", from, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var tag string
|
||||
if distribution.IsDigest(reference) {
|
||||
tag = reference
|
||||
}
|
||||
|
||||
return &metadata.QuotaMetaData{
|
||||
Project: project,
|
||||
Tag: tag,
|
||||
Digest: art.Digest,
|
||||
RepoName: parseRepositoryName(r.URL.EscapedPath()),
|
||||
Level: level,
|
||||
Msg: message,
|
||||
OccurAt: time.Now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +28,19 @@
|
||||
|
||||
package quota
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/api/artifact"
|
||||
commonmodels "github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
"github.com/goharbor/harbor/src/pkg/quota"
|
||||
"github.com/goharbor/harbor/src/pkg/types"
|
||||
"github.com/goharbor/harbor/src/testing/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
func Test_parseRepositoryName(t *testing.T) {
|
||||
type args struct {
|
||||
@ -52,3 +64,76 @@ func Test_parseRepositoryName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type CopyArtifactMiddlewareTestSuite struct {
|
||||
RequestMiddlewareTestSuite
|
||||
|
||||
artifact *artifact.Artifact
|
||||
}
|
||||
|
||||
func (suite *CopyArtifactMiddlewareTestSuite) SetupTest() {
|
||||
suite.RequestMiddlewareTestSuite.SetupTest()
|
||||
|
||||
mock.OnAnything(suite.quotaController, "IsEnabled").Return(true, nil)
|
||||
|
||||
suite.artifact = &artifact.Artifact{}
|
||||
|
||||
mock.OnAnything(suite.artifactController, "GetByReference").Return(suite.artifact, nil)
|
||||
mock.OnAnything(suite.artifactController, "Walk").Return(nil).Run(func(args mock.Arguments) {
|
||||
walkFn := args.Get(2).(func(*artifact.Artifact) error)
|
||||
walkFn(suite.artifact)
|
||||
})
|
||||
|
||||
mock.OnAnything(suite.projectController, "Get").Return(&commonmodels.Project{}, nil)
|
||||
}
|
||||
|
||||
func (suite *CopyArtifactMiddlewareTestSuite) TestResourcesWarning() {
|
||||
mock.OnAnything(suite.blobController, "List").Return(nil, nil)
|
||||
mock.OnAnything(suite.blobController, "FindMissingAssociationsForProject").Return(nil, nil)
|
||||
mock.OnAnything(suite.quotaController, "Request").Return(nil).Run(func(args mock.Arguments) {
|
||||
f := args.Get(4).(func() error)
|
||||
f()
|
||||
})
|
||||
|
||||
mock.OnAnything(suite.artifactController, "Count").Return(int64(0), nil)
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
{
|
||||
q := "a.Quota{}
|
||||
q.SetHard(types.ResourceList{types.ResourceCount: 100})
|
||||
q.SetUsed(types.ResourceList{types.ResourceCount: 50})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return(q, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0?from=library/photon:2.0.1", nil)
|
||||
eveCtx := notification.NewEventCtx()
|
||||
req = req.WithContext(notification.NewContext(req.Context(), eveCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
CopyArtifactMiddleware()(next).ServeHTTP(rr, req)
|
||||
suite.Equal(http.StatusOK, rr.Code)
|
||||
suite.Equal(0, eveCtx.Events.Len())
|
||||
}
|
||||
|
||||
{
|
||||
q := "a.Quota{}
|
||||
q.SetHard(types.ResourceList{types.ResourceCount: 100})
|
||||
q.SetUsed(types.ResourceList{types.ResourceCount: 85})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return(q, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0?from=library/photon:2.0.1", nil)
|
||||
eveCtx := notification.NewEventCtx()
|
||||
req = req.WithContext(notification.NewContext(req.Context(), eveCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
CopyArtifactMiddleware()(next).ServeHTTP(rr, req)
|
||||
suite.Equal(http.StatusOK, rr.Code)
|
||||
suite.Equal(1, eveCtx.Events.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyArtifactMiddlewareTestSuite(t *testing.T) {
|
||||
suite.Run(t, &CopyArtifactMiddlewareTestSuite{})
|
||||
}
|
||||
|
@ -18,20 +18,25 @@ import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/goharbor/harbor/src/api/blob"
|
||||
"github.com/goharbor/harbor/src/api/event/metadata"
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/internal"
|
||||
"github.com/goharbor/harbor/src/pkg/blob/models"
|
||||
"github.com/goharbor/harbor/src/pkg/distribution"
|
||||
"github.com/goharbor/harbor/src/pkg/notifier/event"
|
||||
"github.com/goharbor/harbor/src/pkg/types"
|
||||
)
|
||||
|
||||
// PutManifestMiddleware middleware to request count and storage resources for the project
|
||||
func PutManifestMiddleware() func(http.Handler) http.Handler {
|
||||
return RequestMiddleware(RequestConfig{
|
||||
ReferenceObject: projectReferenceObject,
|
||||
Resources: putManifestResources,
|
||||
ReferenceObject: projectReferenceObject,
|
||||
Resources: putManifestResources,
|
||||
ResourcesExceeded: putManifestResourcesEvent(1),
|
||||
ResourcesWarning: putManifestResourcesEvent(2),
|
||||
})
|
||||
}
|
||||
|
||||
@ -94,3 +99,42 @@ func putManifestResources(r *http.Request, reference, referenceID string) (types
|
||||
|
||||
return types.ResourceList{types.ResourceCount: 1, types.ResourceStorage: size}, nil
|
||||
}
|
||||
|
||||
func putManifestResourcesEvent(level int) func(*http.Request, string, string, string) event.Metadata {
|
||||
return func(r *http.Request, reference, referenceID string, message string) event.Metadata {
|
||||
ctx := r.Context()
|
||||
|
||||
logger := log.G(ctx).WithFields(log.Fields{"middleware": "quota", "action": "request", "url": r.URL.Path})
|
||||
|
||||
_, descriptor, err := unmarshalManifest(r)
|
||||
if err != nil {
|
||||
logger.Errorf("unmarshal manifest failed, error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
projectID, _ := strconv.ParseInt(referenceID, 10, 64)
|
||||
project, err := projectController.Get(ctx, projectID)
|
||||
if err != nil {
|
||||
logger.Errorf("get project %d failed, error: %v", projectID, err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
path := r.URL.EscapedPath()
|
||||
|
||||
var tag string
|
||||
if ref := distribution.ParseReference(path); !distribution.IsDigest(ref) {
|
||||
tag = ref
|
||||
}
|
||||
|
||||
return &metadata.QuotaMetaData{
|
||||
Project: project,
|
||||
Tag: tag,
|
||||
Digest: descriptor.Digest.String(),
|
||||
RepoName: distribution.ParseName(path),
|
||||
Level: level,
|
||||
Msg: message,
|
||||
OccurAt: time.Now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -21,8 +21,12 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/distribution/manifest/schema2"
|
||||
commonmodels "github.com/goharbor/harbor/src/common/models"
|
||||
ierror "github.com/goharbor/harbor/src/internal/error"
|
||||
"github.com/goharbor/harbor/src/pkg/blob/models"
|
||||
"github.com/goharbor/harbor/src/pkg/distribution"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
"github.com/goharbor/harbor/src/pkg/quota"
|
||||
"github.com/goharbor/harbor/src/pkg/types"
|
||||
"github.com/goharbor/harbor/src/testing/mock"
|
||||
distributiontesting "github.com/goharbor/harbor/src/testing/pkg/distribution"
|
||||
@ -90,6 +94,7 @@ func (suite *PutManifestMiddlewareTestSuite) TestMiddleware() {
|
||||
f := args.Get(4).(func() error)
|
||||
f()
|
||||
})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return("a.Quota{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@ -116,6 +121,7 @@ func (suite *PutManifestMiddlewareTestSuite) TestMiddleware() {
|
||||
f := args.Get(4).(func() error)
|
||||
f()
|
||||
})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return("a.Quota{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@ -142,6 +148,7 @@ func (suite *PutManifestMiddlewareTestSuite) TestMiddleware() {
|
||||
f := args.Get(4).(func() error)
|
||||
f()
|
||||
})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return("a.Quota{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@ -168,6 +175,7 @@ func (suite *PutManifestMiddlewareTestSuite) TestMiddleware() {
|
||||
f := args.Get(4).(func() error)
|
||||
f()
|
||||
})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return("a.Quota{}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
@ -177,6 +185,99 @@ func (suite *PutManifestMiddlewareTestSuite) TestMiddleware() {
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *PutManifestMiddlewareTestSuite) TestResourcesExceeded() {
|
||||
mock.OnAnything(suite.quotaController, "IsEnabled").Return(true, nil)
|
||||
mock.OnAnything(suite.blobController, "Exist").Return(false, nil)
|
||||
mock.OnAnything(suite.blobController, "FindMissingAssociationsForProject").Return(nil, nil)
|
||||
mock.OnAnything(suite.projectController, "Get").Return(&commonmodels.Project{}, nil)
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
{
|
||||
var errs quota.Errors
|
||||
errs = errs.Add(quota.NewResourceOverflowError(types.ResourceCount, 10, 10, 11))
|
||||
errs = errs.Add(quota.NewResourceOverflowError(types.ResourceStorage, 100, 100, 110))
|
||||
mock.OnAnything(suite.quotaController, "Request").Return(errs).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
eveCtx := notification.NewEventCtx()
|
||||
req = req.WithContext(notification.NewContext(req.Context(), eveCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
PutManifestMiddleware()(next).ServeHTTP(rr, req)
|
||||
suite.NotEqual(http.StatusOK, rr.Code)
|
||||
suite.Equal(1, eveCtx.Events.Len())
|
||||
}
|
||||
|
||||
{
|
||||
var errs quota.Errors
|
||||
errs = errs.Add(quota.NewResourceOverflowError(types.ResourceCount, 10, 10, 11))
|
||||
errs = errs.Add(quota.NewResourceOverflowError(types.ResourceStorage, 100, 100, 110))
|
||||
|
||||
err := ierror.DeniedError(errs).WithMessage("Quota exceeded when processing the request of %v", errs)
|
||||
mock.OnAnything(suite.quotaController, "Request").Return(err).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
eveCtx := notification.NewEventCtx()
|
||||
req = req.WithContext(notification.NewContext(req.Context(), eveCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
PutManifestMiddleware()(next).ServeHTTP(rr, req)
|
||||
suite.NotEqual(http.StatusOK, rr.Code)
|
||||
suite.Equal(1, eveCtx.Events.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *PutManifestMiddlewareTestSuite) TestResourcesWarning() {
|
||||
mock.OnAnything(suite.quotaController, "IsEnabled").Return(true, nil)
|
||||
mock.OnAnything(suite.blobController, "Exist").Return(false, nil)
|
||||
mock.OnAnything(suite.blobController, "FindMissingAssociationsForProject").Return(nil, nil)
|
||||
|
||||
mock.OnAnything(suite.quotaController, "Request").Return(nil).Run(func(args mock.Arguments) {
|
||||
f := args.Get(4).(func() error)
|
||||
f()
|
||||
})
|
||||
mock.OnAnything(suite.projectController, "Get").Return(&commonmodels.Project{}, nil)
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
{
|
||||
q := "a.Quota{}
|
||||
q.SetHard(types.ResourceList{types.ResourceCount: 100})
|
||||
q.SetUsed(types.ResourceList{types.ResourceCount: 50})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return(q, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
eveCtx := notification.NewEventCtx()
|
||||
req = req.WithContext(notification.NewContext(req.Context(), eveCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
PutManifestMiddleware()(next).ServeHTTP(rr, req)
|
||||
suite.Equal(http.StatusOK, rr.Code)
|
||||
suite.Equal(0, eveCtx.Events.Len())
|
||||
}
|
||||
|
||||
{
|
||||
q := "a.Quota{}
|
||||
q.SetHard(types.ResourceList{types.ResourceCount: 100})
|
||||
q.SetUsed(types.ResourceList{types.ResourceCount: 85})
|
||||
mock.OnAnything(suite.quotaController, "GetByRef").Return(q, nil).Once()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/v2/library/photon/manifests/2.0", nil)
|
||||
eveCtx := notification.NewEventCtx()
|
||||
req = req.WithContext(notification.NewContext(req.Context(), eveCtx))
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
PutManifestMiddleware()(next).ServeHTTP(rr, req)
|
||||
suite.Equal(http.StatusOK, rr.Code)
|
||||
suite.Equal(1, eveCtx.Events.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutManifestMiddlewareTestSuite(t *testing.T) {
|
||||
suite.Run(t, &PutManifestMiddlewareTestSuite{})
|
||||
}
|
||||
|
@ -18,9 +18,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/goharbor/harbor/src/common/utils/log"
|
||||
"github.com/goharbor/harbor/src/internal"
|
||||
"github.com/goharbor/harbor/src/pkg/notification"
|
||||
"github.com/goharbor/harbor/src/pkg/notifier/event"
|
||||
"github.com/goharbor/harbor/src/pkg/quota"
|
||||
"github.com/goharbor/harbor/src/pkg/types"
|
||||
serror "github.com/goharbor/harbor/src/server/error"
|
||||
"github.com/goharbor/harbor/src/server/middleware"
|
||||
@ -37,10 +41,23 @@ type RequestConfig struct {
|
||||
|
||||
// Resources returns request resources for the reference object
|
||||
Resources func(r *http.Request, reference, referenceID string) (types.ResourceList, error)
|
||||
|
||||
// ResourcesWarningPercent value from 0 to 100
|
||||
ResourcesWarningPercent int
|
||||
|
||||
// ResourcesWarning returns event which will be notified when resources usage exceeded the wanring percent
|
||||
ResourcesWarning func(r *http.Request, reference, referenceID string, message string) event.Metadata
|
||||
|
||||
// ResourcesExceeded returns event which will be notified when resources exceeded the limitation
|
||||
ResourcesExceeded func(r *http.Request, reference, referenceID string, message string) event.Metadata
|
||||
}
|
||||
|
||||
// RequestMiddleware middleware which request resources
|
||||
func RequestMiddleware(config RequestConfig, skippers ...middleware.Skipper) func(http.Handler) http.Handler {
|
||||
if config.ResourcesWarningPercent == 0 {
|
||||
config.ResourcesWarningPercent = 85 // default 85%
|
||||
}
|
||||
|
||||
return middleware.New(func(w http.ResponseWriter, r *http.Request, next http.Handler) {
|
||||
logger := log.G(r.Context()).WithFields(log.Fields{"middleware": "quota", "action": "request", "url": r.URL.Path})
|
||||
|
||||
@ -101,7 +118,54 @@ func RequestMiddleware(config RequestConfig, skippers ...middleware.Skipper) fun
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil && config.ResourcesWarning != nil {
|
||||
tryWarningNotification := func() {
|
||||
q, err := quotaController.GetByRef(r.Context(), reference, referenceID)
|
||||
if err != nil {
|
||||
logger.Warningf("get quota of %s %s failed, error: %v", reference, referenceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
resources, err := q.GetWarningResources(config.ResourcesWarningPercent)
|
||||
if err != nil {
|
||||
logger.Warningf("get warning resources failed, error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(resources) == 0 {
|
||||
logger.Warningf("not warning resources found")
|
||||
return
|
||||
}
|
||||
|
||||
hardLimits, _ := q.GetHard()
|
||||
used, _ := q.GetUsed()
|
||||
|
||||
var parts []string
|
||||
for _, resource := range resources {
|
||||
s := fmt.Sprintf("resource %s used %s of %s",
|
||||
resource, resource.FormatValue(used[resource]), resource.FormatValue(hardLimits[resource]))
|
||||
parts = append(parts, s)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("quota usage reach %d%%: %s", config.ResourcesWarningPercent, strings.Join(parts, "; "))
|
||||
evt := config.ResourcesWarning(r, reference, referenceID, message)
|
||||
notification.AddEvent(r.Context(), evt, true)
|
||||
}
|
||||
|
||||
tryWarningNotification()
|
||||
}
|
||||
|
||||
if err != nil && err != errNonSuccess {
|
||||
if config.ResourcesExceeded != nil {
|
||||
var errs quota.Errors // NOTE: quota.Errors is slice, so we need var here not pointer
|
||||
if errors.As(err, &errs) {
|
||||
if exceeded := errs.Exceeded(); exceeded != nil {
|
||||
evt := config.ResourcesExceeded(r, reference, referenceID, exceeded.Error())
|
||||
notification.AddEvent(r.Context(), evt, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.Reset()
|
||||
serror.SendError(res, err)
|
||||
}
|
||||
|
@ -20,11 +20,13 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/goharbor/harbor/src/api/artifact"
|
||||
"github.com/goharbor/harbor/src/api/blob"
|
||||
"github.com/goharbor/harbor/src/api/project"
|
||||
"github.com/goharbor/harbor/src/api/quota"
|
||||
"github.com/goharbor/harbor/src/common/models"
|
||||
"github.com/goharbor/harbor/src/pkg/types"
|
||||
artifacttesting "github.com/goharbor/harbor/src/testing/api/artifact"
|
||||
blobtesting "github.com/goharbor/harbor/src/testing/api/blob"
|
||||
projecttesting "github.com/goharbor/harbor/src/testing/api/project"
|
||||
quotatesting "github.com/goharbor/harbor/src/testing/api/quota"
|
||||
@ -35,8 +37,11 @@ import (
|
||||
type RequestMiddlewareTestSuite struct {
|
||||
suite.Suite
|
||||
|
||||
originallBlobController blob.Controller
|
||||
blobController *blobtesting.Controller
|
||||
originalArtifactController artifact.Controller
|
||||
artifactController *artifacttesting.Controller
|
||||
|
||||
originalBlobController blob.Controller
|
||||
blobController *blobtesting.Controller
|
||||
|
||||
originalProjectController project.Controller
|
||||
projectController *projecttesting.Controller
|
||||
@ -46,7 +51,11 @@ type RequestMiddlewareTestSuite struct {
|
||||
}
|
||||
|
||||
func (suite *RequestMiddlewareTestSuite) SetupTest() {
|
||||
suite.originallBlobController = blobController
|
||||
suite.originalArtifactController = artifactController
|
||||
suite.artifactController = &artifacttesting.Controller{}
|
||||
artifactController = suite.artifactController
|
||||
|
||||
suite.originalBlobController = blobController
|
||||
suite.blobController = &blobtesting.Controller{}
|
||||
blobController = suite.blobController
|
||||
|
||||
@ -62,7 +71,8 @@ func (suite *RequestMiddlewareTestSuite) SetupTest() {
|
||||
}
|
||||
|
||||
func (suite *RequestMiddlewareTestSuite) TearDownTest() {
|
||||
blobController = suite.originallBlobController
|
||||
artifactController = suite.originalArtifactController
|
||||
blobController = suite.originalBlobController
|
||||
projectController = suite.originalProjectController
|
||||
quotaController = suite.originallQuotaController
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user