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:
Mo Khan
2020-07-23 11:05:21 -04:00
committed by GitHub
parent 23c1b32a02
commit 5fdc20886d
14 changed files with 906 additions and 152 deletions

132
pkg/apiserver/apiserver.go Normal file
View 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
}

View 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
}

View 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(),
&notALoginRequest,
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)
}