From 3ccbf79902c3f587040d419782abc85e0dea73a8 Mon Sep 17 00:00:00 2001 From: ricoberger Date: Sat, 9 Oct 2021 15:45:13 +0200 Subject: [PATCH] Add Custom Resource Definition for Users The new Custom Resource Definition for Users can be used to define the members of a team. For that each user can be a member of multiple teams, which can be defined in the CRD. With the new CRD we also added a users plugin, which can be used to view all users or a single one and to show a list of members of a team on a dashboard. NOTE: At the moment the "id" field of a user isn't used, but make sure that the id is unique accross all users in all clusters and namespace of a kobs instance. Later the id will be used together with the authentication feature of kobs to provide a proper authorisation mechanism. --- CHANGELOG.md | 1 + Makefile | 5 +- app/package.json | 1 + app/src/index.tsx | 2 + cmd/kobs/plugins/plugins.go | 3 + deploy/helm/kobs/Chart.yaml | 2 +- deploy/helm/kobs/crds/kobs.io_users.yaml | 80 +++++++ deploy/kustomize/crds/kobs.io_users.yaml | 80 +++++++ docs/plugins/users.md | 34 +++ docs/resources/users.md | 53 +++++ mkdocs.yml | 2 + pkg/api/apis/user/register.go | 6 + pkg/api/apis/user/v1beta1/doc.go | 4 + pkg/api/apis/user/v1beta1/register.go | 39 ++++ pkg/api/apis/user/v1beta1/types.go | 44 ++++ .../user/v1beta1/zz_generated.deepcopy.go | 123 +++++++++++ .../user/clientset/versioned/clientset.go | 97 +++++++++ .../clients/user/clientset/versioned/doc.go | 20 ++ .../versioned/fake/clientset_generated.go | 82 +++++++ .../user/clientset/versioned/fake/doc.go | 20 ++ .../user/clientset/versioned/fake/register.go | 56 +++++ .../user/clientset/versioned/scheme/doc.go | 20 ++ .../clientset/versioned/scheme/register.go | 56 +++++ .../versioned/typed/user/v1beta1/doc.go | 20 ++ .../versioned/typed/user/v1beta1/fake/doc.go | 20 ++ .../typed/user/v1beta1/fake/fake_user.go | 130 +++++++++++ .../user/v1beta1/fake/fake_user_client.go | 40 ++++ .../typed/user/v1beta1/generated_expansion.go | 21 ++ .../versioned/typed/user/v1beta1/user.go | 178 ++++++++++++++++ .../typed/user/v1beta1/user_client.go | 89 ++++++++ .../informers/externalversions/factory.go | 180 ++++++++++++++++ .../informers/externalversions/generic.go | 62 ++++++ .../internalinterfaces/factory_interfaces.go | 40 ++++ .../externalversions/user/interface.go | 46 ++++ .../user/v1beta1/interface.go | 45 ++++ .../externalversions/user/v1beta1/user.go | 90 ++++++++ .../user/v1beta1/expansion_generated.go | 27 +++ .../clients/user/listers/user/v1beta1/user.go | 99 +++++++++ pkg/api/clusters/cluster/cluster.go | 51 ++++- plugins/teams/src/components/home/Home.tsx | 2 +- .../teams/src/components/page/TeamsItem.tsx | 4 +- plugins/teams/src/index.ts | 6 + plugins/users/package.json | 27 +++ plugins/users/src/assets/icon.png | Bin 0 -> 39993 bytes plugins/users/src/components/home/Home.tsx | 126 +++++++++++ plugins/users/src/components/page/Page.tsx | 23 ++ plugins/users/src/components/page/Teams.tsx | 60 ++++++ plugins/users/src/components/page/User.tsx | 128 +++++++++++ plugins/users/src/components/page/Users.tsx | 107 ++++++++++ .../users/src/components/page/UsersItem.tsx | 43 ++++ plugins/users/src/components/panel/Panel.tsx | 40 ++++ plugins/users/src/components/panel/Users.tsx | 88 ++++++++ plugins/users/src/index.ts | 18 ++ plugins/users/src/utils/helpers.ts | 5 + plugins/users/src/utils/interfaces.ts | 26 +++ plugins/users/tsconfig.esm.json | 12 ++ plugins/users/tsconfig.json | 20 ++ plugins/users/users.go | 201 ++++++++++++++++++ typings.d.ts | 5 + yarn.lock | 23 +- 60 files changed, 2923 insertions(+), 9 deletions(-) create mode 100644 deploy/helm/kobs/crds/kobs.io_users.yaml create mode 100644 deploy/kustomize/crds/kobs.io_users.yaml create mode 100644 docs/plugins/users.md create mode 100644 docs/resources/users.md create mode 100644 pkg/api/apis/user/register.go create mode 100644 pkg/api/apis/user/v1beta1/doc.go create mode 100644 pkg/api/apis/user/v1beta1/register.go create mode 100644 pkg/api/apis/user/v1beta1/types.go create mode 100644 pkg/api/apis/user/v1beta1/zz_generated.deepcopy.go create mode 100644 pkg/api/clients/user/clientset/versioned/clientset.go create mode 100644 pkg/api/clients/user/clientset/versioned/doc.go create mode 100644 pkg/api/clients/user/clientset/versioned/fake/clientset_generated.go create mode 100644 pkg/api/clients/user/clientset/versioned/fake/doc.go create mode 100644 pkg/api/clients/user/clientset/versioned/fake/register.go create mode 100644 pkg/api/clients/user/clientset/versioned/scheme/doc.go create mode 100644 pkg/api/clients/user/clientset/versioned/scheme/register.go create mode 100644 pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/doc.go create mode 100644 pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/doc.go create mode 100644 pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user.go create mode 100644 pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user_client.go create mode 100644 pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/generated_expansion.go create mode 100644 pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user.go create mode 100644 pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user_client.go create mode 100644 pkg/api/clients/user/informers/externalversions/factory.go create mode 100644 pkg/api/clients/user/informers/externalversions/generic.go create mode 100644 pkg/api/clients/user/informers/externalversions/internalinterfaces/factory_interfaces.go create mode 100644 pkg/api/clients/user/informers/externalversions/user/interface.go create mode 100644 pkg/api/clients/user/informers/externalversions/user/v1beta1/interface.go create mode 100644 pkg/api/clients/user/informers/externalversions/user/v1beta1/user.go create mode 100644 pkg/api/clients/user/listers/user/v1beta1/expansion_generated.go create mode 100644 pkg/api/clients/user/listers/user/v1beta1/user.go create mode 100644 plugins/users/package.json create mode 100644 plugins/users/src/assets/icon.png create mode 100644 plugins/users/src/components/home/Home.tsx create mode 100644 plugins/users/src/components/page/Page.tsx create mode 100644 plugins/users/src/components/page/Teams.tsx create mode 100644 plugins/users/src/components/page/User.tsx create mode 100644 plugins/users/src/components/page/Users.tsx create mode 100644 plugins/users/src/components/page/UsersItem.tsx create mode 100644 plugins/users/src/components/panel/Panel.tsx create mode 100644 plugins/users/src/components/panel/Users.tsx create mode 100644 plugins/users/src/index.ts create mode 100644 plugins/users/src/utils/helpers.ts create mode 100644 plugins/users/src/utils/interfaces.ts create mode 100644 plugins/users/tsconfig.esm.json create mode 100644 plugins/users/tsconfig.json create mode 100644 plugins/users/users.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d4621af8e..95beb6963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ NOTE: As semantic versioning states all 0.y.z releases can contain breaking chan - [#160](https://github.com/kobsio/kobs/pull/160): Allow users to sort the returned logs within the documents table in the ClickHouse plugin. - [#161](https://github.com/kobsio/kobs/pull/161): Add support for materialized columns, to improve query performance for most frequently queried field. - [#162](https://github.com/kobsio/kobs/pull/162): Add support to visualize logs in the ClickHouse plugin. +- [#170](https://github.com/kobsio/kobs/pull/170): Add Custom Resource Definition for Users. ### Fixed diff --git a/Makefile b/Makefile index 46cea4f30..c379ff038 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ REPO ?= github.com/kobsio/kobs REVISION ?= $(shell git rev-parse HEAD) VERSION ?= $(shell git describe --tags) -CRDS ?= team application dashboard +CRDS ?= team application dashboard user .PHONY: build build: @@ -27,6 +27,7 @@ generate-crds: rm -rf ./pkg/api/clients/$$crd/clientset; \ rm -rf ./pkg/api/clients/$$crd/informers; \ rm -rf ./pkg/api/clients/$$crd/listers; \ + mkdir -p ./pkg/api/clients/$$crd; \ mv ./tmp/github.com/kobsio/kobs/pkg/api/apis/$$crd/v1beta1/zz_generated.deepcopy.go ./pkg/api/apis/$$crd/v1beta1; \ mv ./tmp/github.com/kobsio/kobs/pkg/api/clients/$$crd/clientset ./pkg/api/clients/$$crd/clientset; \ mv ./tmp/github.com/kobsio/kobs/pkg/api/clients/$$crd/informers ./pkg/api/clients/$$crd/informers; \ @@ -34,7 +35,7 @@ generate-crds: rm -rf ./tmp; \ done - controller-gen "crd:crdVersions={v1},trivialVersions=true" paths="./..." output:crd:artifacts:config=deploy/kustomize/crds + controller-gen "crd:crdVersions={v1},trivialVersions=true" paths="./pkg/..." output:crd:artifacts:config=deploy/kustomize/crds for crd in $(CRDS); do \ cp ./deploy/kustomize/crds/kobs.io_$$crd\s.yaml ./deploy/helm/kobs/crds/kobs.io_$$crd\s.yaml; \ diff --git a/app/package.json b/app/package.json index 62bc2561d..c89749052 100644 --- a/app/package.json +++ b/app/package.json @@ -18,6 +18,7 @@ "@kobsio/plugin-resources": "*", "@kobsio/plugin-rss": "*", "@kobsio/plugin-teams": "*", + "@kobsio/plugin-users": "*", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "react": "^17.0.2", diff --git a/app/src/index.tsx b/app/src/index.tsx index 7e3b82c06..8476e7110 100644 --- a/app/src/index.tsx +++ b/app/src/index.tsx @@ -8,6 +8,7 @@ import './index.css'; import { App } from '@kobsio/plugin-core'; import resourcesPlugin from '@kobsio/plugin-resources'; import teamsPlugin from '@kobsio/plugin-teams'; +import usersPlugin from '@kobsio/plugin-users'; import applicationsPlugin from '@kobsio/plugin-applications'; import dashboardsPlugin from '@kobsio/plugin-dashboards'; import prometheusPlugin from '@kobsio/plugin-prometheus'; @@ -26,6 +27,7 @@ ReactDOM.render( 0 { + if configShallowCopy.Burst <= 0 { + return nil, fmt.Errorf("burst is required to be greater than 0 when RateLimiter is not set and QPS is set to greater than 0") + } + configShallowCopy.RateLimiter = flowcontrol.NewTokenBucketRateLimiter(configShallowCopy.QPS, configShallowCopy.Burst) + } + var cs Clientset + var err error + cs.kobsV1beta1, err = kobsv1beta1.NewForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + + cs.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(&configShallowCopy) + if err != nil { + return nil, err + } + return &cs, nil +} + +// NewForConfigOrDie creates a new Clientset for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *Clientset { + var cs Clientset + cs.kobsV1beta1 = kobsv1beta1.NewForConfigOrDie(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClientForConfigOrDie(c) + return &cs +} + +// New creates a new Clientset for the given RESTClient. +func New(c rest.Interface) *Clientset { + var cs Clientset + cs.kobsV1beta1 = kobsv1beta1.New(c) + + cs.DiscoveryClient = discovery.NewDiscoveryClient(c) + return &cs +} diff --git a/pkg/api/clients/user/clientset/versioned/doc.go b/pkg/api/clients/user/clientset/versioned/doc.go new file mode 100644 index 000000000..41721ca52 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated clientset. +package versioned diff --git a/pkg/api/clients/user/clientset/versioned/fake/clientset_generated.go b/pkg/api/clients/user/clientset/versioned/fake/clientset_generated.go new file mode 100644 index 000000000..e5d3b31a6 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/fake/clientset_generated.go @@ -0,0 +1,82 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + clientset "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned" + kobsv1beta1 "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1" + fakekobsv1beta1 "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + "k8s.io/client-go/testing" +) + +// NewSimpleClientset returns a clientset that will respond with the provided objects. +// It's backed by a very simple object tracker that processes creates, updates and deletions as-is, +// without applying any validations and/or defaults. It shouldn't be considered a replacement +// for a real clientset and is mostly useful in simple unit tests. +func NewSimpleClientset(objects ...runtime.Object) *Clientset { + o := testing.NewObjectTracker(scheme, codecs.UniversalDecoder()) + for _, obj := range objects { + if err := o.Add(obj); err != nil { + panic(err) + } + } + + cs := &Clientset{tracker: o} + cs.discovery = &fakediscovery.FakeDiscovery{Fake: &cs.Fake} + cs.AddReactor("*", "*", testing.ObjectReaction(o)) + cs.AddWatchReactor("*", func(action testing.Action) (handled bool, ret watch.Interface, err error) { + gvr := action.GetResource() + ns := action.GetNamespace() + watch, err := o.Watch(gvr, ns) + if err != nil { + return false, nil, err + } + return true, watch, nil + }) + + return cs +} + +// Clientset implements clientset.Interface. Meant to be embedded into a +// struct to get a default implementation. This makes faking out just the method +// you want to test easier. +type Clientset struct { + testing.Fake + discovery *fakediscovery.FakeDiscovery + tracker testing.ObjectTracker +} + +func (c *Clientset) Discovery() discovery.DiscoveryInterface { + return c.discovery +} + +func (c *Clientset) Tracker() testing.ObjectTracker { + return c.tracker +} + +var _ clientset.Interface = &Clientset{} + +// KobsV1beta1 retrieves the KobsV1beta1Client +func (c *Clientset) KobsV1beta1() kobsv1beta1.KobsV1beta1Interface { + return &fakekobsv1beta1.FakeKobsV1beta1{Fake: &c.Fake} +} diff --git a/pkg/api/clients/user/clientset/versioned/fake/doc.go b/pkg/api/clients/user/clientset/versioned/fake/doc.go new file mode 100644 index 000000000..9b99e7167 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/fake/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated fake clientset. +package fake diff --git a/pkg/api/clients/user/clientset/versioned/fake/register.go b/pkg/api/clients/user/clientset/versioned/fake/register.go new file mode 100644 index 000000000..6a51b92ba --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/fake/register.go @@ -0,0 +1,56 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + kobsv1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var scheme = runtime.NewScheme() +var codecs = serializer.NewCodecFactory(scheme) + +var localSchemeBuilder = runtime.SchemeBuilder{ + kobsv1beta1.AddToScheme, +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +var AddToScheme = localSchemeBuilder.AddToScheme + +func init() { + v1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"}) + utilruntime.Must(AddToScheme(scheme)) +} diff --git a/pkg/api/clients/user/clientset/versioned/scheme/doc.go b/pkg/api/clients/user/clientset/versioned/scheme/doc.go new file mode 100644 index 000000000..7dc375616 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/scheme/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package contains the scheme of the automatically generated clientset. +package scheme diff --git a/pkg/api/clients/user/clientset/versioned/scheme/register.go b/pkg/api/clients/user/clientset/versioned/scheme/register.go new file mode 100644 index 000000000..2444e3a05 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/scheme/register.go @@ -0,0 +1,56 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package scheme + +import ( + kobsv1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + serializer "k8s.io/apimachinery/pkg/runtime/serializer" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" +) + +var Scheme = runtime.NewScheme() +var Codecs = serializer.NewCodecFactory(Scheme) +var ParameterCodec = runtime.NewParameterCodec(Scheme) +var localSchemeBuilder = runtime.SchemeBuilder{ + kobsv1beta1.AddToScheme, +} + +// AddToScheme adds all types of this clientset into the given scheme. This allows composition +// of clientsets, like in: +// +// import ( +// "k8s.io/client-go/kubernetes" +// clientsetscheme "k8s.io/client-go/kubernetes/scheme" +// aggregatorclientsetscheme "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/scheme" +// ) +// +// kclientset, _ := kubernetes.NewForConfig(c) +// _ = aggregatorclientsetscheme.AddToScheme(clientsetscheme.Scheme) +// +// After this, RawExtensions in Kubernetes types will serialize kube-aggregator types +// correctly. +var AddToScheme = localSchemeBuilder.AddToScheme + +func init() { + v1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"}) + utilruntime.Must(AddToScheme(Scheme)) +} diff --git a/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/doc.go b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/doc.go new file mode 100644 index 000000000..771101956 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// This package has the automatically generated typed clients. +package v1beta1 diff --git a/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/doc.go b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/doc.go new file mode 100644 index 000000000..16f443990 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/doc.go @@ -0,0 +1,20 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +// Package fake has the automatically generated clients. +package fake diff --git a/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user.go b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user.go new file mode 100644 index 000000000..c1064904b --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user.go @@ -0,0 +1,130 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + + v1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + schema "k8s.io/apimachinery/pkg/runtime/schema" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeUsers implements UserInterface +type FakeUsers struct { + Fake *FakeKobsV1beta1 + ns string +} + +var usersResource = schema.GroupVersionResource{Group: "kobs.io", Version: "v1beta1", Resource: "users"} + +var usersKind = schema.GroupVersionKind{Group: "kobs.io", Version: "v1beta1", Kind: "User"} + +// Get takes name of the user, and returns the corresponding user object, and an error if there is any. +func (c *FakeUsers) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1beta1.User, err error) { + obj, err := c.Fake. + Invokes(testing.NewGetAction(usersResource, c.ns, name), &v1beta1.User{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.User), err +} + +// List takes label and field selectors, and returns the list of Users that match those selectors. +func (c *FakeUsers) List(ctx context.Context, opts v1.ListOptions) (result *v1beta1.UserList, err error) { + obj, err := c.Fake. + Invokes(testing.NewListAction(usersResource, usersKind, c.ns, opts), &v1beta1.UserList{}) + + if obj == nil { + return nil, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v1beta1.UserList{ListMeta: obj.(*v1beta1.UserList).ListMeta} + for _, item := range obj.(*v1beta1.UserList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested users. +func (c *FakeUsers) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchAction(usersResource, c.ns, opts)) + +} + +// Create takes the representation of a user and creates it. Returns the server's representation of the user, and an error, if there is any. +func (c *FakeUsers) Create(ctx context.Context, user *v1beta1.User, opts v1.CreateOptions) (result *v1beta1.User, err error) { + obj, err := c.Fake. + Invokes(testing.NewCreateAction(usersResource, c.ns, user), &v1beta1.User{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.User), err +} + +// Update takes the representation of a user and updates it. Returns the server's representation of the user, and an error, if there is any. +func (c *FakeUsers) Update(ctx context.Context, user *v1beta1.User, opts v1.UpdateOptions) (result *v1beta1.User, err error) { + obj, err := c.Fake. + Invokes(testing.NewUpdateAction(usersResource, c.ns, user), &v1beta1.User{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.User), err +} + +// Delete takes name of the user and deletes it. Returns an error if one occurs. +func (c *FakeUsers) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteAction(usersResource, c.ns, name), &v1beta1.User{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeUsers) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionAction(usersResource, c.ns, listOpts) + + _, err := c.Fake.Invokes(action, &v1beta1.UserList{}) + return err +} + +// Patch applies the patch and returns the patched user. +func (c *FakeUsers) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1beta1.User, err error) { + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceAction(usersResource, c.ns, name, pt, data, subresources...), &v1beta1.User{}) + + if obj == nil { + return nil, err + } + return obj.(*v1beta1.User), err +} diff --git a/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user_client.go b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user_client.go new file mode 100644 index 000000000..3e1e741e0 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/fake/fake_user_client.go @@ -0,0 +1,40 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + v1beta1 "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1" + rest "k8s.io/client-go/rest" + testing "k8s.io/client-go/testing" +) + +type FakeKobsV1beta1 struct { + *testing.Fake +} + +func (c *FakeKobsV1beta1) Users(namespace string) v1beta1.UserInterface { + return &FakeUsers{c, namespace} +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *FakeKobsV1beta1) RESTClient() rest.Interface { + var ret *rest.RESTClient + return ret +} diff --git a/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/generated_expansion.go b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/generated_expansion.go new file mode 100644 index 000000000..0a76c5b82 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/generated_expansion.go @@ -0,0 +1,21 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1beta1 + +type UserExpansion interface{} diff --git a/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user.go b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user.go new file mode 100644 index 000000000..a890a74a8 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user.go @@ -0,0 +1,178 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1beta1 + +import ( + "context" + "time" + + v1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + scheme "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + rest "k8s.io/client-go/rest" +) + +// UsersGetter has a method to return a UserInterface. +// A group's client should implement this interface. +type UsersGetter interface { + Users(namespace string) UserInterface +} + +// UserInterface has methods to work with User resources. +type UserInterface interface { + Create(ctx context.Context, user *v1beta1.User, opts v1.CreateOptions) (*v1beta1.User, error) + Update(ctx context.Context, user *v1beta1.User, opts v1.UpdateOptions) (*v1beta1.User, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v1beta1.User, error) + List(ctx context.Context, opts v1.ListOptions) (*v1beta1.UserList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1beta1.User, err error) + UserExpansion +} + +// users implements UserInterface +type users struct { + client rest.Interface + ns string +} + +// newUsers returns a Users +func newUsers(c *KobsV1beta1Client, namespace string) *users { + return &users{ + client: c.RESTClient(), + ns: namespace, + } +} + +// Get takes name of the user, and returns the corresponding user object, and an error if there is any. +func (c *users) Get(ctx context.Context, name string, options v1.GetOptions) (result *v1beta1.User, err error) { + result = &v1beta1.User{} + err = c.client.Get(). + Namespace(c.ns). + Resource("users"). + Name(name). + VersionedParams(&options, scheme.ParameterCodec). + Do(ctx). + Into(result) + return +} + +// List takes label and field selectors, and returns the list of Users that match those selectors. +func (c *users) List(ctx context.Context, opts v1.ListOptions) (result *v1beta1.UserList, err error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + result = &v1beta1.UserList{} + err = c.client.Get(). + Namespace(c.ns). + Resource("users"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Do(ctx). + Into(result) + return +} + +// Watch returns a watch.Interface that watches the requested users. +func (c *users) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + var timeout time.Duration + if opts.TimeoutSeconds != nil { + timeout = time.Duration(*opts.TimeoutSeconds) * time.Second + } + opts.Watch = true + return c.client.Get(). + Namespace(c.ns). + Resource("users"). + VersionedParams(&opts, scheme.ParameterCodec). + Timeout(timeout). + Watch(ctx) +} + +// Create takes the representation of a user and creates it. Returns the server's representation of the user, and an error, if there is any. +func (c *users) Create(ctx context.Context, user *v1beta1.User, opts v1.CreateOptions) (result *v1beta1.User, err error) { + result = &v1beta1.User{} + err = c.client.Post(). + Namespace(c.ns). + Resource("users"). + VersionedParams(&opts, scheme.ParameterCodec). + Body(user). + Do(ctx). + Into(result) + return +} + +// Update takes the representation of a user and updates it. Returns the server's representation of the user, and an error, if there is any. +func (c *users) Update(ctx context.Context, user *v1beta1.User, opts v1.UpdateOptions) (result *v1beta1.User, err error) { + result = &v1beta1.User{} + err = c.client.Put(). + Namespace(c.ns). + Resource("users"). + Name(user.Name). + VersionedParams(&opts, scheme.ParameterCodec). + Body(user). + Do(ctx). + Into(result) + return +} + +// Delete takes name of the user and deletes it. Returns an error if one occurs. +func (c *users) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + return c.client.Delete(). + Namespace(c.ns). + Resource("users"). + Name(name). + Body(&opts). + Do(ctx). + Error() +} + +// DeleteCollection deletes a collection of objects. +func (c *users) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + var timeout time.Duration + if listOpts.TimeoutSeconds != nil { + timeout = time.Duration(*listOpts.TimeoutSeconds) * time.Second + } + return c.client.Delete(). + Namespace(c.ns). + Resource("users"). + VersionedParams(&listOpts, scheme.ParameterCodec). + Timeout(timeout). + Body(&opts). + Do(ctx). + Error() +} + +// Patch applies the patch and returns the patched user. +func (c *users) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v1beta1.User, err error) { + result = &v1beta1.User{} + err = c.client.Patch(pt). + Namespace(c.ns). + Resource("users"). + Name(name). + SubResource(subresources...). + VersionedParams(&opts, scheme.ParameterCodec). + Body(data). + Do(ctx). + Into(result) + return +} diff --git a/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user_client.go b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user_client.go new file mode 100644 index 000000000..eb47ccf39 --- /dev/null +++ b/pkg/api/clients/user/clientset/versioned/typed/user/v1beta1/user_client.go @@ -0,0 +1,89 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by client-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned/scheme" + rest "k8s.io/client-go/rest" +) + +type KobsV1beta1Interface interface { + RESTClient() rest.Interface + UsersGetter +} + +// KobsV1beta1Client is used to interact with features provided by the kobs.io group. +type KobsV1beta1Client struct { + restClient rest.Interface +} + +func (c *KobsV1beta1Client) Users(namespace string) UserInterface { + return newUsers(c, namespace) +} + +// NewForConfig creates a new KobsV1beta1Client for the given config. +func NewForConfig(c *rest.Config) (*KobsV1beta1Client, error) { + config := *c + if err := setConfigDefaults(&config); err != nil { + return nil, err + } + client, err := rest.RESTClientFor(&config) + if err != nil { + return nil, err + } + return &KobsV1beta1Client{client}, nil +} + +// NewForConfigOrDie creates a new KobsV1beta1Client for the given config and +// panics if there is an error in the config. +func NewForConfigOrDie(c *rest.Config) *KobsV1beta1Client { + client, err := NewForConfig(c) + if err != nil { + panic(err) + } + return client +} + +// New creates a new KobsV1beta1Client for the given RESTClient. +func New(c rest.Interface) *KobsV1beta1Client { + return &KobsV1beta1Client{c} +} + +func setConfigDefaults(config *rest.Config) error { + gv := v1beta1.SchemeGroupVersion + config.GroupVersion = &gv + config.APIPath = "/apis" + config.NegotiatedSerializer = scheme.Codecs.WithoutConversion() + + if config.UserAgent == "" { + config.UserAgent = rest.DefaultKubernetesUserAgent() + } + + return nil +} + +// RESTClient returns a RESTClient that is used to communicate +// with API server by this client implementation. +func (c *KobsV1beta1Client) RESTClient() rest.Interface { + if c == nil { + return nil + } + return c.restClient +} diff --git a/pkg/api/clients/user/informers/externalversions/factory.go b/pkg/api/clients/user/informers/externalversions/factory.go new file mode 100644 index 000000000..cdf91ed68 --- /dev/null +++ b/pkg/api/clients/user/informers/externalversions/factory.go @@ -0,0 +1,180 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + reflect "reflect" + sync "sync" + time "time" + + versioned "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned" + internalinterfaces "github.com/kobsio/kobs/pkg/api/clients/user/informers/externalversions/internalinterfaces" + user "github.com/kobsio/kobs/pkg/api/clients/user/informers/externalversions/user" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// SharedInformerOption defines the functional option type for SharedInformerFactory. +type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory + +type sharedInformerFactory struct { + client versioned.Interface + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc + lock sync.Mutex + defaultResync time.Duration + customResync map[reflect.Type]time.Duration + + informers map[reflect.Type]cache.SharedIndexInformer + // startedInformers is used for tracking which informers have been started. + // This allows Start() to be called multiple times safely. + startedInformers map[reflect.Type]bool +} + +// WithCustomResyncConfig sets a custom resync period for the specified informer types. +func WithCustomResyncConfig(resyncConfig map[v1.Object]time.Duration) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + for k, v := range resyncConfig { + factory.customResync[reflect.TypeOf(k)] = v + } + return factory + } +} + +// WithTweakListOptions sets a custom filter on all listers of the configured SharedInformerFactory. +func WithTweakListOptions(tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.tweakListOptions = tweakListOptions + return factory + } +} + +// WithNamespace limits the SharedInformerFactory to the specified namespace. +func WithNamespace(namespace string) SharedInformerOption { + return func(factory *sharedInformerFactory) *sharedInformerFactory { + factory.namespace = namespace + return factory + } +} + +// NewSharedInformerFactory constructs a new instance of sharedInformerFactory for all namespaces. +func NewSharedInformerFactory(client versioned.Interface, defaultResync time.Duration) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync) +} + +// NewFilteredSharedInformerFactory constructs a new instance of sharedInformerFactory. +// Listers obtained via this SharedInformerFactory will be subject to the same filters +// as specified here. +// Deprecated: Please use NewSharedInformerFactoryWithOptions instead +func NewFilteredSharedInformerFactory(client versioned.Interface, defaultResync time.Duration, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) SharedInformerFactory { + return NewSharedInformerFactoryWithOptions(client, defaultResync, WithNamespace(namespace), WithTweakListOptions(tweakListOptions)) +} + +// NewSharedInformerFactoryWithOptions constructs a new instance of a SharedInformerFactory with additional options. +func NewSharedInformerFactoryWithOptions(client versioned.Interface, defaultResync time.Duration, options ...SharedInformerOption) SharedInformerFactory { + factory := &sharedInformerFactory{ + client: client, + namespace: v1.NamespaceAll, + defaultResync: defaultResync, + informers: make(map[reflect.Type]cache.SharedIndexInformer), + startedInformers: make(map[reflect.Type]bool), + customResync: make(map[reflect.Type]time.Duration), + } + + // Apply all options + for _, opt := range options { + factory = opt(factory) + } + + return factory +} + +// Start initializes all requested informers. +func (f *sharedInformerFactory) Start(stopCh <-chan struct{}) { + f.lock.Lock() + defer f.lock.Unlock() + + for informerType, informer := range f.informers { + if !f.startedInformers[informerType] { + go informer.Run(stopCh) + f.startedInformers[informerType] = true + } + } +} + +// WaitForCacheSync waits for all started informers' cache were synced. +func (f *sharedInformerFactory) WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool { + informers := func() map[reflect.Type]cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informers := map[reflect.Type]cache.SharedIndexInformer{} + for informerType, informer := range f.informers { + if f.startedInformers[informerType] { + informers[informerType] = informer + } + } + return informers + }() + + res := map[reflect.Type]bool{} + for informType, informer := range informers { + res[informType] = cache.WaitForCacheSync(stopCh, informer.HasSynced) + } + return res +} + +// InternalInformerFor returns the SharedIndexInformer for obj using an internal +// client. +func (f *sharedInformerFactory) InformerFor(obj runtime.Object, newFunc internalinterfaces.NewInformerFunc) cache.SharedIndexInformer { + f.lock.Lock() + defer f.lock.Unlock() + + informerType := reflect.TypeOf(obj) + informer, exists := f.informers[informerType] + if exists { + return informer + } + + resyncPeriod, exists := f.customResync[informerType] + if !exists { + resyncPeriod = f.defaultResync + } + + informer = newFunc(f.client, resyncPeriod) + f.informers[informerType] = informer + + return informer +} + +// SharedInformerFactory provides shared informers for resources in all known +// API group versions. +type SharedInformerFactory interface { + internalinterfaces.SharedInformerFactory + ForResource(resource schema.GroupVersionResource) (GenericInformer, error) + WaitForCacheSync(stopCh <-chan struct{}) map[reflect.Type]bool + + Kobs() user.Interface +} + +func (f *sharedInformerFactory) Kobs() user.Interface { + return user.New(f, f.namespace, f.tweakListOptions) +} diff --git a/pkg/api/clients/user/informers/externalversions/generic.go b/pkg/api/clients/user/informers/externalversions/generic.go new file mode 100644 index 000000000..ff1498496 --- /dev/null +++ b/pkg/api/clients/user/informers/externalversions/generic.go @@ -0,0 +1,62 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package externalversions + +import ( + "fmt" + + v1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + schema "k8s.io/apimachinery/pkg/runtime/schema" + cache "k8s.io/client-go/tools/cache" +) + +// GenericInformer is type of SharedIndexInformer which will locate and delegate to other +// sharedInformers based on type +type GenericInformer interface { + Informer() cache.SharedIndexInformer + Lister() cache.GenericLister +} + +type genericInformer struct { + informer cache.SharedIndexInformer + resource schema.GroupResource +} + +// Informer returns the SharedIndexInformer. +func (f *genericInformer) Informer() cache.SharedIndexInformer { + return f.informer +} + +// Lister returns the GenericLister. +func (f *genericInformer) Lister() cache.GenericLister { + return cache.NewGenericLister(f.Informer().GetIndexer(), f.resource) +} + +// ForResource gives generic access to a shared informer of the matching type +// TODO extend this to unknown resources with a client pool +func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) { + switch resource { + // Group=kobs.io, Version=v1beta1 + case v1beta1.SchemeGroupVersion.WithResource("users"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Kobs().V1beta1().Users().Informer()}, nil + + } + + return nil, fmt.Errorf("no informer found for %v", resource) +} diff --git a/pkg/api/clients/user/informers/externalversions/internalinterfaces/factory_interfaces.go b/pkg/api/clients/user/informers/externalversions/internalinterfaces/factory_interfaces.go new file mode 100644 index 000000000..7cd28e0cf --- /dev/null +++ b/pkg/api/clients/user/informers/externalversions/internalinterfaces/factory_interfaces.go @@ -0,0 +1,40 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package internalinterfaces + +import ( + time "time" + + versioned "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + cache "k8s.io/client-go/tools/cache" +) + +// NewInformerFunc takes versioned.Interface and time.Duration to return a SharedIndexInformer. +type NewInformerFunc func(versioned.Interface, time.Duration) cache.SharedIndexInformer + +// SharedInformerFactory a small interface to allow for adding an informer without an import cycle +type SharedInformerFactory interface { + Start(stopCh <-chan struct{}) + InformerFor(obj runtime.Object, newFunc NewInformerFunc) cache.SharedIndexInformer +} + +// TweakListOptionsFunc is a function that transforms a v1.ListOptions. +type TweakListOptionsFunc func(*v1.ListOptions) diff --git a/pkg/api/clients/user/informers/externalversions/user/interface.go b/pkg/api/clients/user/informers/externalversions/user/interface.go new file mode 100644 index 000000000..116664ad5 --- /dev/null +++ b/pkg/api/clients/user/informers/externalversions/user/interface.go @@ -0,0 +1,46 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package user + +import ( + internalinterfaces "github.com/kobsio/kobs/pkg/api/clients/user/informers/externalversions/internalinterfaces" + v1beta1 "github.com/kobsio/kobs/pkg/api/clients/user/informers/externalversions/user/v1beta1" +) + +// Interface provides access to each of this group's versions. +type Interface interface { + // V1beta1 provides access to shared informers for resources in V1beta1. + V1beta1() v1beta1.Interface +} + +type group struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &group{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// V1beta1 returns a new v1beta1.Interface. +func (g *group) V1beta1() v1beta1.Interface { + return v1beta1.New(g.factory, g.namespace, g.tweakListOptions) +} diff --git a/pkg/api/clients/user/informers/externalversions/user/v1beta1/interface.go b/pkg/api/clients/user/informers/externalversions/user/v1beta1/interface.go new file mode 100644 index 000000000..4794a3db4 --- /dev/null +++ b/pkg/api/clients/user/informers/externalversions/user/v1beta1/interface.go @@ -0,0 +1,45 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1beta1 + +import ( + internalinterfaces "github.com/kobsio/kobs/pkg/api/clients/user/informers/externalversions/internalinterfaces" +) + +// Interface provides access to all the informers in this group version. +type Interface interface { + // Users returns a UserInformer. + Users() UserInformer +} + +type version struct { + factory internalinterfaces.SharedInformerFactory + namespace string + tweakListOptions internalinterfaces.TweakListOptionsFunc +} + +// New returns a new Interface. +func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakListOptions internalinterfaces.TweakListOptionsFunc) Interface { + return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions} +} + +// Users returns a UserInformer. +func (v *version) Users() UserInformer { + return &userInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} diff --git a/pkg/api/clients/user/informers/externalversions/user/v1beta1/user.go b/pkg/api/clients/user/informers/externalversions/user/v1beta1/user.go new file mode 100644 index 000000000..8daa27049 --- /dev/null +++ b/pkg/api/clients/user/informers/externalversions/user/v1beta1/user.go @@ -0,0 +1,90 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by informer-gen. DO NOT EDIT. + +package v1beta1 + +import ( + "context" + time "time" + + userv1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + versioned "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned" + internalinterfaces "github.com/kobsio/kobs/pkg/api/clients/user/informers/externalversions/internalinterfaces" + v1beta1 "github.com/kobsio/kobs/pkg/api/clients/user/listers/user/v1beta1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// UserInformer provides access to a shared informer and lister for +// Users. +type UserInformer interface { + Informer() cache.SharedIndexInformer + Lister() v1beta1.UserLister +} + +type userInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewUserInformer constructs a new informer for User type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewUserInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredUserInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredUserInformer constructs a new informer for User type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredUserInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.KobsV1beta1().Users(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.KobsV1beta1().Users(namespace).Watch(context.TODO(), options) + }, + }, + &userv1beta1.User{}, + resyncPeriod, + indexers, + ) +} + +func (f *userInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredUserInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *userInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&userv1beta1.User{}, f.defaultInformer) +} + +func (f *userInformer) Lister() v1beta1.UserLister { + return v1beta1.NewUserLister(f.Informer().GetIndexer()) +} diff --git a/pkg/api/clients/user/listers/user/v1beta1/expansion_generated.go b/pkg/api/clients/user/listers/user/v1beta1/expansion_generated.go new file mode 100644 index 000000000..1c90ff792 --- /dev/null +++ b/pkg/api/clients/user/listers/user/v1beta1/expansion_generated.go @@ -0,0 +1,27 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1beta1 + +// UserListerExpansion allows custom methods to be added to +// UserLister. +type UserListerExpansion interface{} + +// UserNamespaceListerExpansion allows custom methods to be added to +// UserNamespaceLister. +type UserNamespaceListerExpansion interface{} diff --git a/pkg/api/clients/user/listers/user/v1beta1/user.go b/pkg/api/clients/user/listers/user/v1beta1/user.go new file mode 100644 index 000000000..073733977 --- /dev/null +++ b/pkg/api/clients/user/listers/user/v1beta1/user.go @@ -0,0 +1,99 @@ +/* +Copyright The Kubernetes 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. +*/ + +// Code generated by lister-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1beta1 "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/tools/cache" +) + +// UserLister helps list Users. +// All objects returned here must be treated as read-only. +type UserLister interface { + // List lists all Users in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1beta1.User, err error) + // Users returns an object that can list and get Users. + Users(namespace string) UserNamespaceLister + UserListerExpansion +} + +// userLister implements the UserLister interface. +type userLister struct { + indexer cache.Indexer +} + +// NewUserLister returns a new UserLister. +func NewUserLister(indexer cache.Indexer) UserLister { + return &userLister{indexer: indexer} +} + +// List lists all Users in the indexer. +func (s *userLister) List(selector labels.Selector) (ret []*v1beta1.User, err error) { + err = cache.ListAll(s.indexer, selector, func(m interface{}) { + ret = append(ret, m.(*v1beta1.User)) + }) + return ret, err +} + +// Users returns an object that can list and get Users. +func (s *userLister) Users(namespace string) UserNamespaceLister { + return userNamespaceLister{indexer: s.indexer, namespace: namespace} +} + +// UserNamespaceLister helps list and get Users. +// All objects returned here must be treated as read-only. +type UserNamespaceLister interface { + // List lists all Users in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v1beta1.User, err error) + // Get retrieves the User from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v1beta1.User, error) + UserNamespaceListerExpansion +} + +// userNamespaceLister implements the UserNamespaceLister +// interface. +type userNamespaceLister struct { + indexer cache.Indexer + namespace string +} + +// List lists all Users in the indexer for a given namespace. +func (s userNamespaceLister) List(selector labels.Selector) (ret []*v1beta1.User, err error) { + err = cache.ListAllByNamespace(s.indexer, s.namespace, selector, func(m interface{}) { + ret = append(ret, m.(*v1beta1.User)) + }) + return ret, err +} + +// Get retrieves the User from the indexer for a given namespace and name. +func (s userNamespaceLister) Get(name string) (*v1beta1.User, error) { + obj, exists, err := s.indexer.GetByKey(s.namespace + "/" + name) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.NewNotFound(v1beta1.Resource("user"), name) + } + return obj.(*v1beta1.User), nil +} diff --git a/pkg/api/clusters/cluster/cluster.go b/pkg/api/clusters/cluster/cluster.go index 283575c16..76d99d2c9 100644 --- a/pkg/api/clusters/cluster/cluster.go +++ b/pkg/api/clusters/cluster/cluster.go @@ -14,9 +14,11 @@ import ( application "github.com/kobsio/kobs/pkg/api/apis/application/v1beta1" dashboard "github.com/kobsio/kobs/pkg/api/apis/dashboard/v1beta1" team "github.com/kobsio/kobs/pkg/api/apis/team/v1beta1" + user "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" applicationClientsetVersioned "github.com/kobsio/kobs/pkg/api/clients/application/clientset/versioned" dashboardClientsetVersioned "github.com/kobsio/kobs/pkg/api/clients/dashboard/clientset/versioned" teamClientsetVersioned "github.com/kobsio/kobs/pkg/api/clients/team/clientset/versioned" + userClientsetVersioned "github.com/kobsio/kobs/pkg/api/clients/user/clientset/versioned" "github.com/kobsio/kobs/pkg/api/clusters/cluster/terminal" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" @@ -43,6 +45,7 @@ type Cluster struct { applicationClientset *applicationClientsetVersioned.Clientset teamClientset *teamClientsetVersioned.Clientset dashboardClientset *dashboardClientsetVersioned.Clientset + userClientset *userClientsetVersioned.Clientset name string crds []CRD } @@ -434,6 +437,45 @@ func (c *Cluster) GetDashboard(ctx context.Context, namespace, name string) (*da return &dashboard, nil } +// GetUsers returns a list of users for the given namespace. It also adds the cluster, namespace and user name to the +// User CR, so that this information must not be specified by the user in the CR. +func (c *Cluster) GetUsers(ctx context.Context, namespace string) ([]user.UserSpec, error) { + usersList, err := c.userClientset.KobsV1beta1().Users(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + + var users []user.UserSpec + + for _, userItem := range usersList.Items { + user := userItem.Spec + user.Cluster = c.name + user.Namespace = userItem.Namespace + user.Name = userItem.Name + + users = append(users, user) + } + + return users, nil +} + +// GetUser returns a user for the given namespace and name. After the user is retrieved we replace, the cluster, +// namespace and name in the spec of the User CR. This is needed, so that the user doesn't have to, provide these +// fields. +func (c *Cluster) GetUser(ctx context.Context, namespace, name string) (*user.UserSpec, error) { + userCR, err := c.userClientset.KobsV1beta1().Users(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + user := userCR.Spec + user.Cluster = c.name + user.Namespace = namespace + user.Name = name + + return &user, nil +} + // loadCRDs retrieves all CRDs from the Kubernetes API of this cluster. Then the CRDs are transformed into our internal // CRD format and saved within the cluster. Since this function is only called once after a cluster was loaded, we call // it in a endless loop until it succeeds. @@ -521,7 +563,13 @@ func NewCluster(name string, restConfig *rest.Config) (*Cluster, error) { dashboardClientset, err := dashboardClientsetVersioned.NewForConfig(restConfig) if err != nil { - log.WithError(err).Debugf("Could not create team clientset.") + log.WithError(err).Debugf("Could not create dashboard clientset.") + return nil, err + } + + userClientset, err := userClientsetVersioned.NewForConfig(restConfig) + if err != nil { + log.WithError(err).Debugf("Could not create user clientset.") return nil, err } @@ -533,6 +581,7 @@ func NewCluster(name string, restConfig *rest.Config) (*Cluster, error) { applicationClientset: applicationClientset, teamClientset: teamClientset, dashboardClientset: dashboardClientset, + userClientset: userClientset, name: name, } diff --git a/plugins/teams/src/components/home/Home.tsx b/plugins/teams/src/components/home/Home.tsx index acf8b4292..6a88b22fb 100644 --- a/plugins/teams/src/components/home/Home.tsx +++ b/plugins/teams/src/components/home/Home.tsx @@ -75,7 +75,7 @@ const Home: React.FunctionComponent = () => { return ( - + diff --git a/plugins/teams/src/components/page/TeamsItem.tsx b/plugins/teams/src/components/page/TeamsItem.tsx index 13671418d..ce241b88e 100644 --- a/plugins/teams/src/components/page/TeamsItem.tsx +++ b/plugins/teams/src/components/page/TeamsItem.tsx @@ -1,4 +1,4 @@ -import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; +import { Avatar, Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; import React from 'react'; import { LinkWrapper } from '@kobsio/plugin-core'; @@ -31,7 +31,7 @@ const TeamsItem: React.FunctionComponent = ({ - {name} + {name} {description} diff --git a/plugins/teams/src/index.ts b/plugins/teams/src/index.ts index da02f1947..300c363ab 100644 --- a/plugins/teams/src/index.ts +++ b/plugins/teams/src/index.ts @@ -6,6 +6,9 @@ import Home from './components/home/Home'; import Page from './components/page/Page'; import Panel from './components/panel/Panel'; +import { ITeam } from './utils/interfaces'; +import TeamsItem from './components/page/TeamsItem'; + const teamsPlugin: IPluginComponents = { teams: { home: Home, @@ -16,3 +19,6 @@ const teamsPlugin: IPluginComponents = { }; export default teamsPlugin; + +export { TeamsItem }; +export type ITeamTeam = ITeam; diff --git a/plugins/users/package.json b/plugins/users/package.json new file mode 100644 index 000000000..2ffaa1ed3 --- /dev/null +++ b/plugins/users/package.json @@ -0,0 +1,27 @@ +{ + "name": "@kobsio/plugin-users", + "version": "0.0.0", + "license": "MIT", + "private": false, + "main": "./lib/index.js", + "module": "./lib-esm/index.js", + "types": "./lib/index.d.ts", + "scripts": { + "plugin": "tsc && tsc --build tsconfig.esm.json && cp -r src/assets lib && cp -r src/assets lib-esm" + }, + "dependencies": { + "@kobsio/plugin-core": "*", + "@kobsio/plugin-teams": "*", + "@patternfly/react-core": "^4.128.2", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "@types/react-router-dom": "^5.1.7", + "md5": "^2.3.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-markdown": "^7.0.1", + "react-query": "^3.17.2", + "react-router-dom": "^5.2.0", + "typescript": "^4.3.4" + } +} diff --git a/plugins/users/src/assets/icon.png b/plugins/users/src/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9d606eae890d7e354ba9bb18834fd6160896a14c GIT binary patch literal 39993 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

v%D@Q7S!9{;fLj-q9iMZc41j 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 = () => { + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 500); + + const { isError, isLoading, error, data, refetch } = useQuery(['users/users'], async () => { + try { + const response = await fetch(`/api/plugins/users/users`, { method: 'get' }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data) { + return null; + } + + return ( + + + + + + + + + + + + + +

 

+ + + {data + .filter((user) => + !debouncedSearchTerm + ? true + : user.cluster.includes(debouncedSearchTerm) || + user.namespace.includes(debouncedSearchTerm) || + user.name.includes(debouncedSearchTerm) || + user.fullName.includes(debouncedSearchTerm) || + user.email.includes(debouncedSearchTerm) || + user.position?.includes(debouncedSearchTerm), + ) + .map((user, index) => ( + + + + ))} + +
+ ); +}; + +export default Home; diff --git a/plugins/users/src/components/page/Page.tsx b/plugins/users/src/components/page/Page.tsx new file mode 100644 index 000000000..41c448e05 --- /dev/null +++ b/plugins/users/src/components/page/Page.tsx @@ -0,0 +1,23 @@ +import { Route, Switch } from 'react-router-dom'; +import React from 'react'; + +import { IPluginPageProps } from '@kobsio/plugin-core'; +import User from './User'; +import Users from './Users'; + +// The page implementation for the users plugin supports two different routes. The first page shows a list of all users, +// while the latter one is used to show a single user. +const Page: React.FunctionComponent = ({ name, displayName, description }: IPluginPageProps) => { + return ( + + + + + + + + + ); +}; + +export default Page; diff --git a/plugins/users/src/components/page/Teams.tsx b/plugins/users/src/components/page/Teams.tsx new file mode 100644 index 000000000..adffc3b6f --- /dev/null +++ b/plugins/users/src/components/page/Teams.tsx @@ -0,0 +1,60 @@ +import { Gallery, GalleryItem, PageSection, PageSectionVariants } from '@patternfly/react-core'; +import React from 'react'; +import { useQuery } from 'react-query'; + +import { ITeamTeam, TeamsItem } from '@kobsio/plugin-teams'; +import { IUser } from '../../utils/interfaces'; + +export interface ITeamsProps { + user: IUser; +} + +const Teams: React.FunctionComponent = ({ user }: ITeamsProps) => { + const { isError, isLoading, data } = useQuery(['users/teams', user], async () => { + try { + const response = await fetch(`/api/plugins/users/teams?cluster=${user.cluster}&namespace=${user.namespace}`, { + body: JSON.stringify({ + teams: user.teams, + }), + method: 'post', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }); + + if (isLoading || isError || !data) { + return null; + } + + return ( + + + {data.map((team, index) => ( + + + + ))} + + + ); +}; + +export default Teams; diff --git a/plugins/users/src/components/page/User.tsx b/plugins/users/src/components/page/User.tsx new file mode 100644 index 000000000..cb2fb9286 --- /dev/null +++ b/plugins/users/src/components/page/User.tsx @@ -0,0 +1,128 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + Avatar, + Card, + CardBody, + PageSection, + PageSectionVariants, + Spinner, + TextContent, +} from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import { useHistory, useParams } from 'react-router-dom'; +import React from 'react'; +import ReactMarkdown from 'react-markdown'; + +import { IUser } from '../../utils/interfaces'; +import Teams from './Teams'; +import { getGravatarImageUrl } from '../../utils/helpers'; + +interface IUserParams { + cluster: string; + namespace: string; + name: string; +} + +// User is the component for the users page. It loads a user by the specified cluster, namespace and name URL +// parameter. Everytime the cluster, namespace or name parameter is changed we make an API call to get the user. +const User: React.FunctionComponent = () => { + const history = useHistory(); + const params = useParams(); + + const { isError, isLoading, error, data, refetch } = useQuery( + ['users/user', params.cluster, params.namespace, params.name], + async () => { + try { + const response = await fetch( + `/api/plugins/users/user?cluster=${params.cluster}&namespace=${params.namespace}&name=${params.name}`, + { method: 'get' }, + ); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + // During the API call we are showing a spinner, to show the user that the user is currently loading. + if (isLoading) { + return ; + } + + // When an error occures during the API call, we show the user this error. The user can then go back to the home page, + // to the users page or he can retry the API call. + if (isError) { + return ( + + history.push('/')}>Home + history.push('/users')}>Users + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + // When the data is undefined and the isLoading and isError variables are not true we show nothing. This happens while + // the component is rendered the first time. + if (!data) { + return null; + } + + return ( + + + + +
+ +
{data.fullName}
+
{data.position}
+
+
+
+
+ + {data.teams && } + + {data.bio && ( + + + + + {data.bio} + + + + + )} +
+ ); +}; + +export default User; diff --git a/plugins/users/src/components/page/Users.tsx b/plugins/users/src/components/page/Users.tsx new file mode 100644 index 000000000..84afe6727 --- /dev/null +++ b/plugins/users/src/components/page/Users.tsx @@ -0,0 +1,107 @@ +import { + Alert, + AlertActionLink, + AlertVariant, + Drawer, + DrawerContent, + DrawerContentBody, + Gallery, + GalleryItem, + PageSection, + PageSectionVariants, + Spinner, + Title, +} from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import { IUser } from '../../utils/interfaces'; +import UsersItem from './UsersItem'; + +export interface IUsersProps { + displayName: string; + description: string; +} + +// Users is the page which is used to show all user. The component will display the configured name and description of +// the users plugin. Below this header it will display all the loaded users. +const Users: React.FunctionComponent = ({ displayName, description }: IUsersProps) => { + const history = useHistory(); + + const { isError, isLoading, error, data, refetch } = useQuery(['users/users'], async () => { + try { + const response = await fetch(`/api/plugins/users/users`, { method: 'get' }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }); + + return ( + + + + {displayName} + +

{description}

+
+ + + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( + + history.push('/')}>Home + > => refetch()}> + Retry + +
+ } + > +

{error?.message}

+ + ) : data ? ( + + {data.map((user, index) => ( + + + + ))} + + ) : null} + + + + + + ); +}; + +export default Users; diff --git a/plugins/users/src/components/page/UsersItem.tsx b/plugins/users/src/components/page/UsersItem.tsx new file mode 100644 index 000000000..b7148e80f --- /dev/null +++ b/plugins/users/src/components/page/UsersItem.tsx @@ -0,0 +1,43 @@ +import { Avatar, Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; +import React from 'react'; + +import { LinkWrapper } from '@kobsio/plugin-core'; +import { getGravatarImageUrl } from '../../utils/helpers'; + +interface IUsersItemProps { + cluster: string; + namespace: string; + name: string; + fullName: string; + email: string; + position?: string; +} + +// UsersItem renders a single user in a Card component. The Card is wrapped by our LinkWrapper so that the user is +// redirected to the page of the user, when he clicks on the card. +const UsersItem: React.FunctionComponent = ({ + cluster, + namespace, + name, + fullName, + email, + position, +}: IUsersItemProps) => { + return ( + + + + + {fullName} + + {position ?

{position}

:

 

}
+
+
+ ); +}; + +export default UsersItem; diff --git a/plugins/users/src/components/panel/Panel.tsx b/plugins/users/src/components/panel/Panel.tsx new file mode 100644 index 000000000..75c9ff830 --- /dev/null +++ b/plugins/users/src/components/panel/Panel.tsx @@ -0,0 +1,40 @@ +import React, { memo } from 'react'; + +import { IPluginPanelProps, PluginCard, PluginOptionsMissing } from '@kobsio/plugin-core'; +import { IPanelOptions } from '../../utils/interfaces'; +import Users from './Users'; + +interface IPanelProps extends IPluginPanelProps { + options?: IPanelOptions; +} + +export const Panel: React.FunctionComponent = ({ title, description, defaults, options }: IPanelProps) => { + if (!options || !options.name) { + return ( + + ); + } + + return ( + + + + ); +}; + +export default memo(Panel, (prevProps, nextProps) => { + if (JSON.stringify(prevProps) === JSON.stringify(nextProps)) { + return true; + } + + return false; +}); diff --git a/plugins/users/src/components/panel/Users.tsx b/plugins/users/src/components/panel/Users.tsx new file mode 100644 index 000000000..ebdf3fc01 --- /dev/null +++ b/plugins/users/src/components/panel/Users.tsx @@ -0,0 +1,88 @@ +import { Alert, AlertActionLink, AlertVariant, Gallery, GalleryItem, Spinner } from '@patternfly/react-core'; +import { QueryObserverResult, useQuery } from 'react-query'; +import React from 'react'; + +import { IUser } from '../../utils/interfaces'; +import UsersItem from '../page/UsersItem'; + +interface IUsersProps { + cluster: string; + namespace: string; + name: string; +} + +// The Users component is used to load all users for the specified team. +const Users: React.FunctionComponent = ({ cluster, namespace, name }: IUsersProps) => { + const { isError, isLoading, error, data, refetch } = useQuery( + ['users/team', cluster, namespace, name], + async () => { + try { + const response = await fetch(`/api/plugins/users/team?cluster=${cluster}&namespace=${namespace}&name=${name}`, { + method: 'get', + }); + const json = await response.json(); + + if (response.status >= 200 && response.status < 300) { + return json; + } else { + if (json.error) { + throw new Error(json.error); + } else { + throw new Error('An unknown error occured'); + } + } + } catch (err) { + throw err; + } + }, + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( + + > => refetch()}> + Retry + + + } + > +

{error?.message}

+
+ ); + } + + if (!data) { + return null; + } + + return ( + + {data.map((user, index) => ( + + + + ))} + + ); +}; + +export default Users; diff --git a/plugins/users/src/index.ts b/plugins/users/src/index.ts new file mode 100644 index 000000000..8f4435650 --- /dev/null +++ b/plugins/users/src/index.ts @@ -0,0 +1,18 @@ +import { IPluginComponents } from '@kobsio/plugin-core'; + +import icon from './assets/icon.png'; + +import Home from './components/home/Home'; +import Page from './components/page/Page'; +import Panel from './components/panel/Panel'; + +const usersPlugin: IPluginComponents = { + users: { + home: Home, + icon: icon, + page: Page, + panel: Panel, + }, +}; + +export default usersPlugin; diff --git a/plugins/users/src/utils/helpers.ts b/plugins/users/src/utils/helpers.ts new file mode 100644 index 000000000..dc0dbe5c3 --- /dev/null +++ b/plugins/users/src/utils/helpers.ts @@ -0,0 +1,5 @@ +import md5 from 'md5'; + +export const getGravatarImageUrl = (email: string, size: number): string => { + return 'https://secure.gravatar.com/avatar/' + md5(email.toLowerCase().trim()) + '?size=' + size + '&default=mm'; +}; diff --git a/plugins/users/src/utils/interfaces.ts b/plugins/users/src/utils/interfaces.ts new file mode 100644 index 000000000..794909e77 --- /dev/null +++ b/plugins/users/src/utils/interfaces.ts @@ -0,0 +1,26 @@ +// IUser is the interface for a User CR. The interface must implement the same fields as the Users CRD. the only +// different is that we can be sure that the cluster, namespace and name of a user is always present in the frontend, +// because it will be set when a user is retrieved from the Kubernetes API. +export interface IUser { + cluster: string; + namespace: string; + name: string; + id: string; + fullName: string; + email: string; + position?: string; + bio?: string; + teams?: ITeam[]; +} + +export interface ITeam { + cluster?: string; + namespace?: string; + name: string; +} + +export interface IPanelOptions { + cluster?: string; + namespace?: string; + name?: string; +} diff --git a/plugins/users/tsconfig.esm.json b/plugins/users/tsconfig.esm.json new file mode 100644 index 000000000..acbc1eff8 --- /dev/null +++ b/plugins/users/tsconfig.esm.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "compilerOptions": { + "outDir": "lib-esm", + "module": "esnext", + "target": "esnext", + "moduleResolution": "node", + "lib": ["dom", "esnext"], + "declaration": false + } +} diff --git a/plugins/users/tsconfig.json b/plugins/users/tsconfig.json new file mode 100644 index 000000000..09365d619 --- /dev/null +++ b/plugins/users/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "exclude": ["node_modules", "lib-esm", "lib"], + "include": ["src"], + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "outDir": "lib", + "declaration": true + } +} diff --git a/plugins/users/users.go b/plugins/users/users.go new file mode 100644 index 000000000..3c32f3114 --- /dev/null +++ b/plugins/users/users.go @@ -0,0 +1,201 @@ +package users + +import ( + "encoding/json" + "net/http" + + team "github.com/kobsio/kobs/pkg/api/apis/team/v1beta1" + user "github.com/kobsio/kobs/pkg/api/apis/user/v1beta1" + "github.com/kobsio/kobs/pkg/api/clusters" + "github.com/kobsio/kobs/pkg/api/middleware/errresponse" + "github.com/kobsio/kobs/pkg/api/plugins/plugin" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/sirupsen/logrus" +) + +// Route is the route under which the plugin should be registered in our router for the rest api. +const Route = "/users" + +var ( + log = logrus.WithFields(logrus.Fields{"package": "users"}) +) + +// Config is the structure of the configuration for the users plugin. +type Config struct{} + +// Router implements the router for the resources plugin, which can be registered in the router for our rest api. +type Router struct { + *chi.Mux + clusters *clusters.Clusters + config Config +} + +type getTeamsData struct { + Teams []user.TeamReference `json:"teams"` +} + +func isMember(teams []user.TeamReference, defaultCluster, defaultNamespace, cluster, namespace, name string) bool { + for _, team := range teams { + c := defaultCluster + if team.Cluster != "" { + c = team.Cluster + } + + n := defaultNamespace + if team.Namespace != "" { + n = team.Namespace + } + + if c == cluster && n == namespace && team.Name == name { + return true + } + } + + return false +} + +// getUsers returns a list of users for all clusters and namespaces. We always return all users for all clusters and +// namespaces. For this we are looping though the loaded clusters and callend the GetUsers function for each one. +func (router *Router) getUsers(w http.ResponseWriter, r *http.Request) { + log.Tracef("getUsers") + + var users []user.UserSpec + + for _, cluster := range router.clusters.Clusters { + user, err := cluster.GetUsers(r.Context(), "") + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get users") + return + } + + users = append(users, user...) + } + + log.WithFields(logrus.Fields{"count": len(users)}).Tracef("getUsers") + render.JSON(w, r, users) +} + +// getUser returns a a single user for the given cluster and namespace and name. The cluster, namespace and name is +// defined via a corresponding query parameter. Then we are using the cluster object to get the user via the GetUser +// function. +func (router *Router) getUser(w http.ResponseWriter, r *http.Request) { + clusterName := r.URL.Query().Get("cluster") + namespace := r.URL.Query().Get("namespace") + name := r.URL.Query().Get("name") + + log.WithFields(logrus.Fields{"cluster": clusterName, "namespace": namespace, "name": name}).Tracef("getUser") + + cluster := router.clusters.GetCluster(clusterName) + if cluster == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Invalid cluster name") + return + } + + user, err := cluster.GetUser(r.Context(), namespace, name) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get user") + return + } + + render.JSON(w, r, user) +} + +// getTeams returns all teams, where a users is a member of. +func (router *Router) getTeams(w http.ResponseWriter, r *http.Request) { + defaultClusterName := r.URL.Query().Get("cluster") + defaultNamespace := r.URL.Query().Get("namespace") + + var data getTeamsData + + err := json.NewDecoder(r.Body).Decode(&data) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not decode request body") + return + } + + var teams []*team.TeamSpec + + for _, team := range data.Teams { + c := defaultClusterName + if team.Cluster != "" { + c = team.Cluster + } + + n := defaultNamespace + if team.Namespace != "" { + c = team.Namespace + } + + cluster := router.clusters.GetCluster(c) + if cluster == nil { + errresponse.Render(w, r, nil, http.StatusBadRequest, "Invalid cluster name") + return + } + + t, err := cluster.GetTeam(r.Context(), n, team.Name) + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get team") + return + } + + teams = append(teams, t) + } + + render.JSON(w, r, teams) +} + +// getTeam returns all users for the given team. +func (router *Router) getTeam(w http.ResponseWriter, r *http.Request) { + teamCluster := r.URL.Query().Get("cluster") + teamNamespace := r.URL.Query().Get("namespace") + teamName := r.URL.Query().Get("name") + + log.WithFields(logrus.Fields{"cluster": teamCluster, "namespace": teamNamespace, "name": teamName}).Tracef("getTeam") + + var users []user.UserSpec + var filteredUsers []user.UserSpec + + for _, cluster := range router.clusters.Clusters { + user, err := cluster.GetUsers(r.Context(), "") + if err != nil { + errresponse.Render(w, r, err, http.StatusBadRequest, "Could not get users") + return + } + + users = append(users, user...) + } + + for _, user := range users { + if isMember(user.Teams, user.Cluster, user.Namespace, teamCluster, teamNamespace, teamName) { + filteredUsers = append(filteredUsers, user) + } + } + + render.JSON(w, r, filteredUsers) +} + +// Register returns a new router which can be used in the router for the kobs rest api. +func Register(clusters *clusters.Clusters, plugins *plugin.Plugins, config Config) chi.Router { + plugins.Append(plugin.Plugin{ + Name: "users", + DisplayName: "Users", + Description: "Define the members of your Teams.", + Home: true, + Type: "users", + }) + + router := Router{ + chi.NewRouter(), + clusters, + config, + } + + router.Get("/users", router.getUsers) + router.Get("/user", router.getUser) + router.Post("/teams", router.getTeams) + router.Get("/team", router.getTeam) + + return router +} diff --git a/typings.d.ts b/typings.d.ts index 48b51ad0e..1cc546e20 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -21,3 +21,8 @@ declare module '*.css' { declare module 'cytoscape-dagre'; declare module 'cytoscape-node-html-label'; + +declare module 'md5' { + function md5(data: string, options?: { encoding: string; asBytes: boolean; asString: boolean }): string; + export = md5; +} diff --git a/yarn.lock b/yarn.lock index 6880593c5..630612398 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4718,6 +4718,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +charenc@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= + check-types@^11.1.1: version "11.1.2" resolved "https://registry.yarnpkg.com/check-types/-/check-types-11.1.2.tgz#86a7c12bf5539f6324eb0e70ca8896c0e38f3e2f" @@ -5345,6 +5350,11 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" +crypt@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= + crypto-browserify@^3.11.0: version "3.12.0" resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" @@ -8356,7 +8366,7 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-buffer@^1.1.5: +is-buffer@^1.1.5, is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -9836,6 +9846,15 @@ md5.js@^1.3.4: inherits "^2.0.1" safe-buffer "^5.1.2" +md5@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" + integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g== + dependencies: + charenc "0.0.2" + crypt "0.0.2" + is-buffer "~1.1.6" + mdast-util-definitions@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-5.1.0.tgz#b6d10ef00a3c4cf191e8d9a5fa58d7f4a366f817" @@ -12770,7 +12789,7 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== -react-markdown@7.0.1: +react-markdown@7.0.1, react-markdown@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-7.0.1.tgz#c7365fcd7d1813b3ae68f2200e8f92d47d865627" integrity sha512-pthNPaoiwg0q7hukoE04F2ENwSzijIlWHJ4UMs/96LUe/G/P3FnbP4qHzx3FoNqae+2SqDG8vzniTLnJDeWneg==