Skip to content

Commit 9a9a914

Browse files
committed
add clusterquota projection for associated projects
1 parent 9ca1579 commit 9a9a914

32 files changed

+1130
-183
lines changed

api/swagger-spec/oapi-v1.json

+431-158
Large diffs are not rendered by default.

hack/verify-govet.sh

+1
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ DIR_BLACKLIST='./hack
4848
./pkg/image
4949
./pkg/oauth
5050
./pkg/project
51+
./pkg/quota
5152
./pkg/router
5253
./pkg/security
5354
./pkg/serviceaccounts

pkg/api/validation/coverage_test.go

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
buildapi "github.com/openshift/origin/pkg/build/api"
1313
deployapi "github.com/openshift/origin/pkg/deploy/api"
1414
imageapi "github.com/openshift/origin/pkg/image/api"
15+
quotaapi "github.com/openshift/origin/pkg/quota/api"
1516
)
1617

1718
// KnownValidationExceptions is the list of API types that do NOT have corresponding validation
@@ -25,6 +26,7 @@ var KnownValidationExceptions = []reflect.Type{
2526
reflect.TypeOf(&authorizationapi.IsPersonalSubjectAccessReview{}), // only an api type for runtime.EmbeddedObject, never accepted
2627
reflect.TypeOf(&authorizationapi.SubjectAccessReviewResponse{}), // this object is only returned, never accepted
2728
reflect.TypeOf(&authorizationapi.ResourceAccessReviewResponse{}), // this object is only returned, never accepted
29+
reflect.TypeOf(&quotaapi.AppliedClusterResourceQuota{}), // this object is only returned, never accepted
2830
}
2931

3032
// MissingValidationExceptions is the list of types that were missing validation methods when I started

pkg/api/validation/register.go

-1
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,4 @@ func registerAll() {
9898
Validator.MustRegister(&securityapi.PodSecurityPolicyReview{}, securityvalidation.ValidatePodSecurityPolicyReview, nil)
9999

100100
Validator.MustRegister(&quotaapi.ClusterResourceQuota{}, quotavalidation.ValidateClusterResourceQuota, quotavalidation.ValidateClusterResourceQuotaUpdate)
101-
102101
}
+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package client
2+
3+
import (
4+
kapi "k8s.io/kubernetes/pkg/api"
5+
6+
quotaapi "github.com/openshift/origin/pkg/quota/api"
7+
)
8+
9+
// AppliedClusterResourceQuotasNamespacer has methods to work with AppliedClusterResourceQuota resources in a namespace
10+
type AppliedClusterResourceQuotasNamespacer interface {
11+
AppliedClusterResourceQuotas(namespace string) AppliedClusterResourceQuotaInterface
12+
}
13+
14+
// AppliedClusterResourceQuotaInterface exposes methods on AppliedClusterResourceQuota resources.
15+
type AppliedClusterResourceQuotaInterface interface {
16+
List(opts kapi.ListOptions) (*quotaapi.AppliedClusterResourceQuotaList, error)
17+
Get(name string) (*quotaapi.AppliedClusterResourceQuota, error)
18+
}
19+
20+
// appliedClusterResourceQuotas implements AppliedClusterResourceQuotasNamespacer interface
21+
type appliedClusterResourceQuotas struct {
22+
r *Client
23+
ns string
24+
}
25+
26+
// newAppliedClusterResourceQuotas returns a appliedClusterResourceQuotas
27+
func newAppliedClusterResourceQuotas(c *Client, namespace string) *appliedClusterResourceQuotas {
28+
return &appliedClusterResourceQuotas{
29+
r: c,
30+
ns: namespace,
31+
}
32+
}
33+
34+
// List returns a list of appliedClusterResourceQuotas that match the label and field selectors.
35+
func (c *appliedClusterResourceQuotas) List(opts kapi.ListOptions) (result *quotaapi.AppliedClusterResourceQuotaList, err error) {
36+
result = &quotaapi.AppliedClusterResourceQuotaList{}
37+
err = c.r.Get().Namespace(c.ns).Resource("appliedclusterresourcequotas").VersionedParams(&opts, kapi.ParameterCodec).Do().Into(result)
38+
return
39+
}
40+
41+
// Get returns information about a particular appliedClusterResourceQuota and error if one occurs.
42+
func (c *appliedClusterResourceQuotas) Get(name string) (result *quotaapi.AppliedClusterResourceQuota, err error) {
43+
result = &quotaapi.AppliedClusterResourceQuota{}
44+
err = c.r.Get().Namespace(c.ns).Resource("appliedclusterresourcequotas").Name(name).Do().Into(result)
45+
return
46+
}

pkg/client/client.go

+5
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ type Interface interface {
6161
ClusterRolesInterface
6262
ClusterRoleBindingsInterface
6363
ClusterResourceQuotasInterface
64+
AppliedClusterResourceQuotasNamespacer
6465
}
6566

6667
// Builds provides a REST client for Builds
@@ -268,6 +269,10 @@ func (c *Client) ClusterResourceQuotas() ClusterResourceQuotaInterface {
268269
return newClusterResourceQuotas(c)
269270
}
270271

272+
func (c *Client) AppliedClusterResourceQuotas(namespace string) AppliedClusterResourceQuotaInterface {
273+
return newAppliedClusterResourceQuotas(c, namespace)
274+
}
275+
271276
// Client is an OpenShift client object
272277
type Client struct {
273278
*restclient.RESTClient

pkg/client/testclient/fake.go

+4
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,7 @@ func (c *Fake) ClusterRoleBindings() client.ClusterRoleBindingInterface {
329329
func (c *Fake) ClusterResourceQuotas() client.ClusterResourceQuotaInterface {
330330
return &FakeClusterResourceQuotas{Fake: c}
331331
}
332+
333+
func (c *Fake) AppliedClusterResourceQuotas(namespace string) client.AppliedClusterResourceQuotaInterface {
334+
return &FakeAppliedClusterResourceQuotas{Fake: c, Namespace: namespace}
335+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package testclient
2+
3+
import (
4+
kapi "k8s.io/kubernetes/pkg/api"
5+
ktestclient "k8s.io/kubernetes/pkg/client/unversioned/testclient"
6+
7+
quotaapi "github.com/openshift/origin/pkg/quota/api"
8+
)
9+
10+
type FakeAppliedClusterResourceQuotas struct {
11+
Fake *Fake
12+
Namespace string
13+
}
14+
15+
func (c *FakeAppliedClusterResourceQuotas) Get(name string) (*quotaapi.AppliedClusterResourceQuota, error) {
16+
obj, err := c.Fake.Invokes(ktestclient.NewGetAction("appliedclusterresourcequotas", c.Namespace, name), &quotaapi.AppliedClusterResourceQuota{})
17+
if obj == nil {
18+
return nil, err
19+
}
20+
21+
return obj.(*quotaapi.AppliedClusterResourceQuota), err
22+
}
23+
24+
func (c *FakeAppliedClusterResourceQuotas) List(opts kapi.ListOptions) (*quotaapi.AppliedClusterResourceQuotaList, error) {
25+
obj, err := c.Fake.Invokes(ktestclient.NewListAction("appliedclusterresourcequotas", c.Namespace, opts), &quotaapi.AppliedClusterResourceQuotaList{})
26+
if obj == nil {
27+
return nil, err
28+
}
29+
30+
return obj.(*quotaapi.AppliedClusterResourceQuotaList), err
31+
}

pkg/cmd/cli/describe/describer.go

+16
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func describerMap(c *client.Client, kclient kclient.Interface, host string) map[
6161
userapi.Kind("Group"): &GroupDescriber{c.Groups()},
6262
userapi.Kind("UserIdentityMapping"): &UserIdentityMappingDescriber{c},
6363
quotaapi.Kind("ClusterResourceQuota"): &ClusterQuotaDescriber{c},
64+
quotaapi.Kind("AppliedClusterResourceQuota"): &AppliedClusterQuotaDescriber{c},
6465
}
6566
return m
6667
}
@@ -1415,7 +1416,10 @@ func (d *ClusterQuotaDescriber) Describe(namespace, name string, settings kctl.D
14151416
if err != nil {
14161417
return "", err
14171418
}
1419+
return DescribeClusterQuota(quota)
1420+
}
14181421

1422+
func DescribeClusterQuota(quota *quotaapi.ClusterResourceQuota) (string, error) {
14191423
selector, err := unversioned.LabelSelectorAsSelector(quota.Spec.Selector)
14201424
if err != nil {
14211425
return "", err
@@ -1451,3 +1455,15 @@ func (d *ClusterQuotaDescriber) Describe(namespace, name string, settings kctl.D
14511455
return nil
14521456
})
14531457
}
1458+
1459+
type AppliedClusterQuotaDescriber struct {
1460+
client.Interface
1461+
}
1462+
1463+
func (d *AppliedClusterQuotaDescriber) Describe(namespace, name string, settings kctl.DescriberSettings) (string, error) {
1464+
quota, err := d.AppliedClusterResourceQuotas(namespace).Get(name)
1465+
if err != nil {
1466+
return "", err
1467+
}
1468+
return DescribeClusterQuota(quotaapi.ConvertAppliedClusterResourceQuotaToClusterResourceQuota(quota))
1469+
}

pkg/cmd/cli/describe/printer.go

+15
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ func NewHumanReadablePrinter(noHeaders, withNamespace, wide bool, showAll bool,
134134

135135
p.Handler(clusterResourceQuotaColumns, printClusterResourceQuota)
136136
p.Handler(clusterResourceQuotaColumns, printClusterResourceQuotaList)
137+
p.Handler(clusterResourceQuotaColumns, printAppliedClusterResourceQuota)
138+
p.Handler(clusterResourceQuotaColumns, printAppliedClusterResourceQuotaList)
137139

138140
return p
139141
}
@@ -942,3 +944,16 @@ func printClusterResourceQuotaList(list *quotaapi.ClusterResourceQuotaList, w io
942944
}
943945
return nil
944946
}
947+
948+
func printAppliedClusterResourceQuota(resourceQuota *quotaapi.AppliedClusterResourceQuota, w io.Writer, options kctl.PrintOptions) error {
949+
return printClusterResourceQuota(quotaapi.ConvertAppliedClusterResourceQuotaToClusterResourceQuota(resourceQuota), w, options)
950+
}
951+
952+
func printAppliedClusterResourceQuotaList(list *quotaapi.AppliedClusterResourceQuotaList, w io.Writer, options kctl.PrintOptions) error {
953+
for i := range list.Items {
954+
if err := printClusterResourceQuota(quotaapi.ConvertAppliedClusterResourceQuotaToClusterResourceQuota(&list.Items[i]), w, options); err != nil {
955+
return err
956+
}
957+
}
958+
return nil
959+
}

pkg/cmd/server/bootstrappolicy/policy.go

+8
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ func GetBootstrapClusterRoles() []authorizationapi.ClusterRole {
139139

140140
authorizationapi.NewRule(read...).Groups(projectGroup).Resources("projectrequests", "projects").RuleOrDie(),
141141

142+
authorizationapi.NewRule(read...).Groups(quotaGroup).Resources("appliedclusterresourcequotas").RuleOrDie(),
143+
142144
authorizationapi.NewRule(read...).Groups(quotaGroup).Resources("clusterresourcequotas").RuleOrDie(),
143145

144146
authorizationapi.NewRule(read...).Groups(routeGroup).Resources("routes", "routes/status").RuleOrDie(),
@@ -239,6 +241,8 @@ func GetBootstrapClusterRoles() []authorizationapi.ClusterRole {
239241

240242
authorizationapi.NewRule("get", "patch", "update", "delete").Groups(projectGroup).Resources("projects").RuleOrDie(),
241243

244+
authorizationapi.NewRule(read...).Groups(quotaGroup).Resources("appliedclusterresourcequotas").RuleOrDie(),
245+
242246
authorizationapi.NewRule(readWrite...).Groups(routeGroup).Resources("routes").RuleOrDie(),
243247
authorizationapi.NewRule(read...).Groups(routeGroup).Resources("routes/status").RuleOrDie(),
244248
// an admin can run routers that write back conditions to the route
@@ -286,6 +290,8 @@ func GetBootstrapClusterRoles() []authorizationapi.ClusterRole {
286290

287291
authorizationapi.NewRule("get").Groups(projectGroup).Resources("projects").RuleOrDie(),
288292

293+
authorizationapi.NewRule(read...).Groups(quotaGroup).Resources("appliedclusterresourcequotas").RuleOrDie(),
294+
289295
authorizationapi.NewRule(readWrite...).Groups(routeGroup).Resources("routes").RuleOrDie(),
290296
authorizationapi.NewRule(read...).Groups(routeGroup).Resources("routes/status").RuleOrDie(),
291297

@@ -328,6 +334,8 @@ func GetBootstrapClusterRoles() []authorizationapi.ClusterRole {
328334

329335
authorizationapi.NewRule("get").Groups(projectGroup).Resources("projects").RuleOrDie(),
330336

337+
authorizationapi.NewRule(read...).Groups(quotaGroup).Resources("appliedclusterresourcequotas").RuleOrDie(),
338+
331339
authorizationapi.NewRule(read...).Groups(routeGroup).Resources("routes").RuleOrDie(),
332340
authorizationapi.NewRule(read...).Groups(routeGroup).Resources("routes/status").RuleOrDie(),
333341

pkg/cmd/server/origin/master.go

+3
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import (
9191
"github.com/openshift/origin/pkg/build/registry/buildclone"
9292
"github.com/openshift/origin/pkg/build/registry/buildconfiginstantiate"
9393

94+
appliedclusterresourcequotaregistry "github.com/openshift/origin/pkg/quota/registry/appliedclusterresourcequota"
9495
clusterresourcequotaregistry "github.com/openshift/origin/pkg/quota/registry/clusterresourcequota"
9596

9697
clusterpolicyregistry "github.com/openshift/origin/pkg/authorization/registry/clusterpolicy"
@@ -611,6 +612,8 @@ func (c *MasterConfig) GetRestStorage() map[string]rest.Storage {
611612
"clusterRoles": clusterRoleStorage,
612613

613614
"clusterResourceQuotas": restInPeace(clusterresourcequotaregistry.NewStorage(c.RESTOptionsGetter)),
615+
"appliedClusterResourceQuotas": appliedclusterresourcequotaregistry.NewREST(
616+
c.ClusterQuotaMappingController.GetClusterQuotaMapper(), c.Informers.ClusterResourceQuotas().Lister(), c.Informers.Namespaces().Lister()),
614617
}
615618

616619
if configapi.IsBuildEnabled(&c.Options) {

pkg/cmd/server/origin/master_config.go

+11-7
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"k8s.io/kubernetes/pkg/api/unversioned"
1818
"k8s.io/kubernetes/pkg/apiserver"
1919
"k8s.io/kubernetes/pkg/client/cache"
20+
"k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset"
2021
"k8s.io/kubernetes/pkg/client/restclient"
2122
kclient "k8s.io/kubernetes/pkg/client/unversioned"
2223
clientadapter "k8s.io/kubernetes/pkg/client/unversioned/adapters/internalclientset"
@@ -68,6 +69,7 @@ import (
6869
projectcache "github.com/openshift/origin/pkg/project/cache"
6970
"github.com/openshift/origin/pkg/quota"
7071
quotaadmission "github.com/openshift/origin/pkg/quota/admission/resourcequota"
72+
"github.com/openshift/origin/pkg/quota/controller/clusterquotamapping"
7173
"github.com/openshift/origin/pkg/serviceaccounts"
7274
usercache "github.com/openshift/origin/pkg/user/cache"
7375
groupregistry "github.com/openshift/origin/pkg/user/registry/group"
@@ -76,7 +78,6 @@ import (
7678
useretcd "github.com/openshift/origin/pkg/user/registry/user/etcd"
7779
"github.com/openshift/origin/pkg/util/leaderlease"
7880
"github.com/openshift/origin/pkg/util/restoptions"
79-
"k8s.io/kubernetes/pkg/client/clientset_generated/internalclientset"
8081
)
8182

8283
// MasterConfig defines the required parameters for starting the OpenShift master
@@ -91,9 +92,10 @@ type MasterConfig struct {
9192
Authorizer authorizer.Authorizer
9293
AuthorizationAttributeBuilder authorizer.AuthorizationAttributeBuilder
9394

94-
GroupCache *usercache.GroupCache
95-
ProjectAuthorizationCache *projectauth.AuthorizationCache
96-
ProjectCache *projectcache.ProjectCache
95+
GroupCache *usercache.GroupCache
96+
ProjectAuthorizationCache *projectauth.AuthorizationCache
97+
ProjectCache *projectcache.ProjectCache
98+
ClusterQuotaMappingController *clusterquotamapping.ClusterQuotaMappingController
9799

98100
// RequestContextMapper maps requests to contexts
99101
RequestContextMapper kapi.RequestContextMapper
@@ -197,6 +199,7 @@ func BuildMasterConfig(options configapi.MasterConfig) (*MasterConfig, error) {
197199
}
198200
groupCache := usercache.NewGroupCache(groupregistry.NewRegistry(groupStorage))
199201
projectCache := projectcache.NewProjectCache(privilegedLoopbackKubeClient.Namespaces(), options.ProjectConfig.DefaultNodeSelector)
202+
clusterQuotaMappingController := clusterquotamapping.NewClusterQuotaMappingController(informerFactory.Namespaces(), informerFactory.ClusterResourceQuotas())
200203

201204
kubeletClientConfig := configapi.GetKubeletClientConfig(options)
202205

@@ -274,9 +277,10 @@ func BuildMasterConfig(options configapi.MasterConfig) (*MasterConfig, error) {
274277
Authorizer: authorizer,
275278
AuthorizationAttributeBuilder: newAuthorizationAttributeBuilder(requestContextMapper),
276279

277-
GroupCache: groupCache,
278-
ProjectAuthorizationCache: newProjectAuthorizationCache(authorizer, privilegedLoopbackKubeClient, informerFactory),
279-
ProjectCache: projectCache,
280+
GroupCache: groupCache,
281+
ProjectAuthorizationCache: newProjectAuthorizationCache(authorizer, privilegedLoopbackKubeClient, informerFactory),
282+
ProjectCache: projectCache,
283+
ClusterQuotaMappingController: clusterQuotaMappingController,
280284

281285
RequestContextMapper: requestContextMapper,
282286

pkg/cmd/server/origin/reststorage_validation_test.go

+10-5
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@ import (
1717
"github.com/openshift/origin/pkg/api/validation"
1818
otestclient "github.com/openshift/origin/pkg/client/testclient"
1919
"github.com/openshift/origin/pkg/controller/shared"
20+
quotaapi "github.com/openshift/origin/pkg/quota/api"
21+
"github.com/openshift/origin/pkg/quota/controller/clusterquotamapping"
2022
"github.com/openshift/origin/pkg/util/restoptions"
2123
)
2224

2325
// KnownUpdateValidationExceptions is the list of types that are known to not have an update validation function registered
2426
// If you add something to this list, explain why it doesn't need update validation.
2527
var KnownUpdateValidationExceptions = []reflect.Type{
26-
reflect.TypeOf(&extapi.Scale{}), // scale operation uses the ValidateScale() function for both create and update
28+
reflect.TypeOf(&extapi.Scale{}), // scale operation uses the ValidateScale() function for both create and update
29+
reflect.TypeOf(&quotaapi.AppliedClusterResourceQuota{}), // this only retrieved, never created. its a virtual projection of ClusterResourceQuota
2730
}
2831

2932
// TestValidationRegistration makes sure that any RESTStorage that allows create or update has the correct validation register.
@@ -71,10 +74,12 @@ func TestValidationRegistration(t *testing.T) {
7174
func fakeMasterConfig() *MasterConfig {
7275
etcdHelper := etcdstorage.NewEtcdStorage(nil, api.Codecs.LegacyCodec(), "", false, genericapiserver.DefaultDeserializationCacheSize)
7376

77+
informerFactory := shared.NewInformerFactory(testclient.NewSimpleFake(), otestclient.NewSimpleFake(), shared.DefaultListerWatcherOverrides{}, 1*time.Second)
7478
return &MasterConfig{
75-
KubeletClientConfig: &kubeletclient.KubeletClientConfig{},
76-
RESTOptionsGetter: restoptions.NewSimpleGetter(etcdHelper),
77-
EtcdHelper: etcdHelper,
78-
Informers: shared.NewInformerFactory(testclient.NewSimpleFake(), otestclient.NewSimpleFake(), shared.DefaultListerWatcherOverrides{}, 1*time.Second),
79+
KubeletClientConfig: &kubeletclient.KubeletClientConfig{},
80+
RESTOptionsGetter: restoptions.NewSimpleGetter(etcdHelper),
81+
EtcdHelper: etcdHelper,
82+
Informers: informerFactory,
83+
ClusterQuotaMappingController: clusterquotamapping.NewClusterQuotaMappingController(informerFactory.Namespaces(), informerFactory.ClusterResourceQuotas()),
7984
}
8085
}

pkg/cmd/server/origin/run_components.go

+6-3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"io/ioutil"
55
"net"
66
"path"
7+
"sync"
78
"time"
89

910
"github.com/golang/glog"
@@ -52,7 +53,6 @@ import (
5253
imageapi "github.com/openshift/origin/pkg/image/api"
5354
quota "github.com/openshift/origin/pkg/quota"
5455
quotacontroller "github.com/openshift/origin/pkg/quota/controller"
55-
"github.com/openshift/origin/pkg/quota/controller/clusterquotamapping"
5656
serviceaccountcontrollers "github.com/openshift/origin/pkg/serviceaccounts/controllers"
5757
)
5858

@@ -502,7 +502,10 @@ func (c *MasterConfig) RunResourceQuotaManager(cm *cmapp.CMServer) {
502502
go kresourcequota.NewResourceQuotaController(resourceQuotaControllerOptions).Run(concurrentResourceQuotaSyncs, utilwait.NeverStop)
503503
}
504504

505+
var initClusterQuotaMapping sync.Once
506+
505507
func (c *MasterConfig) RunClusterQuotaMappingController() {
506-
controller := clusterquotamapping.NewClusterQuotaMappingController(c.Informers.Namespaces(), c.Informers.ClusterResourceQuotas())
507-
go controller.Run(5, utilwait.NeverStop)
508+
initClusterQuotaMapping.Do(func() {
509+
go c.ClusterQuotaMappingController.Run(5, utilwait.NeverStop)
510+
})
508511
}

pkg/cmd/server/start/start_master.go

+1
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ func StartAPI(oc *origin.MasterConfig, kc *kubernetes.MasterConfig) error {
446446

447447
// Must start policy caching immediately
448448
oc.Informers.StartCore(utilwait.NeverStop)
449+
oc.RunClusterQuotaMappingController()
449450
oc.RunGroupCache()
450451
oc.RunProjectCache()
451452

0 commit comments

Comments
 (0)