mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-01-07 05:57:02 +00:00
Initial aggregated API server (#15)
Add initial aggregated API server (squashed from a bunch of commits). Signed-off-by: Andrew Keesler <akeesler@vmware.com> Signed-off-by: Aram Price <pricear@vmware.com> Signed-off-by: Ryan Richard <richardry@vmware.com>
This commit is contained in:
132
pkg/apiserver/apiserver.go
Normal file
132
pkg/apiserver/apiserver.go
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright 2020 VMware, Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package apiserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/client-go/pkg/version"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
placeholderapi "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder"
|
||||
placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1"
|
||||
"github.com/suzerain-io/placeholder-name/pkg/registry/loginrequest"
|
||||
)
|
||||
|
||||
var (
|
||||
//nolint: gochecknoglobals
|
||||
scheme = runtime.NewScheme()
|
||||
//nolint: gochecknoglobals
|
||||
//nolint: golint
|
||||
Codecs = serializer.NewCodecFactory(scheme)
|
||||
)
|
||||
|
||||
//nolint: gochecknoinits
|
||||
func init() {
|
||||
utilruntime.Must(placeholderv1alpha1.AddToScheme(scheme))
|
||||
utilruntime.Must(placeholderapi.AddToScheme(scheme))
|
||||
|
||||
// add the options to empty v1
|
||||
metav1.AddToGroupVersion(scheme, schema.GroupVersion{Version: "v1"})
|
||||
|
||||
unversioned := schema.GroupVersion{Group: "", Version: "v1"}
|
||||
scheme.AddUnversionedTypes(unversioned,
|
||||
&metav1.Status{},
|
||||
&metav1.APIVersions{},
|
||||
&metav1.APIGroupList{},
|
||||
&metav1.APIGroup{},
|
||||
&metav1.APIResourceList{},
|
||||
)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
GenericConfig *genericapiserver.RecommendedConfig
|
||||
ExtraConfig ExtraConfig
|
||||
}
|
||||
|
||||
type ExtraConfig struct {
|
||||
Webhook authenticator.Token
|
||||
}
|
||||
|
||||
type PlaceHolderServer struct {
|
||||
GenericAPIServer *genericapiserver.GenericAPIServer
|
||||
}
|
||||
|
||||
type completedConfig struct {
|
||||
GenericConfig genericapiserver.CompletedConfig
|
||||
ExtraConfig *ExtraConfig
|
||||
}
|
||||
|
||||
type CompletedConfig struct {
|
||||
// Embed a private pointer that cannot be instantiated outside of this package.
|
||||
*completedConfig
|
||||
}
|
||||
|
||||
// Complete fills in any fields not set that are required to have valid data. It's mutating the receiver.
|
||||
func (c *Config) Complete() CompletedConfig {
|
||||
completedCfg := completedConfig{
|
||||
c.GenericConfig.Complete(),
|
||||
&c.ExtraConfig,
|
||||
}
|
||||
|
||||
versionInfo := version.Get()
|
||||
completedCfg.GenericConfig.Version = &versionInfo
|
||||
|
||||
return CompletedConfig{completedConfig: &completedCfg}
|
||||
}
|
||||
|
||||
// New returns a new instance of AdmissionServer from the given config.
|
||||
func (c completedConfig) New() (*PlaceHolderServer, error) {
|
||||
genericServer, err := c.GenericConfig.New("place-holder-server", genericapiserver.NewEmptyDelegate()) // completion is done in Complete, no need for a second time
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("completion error: %w", err)
|
||||
}
|
||||
|
||||
s := &PlaceHolderServer{
|
||||
GenericAPIServer: genericServer,
|
||||
}
|
||||
|
||||
gvr := placeholderv1alpha1.SchemeGroupVersion.WithResource("loginrequests")
|
||||
|
||||
apiGroupInfo := genericapiserver.APIGroupInfo{
|
||||
PrioritizedVersions: []schema.GroupVersion{gvr.GroupVersion()},
|
||||
VersionedResourcesStorageMap: map[string]map[string]rest.Storage{},
|
||||
OptionsExternalVersion: &schema.GroupVersion{Version: "v1"},
|
||||
Scheme: scheme,
|
||||
ParameterCodec: metav1.ParameterCodec,
|
||||
NegotiatedSerializer: Codecs,
|
||||
}
|
||||
|
||||
loginRequestStorage := loginrequest.NewREST(c.ExtraConfig.Webhook)
|
||||
|
||||
v1alpha1Storage, ok := apiGroupInfo.VersionedResourcesStorageMap[gvr.Version]
|
||||
if !ok {
|
||||
v1alpha1Storage = map[string]rest.Storage{}
|
||||
}
|
||||
v1alpha1Storage[gvr.Resource] = loginRequestStorage
|
||||
apiGroupInfo.VersionedResourcesStorageMap[gvr.Version] = v1alpha1Storage
|
||||
|
||||
if err := s.GenericAPIServer.InstallAPIGroup(&apiGroupInfo); err != nil {
|
||||
return nil, fmt.Errorf("install API group error: %w", err)
|
||||
}
|
||||
|
||||
s.GenericAPIServer.AddPostStartHookOrDie("place-holder-post-start-hook",
|
||||
func(context genericapiserver.PostStartHookContext) error {
|
||||
klog.InfoS("post start hook", "foo", "bar")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
114
pkg/registry/loginrequest/rest.go
Normal file
114
pkg/registry/loginrequest/rest.go
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
Copyright 2020 VMware, Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Package loginrequest provides REST functionality for the LoginRequest resource.
|
||||
package loginrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
placeholderapi "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder"
|
||||
)
|
||||
|
||||
var (
|
||||
_ rest.Creater = &REST{}
|
||||
_ rest.NamespaceScopedStrategy = &REST{}
|
||||
_ rest.Scoper = &REST{}
|
||||
_ rest.Storage = &REST{}
|
||||
)
|
||||
|
||||
func NewREST(webhook authenticator.Token) *REST {
|
||||
return &REST{
|
||||
webhook: webhook,
|
||||
}
|
||||
}
|
||||
|
||||
type REST struct {
|
||||
webhook authenticator.Token
|
||||
}
|
||||
|
||||
func (r *REST) New() runtime.Object {
|
||||
return &placeholderapi.LoginRequest{}
|
||||
}
|
||||
|
||||
func (r *REST) NamespaceScoped() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
|
||||
loginRequest, ok := obj.(*placeholderapi.LoginRequest)
|
||||
if !ok {
|
||||
return nil, apierrors.NewBadRequest(fmt.Sprintf("not a LoginRequest: %#v", obj))
|
||||
}
|
||||
|
||||
// TODO refactor all validation checks into a validation function in another package (e.g. see subjectaccessreqview api in k8s)
|
||||
|
||||
// TODO also validate .spec.type
|
||||
token := loginRequest.Spec.Token
|
||||
if token == nil || len(token.Value) == 0 {
|
||||
errs := field.ErrorList{field.Required(field.NewPath("spec", "token", "value"), "token must be supplied")}
|
||||
return nil, apierrors.NewInvalid(placeholderapi.Kind(loginRequest.Kind), loginRequest.Name, errs)
|
||||
}
|
||||
|
||||
// let dynamic admission webhooks have a chance to validate (but not mutate) as well
|
||||
// TODO Are we okay with admission webhooks being able to see tokens? Maybe strip token out before passing obj to createValidation.
|
||||
// Since we are an aggregated API, we should investigate to see if the kube API server is already invoking admission hooks for us.
|
||||
// Even if it is, its okay to call it again here. If the kube API server is already calling the webhooks and passing
|
||||
// the token, then there is probably no reason for us to avoid passing the token when we call the webhooks here, since
|
||||
// they already got the token.
|
||||
if createValidation != nil {
|
||||
if err := createValidation(ctx, obj.DeepCopyObject()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// just a sanity check, not sure how to honor a dry run on a virtual API
|
||||
if options != nil {
|
||||
if len(options.DryRun) != 0 {
|
||||
errs := field.ErrorList{field.NotSupported(field.NewPath("dryRun"), options.DryRun, nil)}
|
||||
return nil, apierrors.NewInvalid(placeholderapi.Kind(loginRequest.Kind), loginRequest.Name, errs)
|
||||
}
|
||||
}
|
||||
|
||||
// the incoming context could have an audience attached to it technically
|
||||
// sine we do not want to handle audiences right now, do not pass it through directly
|
||||
// instead we just propagate cancellation of the parent context
|
||||
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
cancel()
|
||||
case <-cancelCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
// TODO do the actual business logic of this endpoint here
|
||||
|
||||
_, _, err := r.webhook.AuthenticateToken(cancelCtx, token.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authenticate token failed: %w", err)
|
||||
}
|
||||
|
||||
// make a new object so that we do not return the original token in the response
|
||||
out := &placeholderapi.LoginRequest{
|
||||
Status: placeholderapi.LoginRequestStatus{
|
||||
ExpirationTimestamp: nil,
|
||||
Token: "snorlax",
|
||||
ClientCertificateData: "",
|
||||
ClientKeyData: "",
|
||||
},
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
240
pkg/registry/loginrequest/rest_test.go
Normal file
240
pkg/registry/loginrequest/rest_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
Copyright 2020 VMware, Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package loginrequest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
placeholderapi "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder"
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
type FakeToken struct {
|
||||
calledWithToken string
|
||||
calledWithContext context.Context
|
||||
timeout time.Duration
|
||||
reachedTimeout bool
|
||||
cancelled bool
|
||||
webhookStartedRunningNotificationChan chan bool
|
||||
returnErr error
|
||||
}
|
||||
|
||||
func (f *FakeToken) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
|
||||
f.calledWithToken = token
|
||||
f.calledWithContext = ctx
|
||||
if f.webhookStartedRunningNotificationChan != nil {
|
||||
f.webhookStartedRunningNotificationChan <- true
|
||||
}
|
||||
afterCh := time.After(f.timeout)
|
||||
select {
|
||||
case <-afterCh:
|
||||
f.reachedTimeout = true
|
||||
case <-ctx.Done():
|
||||
f.cancelled = true
|
||||
}
|
||||
return &authenticator.Response{}, true, f.returnErr
|
||||
}
|
||||
|
||||
func callCreate(ctx context.Context, storage *REST, loginRequest *placeholderapi.LoginRequest) (runtime.Object, error) {
|
||||
return storage.Create(
|
||||
ctx,
|
||||
loginRequest,
|
||||
rest.ValidateAllObjectFunc,
|
||||
&metav1.CreateOptions{
|
||||
DryRun: []string{},
|
||||
})
|
||||
}
|
||||
|
||||
func validLoginRequest() *placeholderapi.LoginRequest {
|
||||
return loginRequest(placeholderapi.LoginRequestSpec{
|
||||
Type: placeholderapi.TokenLoginCredentialType,
|
||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: "a token"},
|
||||
})
|
||||
}
|
||||
|
||||
func loginRequest(spec placeholderapi.LoginRequestSpec) *placeholderapi.LoginRequest {
|
||||
return &placeholderapi.LoginRequest{
|
||||
TypeMeta: metav1.TypeMeta{},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "request name",
|
||||
},
|
||||
Spec: spec,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSucceedsWhenGivenAToken(t *testing.T) {
|
||||
webhook := FakeToken{}
|
||||
storage := NewREST(&webhook)
|
||||
requestToken := "a token"
|
||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||
Type: placeholderapi.TokenLoginCredentialType,
|
||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: requestToken},
|
||||
}))
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, response, &placeholderapi.LoginRequest{
|
||||
Status: placeholderapi.LoginRequestStatus{
|
||||
ExpirationTimestamp: nil,
|
||||
Token: "snorlax",
|
||||
ClientCertificateData: "",
|
||||
ClientKeyData: "",
|
||||
},
|
||||
})
|
||||
require.Equal(t, requestToken, webhook.calledWithToken)
|
||||
}
|
||||
|
||||
func TestCreateDoesNotPassAdditionalContextInfoToTheWebhook(t *testing.T) {
|
||||
webhook := FakeToken{}
|
||||
storage := NewREST(&webhook)
|
||||
ctx := context.WithValue(context.Background(), contextKey{}, "context-value")
|
||||
|
||||
_, err := callCreate(ctx, storage, validLoginRequest())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, webhook.calledWithContext.Value("context-key"))
|
||||
}
|
||||
|
||||
func TestCreateCancelsTheWebhookInvocationWhenTheCallToCreateIsCancelledItself(t *testing.T) {
|
||||
webhookStartedRunningNotificationChan := make(chan bool)
|
||||
webhook := FakeToken{
|
||||
timeout: time.Second * 2,
|
||||
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
||||
}
|
||||
storage := NewREST(&webhook)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
c := make(chan bool)
|
||||
go func() {
|
||||
_, err := callCreate(ctx, storage, validLoginRequest())
|
||||
c <- true
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
require.False(t, webhook.cancelled)
|
||||
require.False(t, webhook.reachedTimeout)
|
||||
<-webhookStartedRunningNotificationChan // wait long enough to make sure that the webhook has started
|
||||
cancel() // cancel the context that was passed to storage.Create() above
|
||||
<-c // wait for the above call to storage.Create() to be finished
|
||||
require.True(t, webhook.cancelled)
|
||||
require.False(t, webhook.reachedTimeout)
|
||||
require.Equal(t, context.Canceled, webhook.calledWithContext.Err()) // the inner context is cancelled
|
||||
}
|
||||
|
||||
func TestCreateAllowsTheWebhookInvocationToFinishWhenTheCallToCreateIsNotCancelledItself(t *testing.T) {
|
||||
webhookStartedRunningNotificationChan := make(chan bool)
|
||||
webhook := FakeToken{
|
||||
timeout: 0,
|
||||
webhookStartedRunningNotificationChan: webhookStartedRunningNotificationChan,
|
||||
}
|
||||
storage := NewREST(&webhook)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
c := make(chan bool)
|
||||
go func() {
|
||||
_, err := callCreate(ctx, storage, validLoginRequest())
|
||||
c <- true
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
require.False(t, webhook.cancelled)
|
||||
require.False(t, webhook.reachedTimeout)
|
||||
<-webhookStartedRunningNotificationChan // wait long enough to make sure that the webhook has started
|
||||
<-c // wait for the above call to storage.Create() to be finished
|
||||
require.False(t, webhook.cancelled)
|
||||
require.True(t, webhook.reachedTimeout)
|
||||
require.Equal(t, context.Canceled, webhook.calledWithContext.Err()) // the inner context is cancelled (in this case by the "defer")
|
||||
}
|
||||
|
||||
func TestCreateFailsWhenWebhookFails(t *testing.T) {
|
||||
webhook := FakeToken{
|
||||
returnErr: errors.New("some webhook error"),
|
||||
}
|
||||
storage := NewREST(&webhook)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
_, err := callCreate(ctx, storage, validLoginRequest())
|
||||
require.EqualError(t, err, "authenticate token failed: some webhook error")
|
||||
}
|
||||
|
||||
func TestCreateFailsWhenGivenTheWrongInputType(t *testing.T) {
|
||||
notALoginRequest := runtime.Unknown{}
|
||||
response, err := NewREST(&FakeToken{}).Create(
|
||||
genericapirequest.NewContext(),
|
||||
¬ALoginRequest,
|
||||
rest.ValidateAllObjectFunc,
|
||||
&metav1.CreateOptions{})
|
||||
|
||||
require.Nil(t, response)
|
||||
require.True(t, apierrors.IsBadRequest(err))
|
||||
var status apierrors.APIStatus
|
||||
errors.As(err, &status)
|
||||
require.Contains(t, status.Status().Message, "not a LoginRequest")
|
||||
}
|
||||
|
||||
func TestCreateFailsWhenTokenIsNilInRequest(t *testing.T) {
|
||||
storage := NewREST(&FakeToken{})
|
||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||
Type: placeholderapi.TokenLoginCredentialType,
|
||||
Token: nil,
|
||||
}))
|
||||
|
||||
require.Nil(t, response)
|
||||
require.True(t, apierrors.IsInvalid(err))
|
||||
var status apierrors.APIStatus
|
||||
errors.As(err, &status)
|
||||
require.Equal(t,
|
||||
`.placeholder.suzerain-io.github.io "request name" is invalid: spec.token.value: Required value: token must be supplied`,
|
||||
status.Status().Message)
|
||||
}
|
||||
|
||||
func TestCreateFailsWhenTokenValueIsEmptyInRequest(t *testing.T) {
|
||||
storage := NewREST(&FakeToken{})
|
||||
response, err := callCreate(context.Background(), storage, loginRequest(placeholderapi.LoginRequestSpec{
|
||||
Type: placeholderapi.TokenLoginCredentialType,
|
||||
Token: &placeholderapi.LoginRequestTokenCredential{Value: ""},
|
||||
}))
|
||||
|
||||
require.Nil(t, response)
|
||||
require.True(t, apierrors.IsInvalid(err))
|
||||
var status apierrors.APIStatus
|
||||
errors.As(err, &status)
|
||||
require.Equal(t,
|
||||
`.placeholder.suzerain-io.github.io "request name" is invalid: spec.token.value: Required value: token must be supplied`,
|
||||
status.Status().Message)
|
||||
}
|
||||
|
||||
func TestCreateFailsWhenRequestOptionsDryRunIsNotEmpty(t *testing.T) {
|
||||
response, err := NewREST(&FakeToken{}).Create(
|
||||
genericapirequest.NewContext(),
|
||||
validLoginRequest(),
|
||||
rest.ValidateAllObjectFunc,
|
||||
&metav1.CreateOptions{
|
||||
DryRun: []string{"some dry run flag"},
|
||||
})
|
||||
|
||||
require.Nil(t, response)
|
||||
require.True(t, apierrors.IsInvalid(err))
|
||||
var status apierrors.APIStatus
|
||||
errors.As(err, &status)
|
||||
require.Equal(t,
|
||||
`.placeholder.suzerain-io.github.io "request name" is invalid: dryRun: Unsupported value: []string{"some dry run flag"}`,
|
||||
status.Status().Message)
|
||||
}
|
||||
Reference in New Issue
Block a user