Merge pull request #90 from suzerain-io/easy_demo
Add <20 minutes Pinniped demo
This commit is contained in:
commit
b1d9665b03
@ -19,13 +19,16 @@ COPY tools ./tools
|
||||
COPY hack ./hack
|
||||
|
||||
# Build the executable binary (CGO_ENABLED=0 means static linking)
|
||||
RUN mkdir out && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(hack/get-ldflags.sh)" -o out ./cmd/pinniped-server/...
|
||||
RUN mkdir out \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(hack/get-ldflags.sh)" -o out ./cmd/pinniped-server/... \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o out ./cmd/local-user-authenticator/...
|
||||
|
||||
# Use a runtime image based on Debian slim
|
||||
FROM debian:10.5-slim
|
||||
|
||||
# Copy the binary from the build-env stage
|
||||
# Copy the binaries from the build-env stage
|
||||
COPY --from=build-env /work/out/pinniped-server /usr/local/bin/pinniped-server
|
||||
COPY --from=build-env /work/out/local-user-authenticator /usr/local/bin/local-user-authenticator
|
||||
|
||||
# Document the port
|
||||
EXPOSE 443
|
||||
|
357
cmd/local-user-authenticator/main.go
Normal file
357
cmd/local-user-authenticator/main.go
Normal file
@ -0,0 +1,357 @@
|
||||
/*
|
||||
Copyright 2020 VMware, Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Package main provides a authentication webhook program.
|
||||
//
|
||||
// This webhook is meant to be used in demo settings to play around with
|
||||
// Pinniped. As well, it can come in handy in integration tests.
|
||||
//
|
||||
// This webhook is NOT meant for use in production systems.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
kubeinformers "k8s.io/client-go/informers"
|
||||
corev1informers "k8s.io/client-go/informers/core/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
restclient "k8s.io/client-go/rest"
|
||||
"k8s.io/klog/v2"
|
||||
|
||||
"github.com/suzerain-io/pinniped/internal/constable"
|
||||
"github.com/suzerain-io/pinniped/internal/controller/apicerts"
|
||||
"github.com/suzerain-io/pinniped/internal/controllerlib"
|
||||
"github.com/suzerain-io/pinniped/internal/provider"
|
||||
)
|
||||
|
||||
const (
|
||||
// This string must match the name of the Namespace declared in the deployment yaml.
|
||||
namespace = "local-user-authenticator"
|
||||
// This string must match the name of the Service declared in the deployment yaml.
|
||||
serviceName = "local-user-authenticator"
|
||||
|
||||
unauthenticatedResponse = `{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":false}}`
|
||||
authenticatedResponseTemplate = `{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":true,"user":{"username":"%s","uid":"%s","groups":%s}}}`
|
||||
|
||||
singletonWorker = 1
|
||||
defaultResyncInterval = 3 * time.Minute
|
||||
|
||||
invalidRequest = constable.Error("invalid request")
|
||||
)
|
||||
|
||||
type webhook struct {
|
||||
certProvider provider.DynamicTLSServingCertProvider
|
||||
secretInformer corev1informers.SecretInformer
|
||||
}
|
||||
|
||||
func newWebhook(
|
||||
certProvider provider.DynamicTLSServingCertProvider,
|
||||
secretInformer corev1informers.SecretInformer,
|
||||
) *webhook {
|
||||
return &webhook{
|
||||
certProvider: certProvider,
|
||||
secretInformer: secretInformer,
|
||||
}
|
||||
}
|
||||
|
||||
// start runs the webhook in a separate goroutine and returns whether or not the
|
||||
// webhook was started successfully.
|
||||
func (w *webhook) start(ctx context.Context, l net.Listener) error {
|
||||
server := http.Server{
|
||||
Handler: w,
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
certPEM, keyPEM := w.certProvider.CurrentCertKeyContent()
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
return &cert, err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
// Per ListenAndServeTLS doc, the {cert,key}File parameters can be empty
|
||||
// since we want to use the certs from http.Server.TLSConfig.
|
||||
errCh <- server.ServeTLS(l, "", "")
|
||||
}()
|
||||
|
||||
go func() {
|
||||
select {
|
||||
case err := <-errCh:
|
||||
klog.InfoS("server exited", "err", err)
|
||||
case <-ctx.Done():
|
||||
klog.InfoS("server context cancelled", "err", ctx.Err())
|
||||
if err := server.Shutdown(context.Background()); err != nil {
|
||||
klog.InfoS("server shutdown failed", "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *webhook) ServeHTTP(rsp http.ResponseWriter, req *http.Request) {
|
||||
defer req.Body.Close()
|
||||
|
||||
username, password, err := getUsernameAndPasswordFromRequest(rsp, req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
secret, err := w.secretInformer.Lister().Secrets(namespace).Get(username)
|
||||
notFound := k8serrors.IsNotFound(err)
|
||||
if err != nil && !notFound {
|
||||
klog.InfoS("could not get secret", "err", err)
|
||||
rsp.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if notFound {
|
||||
klog.InfoS("user not found")
|
||||
respondWithUnauthenticated(rsp)
|
||||
return
|
||||
}
|
||||
|
||||
passwordMatches := bcrypt.CompareHashAndPassword(
|
||||
secret.Data["passwordHash"],
|
||||
[]byte(password),
|
||||
) == nil
|
||||
if !passwordMatches {
|
||||
klog.InfoS("invalid password in request")
|
||||
respondWithUnauthenticated(rsp)
|
||||
return
|
||||
}
|
||||
|
||||
groups := []string{}
|
||||
groupsBuf := bytes.NewBuffer(secret.Data["groups"])
|
||||
if groupsBuf.Len() > 0 {
|
||||
groupsCSVReader := csv.NewReader(groupsBuf)
|
||||
groups, err = groupsCSVReader.Read()
|
||||
if err != nil {
|
||||
klog.InfoS("could not read groups", "err", err)
|
||||
rsp.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
trimLeadingAndTrailingWhitespace(groups)
|
||||
}
|
||||
|
||||
klog.InfoS("successful authentication")
|
||||
respondWithAuthenticated(rsp, secret.ObjectMeta.Name, string(secret.UID), groups)
|
||||
}
|
||||
|
||||
func getUsernameAndPasswordFromRequest(rsp http.ResponseWriter, req *http.Request) (string, string, error) {
|
||||
if req.URL.Path != "/authenticate" {
|
||||
klog.InfoS("received request path other than /authenticate", "path", req.URL.Path)
|
||||
rsp.WriteHeader(http.StatusNotFound)
|
||||
return "", "", invalidRequest
|
||||
}
|
||||
|
||||
if req.Method != http.MethodPost {
|
||||
klog.InfoS("received request method other than post", "method", req.Method)
|
||||
rsp.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return "", "", invalidRequest
|
||||
}
|
||||
|
||||
if !headerContains(req, "Content-Type", "application/json") {
|
||||
klog.InfoS("content type is not application/json", "Content-Type", req.Header.Values("Content-Type"))
|
||||
rsp.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
return "", "", invalidRequest
|
||||
}
|
||||
|
||||
if !headerContains(req, "Accept", "application/json") &&
|
||||
!headerContains(req, "Accept", "application/*") &&
|
||||
!headerContains(req, "Accept", "*/*") {
|
||||
klog.InfoS("client does not accept application/json", "Accept", req.Header.Values("Accept"))
|
||||
rsp.WriteHeader(http.StatusUnsupportedMediaType)
|
||||
return "", "", invalidRequest
|
||||
}
|
||||
|
||||
var body authenticationv1.TokenReview
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
klog.InfoS("failed to decode body", "err", err)
|
||||
rsp.WriteHeader(http.StatusBadRequest)
|
||||
return "", "", invalidRequest
|
||||
}
|
||||
|
||||
tokenSegments := strings.SplitN(body.Spec.Token, ":", 2)
|
||||
if len(tokenSegments) != 2 {
|
||||
klog.InfoS("bad token format in request")
|
||||
rsp.WriteHeader(http.StatusBadRequest)
|
||||
return "", "", invalidRequest
|
||||
}
|
||||
|
||||
return tokenSegments[0], tokenSegments[1], nil
|
||||
}
|
||||
|
||||
func headerContains(req *http.Request, headerName, s string) bool {
|
||||
headerValues := req.Header.Values(headerName)
|
||||
for i := range headerValues {
|
||||
mimeTypes := strings.Split(headerValues[i], ",")
|
||||
for _, mimeType := range mimeTypes {
|
||||
mediaType, _, _ := mime.ParseMediaType(mimeType)
|
||||
if mediaType == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func trimLeadingAndTrailingWhitespace(ss []string) {
|
||||
for i := range ss {
|
||||
ss[i] = strings.TrimSpace(ss[i])
|
||||
}
|
||||
}
|
||||
|
||||
func respondWithUnauthenticated(rsp http.ResponseWriter) {
|
||||
rsp.Header().Add("Content-Type", "application/json")
|
||||
_, _ = rsp.Write([]byte(unauthenticatedResponse))
|
||||
}
|
||||
|
||||
func respondWithAuthenticated(
|
||||
rsp http.ResponseWriter,
|
||||
username, uid string,
|
||||
groups []string,
|
||||
) {
|
||||
rsp.Header().Add("Content-Type", "application/json")
|
||||
groupsJSONBytes, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
klog.InfoS("could not encode response", "err", err)
|
||||
rsp.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
jsonBody := fmt.Sprintf(authenticatedResponseTemplate, username, uid, groupsJSONBytes)
|
||||
_, _ = rsp.Write([]byte(jsonBody))
|
||||
}
|
||||
|
||||
func newK8sClient() (kubernetes.Interface, error) {
|
||||
kubeConfig, err := restclient.InClusterConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not load in-cluster configuration: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the core Kubernetes API.
|
||||
kubeClient, err := kubernetes.NewForConfig(kubeConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not load in-cluster configuration: %w", err)
|
||||
}
|
||||
|
||||
return kubeClient, nil
|
||||
}
|
||||
|
||||
func startControllers(
|
||||
ctx context.Context,
|
||||
dynamicCertProvider provider.DynamicTLSServingCertProvider,
|
||||
kubeClient kubernetes.Interface,
|
||||
kubeInformers kubeinformers.SharedInformerFactory,
|
||||
) {
|
||||
aVeryLongTime := time.Hour * 24 * 365 * 100
|
||||
|
||||
// Create controller manager.
|
||||
controllerManager := controllerlib.
|
||||
NewManager().
|
||||
WithController(
|
||||
apicerts.NewCertsManagerController(
|
||||
namespace,
|
||||
kubeClient,
|
||||
kubeInformers.Core().V1().Secrets(),
|
||||
controllerlib.WithInformer,
|
||||
controllerlib.WithInitialEvent,
|
||||
aVeryLongTime,
|
||||
"local-user-authenticator CA",
|
||||
serviceName,
|
||||
),
|
||||
singletonWorker,
|
||||
).
|
||||
WithController(
|
||||
apicerts.NewCertsObserverController(
|
||||
namespace,
|
||||
dynamicCertProvider,
|
||||
kubeInformers.Core().V1().Secrets(),
|
||||
controllerlib.WithInformer,
|
||||
),
|
||||
singletonWorker,
|
||||
)
|
||||
|
||||
kubeInformers.Start(ctx.Done())
|
||||
|
||||
go controllerManager.Start(ctx)
|
||||
}
|
||||
|
||||
func startWebhook(
|
||||
ctx context.Context,
|
||||
l net.Listener,
|
||||
dynamicCertProvider provider.DynamicTLSServingCertProvider,
|
||||
secretInformer corev1informers.SecretInformer,
|
||||
) error {
|
||||
return newWebhook(dynamicCertProvider, secretInformer).start(ctx, l)
|
||||
}
|
||||
|
||||
func waitForSignal() os.Signal {
|
||||
signalCh := make(chan os.Signal, 1)
|
||||
signal.Notify(signalCh, os.Interrupt)
|
||||
return <-signalCh
|
||||
}
|
||||
|
||||
func run() error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
kubeClient, err := newK8sClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create k8s client: %w", err)
|
||||
}
|
||||
|
||||
kubeInformers := kubeinformers.NewSharedInformerFactoryWithOptions(
|
||||
kubeClient,
|
||||
defaultResyncInterval,
|
||||
kubeinformers.WithNamespace(namespace),
|
||||
)
|
||||
|
||||
dynamicCertProvider := provider.NewDynamicTLSServingCertProvider()
|
||||
|
||||
startControllers(ctx, dynamicCertProvider, kubeClient, kubeInformers)
|
||||
klog.InfoS("controllers are ready")
|
||||
|
||||
//nolint: gosec
|
||||
l, err := net.Listen("tcp", ":443")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create listener: %w", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
err = startWebhook(ctx, l, dynamicCertProvider, kubeInformers.Core().V1().Secrets())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot start webhook: %w", err)
|
||||
}
|
||||
klog.InfoS("webhook is ready", "address", l.Addr().String())
|
||||
|
||||
gotSignal := waitForSignal()
|
||||
klog.InfoS("webhook exiting", "signal", gotSignal)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
klog.Fatal(err)
|
||||
}
|
||||
}
|
494
cmd/local-user-authenticator/main_test.go
Normal file
494
cmd/local-user-authenticator/main_test.go
Normal file
@ -0,0 +1,494 @@
|
||||
/*
|
||||
Copyright 2020 VMware, Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
kubeinformers "k8s.io/client-go/informers"
|
||||
corev1informers "k8s.io/client-go/informers/core/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
kubernetesfake "k8s.io/client-go/kubernetes/fake"
|
||||
|
||||
"github.com/suzerain-io/pinniped/internal/certauthority"
|
||||
"github.com/suzerain-io/pinniped/internal/provider"
|
||||
)
|
||||
|
||||
func TestWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
user, otherUser, colonUser, noGroupUser, oneGroupUser, passwordUndefinedUser, emptyPasswordUser, underfinedGroupsUser :=
|
||||
"some-user", "other-user", "colon-user", "no-group-user", "one-group-user", "password-undefined-user", "empty-password-user", "undefined-groups-user"
|
||||
uid, otherUID, colonUID, noGroupUID, oneGroupUID, passwordUndefinedUID, emptyPasswordUID, underfinedGroupsUID :=
|
||||
"some-uid", "other-uid", "colon-uid", "no-group-uid", "one-group-uid", "password-undefined-uid", "empty-password-uid", "undefined-groups-uid"
|
||||
password, otherPassword, colonPassword, noGroupPassword, oneGroupPassword, undefinedGroupsPassword :=
|
||||
"some-password", "other-password", "some-:-password", "no-group-password", "one-group-password", "undefined-groups-password"
|
||||
|
||||
group0, group1 := "some-group-0", "some-group-1"
|
||||
groups := group0 + " , " + group1
|
||||
|
||||
kubeClient := kubernetesfake.NewSimpleClientset()
|
||||
addSecretToFakeClientTracker(t, kubeClient, user, uid, password, groups)
|
||||
addSecretToFakeClientTracker(t, kubeClient, otherUser, otherUID, otherPassword, groups)
|
||||
addSecretToFakeClientTracker(t, kubeClient, colonUser, colonUID, colonPassword, groups)
|
||||
addSecretToFakeClientTracker(t, kubeClient, noGroupUser, noGroupUID, noGroupPassword, "")
|
||||
addSecretToFakeClientTracker(t, kubeClient, oneGroupUser, oneGroupUID, oneGroupPassword, group0)
|
||||
addSecretToFakeClientTracker(t, kubeClient, emptyPasswordUser, emptyPasswordUID, "", groups)
|
||||
|
||||
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(passwordUndefinedUID),
|
||||
Name: passwordUndefinedUser,
|
||||
Namespace: "local-user-authenticator",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"groups": []byte(groups),
|
||||
},
|
||||
}))
|
||||
|
||||
undefinedGroupsUserPasswordHash, err := bcrypt.GenerateFromPassword([]byte(undefinedGroupsPassword), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, kubeClient.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(underfinedGroupsUID),
|
||||
Name: underfinedGroupsUser,
|
||||
Namespace: "local-user-authenticator",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"passwordHash": undefinedGroupsUserPasswordHash,
|
||||
},
|
||||
}))
|
||||
|
||||
secretInformer := createSecretInformer(t, kubeClient)
|
||||
|
||||
certProvider, caBundle, serverName := newCertProvider(t)
|
||||
w := newWebhook(certProvider, secretInformer)
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
require.NoError(t, w.start(ctx, l))
|
||||
|
||||
client := newClient(caBundle, serverName)
|
||||
|
||||
goodURL := fmt.Sprintf("https://%s/authenticate", l.Addr().String())
|
||||
goodRequestHeaders := map[string][]string{
|
||||
"Content-Type": {"application/json; charset=UTF-8"},
|
||||
"Accept": {"application/json, */*"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
method string
|
||||
headers map[string][]string
|
||||
body func() (io.ReadCloser, error)
|
||||
|
||||
wantStatus int
|
||||
wantHeaders map[string][]string
|
||||
wantBody *string
|
||||
}{
|
||||
{
|
||||
name: "success for a user who belongs to multiple groups",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}),
|
||||
},
|
||||
{
|
||||
name: "success for a user who belongs to one groups",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(oneGroupUser + ":" + oneGroupPassword) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(oneGroupUser, oneGroupUID, []string{group0}),
|
||||
},
|
||||
{
|
||||
name: "success for a user who belongs to no groups",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(noGroupUser + ":" + noGroupPassword) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(noGroupUser, noGroupUID, []string{}),
|
||||
},
|
||||
{
|
||||
name: "wrong username for password",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(otherUser + ":" + password) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: unauthenticatedResponseJSON(),
|
||||
},
|
||||
{
|
||||
name: "when a user has no password hash in the secret",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: unauthenticatedResponseJSON(),
|
||||
},
|
||||
{
|
||||
name: "success for a user has no groups defined in the secret",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) {
|
||||
return newTokenReviewBody(underfinedGroupsUser + ":" + undefinedGroupsPassword)
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(underfinedGroupsUser, underfinedGroupsUID, []string{}),
|
||||
},
|
||||
{
|
||||
name: "when a user has empty string as their password",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(passwordUndefinedUser + ":foo") },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: unauthenticatedResponseJSON(),
|
||||
},
|
||||
{
|
||||
name: "wrong password for username",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + otherPassword) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: unauthenticatedResponseJSON(),
|
||||
},
|
||||
{
|
||||
name: "non-existent password for username",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + "some-non-existent-password") },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: unauthenticatedResponseJSON(),
|
||||
},
|
||||
{
|
||||
name: "non-existent username",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-non-existent-user" + ":" + password) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: unauthenticatedResponseJSON(),
|
||||
},
|
||||
{
|
||||
name: "bad token format (missing colon)",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user) },
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "password contains colon",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(colonUser + ":" + colonPassword) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(colonUser, colonUID, []string{group0, group1}),
|
||||
},
|
||||
{
|
||||
name: "bad path",
|
||||
url: fmt.Sprintf("https://%s/tuna", l.Addr().String()),
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
||||
wantStatus: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "bad method",
|
||||
url: goodURL,
|
||||
method: http.MethodGet,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
||||
wantStatus: http.StatusMethodNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "bad content type",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: map[string][]string{
|
||||
"Content-Type": {"application/xml"},
|
||||
"Accept": {"application/json"},
|
||||
},
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
||||
wantStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
name: "bad accept",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"Accept": {"application/xml"},
|
||||
},
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody("some-token") },
|
||||
wantStatus: http.StatusUnsupportedMediaType,
|
||||
},
|
||||
{
|
||||
name: "success when there are multiple accepts and one of them is json",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"Accept": {"something/else, application/xml, application/json"},
|
||||
},
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}),
|
||||
},
|
||||
{
|
||||
name: "success when there are multiple accepts and one of them is */*",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"Accept": {"something/else, */*, application/foo"},
|
||||
},
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}),
|
||||
},
|
||||
{
|
||||
name: "success when there are multiple accepts and one of them is application/*",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"Accept": {"something/else, application/*, application/foo"},
|
||||
},
|
||||
body: func() (io.ReadCloser, error) { return newTokenReviewBody(user + ":" + password) },
|
||||
wantStatus: http.StatusOK,
|
||||
wantHeaders: map[string][]string{"Content-Type": {"application/json"}},
|
||||
wantBody: authenticatedResponseJSON(user, uid, []string{group0, group1}),
|
||||
},
|
||||
{
|
||||
name: "bad body",
|
||||
url: goodURL,
|
||||
method: http.MethodPost,
|
||||
headers: goodRequestHeaders,
|
||||
body: func() (io.ReadCloser, error) { return ioutil.NopCloser(bytes.NewBuffer([]byte("invalid body"))), nil },
|
||||
wantStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
parsedURL, err := url.Parse(test.url)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := test.body()
|
||||
require.NoError(t, err)
|
||||
|
||||
rsp, err := client.Do(&http.Request{
|
||||
Method: test.method,
|
||||
URL: parsedURL,
|
||||
Header: test.headers,
|
||||
Body: body,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer rsp.Body.Close()
|
||||
|
||||
require.Equal(t, test.wantStatus, rsp.StatusCode)
|
||||
|
||||
if test.wantHeaders != nil {
|
||||
for k, v := range test.wantHeaders {
|
||||
require.Equal(t, v, rsp.Header.Values(k))
|
||||
}
|
||||
}
|
||||
|
||||
responseBody, err := ioutil.ReadAll(rsp.Body)
|
||||
require.NoError(t, err)
|
||||
if test.wantBody != nil {
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, *test.wantBody, string(responseBody))
|
||||
} else {
|
||||
require.Empty(t, responseBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createSecretInformer(t *testing.T, kubeClient kubernetes.Interface) corev1informers.SecretInformer {
|
||||
t.Helper()
|
||||
|
||||
kubeInformers := kubeinformers.NewSharedInformerFactory(kubeClient, 0)
|
||||
|
||||
secretInformer := kubeInformers.Core().V1().Secrets()
|
||||
|
||||
// We need to call Informer() on the secretInformer to lazily instantiate the
|
||||
// informer factory before syncing it.
|
||||
secretInformer.Informer()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
defer cancel()
|
||||
|
||||
kubeInformers.Start(ctx.Done())
|
||||
|
||||
informerTypesSynced := kubeInformers.WaitForCacheSync(ctx.Done())
|
||||
require.True(t, informerTypesSynced[reflect.TypeOf(&corev1.Secret{})])
|
||||
|
||||
return secretInformer
|
||||
}
|
||||
|
||||
// newClientProvider returns a provider.DynamicTLSServingCertProvider configured
|
||||
// with valid serving cert, the CA bundle that can be used to verify the serving
|
||||
// cert, and the server name that can be used to verify the TLS peer.
|
||||
func newCertProvider(t *testing.T) (provider.DynamicTLSServingCertProvider, []byte, string) {
|
||||
t.Helper()
|
||||
|
||||
ca, err := certauthority.New(pkix.Name{CommonName: "local-user-authenticator CA"}, time.Hour*24)
|
||||
require.NoError(t, err)
|
||||
|
||||
serverName := "local-user-authenticator"
|
||||
cert, err := ca.Issue(
|
||||
pkix.Name{CommonName: serverName},
|
||||
[]string{serverName},
|
||||
time.Hour*24,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM, keyPEM, err := certauthority.ToPEM(cert)
|
||||
require.NoError(t, err)
|
||||
|
||||
certProvider := provider.NewDynamicTLSServingCertProvider()
|
||||
certProvider.Set(certPEM, keyPEM)
|
||||
|
||||
return certProvider, ca.Bundle(), serverName
|
||||
}
|
||||
|
||||
// newClient creates an http.Client that can be used to make an HTTPS call to a
|
||||
// service whose serving certs can be verified by the provided CA bundle.
|
||||
func newClient(caBundle []byte, serverName string) *http.Client {
|
||||
rootCAs := x509.NewCertPool()
|
||||
rootCAs.AppendCertsFromPEM(caBundle)
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
RootCAs: rootCAs,
|
||||
ServerName: serverName,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newTokenReviewBody creates an io.ReadCloser that contains a JSON-encoded
|
||||
// TokenReview request.
|
||||
func newTokenReviewBody(token string) (io.ReadCloser, error) {
|
||||
buf := bytes.NewBuffer([]byte{})
|
||||
tr := authenticationv1.TokenReview{
|
||||
Spec: authenticationv1.TokenReviewSpec{
|
||||
Token: token,
|
||||
},
|
||||
}
|
||||
err := json.NewEncoder(buf).Encode(&tr)
|
||||
return ioutil.NopCloser(buf), err
|
||||
}
|
||||
|
||||
func unauthenticatedResponseJSON() *string {
|
||||
// Very specific expected result. Avoid using json package so we know exactly what we're asserting.
|
||||
s := `{
|
||||
"apiVersion": "authentication.k8s.io/v1beta1",
|
||||
"kind": "TokenReview",
|
||||
"status": {
|
||||
"authenticated": false
|
||||
}
|
||||
}`
|
||||
return &s
|
||||
}
|
||||
|
||||
func authenticatedResponseJSON(user, uid string, groups []string) *string {
|
||||
quotedGroups := make([]string, len(groups))
|
||||
for i, group := range groups {
|
||||
quotedGroups[i] = `"` + group + `"`
|
||||
}
|
||||
|
||||
// Very specific expected result. Avoid using json package so we know exactly what we're asserting.
|
||||
authenticatedJSONTemplate := `{
|
||||
"apiVersion": "authentication.k8s.io/v1beta1",
|
||||
"kind": "TokenReview",
|
||||
"status": {
|
||||
"authenticated": true,
|
||||
"user": {
|
||||
"username": "%s",
|
||||
"uid": "%s",
|
||||
"groups": [%s]
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
s := fmt.Sprintf(authenticatedJSONTemplate, user, uid, strings.Join(quotedGroups, ","))
|
||||
return &s
|
||||
}
|
||||
|
||||
func addSecretToFakeClientTracker(t *testing.T, kubeClient *kubernetesfake.Clientset, username, uid, password, groups string) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(uid),
|
||||
Name: username,
|
||||
Namespace: "local-user-authenticator",
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"passwordHash": passwordHash,
|
||||
"groups": []byte(groups),
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, kubeClient.Tracker().Add(secret))
|
||||
}
|
115
deploy-local-user-authenticator/README.md
Normal file
115
deploy-local-user-authenticator/README.md
Normal file
@ -0,0 +1,115 @@
|
||||
# Deploying `local-user-authenticator`
|
||||
|
||||
## What is `local-user-authenticator`?
|
||||
|
||||
The `local-user-authenticator` app is an identity provider used for integration testing and demos.
|
||||
If you would like to demo Pinniped, but you don't have a compatible identity provider handy,
|
||||
you can use Pinniped's `local-user-authenticator` identity provider. Note that this is not recommended for
|
||||
production use.
|
||||
|
||||
The `local-user-authenticator` is a Kubernetes Deployment which runs a webhook server that implements the Kubernetes
|
||||
[Webhook Token Authentication interface](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#webhook-token-authentication).
|
||||
|
||||
User accounts can be created and edited dynamically using `kubectl` commands (see below).
|
||||
|
||||
## Tools
|
||||
|
||||
This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files.
|
||||
Either [install `ytt`](https://get-ytt.io/) or use the [container image from Dockerhub](https://hub.docker.com/r/k14s/image/tags).
|
||||
|
||||
As well, this demo requires a tool capable of generating a `bcrypt` hash in order to interact with
|
||||
the webhook. The example below uses `htpasswd`, which is installed on most macOS systems, and can be
|
||||
installed on some Linux systems via the `apache2-utils` package (e.g., `apt-get install
|
||||
apache2-utils`).
|
||||
|
||||
## Procedure
|
||||
|
||||
1. The configuration options are in [values.yml](values.yaml). Fill in the values in that file, or override those values
|
||||
using `ytt` command-line options in the command below.
|
||||
2. In a terminal, cd to this `deploy-local-user-authenticator` directory
|
||||
3. To generate the final YAML files, run: `ytt --file .`
|
||||
4. Deploy the generated YAML using your preferred deployment tool, such as `kubectl` or [`kapp`](https://get-kapp.io/).
|
||||
For example: `ytt --file . | kapp deploy --yes --app local-user-authenticator --diff-changes --file -`
|
||||
|
||||
## Configuring After Installing
|
||||
|
||||
### Create Users
|
||||
|
||||
Use `kubectl` to create, edit, and delete user accounts by creating a `Secret` for each user account in the same
|
||||
namespace where `local-user-authenticator` is deployed. The name of the `Secret` resource is the username.
|
||||
Store the user's group membership and `bcrypt` encrypted password as the contents of the `Secret`.
|
||||
For example, to create a user named `ryan` with the password `password123`
|
||||
who belongs to the groups `group1` and `group2`, use:
|
||||
|
||||
```bash
|
||||
kubectl create secret generic ryan \
|
||||
--namespace local-user-authenticator \
|
||||
--from-literal=groups=group1,group2 \
|
||||
--from-literal=passwordHash=$(htpasswd -nbBC 10 x password123 | sed -e "s/^x://")
|
||||
```
|
||||
|
||||
### Get the `local-user-authenticator` App's Auto-Generated Certificate Authority Bundle
|
||||
|
||||
Fetch the auto-generated CA bundle for the `local-user-authenticator`'s HTTP TLS endpoint.
|
||||
|
||||
```bash
|
||||
kubectl get secret api-serving-cert --namespace local-user-authenticator \
|
||||
-o jsonpath={.data.caCertificate} \
|
||||
| base64 -d \
|
||||
| tee /tmp/local-user-authenticator-ca
|
||||
```
|
||||
|
||||
### Configuring Pinniped to Use `local-user-authenticator` as an Identity Provider
|
||||
|
||||
When installing Pinniped on the same cluster, configure `local-user-authenticator` as an Identity Provider for Pinniped
|
||||
using the webhook URL `https://local-user-authenticator.local-user-authenticator.svc/authenticate`
|
||||
along with the CA bundle fetched by the above command.
|
||||
|
||||
### Optional: Manually Test the Webhook Endpoint
|
||||
|
||||
1. Start a pod from which you can curl the endpoint from inside the cluster.
|
||||
|
||||
```bash
|
||||
kubectl run curlpod --image=curlimages/curl --command -- /bin/sh -c "while true; do echo hi; sleep 120; done"
|
||||
```
|
||||
|
||||
1. Copy the CA bundle that was fetched above onto the new pod.
|
||||
|
||||
```bash
|
||||
kubectl cp /tmp/local-user-authenticator-ca curlpod:/tmp/local-user-authenticator-ca
|
||||
```
|
||||
|
||||
1. Run a `curl` command to try to authenticate as the user created above.
|
||||
|
||||
```bash
|
||||
kubectl -it exec curlpod -- curl https://local-user-authenticator.local-user-authenticator.svc/authenticate \
|
||||
--cacert /tmp/local-user-authenticator-ca \
|
||||
-H 'Content-Type: application/json' -H 'Accept: application/json' -d '
|
||||
{
|
||||
"apiVersion": "authentication.k8s.io/v1beta1",
|
||||
"kind": "TokenReview",
|
||||
"spec": {
|
||||
"token": "ryan:password123"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
When authentication is successful the above command should return some JSON similar to the following.
|
||||
Note that the value of `authenticated` is `true` to indicate a successful authentication.
|
||||
|
||||
```json
|
||||
{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":true,"user":{"username":"ryan","uid":"19c433ec-8f58-44ca-9ef0-2d1081ccb876","groups":["group1","group2"]}}}
|
||||
```
|
||||
|
||||
Trying the above `curl` command again with the wrong username or password in the body of the request
|
||||
should result in a JSON response which indicates that the authentication failed.
|
||||
|
||||
```json
|
||||
{"apiVersion":"authentication.k8s.io/v1beta1","kind":"TokenReview","status":{"authenticated":false}}
|
||||
```
|
||||
|
||||
1. Remove the curl pod.
|
||||
|
||||
```bash
|
||||
kubectl delete pod curlpod
|
||||
```
|
63
deploy-local-user-authenticator/deployment.yaml
Normal file
63
deploy-local-user-authenticator/deployment.yaml
Normal file
@ -0,0 +1,63 @@
|
||||
#! Copyright 2020 VMware, Inc.
|
||||
#! SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#@ load("@ytt:data", "data")
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: local-user-authenticator
|
||||
labels:
|
||||
name: local-user-authenticator
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: local-user-authenticator-service-account
|
||||
namespace: local-user-authenticator
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: local-user-authenticator
|
||||
namespace: local-user-authenticator
|
||||
labels:
|
||||
app: local-user-authenticator
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: local-user-authenticator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: local-user-authenticator
|
||||
spec:
|
||||
serviceAccountName: local-user-authenticator-service-account
|
||||
containers:
|
||||
- name: local-user-authenticator
|
||||
#@ if data.values.image_digest:
|
||||
image: #@ data.values.image_repo + "@" + data.values.image_digest
|
||||
#@ else:
|
||||
image: #@ data.values.image_repo + ":" + data.values.image_tag
|
||||
#@ end
|
||||
imagePullPolicy: IfNotPresent
|
||||
command: #! override the default entrypoint
|
||||
- /usr/local/bin/local-user-authenticator
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: local-user-authenticator
|
||||
namespace: local-user-authenticator
|
||||
labels:
|
||||
app: local-user-authenticator
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: local-user-authenticator
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 443
|
||||
targetPort: 443
|
30
deploy-local-user-authenticator/rbac.yaml
Normal file
30
deploy-local-user-authenticator/rbac.yaml
Normal file
@ -0,0 +1,30 @@
|
||||
#! Copyright 2020 VMware, Inc.
|
||||
#! SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#@ load("@ytt:data", "data")
|
||||
|
||||
#! Give permission to various objects within the app's own namespace
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: local-user-authenticator-aggregated-api-server-role
|
||||
namespace: local-user-authenticator
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: [secrets]
|
||||
verbs: [create, get, list, patch, update, watch]
|
||||
---
|
||||
kind: RoleBinding
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: local-user-authenticator-aggregated-api-server-role-binding
|
||||
namespace: local-user-authenticator
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: local-user-authenticator-service-account
|
||||
namespace: local-user-authenticator
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: local-user-authenticator-aggregated-api-server-role
|
||||
apiGroup: rbac.authorization.k8s.io
|
10
deploy-local-user-authenticator/values.yaml
Normal file
10
deploy-local-user-authenticator/values.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
#! Copyright 2020 VMware, Inc.
|
||||
#! SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#@data/values
|
||||
---
|
||||
|
||||
#! Specify either an image_digest or an image_tag. If both are given, only image_digest will be used.
|
||||
image_repo: #! e.g. registry.example.com/your-project-name/repo-name
|
||||
image_digest: #! e.g. sha256:f3c4fdfd3ef865d4b97a1fd295d94acc3f0c654c46b6f27ffad5cf80216903c8
|
||||
image_tag: #! e.g. latest
|
@ -1,5 +1,12 @@
|
||||
# Deploying
|
||||
|
||||
## Connecting Pinniped to an Identity Provider
|
||||
|
||||
If you would like to try Pinniped, but you don't have a compatible identity provider,
|
||||
you can use Pinniped's test identity provider.
|
||||
See [deploy-local-user-authenticator/README.md](../deploy-local-user-authenticator/README.md)
|
||||
for details.
|
||||
|
||||
## Tools
|
||||
|
||||
This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files.
|
||||
|
1
go.mod
1
go.mod
@ -14,6 +14,7 @@ require (
|
||||
github.com/stretchr/testify v1.6.1
|
||||
github.com/suzerain-io/pinniped/generated/1.19/apis v0.0.0-00010101000000-000000000000
|
||||
github.com/suzerain-io/pinniped/generated/1.19/client v0.0.0-00010101000000-000000000000
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
k8s.io/api v0.19.0
|
||||
k8s.io/apimachinery v0.19.0
|
||||
k8s.io/apiserver v0.19.0
|
||||
|
12
go.sum
12
go.sum
@ -100,6 +100,7 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/daixiang0/gci v0.0.0-20200727065011-66f1df783cb2 h1:3Lhhps85OdA8ezsEKu+IA1hE+DBTjt/fjd7xNCrHbVA=
|
||||
github.com/daixiang0/gci v0.0.0-20200727065011-66f1df783cb2/go.mod h1:+AV8KmHTGxxwp/pY84TLQfFKp2vuKXXJVzF3kD/hfR4=
|
||||
github.com/daixiang0/gci v0.2.4 h1:BUCKk5nlK2m+kRIsoj+wb/5hazHvHeZieBKWd9Afa8Q=
|
||||
github.com/daixiang0/gci v0.2.4/go.mod h1:+AV8KmHTGxxwp/pY84TLQfFKp2vuKXXJVzF3kD/hfR4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@ -134,6 +135,7 @@ github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2H
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-critic/go-critic v0.5.0 h1:Ic2p5UCl5fX/2WX2w8nroPpPhxRNsNTMlJzsu/uqwnM=
|
||||
github.com/go-critic/go-critic v0.5.0/go.mod h1:4jeRh3ZAVnRYhuWdOEvwzVqLUpxMSoAT0xZ74JsTPlo=
|
||||
github.com/go-critic/go-critic v0.5.2 h1:3RJdgf6u4NZUumoP8nzbqiiNT8e1tC2Oc7jlgqre/IA=
|
||||
github.com/go-critic/go-critic v0.5.2/go.mod h1:cc0+HvdE3lFpqLecgqMaJcvWWH77sLdBp+wLGPM1Yyo=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
@ -197,6 +199,7 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc=
|
||||
github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gofrs/flock v0.8.0 h1:MSdYClljsF3PbENUUEx85nkWfJSGfzYI9yEBZOJz6CY=
|
||||
github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
@ -435,6 +438,7 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856 h1:W3KBC2LFyfgd+wNudlfgCCsTo4q97MeNWrfz8/wSdSc=
|
||||
github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c=
|
||||
github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0 h1:eMV1t2NQRc3r1k3guWiv/zEeqZZP6kPvpUfy6byfL1g=
|
||||
github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c=
|
||||
github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
@ -490,6 +494,7 @@ github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40T
|
||||
github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI=
|
||||
github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8 h1:DvnesvLtRPQOvaUbfXfh0tpMHg29by0H7F2U+QIkSu8=
|
||||
github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8/go.mod h1:CGFX09Ci3pq9QZdj86B+VGIdNj4VyCo2iPOGS9esB/k=
|
||||
github.com/quasilyte/go-ruleguard v0.2.0 h1:UOVMyH2EKkxIfzrULvA9n/tO+HtEhqD9mrLSWMr5FwU=
|
||||
github.com/quasilyte/go-ruleguard v0.2.0/go.mod h1:2RT/tf0Ce0UDj5y243iWKosQogJd8+1G3Rs2fxmlYnw=
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY=
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
|
||||
@ -531,6 +536,7 @@ github.com/sonatard/noctx v0.0.1 h1:VC1Qhl6Oxx9vvWo3UDgrGXYCeKCe3Wbw7qAWL6FrmTY=
|
||||
github.com/sonatard/noctx v0.0.1/go.mod h1:9D2D/EoULe8Yy2joDHJj7bv3sZoq9AaSb8B4lqBjiZI=
|
||||
github.com/sourcegraph/go-diff v0.5.3 h1:lhIKJ2nXLZZ+AfbHpYxTn0pXpNTTui0DX7DO3xeb1Zs=
|
||||
github.com/sourcegraph/go-diff v0.5.3/go.mod h1:v9JDtjCE4HHHCZGId75rg8gkKKa98RVjBcBGsVmMmak=
|
||||
github.com/sourcegraph/go-diff v0.6.0 h1:WbN9e/jD8ujU+o0vd9IFN5AEwtfB0rn/zM/AANaClqQ=
|
||||
github.com/sourcegraph/go-diff v0.6.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
@ -551,9 +557,11 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
|
||||
github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM=
|
||||
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk=
|
||||
github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/ssgreg/nlreturn/v2 v2.0.1 h1:+lm6xFjVuNw/9t/Fh5sIwfNWefiD5bddzc6vwJ1TvRI=
|
||||
github.com/ssgreg/nlreturn/v2 v2.0.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
|
||||
github.com/ssgreg/nlreturn/v2 v2.1.0 h1:6/s4Rc49L6Uo6RLjhWZGBpWWjfzk2yrf1nIW8m4wgVA=
|
||||
github.com/ssgreg/nlreturn/v2 v2.1.0/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@ -581,6 +589,7 @@ github.com/tommy-muehle/go-mnd v1.3.1-0.20200224220436-e6f9a994e8fa/go.mod h1:dS
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ultraware/funlen v0.0.2 h1:Av96YVBwwNSe4MLR7iI/BIa3VyI7/djnto/pK3Uxbdo=
|
||||
github.com/ultraware/funlen v0.0.2/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA=
|
||||
github.com/ultraware/funlen v0.0.3 h1:5ylVWm8wsNwH5aWo9438pwvsK0QiqVuUrt9bn7S/iLA=
|
||||
github.com/ultraware/funlen v0.0.3/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA=
|
||||
github.com/ultraware/whitespace v0.0.4 h1:If7Va4cM03mpgrNH9k49/VOicWpGoG70XPBFFODYDsg=
|
||||
github.com/ultraware/whitespace v0.0.4/go.mod h1:aVMh/gQve5Maj9hQ/hg+F75lr/X5A89uZnzAmWSineA=
|
||||
@ -784,11 +793,13 @@ golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roY
|
||||
golang.org/x/tools v0.0.0-20200701041122-1837592efa10/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305 h1:yaM5S0KcY0lIoZo7Fl+oi91b/DdlU2zuWpfHrpWbCS0=
|
||||
golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0 h1:SQvH+DjrwqD1hyyQU+K7JegHz1KEZgEwt17p9d6R2eg=
|
||||
golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
@ -872,6 +883,7 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.5 h1:nI5egYTGJakVyOryqLs1cQO5dO0ksin5XXs2pspk75k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.5/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
k8s.io/api v0.19.0 h1:XyrFIJqTYZJ2DU7FBE/bSPz7b1HvbVBuBf07oeo6eTc=
|
||||
k8s.io/api v0.19.0/go.mod h1:I1K45XlvTrDjmj5LoM5LuP/KYrhWbjUKT/SoPG0qTjw=
|
||||
|
263
hack/prepare-for-integration-tests.sh
Executable file
263
hack/prepare-for-integration-tests.sh
Executable file
@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script can be used to prepare a kind cluster and deploy the app.
|
||||
# You can call this script again to redeploy the app.
|
||||
# It will also output instructions on how to run the integration.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
#
|
||||
# Helper functions
|
||||
#
|
||||
function log_note() {
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
if [[ $COLORTERM =~ ^(truecolor|24bit)$ ]]; then
|
||||
echo -e "${GREEN}$*${NC}"
|
||||
else
|
||||
echo "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
function log_warning() {
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
if [[ $COLORTERM =~ ^(truecolor|24bit)$ ]]; then
|
||||
echo -e "😒${YELLOW} Warning: $* ${NC}"
|
||||
else
|
||||
echo ":/ Warning: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
function log_error() {
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
if [[ $COLORTERM =~ ^(truecolor|24bit)$ ]]; then
|
||||
echo -e "🙁${RED} Error: $* ${NC}"
|
||||
else
|
||||
echo ":( Error: $*"
|
||||
fi
|
||||
}
|
||||
|
||||
#
|
||||
# Handle argument parsing and help message
|
||||
#
|
||||
help=no
|
||||
skip_build=no
|
||||
|
||||
PARAMS=""
|
||||
while (("$#")); do
|
||||
case "$1" in
|
||||
-h | --help)
|
||||
help=yes
|
||||
shift
|
||||
;;
|
||||
-s | --skip-build)
|
||||
skip_build=yes
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
log_error "Unsupported flag $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
PARAMS="$PARAMS $1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
eval set -- "$PARAMS"
|
||||
|
||||
if [[ "$help" == "yes" ]]; then
|
||||
me="$(basename "${BASH_SOURCE[0]}")"
|
||||
echo "Usage:"
|
||||
echo " $me [flags] [path/to/pinniped]"
|
||||
echo
|
||||
echo " path/to/pinniped default: \$PWD ($PWD)"
|
||||
echo
|
||||
echo "Flags:"
|
||||
echo " -h, --help: print this usage"
|
||||
echo " -s, --skip-build: reuse the most recently built image of the app instead of building"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pinniped_path="${1-$PWD}"
|
||||
|
||||
#
|
||||
# Check for dependencies
|
||||
#
|
||||
if ! command -v kind >/dev/null; then
|
||||
log_error "Please install kind. e.g. 'brew install kind' for MacOS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v ytt >/dev/null; then
|
||||
log_error "Please install ytt. e.g. 'brew tap k14s/tap && brew install ytt' for MacOS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v kapp >/dev/null; then
|
||||
log_error "Please install kapp. e.g. 'brew tap k14s/tap && brew install kapp' for MacOS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v kubectl >/dev/null; then
|
||||
log_error "Please install kubectl. e.g. 'brew install kubectl' for MacOS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$pinniped_path" || exit 1
|
||||
|
||||
if [[ ! -f Dockerfile || ! -d deploy ]]; then
|
||||
log_error "$pinniped_path does not appear to be the path to the source code repo directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#
|
||||
# Setup kind and build the app
|
||||
#
|
||||
log_note "Checking for running kind clusters..."
|
||||
if ! kind get clusters | grep -q -e '^kind$'; then
|
||||
log_note "Creating a kind cluster..."
|
||||
kind create cluster
|
||||
else
|
||||
if ! kubectl cluster-info | grep master | grep -q 127.0.0.1; then
|
||||
log_error "Seems like your kubeconfig is not targeting a local cluster."
|
||||
log_error "Exiting to avoid accidentally running tests against a real cluster."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
registry="docker.io"
|
||||
repo="test/build"
|
||||
registry_repo="$registry/$repo"
|
||||
tag=$(uuidgen) # always a new tag to force K8s to reload the image on redeploy
|
||||
|
||||
if [[ "$skip_build" == "yes" ]]; then
|
||||
most_recent_tag=$(docker images "$repo" --format "{{.Tag}}" | head -1)
|
||||
if [[ -n "$most_recent_tag" ]]; then
|
||||
tag="$most_recent_tag"
|
||||
do_build=no
|
||||
else
|
||||
# Oops, there was no previous build. Need to build anyway.
|
||||
do_build=yes
|
||||
fi
|
||||
else
|
||||
do_build=yes
|
||||
fi
|
||||
|
||||
registry_repo_tag="${registry_repo}:${tag}"
|
||||
|
||||
if [[ "$do_build" == "yes" ]]; then
|
||||
# Rebuild the code
|
||||
log_note "Docker building the app..."
|
||||
docker build . --tag "$registry_repo_tag"
|
||||
fi
|
||||
|
||||
# Load it into the cluster
|
||||
log_note "Loading the app's container image into the kind cluster..."
|
||||
kind load docker-image "$registry_repo_tag"
|
||||
|
||||
manifest=/tmp/manifest.yaml
|
||||
|
||||
#
|
||||
# Deploy local-user-authenticator
|
||||
#
|
||||
pushd deploy-local-user-authenticator >/dev/null
|
||||
|
||||
log_note "Deploying the local-user-authenticator app to the cluster..."
|
||||
ytt --file . \
|
||||
--data-value "image_repo=$registry_repo" \
|
||||
--data-value "image_tag=$tag" >"$manifest"
|
||||
|
||||
kubectl apply --dry-run=client -f "$manifest" # Validate manifest schema.
|
||||
kapp deploy --yes --app local-user-authenticator --diff-changes --file "$manifest"
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
test_username="test-username"
|
||||
test_groups="test-group-0,test-group-1"
|
||||
set +o pipefail
|
||||
test_password="$(cat /dev/urandom | env LC_CTYPE=C tr -dc 'a-z0-9' | fold -w 32 | head -n 1)"
|
||||
set -o pipefail
|
||||
if [[ ${#test_password} -ne 32 ]]; then
|
||||
log_error "Could not create random test user password"
|
||||
exit 1
|
||||
fi
|
||||
log_note "Creating test user '$test_username'..."
|
||||
kubectl create secret generic "$test_username" \
|
||||
--namespace local-user-authenticator \
|
||||
--from-literal=groups="$test_groups" \
|
||||
--from-literal=passwordHash="$(htpasswd -nbBC 10 x "$test_password" | sed -e "s/^x://")" \
|
||||
--dry-run=client \
|
||||
--output yaml |
|
||||
kubectl apply -f -
|
||||
|
||||
app_name="pinniped"
|
||||
namespace="integration"
|
||||
webhook_url="https://local-user-authenticator.local-user-authenticator.svc/authenticate"
|
||||
webhook_ca_bundle="$(kubectl get secret api-serving-cert --namespace local-user-authenticator -o 'jsonpath={.data.caCertificate}')"
|
||||
discovery_url="$(TERM=dumb kubectl cluster-info | awk '/Kubernetes master/ {print $NF}')"
|
||||
|
||||
#
|
||||
# Deploy Pinniped
|
||||
#
|
||||
pushd deploy >/dev/null
|
||||
|
||||
log_note "Deploying the Pinniped app to the cluster..."
|
||||
ytt --file . \
|
||||
--data-value "app_name=$app_name" \
|
||||
--data-value "namespace=$namespace" \
|
||||
--data-value "image_repo=$registry_repo" \
|
||||
--data-value "image_tag=$tag" \
|
||||
--data-value "webhook_url=$webhook_url" \
|
||||
--data-value "webhook_ca_bundle=$webhook_ca_bundle" \
|
||||
--data-value "discovery_url=$discovery_url" >"$manifest"
|
||||
|
||||
kubectl apply --dry-run=client -f "$manifest" # Validate manifest schema.
|
||||
kapp deploy --yes --app "$app_name" --diff-changes --file "$manifest"
|
||||
|
||||
popd >/dev/null
|
||||
|
||||
#
|
||||
# Create the environment file
|
||||
#
|
||||
kind_capabilities_file="$pinniped_path/test/cluster_capabilities/kind.yaml"
|
||||
pinniped_cluster_capability_file_content=$(cat "$kind_capabilities_file")
|
||||
|
||||
cat <<EOF >/tmp/integration-test-env
|
||||
# The following env vars should be set before running 'cd test && go test ./...'
|
||||
export PINNIPED_NAMESPACE=${namespace}
|
||||
export PINNIPED_APP_NAME=${app_name}
|
||||
export PINNIPED_TEST_USER_USERNAME=${test_username}
|
||||
export PINNIPED_TEST_USER_GROUPS=${test_groups}
|
||||
export PINNIPED_TEST_USER_TOKEN=${test_username}:${test_password}
|
||||
|
||||
read -r -d '' PINNIPED_CLUSTER_CAPABILITY_YAML << PINNIPED_CLUSTER_CAPABILITY_YAML_EOF || true
|
||||
${pinniped_cluster_capability_file_content}
|
||||
PINNIPED_CLUSTER_CAPABILITY_YAML_EOF
|
||||
|
||||
export PINNIPED_CLUSTER_CAPABILITY_YAML
|
||||
EOF
|
||||
|
||||
#
|
||||
# Print instructions for next steps
|
||||
#
|
||||
goland_vars=$(grep -v '^#' /tmp/integration-test-env | grep -E '^export .+=' | sed 's/export //g' | tr '\n' ';')
|
||||
|
||||
log_note "Done!"
|
||||
log_note
|
||||
log_note "Ready to run integration tests. For example, you could run all tests using the following commands..."
|
||||
log_note " cd $pinniped_path"
|
||||
log_note ' source /tmp/integration-test-env'
|
||||
log_note ' (cd test && go test -count 1 ./...)'
|
||||
log_note
|
||||
log_note '"Environment" setting for GoLand run configurations:'
|
||||
log_note " ${goland_vars}PINNIPED_CLUSTER_CAPABILITY_FILE=${kind_capabilities_file}"
|
||||
log_note
|
||||
log_note
|
||||
log_note "You can run this script again to deploy local production code changes while you are working."
|
||||
log_note
|
||||
log_note "When you're finished, use 'kind delete cluster' to tear down the cluster."
|
||||
log_note
|
||||
log_note "To delete the deployments, run 'kapp delete -a local-user-authenticator -y && kapp delete -a pinniped -y'."
|
69
internal/controller/apicerts/apiservice_updater.go
Normal file
69
internal/controller/apicerts/apiservice_updater.go
Normal file
@ -0,0 +1,69 @@
|
||||
/*
|
||||
Copyright 2020 VMware, Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package apicerts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
corev1informers "k8s.io/client-go/informers/core/v1"
|
||||
"k8s.io/klog/v2"
|
||||
aggregatorclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset"
|
||||
|
||||
pinnipedcontroller "github.com/suzerain-io/pinniped/internal/controller"
|
||||
"github.com/suzerain-io/pinniped/internal/controllerlib"
|
||||
)
|
||||
|
||||
type apiServiceUpdaterController struct {
|
||||
namespace string
|
||||
aggregatorClient aggregatorclient.Interface
|
||||
secretInformer corev1informers.SecretInformer
|
||||
}
|
||||
|
||||
func NewAPIServiceUpdaterController(
|
||||
namespace string,
|
||||
aggregatorClient aggregatorclient.Interface,
|
||||
secretInformer corev1informers.SecretInformer,
|
||||
withInformer pinnipedcontroller.WithInformerOptionFunc,
|
||||
) controllerlib.Controller {
|
||||
return controllerlib.New(
|
||||
controllerlib.Config{
|
||||
Name: "certs-manager-controller",
|
||||
Syncer: &apiServiceUpdaterController{
|
||||
namespace: namespace,
|
||||
aggregatorClient: aggregatorClient,
|
||||
secretInformer: secretInformer,
|
||||
},
|
||||
},
|
||||
withInformer(
|
||||
secretInformer,
|
||||
pinnipedcontroller.NameAndNamespaceExactMatchFilterFactory(certsSecretName, namespace),
|
||||
controllerlib.InformerOption{},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *apiServiceUpdaterController) Sync(ctx controllerlib.Context) error {
|
||||
// Try to get the secret from the informer cache.
|
||||
certSecret, err := c.secretInformer.Lister().Secrets(c.namespace).Get(certsSecretName)
|
||||
notFound := k8serrors.IsNotFound(err)
|
||||
if err != nil && !notFound {
|
||||
return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err)
|
||||
}
|
||||
if notFound {
|
||||
// The secret does not exist yet, so nothing to do.
|
||||
klog.Info("apiServiceUpdaterController Sync found that the secret does not exist yet or was deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update the APIService to give it the new CA bundle.
|
||||
if err := UpdateAPIService(ctx.Context, c.aggregatorClient, certSecret.Data[caCertificateSecretKey]); err != nil {
|
||||
return fmt.Errorf("could not update the API service: %w", err)
|
||||
}
|
||||
|
||||
klog.Info("apiServiceUpdaterController Sync successfully updated API service")
|
||||
return nil
|
||||
}
|
277
internal/controller/apicerts/apiservice_updater_test.go
Normal file
277
internal/controller/apicerts/apiservice_updater_test.go
Normal file
@ -0,0 +1,277 @@
|
||||
/*
|
||||
Copyright 2020 VMware, Inc.
|
||||
SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package apicerts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sclevine/spec"
|
||||
"github.com/sclevine/spec/report"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
kubeinformers "k8s.io/client-go/informers"
|
||||
kubernetesfake "k8s.io/client-go/kubernetes/fake"
|
||||
coretesting "k8s.io/client-go/testing"
|
||||
apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
|
||||
aggregatorfake "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/fake"
|
||||
|
||||
pinnipedv1alpha1 "github.com/suzerain-io/pinniped/generated/1.19/apis/pinniped/v1alpha1"
|
||||
"github.com/suzerain-io/pinniped/internal/controllerlib"
|
||||
"github.com/suzerain-io/pinniped/internal/testutil"
|
||||
)
|
||||
|
||||
func TestAPIServiceUpdaterControllerOptions(t *testing.T) {
|
||||
spec.Run(t, "options", func(t *testing.T, when spec.G, it spec.S) {
|
||||
const installedInNamespace = "some-namespace"
|
||||
|
||||
var r *require.Assertions
|
||||
var observableWithInformerOption *testutil.ObservableWithInformerOption
|
||||
var secretsInformerFilter controllerlib.Filter
|
||||
|
||||
it.Before(func() {
|
||||
r = require.New(t)
|
||||
observableWithInformerOption = testutil.NewObservableWithInformerOption()
|
||||
secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets()
|
||||
_ = NewAPIServiceUpdaterController(
|
||||
installedInNamespace,
|
||||
nil,
|
||||
secretsInformer,
|
||||
observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters
|
||||
)
|
||||
secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer)
|
||||
})
|
||||
|
||||
when("watching Secret objects", func() {
|
||||
var subject controllerlib.Filter
|
||||
var target, wrongNamespace, wrongName, unrelated *corev1.Secret
|
||||
|
||||
it.Before(func() {
|
||||
subject = secretsInformerFilter
|
||||
target = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "api-serving-cert", Namespace: installedInNamespace}}
|
||||
wrongNamespace = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "api-serving-cert", Namespace: "wrong-namespace"}}
|
||||
wrongName = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: installedInNamespace}}
|
||||
unrelated = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "wrong-name", Namespace: "wrong-namespace"}}
|
||||
})
|
||||
|
||||
when("the target Secret changes", func() {
|
||||
it("returns true to trigger the sync method", func() {
|
||||
r.True(subject.Add(target))
|
||||
r.True(subject.Update(target, unrelated))
|
||||
r.True(subject.Update(unrelated, target))
|
||||
r.True(subject.Delete(target))
|
||||
})
|
||||
})
|
||||
|
||||
when("a Secret from another namespace changes", func() {
|
||||
it("returns false to avoid triggering the sync method", func() {
|
||||
r.False(subject.Add(wrongNamespace))
|
||||
r.False(subject.Update(wrongNamespace, unrelated))
|
||||
r.False(subject.Update(unrelated, wrongNamespace))
|
||||
r.False(subject.Delete(wrongNamespace))
|
||||
})
|
||||
})
|
||||
|
||||
when("a Secret with a different name changes", func() {
|
||||
it("returns false to avoid triggering the sync method", func() {
|
||||
r.False(subject.Add(wrongName))
|
||||
r.False(subject.Update(wrongName, unrelated))
|
||||
r.False(subject.Update(unrelated, wrongName))
|
||||
r.False(subject.Delete(wrongName))
|
||||
})
|
||||
})
|
||||
|
||||
when("a Secret with a different name and a different namespace changes", func() {
|
||||
it("returns false to avoid triggering the sync method", func() {
|
||||
r.False(subject.Add(unrelated))
|
||||
r.False(subject.Update(unrelated, unrelated))
|
||||
r.False(subject.Delete(unrelated))
|
||||
})
|
||||
})
|
||||
})
|
||||
}, spec.Parallel(), spec.Report(report.Terminal{}))
|
||||
}
|
||||
|
||||
func TestAPIServiceUpdaterControllerSync(t *testing.T) {
|
||||
spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) {
|
||||
const installedInNamespace = "some-namespace"
|
||||
|
||||
var r *require.Assertions
|
||||
|
||||
var subject controllerlib.Controller
|
||||
var aggregatorAPIClient *aggregatorfake.Clientset
|
||||
var kubeInformerClient *kubernetesfake.Clientset
|
||||
var kubeInformers kubeinformers.SharedInformerFactory
|
||||
var timeoutContext context.Context
|
||||
var timeoutContextCancel context.CancelFunc
|
||||
var syncContext *controllerlib.Context
|
||||
|
||||
// Defer starting the informers until the last possible moment so that the
|
||||
// nested Before's can keep adding things to the informer caches.
|
||||
var startInformersAndController = func() {
|
||||
// Set this at the last second to allow for injection of server override.
|
||||
subject = NewAPIServiceUpdaterController(
|
||||
installedInNamespace,
|
||||
aggregatorAPIClient,
|
||||
kubeInformers.Core().V1().Secrets(),
|
||||
controllerlib.WithInformer,
|
||||
)
|
||||
|
||||
// Set this at the last second to support calling subject.Name().
|
||||
syncContext = &controllerlib.Context{
|
||||
Context: timeoutContext,
|
||||
Name: subject.Name(),
|
||||
Key: controllerlib.Key{
|
||||
Namespace: installedInNamespace,
|
||||
Name: "api-serving-cert",
|
||||
},
|
||||
}
|
||||
|
||||
// Must start informers before calling TestRunSynchronously()
|
||||
kubeInformers.Start(timeoutContext.Done())
|
||||
controllerlib.TestRunSynchronously(t, subject)
|
||||
}
|
||||
|
||||
it.Before(func() {
|
||||
r = require.New(t)
|
||||
|
||||
timeoutContext, timeoutContextCancel = context.WithTimeout(context.Background(), time.Second*3)
|
||||
|
||||
kubeInformerClient = kubernetesfake.NewSimpleClientset()
|
||||
kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0)
|
||||
aggregatorAPIClient = aggregatorfake.NewSimpleClientset()
|
||||
})
|
||||
|
||||
it.After(func() {
|
||||
timeoutContextCancel()
|
||||
})
|
||||
|
||||
when("there is not yet an api-serving-cert Secret in the installation namespace or it was deleted", func() {
|
||||
it.Before(func() {
|
||||
unrelatedSecret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "some other secret",
|
||||
Namespace: installedInNamespace,
|
||||
},
|
||||
}
|
||||
err := kubeInformerClient.Tracker().Add(unrelatedSecret)
|
||||
r.NoError(err)
|
||||
})
|
||||
|
||||
it("does not need to make any API calls with its API client", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.NoError(err)
|
||||
r.Empty(aggregatorAPIClient.Actions())
|
||||
})
|
||||
})
|
||||
|
||||
when("there is an api-serving-cert Secret already in the installation namespace", func() {
|
||||
it.Before(func() {
|
||||
apiServingCertSecret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "api-serving-cert",
|
||||
Namespace: installedInNamespace,
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"caCertificate": []byte("fake CA cert"),
|
||||
"tlsPrivateKey": []byte("fake private key"),
|
||||
"tlsCertificateChain": []byte("fake cert chain"),
|
||||
},
|
||||
}
|
||||
err := kubeInformerClient.Tracker().Add(apiServingCertSecret)
|
||||
r.NoError(err)
|
||||
})
|
||||
|
||||
when("the APIService exists", func() {
|
||||
it.Before(func() {
|
||||
apiService := &apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName,
|
||||
},
|
||||
Spec: apiregistrationv1.APIServiceSpec{
|
||||
CABundle: nil,
|
||||
VersionPriority: 1234,
|
||||
},
|
||||
}
|
||||
err := aggregatorAPIClient.Tracker().Add(apiService)
|
||||
r.NoError(err)
|
||||
})
|
||||
|
||||
it("updates the APIService's ca bundle", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.NoError(err)
|
||||
|
||||
// Make sure we updated the APIService caBundle and left it otherwise unchanged
|
||||
r.Len(aggregatorAPIClient.Actions(), 2)
|
||||
r.Equal("get", aggregatorAPIClient.Actions()[0].GetVerb())
|
||||
expectedAPIServiceName := pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName
|
||||
expectedUpdateAction := coretesting.NewUpdateAction(
|
||||
schema.GroupVersionResource{
|
||||
Group: apiregistrationv1.GroupName,
|
||||
Version: "v1",
|
||||
Resource: "apiservices",
|
||||
},
|
||||
"",
|
||||
&apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: expectedAPIServiceName,
|
||||
Namespace: "",
|
||||
},
|
||||
Spec: apiregistrationv1.APIServiceSpec{
|
||||
VersionPriority: 1234, // only the CABundle is updated, this other field is left unchanged
|
||||
CABundle: []byte("fake CA cert"),
|
||||
},
|
||||
},
|
||||
)
|
||||
r.Equal(expectedUpdateAction, aggregatorAPIClient.Actions()[1])
|
||||
})
|
||||
|
||||
when("updating the APIService fails", func() {
|
||||
it.Before(func() {
|
||||
aggregatorAPIClient.PrependReactor(
|
||||
"update",
|
||||
"apiservices",
|
||||
func(_ coretesting.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, errors.New("update failed")
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("returns the update error", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.EqualError(err, "could not update the API service: could not update API service: update failed")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
when("the APIService does not exist", func() {
|
||||
it.Before(func() {
|
||||
unrelatedAPIService := &apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "some other api service"},
|
||||
Spec: apiregistrationv1.APIServiceSpec{},
|
||||
}
|
||||
err := aggregatorAPIClient.Tracker().Add(unrelatedAPIService)
|
||||
r.NoError(err)
|
||||
})
|
||||
|
||||
it("returns an error", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.Error(err)
|
||||
r.Regexp("could not get existing version of API service: .* not found", err.Error())
|
||||
})
|
||||
})
|
||||
})
|
||||
}, spec.Parallel(), spec.Report(report.Terminal{}))
|
||||
}
|
@ -69,7 +69,7 @@ func (c *certsExpirerController) Sync(ctx controllerlib.Context) error {
|
||||
return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err)
|
||||
}
|
||||
if notFound {
|
||||
klog.Info("certsExpirerController Sync() found that the secret does not exist yet or was deleted")
|
||||
klog.Info("certsExpirerController Sync found that the secret does not exist yet or was deleted")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -78,13 +78,13 @@ func (c *certsExpirerController) Sync(ctx controllerlib.Context) error {
|
||||
// If we can't read the cert, then really all we can do is log something,
|
||||
// since if we returned an error then the controller lib would just call us
|
||||
// again and again, which would probably yield the same results.
|
||||
klog.Warningf("certsExpirerController Sync() found that the secret is malformed: %s", err.Error())
|
||||
klog.Warningf("certsExpirerController Sync found that the secret is malformed: %s", err.Error())
|
||||
return nil
|
||||
}
|
||||
|
||||
certAge := time.Since(notBefore)
|
||||
renewDelta := certAge - c.renewBefore
|
||||
klog.Infof("certsExpirerController Sync() found a renew delta of %s", renewDelta)
|
||||
klog.Infof("certsExpirerController Sync found a renew delta of %s", renewDelta)
|
||||
if renewDelta >= 0 || time.Now().After(notAfter) {
|
||||
err := c.k8sClient.
|
||||
CoreV1().
|
||||
|
@ -16,7 +16,6 @@ import (
|
||||
corev1informers "k8s.io/client-go/informers/core/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/klog/v2"
|
||||
aggregatorclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset"
|
||||
|
||||
"github.com/suzerain-io/pinniped/internal/certauthority"
|
||||
pinnipedcontroller "github.com/suzerain-io/pinniped/internal/controller"
|
||||
@ -32,34 +31,37 @@ const (
|
||||
)
|
||||
|
||||
type certsManagerController struct {
|
||||
namespace string
|
||||
k8sClient kubernetes.Interface
|
||||
aggregatorClient aggregatorclient.Interface
|
||||
secretInformer corev1informers.SecretInformer
|
||||
namespace string
|
||||
k8sClient kubernetes.Interface
|
||||
secretInformer corev1informers.SecretInformer
|
||||
|
||||
// certDuration is the lifetime of both the serving certificate and its CA
|
||||
// certificate that this controller will use when issuing the certificates.
|
||||
certDuration time.Duration
|
||||
|
||||
generatedCACommonName string
|
||||
serviceNameForGeneratedCertCommonName string
|
||||
}
|
||||
|
||||
func NewCertsManagerController(
|
||||
namespace string,
|
||||
func NewCertsManagerController(namespace string,
|
||||
k8sClient kubernetes.Interface,
|
||||
aggregatorClient aggregatorclient.Interface,
|
||||
secretInformer corev1informers.SecretInformer,
|
||||
withInformer pinnipedcontroller.WithInformerOptionFunc,
|
||||
withInitialEvent pinnipedcontroller.WithInitialEventOptionFunc,
|
||||
certDuration time.Duration,
|
||||
generatedCACommonName string,
|
||||
serviceNameForGeneratedCertCommonName string,
|
||||
) controllerlib.Controller {
|
||||
return controllerlib.New(
|
||||
controllerlib.Config{
|
||||
Name: "certs-manager-controller",
|
||||
Syncer: &certsManagerController{
|
||||
namespace: namespace,
|
||||
k8sClient: k8sClient,
|
||||
aggregatorClient: aggregatorClient,
|
||||
secretInformer: secretInformer,
|
||||
certDuration: certDuration,
|
||||
namespace: namespace,
|
||||
k8sClient: k8sClient,
|
||||
secretInformer: secretInformer,
|
||||
certDuration: certDuration,
|
||||
generatedCACommonName: generatedCACommonName,
|
||||
serviceNameForGeneratedCertCommonName: serviceNameForGeneratedCertCommonName,
|
||||
},
|
||||
},
|
||||
withInformer(
|
||||
@ -88,16 +90,13 @@ func (c *certsManagerController) Sync(ctx controllerlib.Context) error {
|
||||
}
|
||||
|
||||
// Create a CA.
|
||||
aggregatedAPIServerCA, err := certauthority.New(pkix.Name{CommonName: "Pinniped CA"}, c.certDuration)
|
||||
aggregatedAPIServerCA, err := certauthority.New(pkix.Name{CommonName: c.generatedCACommonName}, c.certDuration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not initialize CA: %w", err)
|
||||
}
|
||||
|
||||
// This string must match the name of the Service declared in the deployment yaml.
|
||||
const serviceName = "pinniped-api"
|
||||
|
||||
// Using the CA from above, create a TLS server cert for the aggregated API server to use.
|
||||
serviceEndpoint := serviceName + "." + c.namespace + ".svc"
|
||||
serviceEndpoint := c.serviceNameForGeneratedCertCommonName + "." + c.namespace + ".svc"
|
||||
aggregatedAPIServerTLSCert, err := aggregatedAPIServerCA.Issue(
|
||||
pkix.Name{CommonName: serviceEndpoint},
|
||||
[]string{serviceEndpoint},
|
||||
@ -129,11 +128,6 @@ func (c *certsManagerController) Sync(ctx controllerlib.Context) error {
|
||||
return fmt.Errorf("could not create secret: %w", err)
|
||||
}
|
||||
|
||||
// Update the APIService to give it the new CA bundle.
|
||||
if err := UpdateAPIService(ctx.Context, c.aggregatorClient, aggregatedAPIServerCA.Bundle()); err != nil {
|
||||
return fmt.Errorf("could not update the API service: %w", err)
|
||||
}
|
||||
|
||||
klog.Info("certsManagerController Sync successfully created secret and updated API service")
|
||||
klog.Info("certsManagerController Sync successfully created secret")
|
||||
return nil
|
||||
}
|
||||
|
@ -21,10 +21,7 @@ import (
|
||||
kubeinformers "k8s.io/client-go/informers"
|
||||
kubernetesfake "k8s.io/client-go/kubernetes/fake"
|
||||
coretesting "k8s.io/client-go/testing"
|
||||
apiregistrationv1 "k8s.io/kube-aggregator/pkg/apis/apiregistration/v1"
|
||||
aggregatorfake "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset/fake"
|
||||
|
||||
pinnipedv1alpha1 "github.com/suzerain-io/pinniped/generated/1.19/apis/pinniped/v1alpha1"
|
||||
"github.com/suzerain-io/pinniped/internal/controllerlib"
|
||||
"github.com/suzerain-io/pinniped/internal/testutil"
|
||||
)
|
||||
@ -43,15 +40,7 @@ func TestManagerControllerOptions(t *testing.T) {
|
||||
observableWithInformerOption = testutil.NewObservableWithInformerOption()
|
||||
observableWithInitialEventOption = testutil.NewObservableWithInitialEventOption()
|
||||
secretsInformer := kubeinformers.NewSharedInformerFactory(nil, 0).Core().V1().Secrets()
|
||||
_ = NewCertsManagerController(
|
||||
installedInNamespace,
|
||||
nil,
|
||||
nil,
|
||||
secretsInformer,
|
||||
observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters
|
||||
observableWithInitialEventOption.WithInitialEvent, // make it possible to observe the behavior of the initial event
|
||||
0, // certDuration, not needed for this test
|
||||
)
|
||||
_ = NewCertsManagerController(installedInNamespace, nil, secretsInformer, observableWithInformerOption.WithInformer, observableWithInitialEventOption.WithInitialEvent, 0, "Pinniped CA", "pinniped-api")
|
||||
secretsInformerFilter = observableWithInformerOption.GetFilterForInformer(secretsInformer)
|
||||
})
|
||||
|
||||
@ -123,7 +112,6 @@ func TestManagerControllerSync(t *testing.T) {
|
||||
|
||||
var subject controllerlib.Controller
|
||||
var kubeAPIClient *kubernetesfake.Clientset
|
||||
var aggregatorAPIClient *aggregatorfake.Clientset
|
||||
var kubeInformerClient *kubernetesfake.Clientset
|
||||
var kubeInformers kubeinformers.SharedInformerFactory
|
||||
var timeoutContext context.Context
|
||||
@ -137,11 +125,12 @@ func TestManagerControllerSync(t *testing.T) {
|
||||
subject = NewCertsManagerController(
|
||||
installedInNamespace,
|
||||
kubeAPIClient,
|
||||
aggregatorAPIClient,
|
||||
kubeInformers.Core().V1().Secrets(),
|
||||
controllerlib.WithInformer,
|
||||
controllerlib.WithInitialEvent,
|
||||
certDuration,
|
||||
"Pinniped CA",
|
||||
"pinniped-api",
|
||||
)
|
||||
|
||||
// Set this at the last second to support calling subject.Name().
|
||||
@ -167,7 +156,6 @@ func TestManagerControllerSync(t *testing.T) {
|
||||
kubeInformerClient = kubernetesfake.NewSimpleClientset()
|
||||
kubeInformers = kubeinformers.NewSharedInformerFactory(kubeInformerClient, 0)
|
||||
kubeAPIClient = kubernetesfake.NewSimpleClientset()
|
||||
aggregatorAPIClient = aggregatorfake.NewSimpleClientset()
|
||||
})
|
||||
|
||||
it.After(func() {
|
||||
@ -186,111 +174,35 @@ func TestManagerControllerSync(t *testing.T) {
|
||||
r.NoError(err)
|
||||
})
|
||||
|
||||
when("the APIService exists", func() {
|
||||
it.Before(func() {
|
||||
apiService := &apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName,
|
||||
},
|
||||
Spec: apiregistrationv1.APIServiceSpec{
|
||||
CABundle: nil,
|
||||
VersionPriority: 1234,
|
||||
},
|
||||
}
|
||||
err := aggregatorAPIClient.Tracker().Add(apiService)
|
||||
r.NoError(err)
|
||||
})
|
||||
it("creates the api-serving-cert Secret", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.NoError(err)
|
||||
|
||||
it("creates the api-serving-cert Secret and updates the APIService's ca bundle", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.NoError(err)
|
||||
// Check all the relevant fields from the create Secret action
|
||||
r.Len(kubeAPIClient.Actions(), 1)
|
||||
actualAction := kubeAPIClient.Actions()[0].(coretesting.CreateActionImpl)
|
||||
r.Equal(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, actualAction.GetResource())
|
||||
r.Equal(installedInNamespace, actualAction.GetNamespace())
|
||||
actualSecret := actualAction.GetObject().(*corev1.Secret)
|
||||
r.Equal("api-serving-cert", actualSecret.Name)
|
||||
r.Equal(installedInNamespace, actualSecret.Namespace)
|
||||
actualCACert := actualSecret.StringData["caCertificate"]
|
||||
actualPrivateKey := actualSecret.StringData["tlsPrivateKey"]
|
||||
actualCertChain := actualSecret.StringData["tlsCertificateChain"]
|
||||
r.NotEmpty(actualCACert)
|
||||
r.NotEmpty(actualPrivateKey)
|
||||
r.NotEmpty(actualCertChain)
|
||||
|
||||
// Check all the relevant fields from the create Secret action
|
||||
r.Len(kubeAPIClient.Actions(), 1)
|
||||
actualAction := kubeAPIClient.Actions()[0].(coretesting.CreateActionImpl)
|
||||
r.Equal(schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"}, actualAction.GetResource())
|
||||
r.Equal(installedInNamespace, actualAction.GetNamespace())
|
||||
actualSecret := actualAction.GetObject().(*corev1.Secret)
|
||||
r.Equal("api-serving-cert", actualSecret.Name)
|
||||
r.Equal(installedInNamespace, actualSecret.Namespace)
|
||||
actualCACert := actualSecret.StringData["caCertificate"]
|
||||
actualPrivateKey := actualSecret.StringData["tlsPrivateKey"]
|
||||
actualCertChain := actualSecret.StringData["tlsCertificateChain"]
|
||||
r.NotEmpty(actualCACert)
|
||||
r.NotEmpty(actualPrivateKey)
|
||||
r.NotEmpty(actualCertChain)
|
||||
// Validate the created CA's lifetime.
|
||||
validCACert := testutil.ValidateCertificate(t, actualCACert, actualCACert)
|
||||
validCACert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute)
|
||||
|
||||
// Validate the created CA's lifetime.
|
||||
validCACert := testutil.ValidateCertificate(t, actualCACert, actualCACert)
|
||||
validCACert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute)
|
||||
|
||||
// Validate the created cert using the CA, and also validate the cert's hostname
|
||||
validCert := testutil.ValidateCertificate(t, actualCACert, actualCertChain)
|
||||
validCert.RequireDNSName("pinniped-api." + installedInNamespace + ".svc")
|
||||
validCert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute)
|
||||
validCert.RequireMatchesPrivateKey(actualPrivateKey)
|
||||
|
||||
// Make sure we updated the APIService caBundle and left it otherwise unchanged
|
||||
r.Len(aggregatorAPIClient.Actions(), 2)
|
||||
r.Equal("get", aggregatorAPIClient.Actions()[0].GetVerb())
|
||||
expectedAPIServiceName := pinnipedv1alpha1.SchemeGroupVersion.Version + "." + pinnipedv1alpha1.GroupName
|
||||
expectedUpdateAction := coretesting.NewUpdateAction(
|
||||
schema.GroupVersionResource{
|
||||
Group: apiregistrationv1.GroupName,
|
||||
Version: "v1",
|
||||
Resource: "apiservices",
|
||||
},
|
||||
"",
|
||||
&apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: expectedAPIServiceName,
|
||||
Namespace: "",
|
||||
},
|
||||
Spec: apiregistrationv1.APIServiceSpec{
|
||||
VersionPriority: 1234, // only the CABundle is updated, this other field is left unchanged
|
||||
CABundle: []byte(actualCACert),
|
||||
},
|
||||
},
|
||||
)
|
||||
r.Equal(expectedUpdateAction, aggregatorAPIClient.Actions()[1])
|
||||
})
|
||||
|
||||
when("updating the APIService fails", func() {
|
||||
it.Before(func() {
|
||||
aggregatorAPIClient.PrependReactor(
|
||||
"update",
|
||||
"apiservices",
|
||||
func(_ coretesting.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, errors.New("update failed")
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("returns the update error", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.EqualError(err, "could not update the API service: could not update API service: update failed")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
when("the APIService does not exist", func() {
|
||||
it.Before(func() {
|
||||
unrelatedAPIService := &apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "some other api service"},
|
||||
Spec: apiregistrationv1.APIServiceSpec{},
|
||||
}
|
||||
err := aggregatorAPIClient.Tracker().Add(unrelatedAPIService)
|
||||
r.NoError(err)
|
||||
})
|
||||
|
||||
it("returns an error", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.Error(err)
|
||||
r.Regexp("could not get existing version of API service: .* not found", err.Error())
|
||||
})
|
||||
// Validate the created cert using the CA, and also validate the cert's hostname
|
||||
validCert := testutil.ValidateCertificate(t, actualCACert, actualCertChain)
|
||||
validCert.RequireDNSName("pinniped-api." + installedInNamespace + ".svc")
|
||||
validCert.RequireLifetime(time.Now(), time.Now().Add(certDuration), 6*time.Minute)
|
||||
validCert.RequireMatchesPrivateKey(actualPrivateKey)
|
||||
})
|
||||
|
||||
when("creating the Secret fails", func() {
|
||||
@ -304,11 +216,10 @@ func TestManagerControllerSync(t *testing.T) {
|
||||
)
|
||||
})
|
||||
|
||||
it("returns the create error and does not update the APIService", func() {
|
||||
it("returns the create error", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.EqualError(err, "could not create secret: create failed")
|
||||
r.Empty(aggregatorAPIClient.Actions())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -325,12 +236,11 @@ func TestManagerControllerSync(t *testing.T) {
|
||||
r.NoError(err)
|
||||
})
|
||||
|
||||
it("does not need to make any API calls with its API clients", func() {
|
||||
it("does not need to make any API calls with its API client", func() {
|
||||
startInformersAndController()
|
||||
err := controllerlib.TestSync(t, subject, *syncContext)
|
||||
r.NoError(err)
|
||||
r.Empty(kubeAPIClient.Actions())
|
||||
r.Empty(aggregatorAPIClient.Actions())
|
||||
})
|
||||
})
|
||||
}, spec.Parallel(), spec.Report(report.Terminal{}))
|
||||
|
@ -54,7 +54,7 @@ func (c *certsObserverController) Sync(_ controllerlib.Context) error {
|
||||
return fmt.Errorf("failed to get %s/%s secret: %w", c.namespace, certsSecretName, err)
|
||||
}
|
||||
if notFound {
|
||||
klog.Info("certsObserverController Sync() found that the secret does not exist yet or was deleted")
|
||||
klog.Info("certsObserverController Sync found that the secret does not exist yet or was deleted")
|
||||
// The secret does not exist yet or was deleted.
|
||||
c.dynamicCertProvider.Set(nil, nil)
|
||||
return nil
|
||||
|
@ -6,6 +6,7 @@ SPDX-License-Identifier: Apache-2.0
|
||||
package apicerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
@ -28,6 +29,11 @@ func UpdateAPIService(ctx context.Context, aggregatorClient aggregatorclient.Int
|
||||
return fmt.Errorf("could not get existing version of API service: %w", err)
|
||||
}
|
||||
|
||||
if bytes.Equal(fetchedAPIService.Spec.CABundle, aggregatedAPIServerCA) {
|
||||
// Already has the same value, perhaps because another process already updated the object, so no need to update.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update just the field we care about.
|
||||
fetchedAPIService.Spec.CABundle = aggregatedAPIServerCA
|
||||
|
||||
|
@ -70,17 +70,44 @@ func TestUpdateAPIService(t *testing.T) {
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "happy path update when the pre-existing APIService already has the same CA bundle so there is no need to update",
|
||||
mocks: func(c *aggregatorv1fake.Clientset) {
|
||||
_ = c.Tracker().Add(&apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: apiServiceName},
|
||||
Spec: apiregistrationv1.APIServiceSpec{
|
||||
GroupPriorityMinimum: 999,
|
||||
CABundle: []byte("some-ca-bundle"),
|
||||
},
|
||||
})
|
||||
c.PrependReactor("update", "apiservices", func(_ kubetesting.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("should not encounter this error because update should be skipped in this case")
|
||||
})
|
||||
},
|
||||
caInput: []byte("some-ca-bundle"),
|
||||
wantObjects: []apiregistrationv1.APIService{{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: apiServiceName},
|
||||
Spec: apiregistrationv1.APIServiceSpec{
|
||||
GroupPriorityMinimum: 999,
|
||||
CABundle: []byte("some-ca-bundle"), // unchanged
|
||||
},
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "error on update",
|
||||
mocks: func(c *aggregatorv1fake.Clientset) {
|
||||
_ = c.Tracker().Add(&apiregistrationv1.APIService{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: apiServiceName},
|
||||
Spec: apiregistrationv1.APIServiceSpec{},
|
||||
Spec: apiregistrationv1.APIServiceSpec{
|
||||
GroupPriorityMinimum: 999,
|
||||
CABundle: []byte("some-other-different-ca-bundle"),
|
||||
},
|
||||
})
|
||||
c.PrependReactor("update", "apiservices", func(_ kubetesting.Action) (bool, runtime.Object, error) {
|
||||
return true, nil, fmt.Errorf("error on update")
|
||||
})
|
||||
},
|
||||
caInput: []byte("some-ca-bundle"),
|
||||
wantErr: "could not update API service: error on update",
|
||||
},
|
||||
{
|
||||
@ -143,6 +170,7 @@ func TestUpdateAPIService(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
@ -47,6 +47,9 @@ func PrepareControllers(
|
||||
kubePublicNamespaceK8sInformers, installationNamespaceK8sInformers, installationNamespacePinnipedInformers :=
|
||||
createInformers(serverInstallationNamespace, k8sClient, pinnipedClient)
|
||||
|
||||
// This string must match the name of the Service declared in the deployment yaml.
|
||||
const serviceName = "pinniped-api"
|
||||
|
||||
// Create controller manager.
|
||||
controllerManager := controllerlib.
|
||||
NewManager().
|
||||
@ -65,11 +68,21 @@ func PrepareControllers(
|
||||
apicerts.NewCertsManagerController(
|
||||
serverInstallationNamespace,
|
||||
k8sClient,
|
||||
aggregatorClient,
|
||||
installationNamespaceK8sInformers.Core().V1().Secrets(),
|
||||
controllerlib.WithInformer,
|
||||
controllerlib.WithInitialEvent,
|
||||
servingCertDuration,
|
||||
"Pinniped CA",
|
||||
serviceName,
|
||||
),
|
||||
singletonWorker,
|
||||
).
|
||||
WithController(
|
||||
apicerts.NewAPIServiceUpdaterController(
|
||||
serverInstallationNamespace,
|
||||
aggregatorClient,
|
||||
installationNamespaceK8sInformers.Core().V1().Secrets(),
|
||||
controllerlib.WithInformer,
|
||||
),
|
||||
singletonWorker,
|
||||
).
|
||||
|
@ -57,7 +57,7 @@ var maskKey = func(s string) string { return strings.ReplaceAll(s, "TESTING KEY"
|
||||
func TestClient(t *testing.T) {
|
||||
library.SkipUnlessIntegration(t)
|
||||
library.SkipUnlessClusterHasCapability(t, library.ClusterSigningKeyIsAvailable)
|
||||
tmcClusterToken := library.GetEnv(t, "PINNIPED_TMC_CLUSTER_TOKEN")
|
||||
token := library.GetEnv(t, "PINNIPED_TEST_USER_TOKEN")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@ -69,7 +69,7 @@ func TestClient(t *testing.T) {
|
||||
|
||||
// Using the CA bundle and host from the current (admin) kubeconfig, do the token exchange.
|
||||
clientConfig := library.NewClientConfig(t)
|
||||
resp, err := client.ExchangeToken(ctx, tmcClusterToken, string(clientConfig.CAData), clientConfig.Host)
|
||||
resp, err := client.ExchangeToken(ctx, token, string(clientConfig.CAData), clientConfig.Host)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp.Status.ExpirationTimestamp)
|
||||
require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute))
|
||||
|
@ -10,6 +10,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -28,6 +29,10 @@ import (
|
||||
func TestSuccessfulCredentialRequest(t *testing.T) {
|
||||
library.SkipUnlessIntegration(t)
|
||||
library.SkipUnlessClusterHasCapability(t, library.ClusterSigningKeyIsAvailable)
|
||||
testUsername := library.GetEnv(t, "PINNIPED_TEST_USER_USERNAME")
|
||||
expectedTestUserGroups := strings.Split(
|
||||
strings.ReplaceAll(library.GetEnv(t, "PINNIPED_TEST_USER_GROUPS"), " ", ""), ",",
|
||||
)
|
||||
|
||||
response, err := makeRequest(t, validCredentialRequestSpecWithRealToken(t))
|
||||
require.NoError(t, err)
|
||||
@ -39,6 +44,8 @@ func TestSuccessfulCredentialRequest(t *testing.T) {
|
||||
require.NotNil(t, response.Status.Credential)
|
||||
require.Empty(t, response.Status.Credential.Token)
|
||||
require.NotEmpty(t, response.Status.Credential.ClientCertificateData)
|
||||
require.Equal(t, testUsername, getCommonName(t, response.Status.Credential.ClientCertificateData))
|
||||
require.ElementsMatch(t, expectedTestUserGroups, getOrganizations(t, response.Status.Credential.ClientCertificateData))
|
||||
require.NotEmpty(t, response.Status.Credential.ClientKeyData)
|
||||
require.NotNil(t, response.Status.Credential.ExpirationTimestamp)
|
||||
require.InDelta(t, time.Until(response.Status.Credential.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute))
|
||||
@ -65,7 +72,7 @@ func TestSuccessfulCredentialRequest(t *testing.T) {
|
||||
Subjects: []rbacv1.Subject{{
|
||||
Kind: rbacv1.UserKind,
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Name: getCommonName(t, response.Status.Credential.ClientCertificateData),
|
||||
Name: testUsername,
|
||||
}},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
Kind: "ClusterRole",
|
||||
@ -85,34 +92,37 @@ func TestSuccessfulCredentialRequest(t *testing.T) {
|
||||
require.NotEmpty(t, listNamespaceResponse.Items)
|
||||
})
|
||||
|
||||
t.Run("access as group", func(t *testing.T) {
|
||||
addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{
|
||||
TypeMeta: metav1.TypeMeta{},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "integration-test-group-readonly-role-binding",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{{
|
||||
Kind: rbacv1.GroupKind,
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Name: "tmc:member",
|
||||
}},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
Kind: "ClusterRole",
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Name: "view",
|
||||
},
|
||||
})
|
||||
for _, group := range expectedTestUserGroups {
|
||||
group := group
|
||||
t.Run("access as group "+group, func(t *testing.T) {
|
||||
addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{
|
||||
TypeMeta: metav1.TypeMeta{},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "integration-test-group-readonly-role-binding",
|
||||
},
|
||||
Subjects: []rbacv1.Subject{{
|
||||
Kind: rbacv1.GroupKind,
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Name: group,
|
||||
}},
|
||||
RoleRef: rbacv1.RoleRef{
|
||||
Kind: "ClusterRole",
|
||||
APIGroup: rbacv1.GroupName,
|
||||
Name: "view",
|
||||
},
|
||||
})
|
||||
|
||||
// Use the client which is authenticated as the TMC group to list namespaces
|
||||
var listNamespaceResponse *v1.NamespaceList
|
||||
var canListNamespaces = func() bool {
|
||||
listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
return err == nil
|
||||
}
|
||||
assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond)
|
||||
require.NoError(t, err) // prints out the error and stops the test in case of failure
|
||||
require.NotEmpty(t, listNamespaceResponse.Items)
|
||||
})
|
||||
// Use the client which is authenticated as the TMC group to list namespaces
|
||||
var listNamespaceResponse *v1.NamespaceList
|
||||
var canListNamespaces = func() bool {
|
||||
listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
return err == nil
|
||||
}
|
||||
assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond)
|
||||
require.NoError(t, err) // prints out the error and stops the test in case of failure
|
||||
require.NotEmpty(t, listNamespaceResponse.Items)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser(t *testing.T) {
|
||||
@ -183,11 +193,10 @@ func makeRequest(t *testing.T, spec v1alpha1.CredentialRequestSpec) (*v1alpha1.C
|
||||
}
|
||||
|
||||
func validCredentialRequestSpecWithRealToken(t *testing.T) v1alpha1.CredentialRequestSpec {
|
||||
tmcClusterToken := library.GetEnv(t, "PINNIPED_TMC_CLUSTER_TOKEN")
|
||||
|
||||
token := library.GetEnv(t, "PINNIPED_TEST_USER_TOKEN")
|
||||
return v1alpha1.CredentialRequestSpec{
|
||||
Type: v1alpha1.TokenCredentialType,
|
||||
Token: &v1alpha1.CredentialRequestTokenCredential{Value: tmcClusterToken},
|
||||
Token: &v1alpha1.CredentialRequestTokenCredential{Value: token},
|
||||
}
|
||||
}
|
||||
|
||||
@ -224,3 +233,13 @@ func getCommonName(t *testing.T, certPEM string) string {
|
||||
|
||||
return cert.Subject.CommonName
|
||||
}
|
||||
|
||||
func getOrganizations(t *testing.T, certPEM string) []string {
|
||||
t.Helper()
|
||||
|
||||
pemBlock, _ := pem.Decode([]byte(certPEM))
|
||||
cert, err := x509.ParseCertificate(pemBlock.Bytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
return cert.Subject.Organization
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user